001/*
002 * Configurate
003 * Copyright (C) zml and Configurate contributors
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *    http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.spongepowered.configurate.loader;
018
019import com.google.errorprone.annotations.Immutable;
020import org.checkerframework.checker.nullness.qual.Nullable;
021import org.spongepowered.configurate.util.Strings;
022
023import java.io.BufferedReader;
024import java.io.IOException;
025import java.nio.CharBuffer;
026import java.util.Iterator;
027import java.util.stream.Stream;
028
029/**
030 * Defines a number of default {@link CommentHandler}s.
031 *
032 * @since 4.0.0
033 */
034@Immutable
035public enum CommentHandlers implements CommentHandler {
036
037    /**
038     * {@link CommentHandler} for comments prefixed by the <code>#</code> character.
039     */
040    HASH(new AbstractPrefixHandler("#")),
041
042    /**
043     * {@link CommentHandler} for comments prefixed by a <code>//</code> escape.
044     */
045    DOUBLE_SLASH(new AbstractPrefixHandler("//")),
046
047    /**
048     * {@link CommentHandler} for comments delineated using <code>/*  *\</code>.
049     */
050    SLASH_BLOCK(new AbstractDelineatedHandler("/*", "*/", "*")),
051
052    /**
053     * {@link CommentHandler} for comments delineated using <code>&lt;!--  --&gt;</code>.
054     */
055    XML_STYLE(new AbstractDelineatedHandler("<!--", "-->", "~"));
056
057    /**
058     * Limit on the number of characters that may be read by a comment handler
059     * while still preserving the mark.
060     */
061    private static final int READAHEAD_LEN = 4096;
062
063    private final CommentHandler delegate;
064
065    CommentHandlers(final CommentHandler delegate) {
066        this.delegate = delegate;
067    }
068
069    @Override
070    public @Nullable String extractHeader(final BufferedReader reader) throws IOException {
071        return this.delegate.extractHeader(reader);
072    }
073
074    @Override
075    public Stream<String> toComment(final Stream<String> lines) {
076        return this.delegate.toComment(lines);
077    }
078
079    /**
080     * Uses provided comment handlers to extract a comment from the reader.
081     *
082     * @param reader to extract a comment from
083     * @param allowedHeaderTypes handlers to try
084     * @return extracted comment, or null if a comment could not be extracted
085     * @throws IOException if an IO error occurs
086     * @since 4.0.0
087     */
088    public static @Nullable String extractComment(final BufferedReader reader,
089            final Iterable<CommentHandler> allowedHeaderTypes) throws IOException {
090        reader.mark(READAHEAD_LEN);
091        for (CommentHandler handler : allowedHeaderTypes) {
092            final @Nullable String comment = handler.extractHeader(reader);
093            if (comment == null) {
094                reader.reset();
095            } else {
096                return comment;
097            }
098        }
099        return null;
100    }
101
102    @Immutable
103    private static final class AbstractDelineatedHandler implements CommentHandler {
104        private final String startSequence;
105        private final String endSequence;
106        private final String lineIndentSequence;
107
108        private AbstractDelineatedHandler(final String startSequence, final String endSequence, final String lineIndentSequence) {
109            this.startSequence = startSequence;
110            this.endSequence = endSequence;
111            this.lineIndentSequence = lineIndentSequence;
112        }
113
114        @Override
115        public @Nullable String extractHeader(final BufferedReader reader) throws IOException {
116            if (!beginsWithPrefix(this.startSequence, reader)) {
117                return null;
118            }
119
120            final StringBuilder build = new StringBuilder();
121            String line = reader.readLine();
122            if (line == null) {
123                return null;
124            }
125            if (handleSingleLine(build, line)) {
126                for (line = reader.readLine(); line != null; line = reader.readLine()) {
127                    if (!handleSingleLine(build, line)) {
128                        break;
129                    }
130                }
131            }
132            line = reader.readLine();
133            if (!(line == null || line.trim().isEmpty())) { // Require a blank line after a comment to make it a header
134                return null;
135            }
136
137            if (build.length() > 0) {
138                return build.toString();
139            } else {
140                return null;
141            }
142        }
143
144        private boolean handleSingleLine(final StringBuilder builder, String line) {
145            boolean moreLines = true;
146            if (line.trim().endsWith(this.endSequence)) {
147                line = line.substring(0, line.lastIndexOf(this.endSequence));
148                if (line.endsWith(" ")) {
149                    line = line.substring(0, line.length() - 1);
150                }
151
152                moreLines = false;
153                if (line.isEmpty()) {
154                    return false;
155                }
156            }
157            if (line.trim().startsWith(this.lineIndentSequence)) {
158                line = line.substring(line.indexOf(this.lineIndentSequence) + 1);
159            }
160
161            if (line.length() > 0 && line.charAt(0) == ' ') {
162                line = line.substring(1);
163            }
164
165            if (builder.length() > 0) {
166                builder.append(AbstractConfigurationLoader.CONFIGURATE_LINE_SEPARATOR);
167            }
168            builder.append(line.replace("\r", "").replace("\n", "").replace("\r\n", ""));
169            return moreLines;
170        }
171
172        @Override
173        public Stream<String> toComment(final Stream<String> lines) {
174            final Stream.Builder<String> build = Stream.builder();
175            boolean first = true;
176            for (Iterator<String> it = lines.iterator(); it.hasNext();) {
177                final String next = it.next();
178                if (first) {
179                    if (!it.hasNext()) {
180                        build.add(this.startSequence + " " + next + " " + this.endSequence);
181                        return build.build();
182                    } else {
183                        build.add(this.startSequence);
184                    }
185                    first = false;
186                }
187                build.add(" " + this.lineIndentSequence + " " + next);
188            }
189            build.add(" " + this.endSequence);
190            return build.build();
191        }
192    }
193
194    @Immutable
195    private static final class AbstractPrefixHandler implements CommentHandler {
196        private final String commentPrefix;
197
198        AbstractPrefixHandler(final String commentPrefix) {
199            this.commentPrefix = commentPrefix;
200        }
201
202        @Override
203        public @Nullable String extractHeader(final BufferedReader reader) throws IOException {
204            if (!beginsWithPrefix(this.commentPrefix, reader)) {
205                return null;
206            }
207            boolean firstLine = true;
208
209            final StringBuilder build = new StringBuilder();
210            for (String line = reader.readLine(); line != null; line = reader.readLine()) {
211                if (firstLine) {
212                    if (line.length() > 0 && line.charAt(0) == ' ') {
213                        line = line.substring(1);
214                    }
215                    build.append(line);
216                    firstLine = false;
217                } else if (line.trim().startsWith(this.commentPrefix)) {
218                    line = line.substring(line.indexOf(this.commentPrefix) + 1);
219                    if (line.length() > 0 && line.charAt(0) == ' ') {
220                        line = line.substring(1);
221                    }
222                    if (build.length() > 0) {
223                        build.append(AbstractConfigurationLoader.CONFIGURATE_LINE_SEPARATOR);
224                    }
225                    build.append(line);
226                } else if (Strings.isBlank(line)) {
227                    break;
228                } else {
229                    return null;
230                }
231            }
232            // We've reached the end of the document?
233            return build.length() > 0 ? build.toString() : null;
234        }
235
236        @Override
237        public Stream<String> toComment(final Stream<String> lines) {
238            return lines
239                    .map(s -> {
240                        if (s.length() > 0 && s.charAt(0) == ' ') {
241                            return this.commentPrefix + s;
242                        } else {
243                            return this.commentPrefix + " " + s;
244                        }
245                    });
246        }
247    }
248
249    /**
250     * Consumes the length of the comment prefix from the reader and returns
251     * whether or not the contents from the reader matches the expected prefix.
252     */
253    static boolean beginsWithPrefix(final String commentPrefix, final BufferedReader reader) throws IOException {
254        final CharBuffer buf = CharBuffer.allocate(commentPrefix.length());
255        if (reader.read(buf) != buf.limit()) {
256            return false;
257        }
258        buf.flip();
259        return commentPrefix.contentEquals(buf);
260    }
261
262}