com.squareup.wire.schema.internal.parser.ProtoParser.java Source code

Java tutorial

Introduction

Here is the source code for com.squareup.wire.schema.internal.parser.ProtoParser.java

Source

/*
 * Copyright (C) 2013 Square, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.squareup.wire.schema.internal.parser;

import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Range;
import com.squareup.wire.schema.Field;
import com.squareup.wire.schema.Location;
import com.squareup.wire.schema.ProtoFile;
import com.squareup.wire.schema.internal.Util;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Basic parser for {@code .proto} schema declarations. */
public final class ProtoParser {
    /** Parse a named {@code .proto} schema. */
    public static ProtoFileElement parse(Location location, String data) {
        return new ProtoParser(location, data.toCharArray()).readProtoFile();
    }

    private final Location location;
    private final char[] data;
    private final ProtoFileElement.Builder fileBuilder;
    private final ImmutableList.Builder<String> publicImports = ImmutableList.builder();
    private final ImmutableList.Builder<String> imports = ImmutableList.builder();
    private final ImmutableList.Builder<TypeElement> nestedTypes = ImmutableList.builder();
    private final ImmutableList.Builder<ServiceElement> services = ImmutableList.builder();
    private final ImmutableList.Builder<ExtendElement> extendsList = ImmutableList.builder();
    private final ImmutableList.Builder<OptionElement> options = ImmutableList.builder();

    /** The number of declarations defined in the current file. */
    private int declarationCount = 0;
    /** The syntax of the file, or null if none is defined. */
    private ProtoFile.Syntax syntax;
    /** Our cursor within the document. {@code data[pos]} is the next character to be read. */
    private int pos;
    /** The number of newline characters encountered thus far. */
    private int line;
    /** The index of the most recent newline character. */
    private int lineStart;
    /** Output package name, or null if none yet encountered. */
    private String packageName;

    /** The current package name + nested type names, separated by dots. */
    private String prefix = "";

    ProtoParser(Location location, char[] data) {
        this.location = location;
        this.data = data;
        this.fileBuilder = ProtoFileElement.builder(location);
    }

    ProtoFileElement readProtoFile() {
        while (true) {
            String documentation = readDocumentation();
            if (pos == data.length) {
                return fileBuilder.syntax(syntax).publicImports(publicImports.build()).imports(imports.build())
                        .types(nestedTypes.build()).services(services.build())
                        .extendDeclarations(extendsList.build()).options(options.build()).build();
            }
            Object declaration = readDeclaration(documentation, Context.FILE);
            if (declaration instanceof TypeElement) {
                nestedTypes.add((TypeElement) declaration);
            } else if (declaration instanceof ServiceElement) {
                services.add((ServiceElement) declaration);
            } else if (declaration instanceof OptionElement) {
                options.add((OptionElement) declaration);
            } else if (declaration instanceof ExtendElement) {
                extendsList.add((ExtendElement) declaration);
            }
        }
    }

    private Object readDeclaration(String documentation, Context context) {
        int index = declarationCount++;

        // Skip unnecessary semicolons, occasionally used after a nested message declaration.
        if (peekChar() == ';') {
            pos++;
            return null;
        }

        Location location = location();
        String label = readWord();

        if (label.equals("package")) {
            if (!context.permitsPackage())
                throw unexpected(location, "'package' in " + context);
            if (packageName != null)
                throw unexpected(location, "too many package names");
            packageName = readName();
            fileBuilder.packageName(packageName);
            prefix = packageName + ".";
            if (readChar() != ';')
                throw unexpected("expected ';'");
            return null;
        } else if (label.equals("import")) {
            if (!context.permitsImport())
                throw unexpected(location, "'import' in " + context);
            String importString = readString();
            if ("public".equals(importString)) {
                publicImports.add(readString());
            } else {
                imports.add(importString);
            }
            if (readChar() != ';')
                throw unexpected("expected ';'");
            return null;
        } else if (label.equals("syntax")) {
            if (!context.permitsSyntax())
                throw unexpected(location, "'syntax' in " + context);
            if (readChar() != '=')
                throw unexpected("expected '='");
            if (index != 0) {
                throw unexpected(location, "'syntax' element must be the first declaration in a file");
            }
            String syntaxString = readQuotedString();
            try {
                syntax = ProtoFile.Syntax.get(syntaxString);
            } catch (IllegalArgumentException e) {
                throw unexpected(location, e.getMessage());
            }
            if (readChar() != ';')
                throw unexpected("expected ';'");
            return null;
        } else if (label.equals("option")) {
            OptionElement result = readOption('=');
            if (readChar() != ';')
                throw unexpected("expected ';'");
            return result;
        } else if (label.equals("reserved")) {
            return readReserved(location, documentation);
        } else if (label.equals("message")) {
            return readMessage(location, documentation);
        } else if (label.equals("enum")) {
            return readEnumElement(location, documentation);
        } else if (label.equals("service")) {
            return readService(location, documentation);
        } else if (label.equals("extend")) {
            return readExtend(location, documentation);
        } else if (label.equals("rpc")) {
            if (!context.permitsRpc())
                throw unexpected(location, "'rpc' in " + context);
            return readRpc(location, documentation);
        } else if (label.equals("oneof")) {
            if (!context.permitsOneOf())
                throw unexpected(location, "'oneof' must be nested in message");
            return readOneOf(documentation);
        } else if (label.equals("extensions")) {
            if (!context.permitsExtensions())
                throw unexpected(location, "'extensions' must be nested");
            return readExtensions(location, documentation);
        } else if (context == Context.MESSAGE || context == Context.EXTEND) {
            return readField(documentation, location, label);
        } else if (context == Context.ENUM) {
            if (readChar() != '=')
                throw unexpected("expected '='");

            EnumConstantElement.Builder builder = EnumConstantElement.builder(location).name(label).tag(readInt());

            ImmutableList.Builder<OptionElement> options = ImmutableList.builder();
            if (peekChar() == '[') {
                readChar();
                while (true) {
                    options.add(readOption('='));
                    char c = readChar();
                    if (c == ']') {
                        break;
                    }
                    if (c != ',') {
                        throw unexpected("Expected ',' or ']");
                    }
                }
            }
            if (readChar() != ';')
                throw unexpected("expected ';'");
            documentation = tryAppendTrailingDocumentation(documentation);
            return builder.documentation(documentation).options(options.build()).build();
        } else {
            throw unexpected(location, "unexpected label: " + label);
        }
    }

    /** Reads a message declaration. */
    private MessageElement readMessage(Location location, String documentation) {
        String name = readName();
        MessageElement.Builder builder = MessageElement.builder(location).name(name).documentation(documentation);

        String previousPrefix = prefix;
        prefix = prefix + name + ".";

        ImmutableList.Builder<FieldElement> fields = ImmutableList.builder();
        ImmutableList.Builder<OneOfElement> oneOfs = ImmutableList.builder();
        ImmutableList.Builder<TypeElement> nestedTypes = ImmutableList.builder();
        ImmutableList.Builder<ExtensionsElement> extensions = ImmutableList.builder();
        ImmutableList.Builder<OptionElement> options = ImmutableList.builder();
        ImmutableList.Builder<ReservedElement> reserveds = ImmutableList.builder();
        ImmutableList.Builder<GroupElement> groups = ImmutableList.builder();

        if (readChar() != '{')
            throw unexpected("expected '{'");
        while (true) {
            String nestedDocumentation = readDocumentation();
            if (peekChar() == '}') {
                pos++;
                break;
            }
            Object declared = readDeclaration(nestedDocumentation, Context.MESSAGE);
            if (declared instanceof FieldElement) {
                fields.add((FieldElement) declared);
            } else if (declared instanceof OneOfElement) {
                oneOfs.add((OneOfElement) declared);
            } else if (declared instanceof GroupElement) {
                groups.add((GroupElement) declared);
            } else if (declared instanceof TypeElement) {
                nestedTypes.add((TypeElement) declared);
            } else if (declared instanceof ExtensionsElement) {
                extensions.add((ExtensionsElement) declared);
            } else if (declared instanceof OptionElement) {
                options.add((OptionElement) declared);
            } else if (declared instanceof ExtendElement) {
                // Extend declarations always add in a global scope regardless of nesting.
                extendsList.add((ExtendElement) declared);
            } else if (declared instanceof ReservedElement) {
                reserveds.add((ReservedElement) declared);
            }
        }
        prefix = previousPrefix;

        return builder.fields(fields.build()).oneOfs(oneOfs.build()).nestedTypes(nestedTypes.build())
                .extensions(extensions.build()).options(options.build()).reserveds(reserveds.build())
                .groups(groups.build()).build();
    }

    /** Reads an extend declaration. */
    private ExtendElement readExtend(Location location, String documentation) {
        String name = readName();
        ExtendElement.Builder builder = ExtendElement.builder(location).name(name).documentation(documentation);

        if (readChar() != '{')
            throw unexpected("expected '{'");
        ImmutableList.Builder<FieldElement> fields = ImmutableList.builder();
        while (true) {
            String nestedDocumentation = readDocumentation();
            if (peekChar() == '}') {
                pos++;
                break;
            }
            Object declared = readDeclaration(nestedDocumentation, Context.EXTEND);
            if (declared instanceof FieldElement) {
                fields.add((FieldElement) declared);
            }
        }
        return builder.fields(fields.build()).build();
    }

    /** Reads a service declaration and returns it. */
    private ServiceElement readService(Location location, String documentation) {
        String name = readName();
        ServiceElement.Builder builder = ServiceElement.builder(location).name(name).documentation(documentation);

        if (readChar() != '{')
            throw unexpected("expected '{'");
        ImmutableList.Builder<RpcElement> rpcs = ImmutableList.builder();
        ImmutableList.Builder<OptionElement> options = ImmutableList.builder();
        while (true) {
            String rpcDocumentation = readDocumentation();
            if (peekChar() == '}') {
                pos++;
                break;
            }
            Object declared = readDeclaration(rpcDocumentation, Context.SERVICE);
            if (declared instanceof RpcElement) {
                rpcs.add((RpcElement) declared);
            } else if (declared instanceof OptionElement) {
                options.add((OptionElement) declared);
            }
        }
        return builder.options(options.build()).rpcs(rpcs.build()).build();
    }

    /** Reads an enumerated type declaration and returns it. */
    private EnumElement readEnumElement(Location location, String documentation) {
        String name = readName();
        EnumElement.Builder builder = EnumElement.builder(location).name(name).documentation(documentation);

        if (readChar() != '{')
            throw unexpected("expected '{'");
        ImmutableList.Builder<EnumConstantElement> constants = ImmutableList.builder();
        ImmutableList.Builder<OptionElement> options = ImmutableList.builder();
        while (true) {
            String valueDocumentation = readDocumentation();
            if (peekChar() == '}') {
                pos++;
                break;
            }
            Object declared = readDeclaration(valueDocumentation, Context.ENUM);
            if (declared instanceof EnumConstantElement) {
                constants.add((EnumConstantElement) declared);
            } else if (declared instanceof OptionElement) {
                options.add((OptionElement) declared);
            }
        }
        return builder.options(options.build()).constants(constants.build()).build();
    }

    private Object readField(String documentation, Location location, String word) {
        Field.Label label;
        String type;
        switch (word) {
        case "required":
            if (syntax == ProtoFile.Syntax.PROTO_3) {
                throw unexpected(location, "'required' label forbidden in proto3 field declarations");
            }
            label = Field.Label.REQUIRED;
            type = readDataType();
            break;

        case "optional":
            if (syntax == ProtoFile.Syntax.PROTO_3) {
                throw unexpected(location, "'optional' label forbidden in proto3 field declarations");
            }
            label = Field.Label.OPTIONAL;
            type = readDataType();
            break;

        case "repeated":
            label = Field.Label.REPEATED;
            type = readDataType();
            break;

        default:
            if (syntax != ProtoFile.Syntax.PROTO_3 && (!word.equals("map") || peekChar() != '<')) {
                throw unexpected(location, "unexpected label: " + word);
            }
            label = null;
            type = readDataType(word);
            break;
        }

        if (type.startsWith("map<") && label != null) {
            throw unexpected(location, "'map' type cannot have label");
        }
        if (type.equals("group")) {
            return readGroup(documentation, label);
        }

        return readField(location, documentation, label, type);
    }

    /** Reads an field declaration and returns it. */
    private FieldElement readField(Location location, String documentation, @Nullable Field.Label label,
            String type) {
        String name = readName();
        if (readChar() != '=')
            throw unexpected("expected '='");
        int tag = readInt();

        FieldElement.Builder builder = FieldElement.builder(location).label(label).type(type).name(name).tag(tag);

        ImmutableList.Builder<OptionElement> options = ImmutableList.builder();
        if (peekChar() == '[') {
            pos++;
            while (true) {
                OptionElement option = readOption('=');
                if (option.name().equals("default")) {
                    builder.defaultValue(String.valueOf(option.value())); // Defaults aren't options!
                } else {
                    options.add(option);
                }

                // Check for optional ',' or closing ']'
                char c = peekChar();
                if (c == ']') {
                    pos++;
                    break;
                } else if (c == ',') {
                    pos++;
                }
            }
        }
        if (readChar() != ';') {
            throw unexpected("expected ';'");
        }
        documentation = tryAppendTrailingDocumentation(documentation);
        return builder.documentation(documentation).options(options.build()).build();
    }

    private OneOfElement readOneOf(String documentation) {
        OneOfElement.Builder builder = OneOfElement.builder().name(readName()).documentation(documentation);
        ImmutableList.Builder<FieldElement> fields = ImmutableList.builder();
        ImmutableList.Builder<GroupElement> groups = ImmutableList.builder();

        if (readChar() != '{')
            throw unexpected("expected '{'");
        while (true) {
            String nestedDocumentation = readDocumentation();
            if (peekChar() == '}') {
                pos++;
                break;
            }
            Location location = location();
            String type = readDataType();
            if (type.equals("group")) {
                groups.add(readGroup(nestedDocumentation, null));
            } else {
                fields.add(readField(location, nestedDocumentation, null, type));
            }
        }
        return builder.fields(fields.build()).groups(groups.build()).build();
    }

    private GroupElement readGroup(String documentation, Field.Label label) {
        String name = readWord();
        if (readChar() != '=') {
            throw unexpected("expected '='");
        }
        int tag = readInt();

        GroupElement.Builder builder = GroupElement.builder().label(label).name(name).tag(tag)
                .documentation(documentation);
        ImmutableList.Builder<FieldElement> fields = ImmutableList.builder();

        if (readChar() != '{')
            throw unexpected("expected '{'");
        while (true) {
            String nestedDocumentation = readDocumentation();
            if (peekChar() == '}') {
                pos++;
                break;
            }
            Location location = location();
            String fieldLabel = readWord();
            Object field = readField(nestedDocumentation, location, fieldLabel);
            if (!(field instanceof FieldElement)) {
                throw unexpected("expected field declaration, was " + field);
            }
            fields.add((FieldElement) field);
        }

        return builder.fields(fields.build()).build();
    }

    /** Reads a reserved tags and names list like "reserved 10, 12 to 14, 'foo';". */
    private ReservedElement readReserved(Location location, String documentation) {
        ImmutableList.Builder<Object> valuesBuilder = ImmutableList.builder();

        while (true) {
            char c = peekChar();
            if (c == '"' || c == '\'') {
                valuesBuilder.add(readQuotedString());
            } else {
                int tagStart = readInt();

                c = peekChar();
                if (c != ',' && c != ';') {
                    if (!readWord().equals("to")) {
                        throw unexpected("expected ',', ';', or 'to'");
                    }
                    int tagEnd = readInt();
                    valuesBuilder.add(Range.closed(tagStart, tagEnd));
                } else {
                    valuesBuilder.add(tagStart);
                }
            }
            c = readChar();
            if (c == ';')
                break;
            if (c != ',')
                throw unexpected("expected ',' or ';'");
        }

        ImmutableList<Object> values = valuesBuilder.build();
        if (values.isEmpty()) {
            throw unexpected("'reserved' must have at least one field name or tag");
        }
        return ReservedElement.create(location, documentation, values);
    }

    /** Reads extensions like "extensions 101;" or "extensions 101 to max;". */
    private ExtensionsElement readExtensions(Location location, String documentation) {
        int start = readInt(); // Range start.
        int end = start;
        if (peekChar() != ';') {
            if (!"to".equals(readWord()))
                throw unexpected("expected ';' or 'to'");
            String s = readWord(); // Range end.
            if (s.equals("max")) {
                end = Util.MAX_TAG_VALUE;
            } else {
                end = Integer.parseInt(s);
            }
        }
        if (readChar() != ';')
            throw unexpected("expected ';'");
        return ExtensionsElement.create(location, start, end, documentation);
    }

    /** Reads a option containing a name, an '=' or ':', and a value. */
    private OptionElement readOption(char keyValueSeparator) {
        boolean isExtension = (peekChar() == '[');
        boolean isParenthesized = (peekChar() == '(');
        String name = readName(); // Option name.
        if (isExtension) {
            name = "[" + name + "]";
        }
        String subName = null;
        char c = readChar();
        if (c == '.') {
            // Read nested field name. For example "baz" in "(foo.bar).baz = 12".
            subName = readName();
            c = readChar();
        }
        if (keyValueSeparator == ':' && c == '{') {
            // In text format, values which are maps can omit a separator. Backtrack so it can be re-read.
            pos--;
        } else if (c != keyValueSeparator) {
            throw unexpected("expected '" + keyValueSeparator + "' in option");
        }
        OptionKindAndValue kindAndValue = readKindAndValue();
        OptionElement.Kind kind = kindAndValue.kind();
        Object value = kindAndValue.value();
        if (subName != null) {
            value = OptionElement.create(subName, kind, value);
            kind = OptionElement.Kind.OPTION;
        }
        return OptionElement.create(name, kind, value, isParenthesized);
    }

    @AutoValue
    abstract static class OptionKindAndValue {
        static OptionKindAndValue of(OptionElement.Kind kind, Object value) {
            return new AutoValue_ProtoParser_OptionKindAndValue(kind, value);
        }

        abstract OptionElement.Kind kind();

        abstract Object value();
    }

    /** Reads a value that can be a map, list, string, number, boolean or enum. */
    private OptionKindAndValue readKindAndValue() {
        char peeked = peekChar();
        switch (peeked) {
        case '{':
            return OptionKindAndValue.of(OptionElement.Kind.MAP, readMap('{', '}', ':'));
        case '[':
            return OptionKindAndValue.of(OptionElement.Kind.LIST, readList());
        case '"':
        case '\'':
            return OptionKindAndValue.of(OptionElement.Kind.STRING, readString());
        default:
            if (Character.isDigit(peeked) || peeked == '-') {
                return OptionKindAndValue.of(OptionElement.Kind.NUMBER, readWord());
            }
            String word = readWord();
            switch (word) {
            case "true":
                return OptionKindAndValue.of(OptionElement.Kind.BOOLEAN, "true");
            case "false":
                return OptionKindAndValue.of(OptionElement.Kind.BOOLEAN, "false");
            default:
                return OptionKindAndValue.of(OptionElement.Kind.ENUM, word);
            }
        }
    }

    /**
     * Returns a map of string keys and values. This is similar to a JSON object,
     * with '{' and '}' surrounding the map, ':' separating keys from values, and
     * ',' separating entries.
     */
    @SuppressWarnings("unchecked")
    private Map<String, Object> readMap(char openBrace, char closeBrace, char keyValueSeparator) {
        if (readChar() != openBrace)
            throw new AssertionError();
        Map<String, Object> result = new LinkedHashMap<>();
        while (true) {
            if (peekChar() == closeBrace) {
                // If we see the close brace, finish immediately. This handles {}/[] and ,}/,] cases.
                pos++;
                return result;
            }

            OptionElement option = readOption(keyValueSeparator);
            String name = option.name();
            Object value = option.value();
            if (value instanceof OptionElement) {
                @SuppressWarnings("unchecked")
                Map<String, Object> nested = (Map<String, Object>) result.get(name);
                if (nested == null) {
                    nested = new LinkedHashMap<>();
                    result.put(name, nested);
                }
                OptionElement valueOption = (OptionElement) value;
                nested.put(valueOption.name(), valueOption.value());
            } else {
                // Add the value(s) to any previous values with the same key
                Object previous = result.get(name);
                if (previous == null) {
                    result.put(name, value);
                } else if (previous instanceof List) {
                    // Add to previous List
                    addToList((List<Object>) previous, value);
                } else {
                    List<Object> newList = new ArrayList<>();
                    newList.add(previous);
                    addToList(newList, value);
                    result.put(name, newList);
                }
            }

            // ',' separator is optional, skip if present
            if (peekChar() == ',') {
                pos++;
            }
        }
    }

    /**
     * Adds an object or objects to a List.
     */
    private void addToList(List<Object> list, Object value) {
        if (value instanceof List) {
            list.addAll((List) value);
        } else {
            list.add(value);
        }
    }

    /**
     * Returns a list of values. This is similar to JSON with '[' and ']'
     * surrounding the list and ',' separating values.
     */
    private List<Object> readList() {
        if (readChar() != '[')
            throw new AssertionError();
        List<Object> result = new ArrayList<>();
        while (true) {
            if (peekChar() == ']') {
                // If we see the close brace, finish immediately. This handles [] and ,] cases.
                pos++;
                return result;
            }

            result.add(readKindAndValue().value());

            char c = peekChar();
            if (c == ',') {
                pos++;
            } else if (c != ']') {
                throw unexpected("expected ',' or ']'");
            }
        }
    }

    /** Reads an rpc and returns it. */
    private RpcElement readRpc(Location location, String documentation) {
        RpcElement.Builder builder = RpcElement.builder(location).name(readName()).documentation(documentation);

        if (readChar() != '(')
            throw unexpected("expected '('");
        String requestType = readDataType();
        builder.requestType(requestType);
        if (readChar() != ')')
            throw unexpected("expected ')'");

        if (!readWord().equals("returns"))
            throw unexpected("expected 'returns'");

        if (readChar() != '(')
            throw unexpected("expected '('");
        String responseType = readDataType();
        builder.responseType(responseType);
        if (readChar() != ')')
            throw unexpected("expected ')'");

        if (peekChar() == '{') {
            ImmutableList.Builder<OptionElement> options = ImmutableList.builder();
            pos++;
            while (true) {
                String rpcDocumentation = readDocumentation();
                if (peekChar() == '}') {
                    pos++;
                    break;
                }
                Object declared = readDeclaration(rpcDocumentation, Context.RPC);
                if (declared instanceof OptionElement) {
                    options.add((OptionElement) declared);
                }
            }
            builder.options(options.build());
        } else if (readChar() != ';')
            throw unexpected("expected ';'");

        return builder.build();
    }

    /** Reads a non-whitespace character and returns it. */
    private char readChar() {
        char result = peekChar();
        pos++;
        return result;
    }

    /**
     * Peeks a non-whitespace character and returns it. The only difference
     * between this and {@code readChar} is that this doesn't consume the char.
     */
    private char peekChar() {
        skipWhitespace(true);
        if (pos == data.length)
            throw unexpected("unexpected end of file");
        return data[pos];
    }

    /** Reads a quoted or unquoted string and returns it. */
    private String readString() {
        skipWhitespace(true);
        char c = peekChar();
        return c == '"' || c == '\'' ? readQuotedString() : readWord();
    }

    private String readQuotedString() {
        char startQuote = readChar();
        if (startQuote != '"' && startQuote != '\'')
            throw new AssertionError();
        StringBuilder result = new StringBuilder();
        while (pos < data.length) {
            char c = data[pos++];
            if (c == startQuote) {
                if (peekChar() == '"' || peekChar() == '\'') {
                    // Adjacent strings are concatenated. Consume new quote and continue reading.
                    startQuote = readChar();
                    continue;
                }
                return result.toString();
            }

            if (c == '\\') {
                if (pos == data.length)
                    throw unexpected("unexpected end of file");
                c = data[pos++];
                switch (c) {
                case 'a':
                    c = 0x7;
                    break;
                case 'b':
                    c = '\b';
                    break;
                case 'f':
                    c = '\f';
                    break;
                case 'n':
                    c = '\n';
                    break;
                case 'r':
                    c = '\r';
                    break;
                case 't':
                    c = '\t';
                    break;
                case 'v':
                    c = 0xb;
                    break;
                case 'x':
                case 'X':
                    c = readNumericEscape(16, 2);
                    break;
                case '0':
                case '1':
                case '2':
                case '3':
                case '4':
                case '5':
                case '6':
                case '7':
                    --pos;
                    c = readNumericEscape(8, 3);
                    break;
                default:
                    // use char as-is
                    break;
                }
            }

            result.append(c);
            if (c == '\n')
                newline();
        }
        throw unexpected("unterminated string");
    }

    private char readNumericEscape(int radix, int len) {
        int value = -1;
        for (int endPos = Math.min(pos + len, data.length); pos < endPos; pos++) {
            int digit = hexDigit(data[pos]);
            if (digit == -1 || digit >= radix)
                break;
            if (value < 0) {
                value = digit;
            } else {
                value = value * radix + digit;
            }
        }
        if (value < 0)
            throw unexpected("expected a digit after \\x or \\X");
        return (char) value;
    }

    private int hexDigit(char c) {
        if (c >= '0' && c <= '9')
            return c - '0';
        else if (c >= 'a' && c <= 'f')
            return c - 'a' + 10;
        else if (c >= 'A' && c <= 'F')
            return c - 'A' + 10;
        else
            return -1;
    }

    /** Reads a (paren-wrapped), [square-wrapped] or naked symbol name. */
    private String readName() {
        String optionName;
        char c = peekChar();
        if (c == '(') {
            pos++;
            optionName = readWord();
            if (readChar() != ')')
                throw unexpected("expected ')'");
        } else if (c == '[') {
            pos++;
            optionName = readWord();
            if (readChar() != ']')
                throw unexpected("expected ']'");
        } else {
            optionName = readWord();
        }
        return optionName;
    }

    /** Reads a scalar, map, or type name. */
    private String readDataType() {
        String name = readWord();
        return readDataType(name);
    }

    /** Reads a scalar, map, or type name with {@code name} as a prefix word. */
    private String readDataType(String name) {
        if (name.equals("map")) {
            if (readChar() != '<')
                throw unexpected("expected '<'");
            String keyType = readDataType();
            if (readChar() != ',')
                throw unexpected("expected ','");
            String valueType = readDataType();
            if (readChar() != '>')
                throw unexpected("expected '>'");
            return String.format("map<%s, %s>", keyType, valueType);
        } else {
            return name;
        }
    }

    /** Reads a non-empty word and returns it. */
    private String readWord() {
        skipWhitespace(true);
        int start = pos;
        while (pos < data.length) {
            char c = data[pos];
            if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || (c == '_')
                    || (c == '-') || (c == '.')) {
                pos++;
            } else {
                break;
            }
        }
        if (start == pos)
            throw unexpected("expected a word");
        return new String(data, start, pos - start);
    }

    /** Reads an integer and returns it. */
    private int readInt() {
        String tag = readWord();
        try {
            int radix = 10;
            if (tag.startsWith("0x") || tag.startsWith("0X")) {
                tag = tag.substring("0x".length());
                radix = 16;
            }
            return Integer.valueOf(tag, radix);
        } catch (Exception e) {
            throw unexpected("expected an integer but was " + tag);
        }
    }

    /**
     * Like {@link #skipWhitespace}, but this returns a string containing all
     * comment text. By convention, comments before a declaration document that
     * declaration.
     */
    private String readDocumentation() {
        String result = null;
        while (true) {
            skipWhitespace(false);
            if (pos == data.length || data[pos] != '/') {
                return result != null ? result : "";
            }
            String comment = readComment();
            result = (result == null) ? comment : (result + "\n" + comment);
        }
    }

    /** Reads a comment and returns its body. */
    private String readComment() {
        if (pos == data.length || data[pos] != '/')
            throw new AssertionError();
        pos++;
        int commentType = pos < data.length ? data[pos++] : -1;
        if (commentType == '*') {
            StringBuilder result = new StringBuilder();
            boolean startOfLine = true;

            for (; pos + 1 < data.length; pos++) {
                char c = data[pos];
                if (c == '*' && data[pos + 1] == '/') {
                    pos += 2;
                    return result.toString().trim();
                }
                if (c == '\n') {
                    result.append('\n');
                    newline();
                    startOfLine = true;
                } else if (!startOfLine) {
                    result.append(c);
                } else if (c == '*') {
                    if (data[pos + 1] == ' ') {
                        pos += 1; // Skip a single leading space, if present.
                    }
                    startOfLine = false;
                } else if (!Character.isWhitespace(c)) {
                    result.append(c);
                    startOfLine = false;
                }
            }
            throw unexpected("unterminated comment");
        } else if (commentType == '/') {
            if (pos < data.length && data[pos] == ' ') {
                pos += 1; // Skip a single leading space, if present.
            }
            int start = pos;
            while (pos < data.length) {
                char c = data[pos++];
                if (c == '\n') {
                    newline();
                    break;
                }
            }
            return new String(data, start, pos - 1 - start);
        } else {
            throw unexpected("unexpected '/'");
        }
    }

    private String tryAppendTrailingDocumentation(String documentation) {
        // Search for a '/' character ignoring spaces and tabs.
        while (pos < data.length) {
            char c = data[pos];
            if (c == ' ' || c == '\t') {
                pos++;
            } else if (c == '/') {
                pos++;
                break;
            } else {
                // Not a whitespace or comment-starting character. Return original documentation.
                return documentation;
            }
        }

        if (pos == data.length || (data[pos] != '/' && data[pos] != '*')) {
            pos--; // Backtrack to start of comment.
            throw unexpected("expected '//' or '/*'");
        }
        boolean isStar = data[pos] == '*';
        pos++;

        if (pos < data.length && data[pos] == ' ') {
            pos++; // Skip a single leading space, if present.
        }

        int start = pos;
        int end;

        if (isStar) {
            // Consume star comment until it closes on the same line.
            while (true) {
                if (pos == data.length || data[pos] == '\n') {
                    throw unexpected("trailing comment must be closed on the same line");
                }
                if (data[pos] == '*' && pos + 1 < data.length && data[pos + 1] == '/') {
                    end = pos - 1; // The character before '*'.
                    pos += 2; // Skip to the character after '/'.
                    break;
                }
                pos++;
            }
            // Ensure nothing follows a trailing star comment.
            while (pos < data.length) {
                char c = data[pos++];
                if (c == '\n') {
                    newline();
                    break;
                }
                if (c != ' ' && c != '\t') {
                    throw unexpected("no syntax may follow trailing comment");
                }
            }
        } else {
            // Consume comment until newline.
            while (true) {
                if (pos == data.length) {
                    end = pos - 1;
                    break;
                }
                char c = data[pos++];
                if (c == '\n') {
                    newline();
                    end = pos - 2; // Account for stepping past the newline.
                    break;
                }
            }
        }

        // Remove trailing whitespace.
        while (end > start && (data[end] == ' ' || data[end] == '\t')) {
            end--;
        }

        if (end == start) {
            return documentation;
        }
        String trailingDocumentation = new String(data, start, end - start + 1);
        if (documentation.isEmpty()) {
            return trailingDocumentation;
        }
        return documentation + '\n' + trailingDocumentation;
    }

    /**
     * Skips whitespace characters and optionally comments. When this returns,
     * either {@code pos == data.length} or a non-whitespace character.
     */
    private void skipWhitespace(boolean skipComments) {
        while (pos < data.length) {
            char c = data[pos];
            if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
                pos++;
                if (c == '\n')
                    newline();
            } else if (skipComments && c == '/') {
                readComment();
            } else {
                break;
            }
        }
    }

    /** Call this every time a '\n' is encountered. */
    private void newline() {
        line++;
        lineStart = pos;
    }

    private Location location() {
        return location.at(line + 1, pos - lineStart + 1);
    }

    private RuntimeException unexpected(String message) {
        return unexpected(location(), message);
    }

    private RuntimeException unexpected(Location location, String message) {
        throw new IllegalStateException(String.format("Syntax error in %s: %s", location, message));
    }

    enum Context {
        FILE, MESSAGE, ENUM, RPC, EXTEND, SERVICE;

        public boolean permitsPackage() {
            return this == FILE;
        }

        public boolean permitsSyntax() {
            return this == FILE;
        }

        public boolean permitsImport() {
            return this == FILE;
        }

        public boolean permitsExtensions() {
            return this != FILE;
        }

        public boolean permitsRpc() {
            return this == SERVICE;
        }

        public boolean permitsOneOf() {
            return this == MESSAGE;
        }
    }
}