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 ninja.leaping.configurate.loader;
018
019import com.google.common.collect.Collections2;
020import org.checkerframework.checker.nullness.qual.NonNull;
021import org.checkerframework.checker.nullness.qual.Nullable;
022
023import java.io.BufferedReader;
024import java.io.IOException;
025import java.nio.CharBuffer;
026import java.util.ArrayList;
027import java.util.Collection;
028import java.util.Optional;
029import java.util.stream.Collectors;
030
031/**
032 * Defines a number of default {@link CommentHandler}s.
033 */
034public enum CommentHandlers implements CommentHandler {
035
036    /**
037     * {@link CommentHandler} for comments prefixed by the <code>#</code> character.
038     */
039    HASH(new AbstractPrefixHandler("#")),
040
041    /**
042     * {@link CommentHandler} for comments prefixed by a <code>//</code> escape.
043     */
044    DOUBLE_SLASH(new AbstractPrefixHandler("//")),
045
046    /**
047     * {@link CommentHandler} for comments delineated using <code>/*  *\</code>.
048     */
049    SLASH_BLOCK(new AbstractDelineatedHandler("/*", "*/", "*")),
050
051    /**
052     * {@link CommentHandler} for comments delineated using <code>&lt;!--  --&gt;</code>.
053     */
054    XML_STYLE(new AbstractDelineatedHandler("<!--", "-->", "~"));
055
056    /**
057     * Limit on the number of characters that may be read by a comment handler while still preserving the mark.
058     */
059    private static final int READAHEAD_LEN = 4096;
060
061    private final CommentHandler delegate;
062
063    CommentHandlers(CommentHandler delegate) {
064        this.delegate = delegate;
065    }
066
067    @NonNull
068    @Override
069    public Optional<String> extractHeader(@NonNull BufferedReader reader) throws IOException {
070        return delegate.extractHeader(reader);
071    }
072
073    @NonNull
074    @Override
075    public Collection<String> toComment(@NonNull Collection<String> lines) {
076        return delegate.toComment(lines);
077    }
078
079    /**
080     * Uses provided comment handlers to extract a comment from the reader.
081     *
082     * @param reader             The reader
083     * @param allowedHeaderTypes The handlers to try
084     * @return The extracted comment, or null if a comment could not be extracted
085     * @throws IOException If an IO error occurs
086     */
087    @Nullable
088    public static String extractComment(@NonNull BufferedReader reader, @NonNull CommentHandler... allowedHeaderTypes) throws IOException {
089        reader.mark(READAHEAD_LEN);
090        for (CommentHandler handler : allowedHeaderTypes) {
091            Optional<String> comment = handler.extractHeader(reader);
092            if (!comment.isPresent()) {
093                reader.reset();
094            } else {
095                return comment.get();
096            }
097        }
098        return null;
099    }
100
101    private static final class AbstractDelineatedHandler implements CommentHandler {
102        private final String startSequence;
103        private final String endSequence;
104        private final String lineIndentSequence;
105
106        private AbstractDelineatedHandler(String startSequence, String endSequence, String lineIndentSequence) {
107            this.startSequence = startSequence;
108            this.endSequence = endSequence;
109            this.lineIndentSequence = lineIndentSequence;
110        }
111
112        @NonNull
113        @Override
114        public Optional<String> extractHeader(@NonNull BufferedReader reader) throws IOException {
115            if (!beginsWithPrefix(this.startSequence, reader)) {
116                return Optional.empty();
117            }
118
119            final StringBuilder build = new StringBuilder();
120            String line = reader.readLine();
121            if (line == null) {
122                return Optional.empty();
123            }
124            if (handleSingleLine(build, line)) {
125                for (line = reader.readLine(); line != null; line = reader.readLine()) {
126                    if (!handleSingleLine(build, line)) {
127                        break;
128                    }
129                }
130            }
131            line = reader.readLine();
132            if (!(line == null || line.trim().isEmpty())) { // Require a blank line after a comment to make it a header
133                return Optional.empty();
134            }
135
136            if (build.length() > 0) {
137                return Optional.of(build.toString());
138            } else {
139                return Optional.empty();
140            }
141        }
142
143        private boolean handleSingleLine(StringBuilder builder, String line) {
144            boolean moreLines = true;
145            if (line.trim().endsWith(endSequence)) {
146                line = line.substring(0, line.lastIndexOf(endSequence));
147                if (line.endsWith(" ")) {
148                    line = line.substring(0, line.length() - 1);
149                }
150
151                moreLines = false;
152                if (line.isEmpty()) {
153                    return false;
154                }
155            }
156            if (line.trim().startsWith(lineIndentSequence)) {
157                line = line.substring(line.indexOf(lineIndentSequence) + 1);
158            }
159
160            if (line.startsWith(" ")) {
161                line = line.substring(1);
162            }
163
164            if (builder.length() > 0) {
165                builder.append(AbstractConfigurationLoader.CONFIGURATE_LINE_SEPARATOR);
166            }
167            builder.append(line.replace("\r", "").replace("\n", "").replace("\r\n", ""));
168            return moreLines;
169        }
170
171        @NonNull
172        @Override
173        public Collection<String> toComment(@NonNull Collection<String> lines) {
174            if (lines.size() == 1) {
175                return lines.stream().map(i -> startSequence + " " + i + " " + endSequence).collect(Collectors.toList());
176            } else {
177                Collection<String> ret = new ArrayList<>();
178                ret.add(startSequence);
179                ret.addAll(lines.stream().map(i -> " " + lineIndentSequence + " " + i).collect(Collectors.toList()));
180                ret.add(" " + endSequence);
181                return ret;
182            }
183        }
184    }
185
186    private static final class AbstractPrefixHandler implements CommentHandler {
187        private final String commentPrefix;
188
189        AbstractPrefixHandler(String commentPrefix) {
190            this.commentPrefix = commentPrefix;
191        }
192
193        @NonNull
194        @Override
195        public Optional<String> extractHeader(@NonNull BufferedReader reader) throws IOException {
196            if (!beginsWithPrefix(commentPrefix, reader)) {
197                return Optional.empty();
198            }
199            boolean firstLine = true;
200
201            StringBuilder build = new StringBuilder();
202            for (String line = reader.readLine(); line != null; line = reader.readLine()) {
203                if (firstLine || line.trim().startsWith(commentPrefix)) {
204                    line = firstLine ? line : line.substring(line.indexOf(commentPrefix) + 1);
205                    firstLine = false;
206                    if (line.startsWith(" ")) {
207                        line = line.substring(1);
208                    }
209                    if (build.length() > 0) {
210                        build.append(AbstractConfigurationLoader.CONFIGURATE_LINE_SEPARATOR);
211                    }
212                    build.append(line);
213                } else {
214                    if (line.trim().isEmpty()) {
215                        break;
216                    } else {
217                        return Optional.empty();
218                    }
219                }
220            }
221            // We've reached the end of the document?
222            return build.length() > 0 ? Optional.of(build.toString()) : Optional.empty();
223        }
224
225        @NonNull
226        @Override
227        public Collection<String> toComment(@NonNull Collection<String> lines) {
228            return Collections2.transform(lines, s -> {
229                if (s.startsWith(" ")) {
230                    return commentPrefix + s;
231                } else {
232                    return commentPrefix + " " + s;
233                }
234            });
235        }
236    }
237
238    /**
239     * Consumes the length of the comment prefix from the reader and returns whether or not the contents from the reader
240     * matches the expected prefix.
241     */
242    static boolean beginsWithPrefix(String commentPrefix, BufferedReader reader) throws IOException {
243        final CharBuffer buf = CharBuffer.allocate(commentPrefix.length());
244        if (reader.read(buf) != buf.limit()) {
245            return false;
246        }
247        buf.flip();
248        return commentPrefix.contentEquals(buf);
249    }
250
251}