PrettySerializer.java

/*
 * Copyright 2015-2016 Providence Authors
 *
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements. See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership. The ASF licenses this file
 * to you 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 net.morimekta.providence.serializer;

import net.morimekta.providence.PApplicationException;
import net.morimekta.providence.PApplicationExceptionType;
import net.morimekta.providence.PEnumBuilder;
import net.morimekta.providence.PEnumValue;
import net.morimekta.providence.PMessage;
import net.morimekta.providence.PMessageBuilder;
import net.morimekta.providence.PMessageOrBuilder;
import net.morimekta.providence.PServiceCall;
import net.morimekta.providence.PServiceCallType;
import net.morimekta.providence.PUnion;
import net.morimekta.providence.descriptor.PContainer;
import net.morimekta.providence.descriptor.PDescriptor;
import net.morimekta.providence.descriptor.PEnumDescriptor;
import net.morimekta.providence.descriptor.PField;
import net.morimekta.providence.descriptor.PList;
import net.morimekta.providence.descriptor.PMap;
import net.morimekta.providence.descriptor.PMessageDescriptor;
import net.morimekta.providence.descriptor.PPrimitive;
import net.morimekta.providence.descriptor.PService;
import net.morimekta.providence.descriptor.PServiceMethod;
import net.morimekta.providence.descriptor.PSet;
import net.morimekta.providence.serializer.pretty.PrettyException;
import net.morimekta.providence.serializer.pretty.PrettyLexer;
import net.morimekta.providence.serializer.pretty.PrettyToken;
import net.morimekta.providence.serializer.pretty.PrettyTokenizer;
import net.morimekta.util.Binary;
import net.morimekta.util.Strings;
import net.morimekta.util.io.CountingOutputStream;
import net.morimekta.util.io.IndentedPrintWriter;
import net.morimekta.util.lexer.LexerException;
import net.morimekta.util.lexer.UncheckedLexerException;

import javax.annotation.Nonnull;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UncheckedIOException;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;

import static java.nio.charset.StandardCharsets.UTF_8;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kFieldSep;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kFieldValueSep;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kKeyValueSep;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kListEnd;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kListSep;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kListStart;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kLiteralDoubleQuote;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kMessageEnd;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kMessageStart;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kParamsEnd;
import static net.morimekta.providence.serializer.pretty.PrettyToken.kParamsStart;

/**
 * Pretty printer that can print message content for easily reading and
 * debugging. This is a write only format used in stringifying messages.
 */
public class PrettySerializer extends Serializer {
    public final static String MEDIA_TYPE = "text/plain";

    private static final PrettySerializer DEBUG_STRING_SERIALIZER = new PrettySerializer();

    private final static String INDENT   = "  ";
    private final static String SPACE    = " ";
    private final static String NEWLINE  = "\n";
    private final static String LIST_SEP = ",";

    private final String  indent;
    private final String  space;
    private final String  newline;
    private final String  entrySep;
    private final boolean strict;
    private final boolean prefixWithQualifiedName;

    /**
     * Prints a pretty formatted string that is optimized for diffing (mainly
     * for testing and debugging).
     *
     * @param message The message to stringify.
     * @param <Message> The message type.
     * @return The resulting string.
     */
    @Nonnull
    public static <Message extends PMessage<Message>>
    String toDebugString(PMessageOrBuilder<Message> message) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        DEBUG_STRING_SERIALIZER.serialize(out, message);
        return new String(out.toByteArray(), UTF_8);
    }

    /**
     * Parses a pretty formatted string, and makes exceptions unchecked.
     *
     * @param string The message string to parse.
     * @param descriptor The message descriptor.
     * @param <Message> The message type.
     * @return The parsed message.
     */
    @Nonnull
    public static <Message extends PMessage<Message>>
    Message parseDebugString(String string, PMessageDescriptor<Message> descriptor) {
        try {
            ByteArrayInputStream in = new ByteArrayInputStream(string.getBytes(UTF_8));
            return DEBUG_STRING_SERIALIZER.deserialize(in, descriptor);
        } catch (LexerException e) {
            throw new UncheckedLexerException(e);
        } catch (IOException e) {
            throw new UncheckedIOException(e.getMessage(), e);
        }
    }

    public PrettySerializer() {
        this(DEFAULT_STRICT);
    }

    public PrettySerializer(boolean strict) {
        this(INDENT, SPACE, NEWLINE, "", strict, false);
    }

    /**
     * Make a PrettySerializer that generates content similar to the PMessage asString methods.
     * The output of this has <b>very little</b> whitespace, so can be pretty difficult to read.
     * It's similar to the {@link #string()} variant, but without the qualified name prefix.
     *
     * @return Compact pretty serializer.
     */
    public PrettySerializer compact() {
        return new PrettySerializer("", "", "", LIST_SEP, strict, false);
    }

    /**
     * Make a PrettySerializer that generates content similar to the PMessage toString methods.
     * The output of this has <b>very little</b> whitespace, so can be pretty difficult to read.
     * It prefixes the message with the root message qualified name, as any
     * {@link PMessage}.toString() would expect.
     *
     * @return String pretty serializer.
     */
    public PrettySerializer string() {
        return new PrettySerializer("", "", "", LIST_SEP, strict, true);
    }

    /**
     * Make a PrettySerializer that generates content similar to what the ProvidenceConfig
     * reads. It will not make use of  references or anything fancy though.
     *
     * @return Config-like pretty serializer.
     */
    public PrettySerializer config() {
        return new PrettySerializer(indent,
                                    space,
                                    newline,
                                    entrySep,
                                    strict,
                                    true);
    }

    private PrettySerializer(String indent,
                             String space,
                             String newline,
                             String entrySep,
                             boolean strict,
                             boolean prefixWithQualifiedName) {
        this.indent = indent;
        this.space = space;
        this.newline = newline;
        this.entrySep = entrySep;
        this.strict = strict;
        this.prefixWithQualifiedName = prefixWithQualifiedName;
    }

    public <Message extends PMessage<Message>>
    int serialize(@Nonnull OutputStream out, @Nonnull PMessageOrBuilder<Message> message) {
        CountingOutputStream cout = new CountingOutputStream(out);
        IndentedPrintWriter builder = new IndentedPrintWriter(cout, indent, newline);
        if (prefixWithQualifiedName) {
            builder.append(message.descriptor().getQualifiedName())
                   .append(space);
        }
        appendMessage(builder, message, false);
        builder.flush();
        return cout.getByteCount();
    }

    @Override
    public <Message extends PMessage<Message>>
    int serialize(@Nonnull OutputStream out, @Nonnull PServiceCall<Message> call) {
        CountingOutputStream cout = new CountingOutputStream(out);
        IndentedPrintWriter builder = new IndentedPrintWriter(cout, indent, newline);

        if (call.getSequence() != 0) {
            builder.format("%d: ", call.getSequence());
        }
        builder.format("%s %s",
                       call.getType().asString().toLowerCase(Locale.US),
                       call.getMethod())
               .begin(indent + indent);

        appendMessage(builder, call.getMessage(), true);

        builder.end()
               .newline()
               .flush();

        return cout.getByteCount();
    }

    @Nonnull
    @Override
    @SuppressWarnings("unchecked")
    public <Message extends PMessage<Message>>
    PServiceCall<Message> deserialize(
            @Nonnull InputStream input,
            @Nonnull PService service) throws IOException {
        String methodName = null;
        int sequence = 0;
        PServiceCallType callType = null;
        try {
            // pretty printed service calls cannot be chained-serialized, so this should be totally safe.
            PrettyLexer lexer     = new PrettyLexer(input);

            PrettyToken token = lexer.expect("Sequence or type");
            if (token.isInteger()) {
                sequence = (int) token.parseInteger();
                lexer.expect("Sequence type sep", t -> t.isSymbol(kKeyValueSep));
                token = lexer.expect("Call Type", PrettyToken::isIdentifier);
            }
            callType = PServiceCallType.findByName(token.toString().toUpperCase(Locale.US));
            if (callType == null) {
                throw new PrettyException(token, "No such call type %s", token)
                        .setLine(token.line())
                        .setExceptionType(PApplicationExceptionType.INVALID_MESSAGE_TYPE);
            }

            token = lexer.expect("method name", PrettyToken::isIdentifier);
            methodName = token.toString();

            PServiceMethod method = service.getMethod(methodName);
            if (method == null) {
                throw new PrettyException(token, "no such method %s on service %s",
                                          methodName, service.getQualifiedName())
                        .setLine(token.line())
                        .setExceptionType(PApplicationExceptionType.UNKNOWN_METHOD);
            }

            lexer.expect("call params start", t -> t.isSymbol(kParamsStart));

            Message message;
            switch (callType) {
                case CALL:
                case ONEWAY:
                    message = (Message) readMessage(
                            lexer,
                            method.getRequestType(),
                            true);
                    break;
                case REPLY:
                    message = (Message) readMessage(
                            lexer,
                            Optional.ofNullable(method.getResponseType())
                                    .orElseThrow(() -> new PrettyException("No reply type for method %s", method.getName())),
                            true);
                    break;
                case EXCEPTION:
                    message = (Message) readMessage(
                            lexer,
                            PApplicationException.kDescriptor,
                            true);
                    break;
                default:
                    throw new IllegalStateException("Unreachable code reached");
            }

            return new PServiceCall<>(methodName, callType, sequence, message);
        } catch (LexerException e) {
            throw new PrettyException(e, e.getMessage());
        } catch (PrettyException e) {
            e.setCallType(callType)
             .setSequenceNo(sequence)
             .setMethodName(methodName);
            throw e;
        } catch (IOException e) {
            throw new SerializerException(e, e.getMessage())
                    .setCallType(callType)
                    .setSequenceNo(sequence)
                    .setMethodName(methodName);
        }
    }

    @Nonnull
    @Override
    public <Message extends PMessage<Message>>
    Message deserialize(@Nonnull InputStream input,
                        @Nonnull PMessageDescriptor<Message> descriptor)
            throws IOException {
        try {
            PrettyTokenizer    tokenizer = new PrettyTokenizer(input);
            PrettyLexer lexer     = new PrettyLexer(tokenizer);
            PrettyToken        first     = lexer.peek("start of message");

            if (first.isSymbol(kMessageStart)) {
                lexer.next();
            } else if (first.isQualifiedIdentifier()) {
                if (first.toString().equals(descriptor.getQualifiedName())) {
                    lexer.next();  // skip the name
                    lexer.expectSymbol("message start after qualifier", kMessageStart);
                } else {
                    throw tokenizer.failure(first, "Expected qualifier " + descriptor.getQualifiedName() +
                                                   " or message start, but got '" + first + "'");
                }
            } else {
                throw tokenizer.failure(first, "Expected message start or qualifier, but got '" + first + "'");
            }
            return readMessage(lexer, descriptor, false);
        } catch (LexerException e) {
            throw new PrettyException(e, e.getMessage());
        }
    }

    private <Message extends PMessage<Message>>
    Message readMessage(PrettyLexer tokenizer,
                        PMessageDescriptor<Message> descriptor,
                        boolean params)
            throws IOException {
        PMessageBuilder<Message> builder = descriptor.builder();

        PrettyToken token = tokenizer.expect("message field or end");
        for (;;) {
            if (params) {
                if (token.isSymbol(kParamsEnd)) {
                    break;
                }
            } else if (token.isSymbol(kMessageEnd)) {
                break;
            }

            if (!token.isIdentifier()) {
                throw new PrettyException(token, "Expected field name, but got '%s'",
                                          Strings.escape(token))
                        .setLine(token.line());
            }
            PField field = descriptor.findFieldByName(token.toString());

            tokenizer.expectSymbol("field value separator", kFieldValueSep);

            if (field == null) {
                consumeValue(tokenizer, tokenizer.expect("field value"));
            } else {
                builder.set(field.getId(), readFieldValue(
                        tokenizer, tokenizer.expect("field value"), field.getDescriptor()));
            }

            token = tokenizer.expect("Message field or end");
            if (token.isSymbol(kListSep) || token.isSymbol(kFieldSep)) {
                token = tokenizer.expect("Message field or end");
            }
        }
        return builder.build();
    }

    private Object readFieldValue(PrettyLexer lexer, PrettyToken token, PDescriptor descriptor)
            throws IOException {
        switch (descriptor.getType()) {
            case VOID: {
                // Even void fields needs a value token...
                // Allow any boolean true value that is an _identifier_. No numbers here.
                switch (token.toString().toLowerCase(Locale.US)) {
                    case "1":
                    case "t":
                    case "true":
                    case "y":
                    case "yes":
                        return Boolean.TRUE;
                }
                throw new PrettyException(token, "Invalid void value " + token)
                        .setLine(token.line());
            }
            case BOOL: {
                switch (token.toString().toLowerCase(Locale.US)) {
                    case "1":
                    case "t":
                    case "true":
                    case "y":
                    case "yes":
                        return Boolean.TRUE;
                    case "0":
                    case "f":
                    case "false":
                    case "n":
                    case "no":
                        return Boolean.FALSE;
                }
                throw new PrettyException(token, "Invalid boolean value " + token)
                        .setLine(token.line());

            }
            case BYTE: {
                if (token.isInteger()) {
                    long val = token.parseInteger();
                    if (val > Byte.MAX_VALUE || val < Byte.MIN_VALUE) {
                        throw new PrettyException(token, "Byte value out of bounds: " + token)
                                .setLine(token.line());
                    }
                    return (byte) val;
                } else {
                    throw new PrettyException(token, "Invalid byte value: " + token)
                            .setLine(token.line());
                }
            }
            case I16: {
                if (token.isInteger()) {
                    long val = token.parseInteger();
                    if (val > Short.MAX_VALUE || val < Short.MIN_VALUE) {
                        throw new PrettyException(token, "Short value out of bounds: " + token)
                                .setLine(token.line());
                    }
                    return (short) val;
                } else {
                    throw new PrettyException(token, "Invalid byte value: " + token)
                            .setLine(token.line());
                }
            }
            case I32: {
                if (token.isInteger()) {
                    long val = token.parseInteger();
                    if (val > Integer.MAX_VALUE || val < Integer.MIN_VALUE) {
                        throw new PrettyException(token, "Integer value out of bounds: " + token)
                                .setLine(token.line());
                    }
                    return (int) val;
                } else {
                    throw new PrettyException(token, "Invalid byte value: " + token)
                            .setLine(token.line());
                }
            }
            case I64: {
                if (token.isInteger()) {
                    return token.parseInteger();
                } else {
                    throw new PrettyException(token, "Invalid byte value: " + token)
                            .setLine(token.line());
                }
            }
            case DOUBLE: {
                try {
                    return token.parseDouble();
                } catch (NumberFormatException nfe) {
                    throw new PrettyException(token, "Number format error: " + nfe.getMessage())
                            .setLine(token.line());
                }
            }
            case STRING: {
                if (!token.isStringLiteral()) {
                    throw new PrettyException(token, "Expected string literal, but got '%s'", token)
                            .setLine(token.line());
                }
                return token.decodeString(strict);
            }
            case BINARY: {
                switch (token.toString()) {
                    case "b64": {
                        lexer.expectSymbol("binary content start", kParamsStart);
                        PrettyToken binary  = lexer.readBinary(kParamsEnd);
                        if (binary == null) {
                            return Binary.empty();
                        }
                        try {
                            return Binary.fromBase64(binary.toString().replaceAll("[\\s\\n=]*", ""));
                        } catch (IllegalArgumentException e) {
                            throw lexer.failure(binary, e.getMessage()).initCause(e);
                        }
                    }
                    case "hex": {
                        lexer.expectSymbol("binary content start", kParamsStart);
                        PrettyToken binary  = lexer.readBinary(kParamsEnd);
                        if (binary == null) {
                            return Binary.empty();
                        }
                        try {
                            return Binary.fromHexString(binary.toString().replaceAll("[\\s\\n]*", ""));
                        } catch (IllegalArgumentException e) {
                            throw lexer.failure(binary, e.getMessage()).initCause(e);
                        }
                    }
                    default:
                        throw new PrettyException(token, "Unrecognized binary format " + token)
                                .setLine(token.line());
                }
            }
            case ENUM: {
                PEnumBuilder b = ((PEnumDescriptor) descriptor).builder();
                b.setByName(token.toString());
                if (strict && !b.valid()) {
                    throw new PrettyException(token, "No such " + descriptor.getQualifiedName() + " value " + token)
                            .setLine(token.line());
                }
                return b.build();
            }
            case MESSAGE: {
                if (!token.isSymbol(kMessageStart)) {
                    throw new PrettyException(token, "Expected message start, but got '%s'", token)
                            .setLine(token.line());
                }
                return readMessage(lexer, (PMessageDescriptor<?>) descriptor, false);
            }
            case MAP: {
                if (!token.isSymbol(kMessageStart)) {
                    throw new PrettyException(token, "Expected map start, but got '%s'", token)
                            .setLine(token.line());
                }
                @SuppressWarnings("unchecked")
                PMap<Object, Object> pMap = (PMap) descriptor;
                PDescriptor kDesc = pMap.keyDescriptor();
                PDescriptor iDesc = pMap.itemDescriptor();

                PMap.Builder<Object, Object> builder = pMap.builder(10);

                token = lexer.expect("list end or value");
                while (!token.isSymbol(kMessageEnd)) {
                    Object key = readFieldValue(lexer, token, kDesc);
                    lexer.expectSymbol("map kv sep", kKeyValueSep);
                    Object value = readFieldValue(lexer, lexer.expect("map value"), iDesc);
                    builder.put(key, value);
                    token = lexer.expect("map sep, end or value");
                    if (token.isSymbol(kListSep) || token.isSymbol(kFieldSep)) {
                        token = lexer.expect("map end or value");
                    }
                }
                return builder.build();
            }
            case LIST: {
                if (!token.isSymbol(kListStart)) {
                    throw new PrettyException(token, "Expected list start, but got '%s'", token)
                            .setLine(token.line());
                }
                @SuppressWarnings("unchecked")
                PList<Object> pList = (PList) descriptor;
                PDescriptor iDesc = pList.itemDescriptor();

                PList.Builder<Object> builder = pList.builder(10);

                token = lexer.expect("list end or value");
                while (!token.isSymbol(kListEnd)) {
                    builder.add(readFieldValue(lexer, token, iDesc));
                    token = lexer.expect("list sep, end or value");
                    if (token.isSymbol(kListSep)) {
                        token = lexer.expect("list end or value");
                    }
                }

                return builder.build();
            }
            case SET: {
                if (!token.isSymbol(kListStart)) {
                    throw new PrettyException(token, "Expected set start, but got '%s'", token)
                            .setLine(token.line());
                }
                @SuppressWarnings("unchecked")
                PSet<Object> pList = (PSet) descriptor;
                PDescriptor iDesc = pList.itemDescriptor();

                PSet.Builder<Object> builder = pList.builder(10);

                token = lexer.expect("set end or value");
                while (!token.isSymbol(kListEnd)) {
                    builder.add(readFieldValue(lexer, token, iDesc));
                    token = lexer.expect("set sep, end or value");
                    if (token.isSymbol(kListSep)) {
                        token = lexer.expect("set end or value");
                    }
                }

                return builder.build();
            }
            default: {
                throw new IllegalStateException("Unhandled field type: " + descriptor.getType());
            }
        }
    }

    @Override
    public boolean binaryProtocol() {
        return false;
    }

    @Override
    public void verifyEndOfContent(@Nonnull InputStream input) throws IOException {
        try {
            PrettyLexer tokenizer = new PrettyLexer(input);
            PrettyToken next      = tokenizer.next();
            if (next != null) {
                throw new PrettyException(next, "More content after end: " + next);
            }
        } catch (LexerException e) {
            throw new PrettyException(e, "More content after end: " + e.getMessage());
        } finally {
            input.close();
        }
    }

    @Nonnull
    @Override
    public String mediaType() {
        return MEDIA_TYPE;
    }

    private void appendMessage(IndentedPrintWriter builder, PMessageOrBuilder<?> message, boolean encloseParams) {
        PMessageDescriptor<?> type = message.descriptor();

        boolean empty = true;
        if (encloseParams) {
            builder.append(kParamsStart);
        } else {
            builder.append(kMessageStart);
        }
        builder.begin();

        if (message instanceof PUnion) {
            if (((PUnion) message).unionFieldIsSet()) {
                PField field = ((PUnion) message).unionField();
                Object o = message.get(field.getId());

                builder.appendln()
                       .append(field.getName())
                       .append(space)
                       .append(kFieldValueSep)
                       .append(space);
                appendTypedValue(builder, field.getDescriptor(), o);
                empty = false;
            }
        } else {
            boolean first = true;
            for (PField field : type.getFields()) {
                if (message.has(field.getId())) {
                    if (first) {
                        first = false;
                    } else {
                        builder.append(entrySep);
                    }
                    Object o = message.get(field.getId());

                    builder.appendln()
                           .append(field.getName())
                           .append(space)
                           .append(kFieldValueSep)
                           .append(space);
                    appendTypedValue(builder, field.getDescriptor(), o);
                    empty = false;
                }
            }
        }

        builder.end();
        if (!empty) {
            builder.appendln();
        }
        if (encloseParams) {
            builder.append(kParamsEnd);
        } else {
            builder.append(kMessageEnd);
        }
    }

    private void appendTypedValue(IndentedPrintWriter writer, PDescriptor descriptor, Object o) {
        switch (descriptor.getType()) {
            case LIST:
            case SET: {
                PContainer<?> containerType = (PContainer<?>) descriptor;
                PDescriptor itemType = containerType.itemDescriptor();
                Collection<?> collection = (Collection<?>) o;
                if (collection.isEmpty()) {
                    writer.append(kListStart)
                          .append(kListEnd);
                    break;
                }

                PPrimitive primitive = PPrimitive.findByName(itemType.getName());
                if (primitive != null &&
                    primitive != PPrimitive.STRING &&
                    primitive != PPrimitive.BINARY &&
                    collection.size() <= 10) {
                    // special case if we have simple primitives (numbers and bools) in a "short" list,
                    // print in one single line.
                    writer.append(kListStart);

                    boolean first = true;
                    for (Object i : collection) {
                        if (first) {
                            first = false;
                        } else {
                            // Lists are always comma-delimited
                            writer.append(kListSep)
                                  .append(space);
                        }
                        appendTypedValue(writer, containerType.itemDescriptor(), i);
                    }
                    writer.append(kListEnd);
                } else {
                    writer.append(kListStart)
                          .begin();

                    boolean first = true;
                    for (Object i : collection) {
                        if (first) {
                            first = false;
                        } else {
                            // Lists are always comma-delimited
                            writer.append(kListSep);
                        }
                        writer.appendln();
                        appendTypedValue(writer, containerType.itemDescriptor(), i);
                    }

                    writer.end()
                          .appendln(kListEnd);
                }
                break;
            }
            case MAP: {
                PMap<?, ?> mapType = (PMap<?, ?>) descriptor;

                Map<?, ?> map = (Map<?, ?>) o;
                if (map.isEmpty()) {
                    writer.append(kMessageStart)
                          .append(kMessageEnd);
                    break;
                }

                writer.append(kMessageStart)
                      .begin();

                boolean first = true;
                for (Map.Entry<?, ?> entry : map.entrySet()) {
                    if (first) {
                        first = false;
                    } else {
                        writer.append(entrySep);
                    }
                    writer.appendln();
                    appendTypedValue(writer, mapType.keyDescriptor(), entry.getKey());
                    writer.append(kKeyValueSep)
                          .append(space);
                    appendTypedValue(writer, mapType.itemDescriptor(), entry.getValue());
                }

                writer.end()
                      .appendln(kMessageEnd);
                break;
            }
            case VOID:
                writer.print(true);
                break;
            case MESSAGE:
                PMessage<?> message = (PMessage<?>) o;
                appendMessage(writer, message, false);
                break;
            default:
                appendPrimitive(writer, o);
                break;
        }
    }

    private void appendPrimitive(IndentedPrintWriter writer, Object o) {
        if (o instanceof PEnumValue) {
            writer.print(((PEnumValue) o).asString());
        } else if (o instanceof CharSequence) {
            writer.print(kLiteralDoubleQuote);
            writer.print(Strings.escape((CharSequence) o));
            writer.print(kLiteralDoubleQuote);
        } else if (o instanceof Binary) {
            Binary b = (Binary) o;
            writer.append(PrettyToken.B64)
                  .append(kParamsStart)
                  .append(b.toBase64())
                  .append(kParamsEnd);
        } else if (o instanceof Boolean) {
            writer.print(((Boolean) o).booleanValue());
        } else if (o instanceof Byte || o instanceof Short || o instanceof Integer || o instanceof Long) {
            writer.print(o.toString());
        } else if (o instanceof Double) {
            Double d = (Double) o;
            if (d.equals(((double) d.longValue()))) {
                // actually an integer or long value.
                writer.print(d.longValue());
            } else {
                writer.print(d.doubleValue());
            }
        } else {
            throw new IllegalArgumentException("Unknown primitive type class " + o.getClass()
                                                                                  .getSimpleName());
        }
    }

    private void consumeValue(PrettyLexer lexer, PrettyToken token) throws IOException {
        if (token.isSymbol(kMessageStart)) {
            // message or map.
            token = lexer.expect("map or message first entry");
            // ignore empty map or message.
            if (!token.isSymbol(kMessageEnd)) {
                // key = value: message
                // key : value: map

                // potential message.
                boolean idKey = token.isIdentifier();
                consumeValue(lexer, token);
                if (lexer.expectSymbol("map", ':', '=').isSymbol(kFieldValueSep)) {
                    // message!
                    if (!idKey) {
                        throw lexer.failure(token, "Invalid field name token");
                    }
                    consumeValue(lexer, lexer.expect("message field value"));
                    token = nextNotLineSep(lexer);
                    while (!token.isSymbol(kMessageEnd)) {
                        if (!token.isIdentifier()) {
                            throw lexer.failure(token, "Invalid field name token");
                        }
                        lexer.expectSymbol("message field value sep", kFieldValueSep);
                        consumeValue(lexer, lexer.expect("message field value"));
                        token = nextNotLineSep(lexer);
                    }
                } else {
                    // map!
                    consumeValue(lexer, lexer.expect("map entry value"));
                    token = nextNotLineSep(lexer);
                    while (!token.isSymbol(kMessageEnd)) {
                        consumeValue(lexer, token);
                        lexer.expectSymbol("message field value sep", kKeyValueSep);
                        consumeValue(lexer, lexer.expect("message field value"));
                        token = nextNotLineSep(lexer);
                    }
                }

                if (!token.isSymbol(kFieldValueSep)) {
                    // assume map.
                    while (!token.isSymbol(kMessageEnd)) {
                        consumeValue(lexer, token);
                        lexer.expectSymbol("key value sep.", kKeyValueSep);
                        consumeValue(lexer, lexer.expect("map value"));

                        // maps do *not* require separator, but allows ',' separator, and separator after last.
                        token = nextNotLineSep(lexer);
                    }
                } else {
                    // assume message.
                    while (!token.isSymbol(kMessageEnd)) {
                        if (!token.isIdentifier()) {
                            throw new PrettyException(token, "Invalid field name: " + token)
                                    .setLine(token.line());
                        }

                        lexer.expectSymbol("field value sep.", kFieldValueSep);
                        consumeValue(lexer, lexer.expect(""));
                        token = nextNotLineSep(lexer);
                    }
                }
            }
        } else if (token.isSymbol(kListStart)) {
            token = lexer.expect("");
            while (!token.isSymbol(kListEnd)) {
                consumeValue(lexer, token);
                // lists and sets require list separator (,), and allows trailing separator.
                if (lexer.expectSymbol("list separator or end", kListSep, kListEnd).isSymbol(kListEnd)) {
                    break;
                }
                token = lexer.expect("list value or end");
            }
        } else if (token.toString().equals(PrettyToken.HEX) ||
                   token.toString().equals(PrettyToken.B64)) {
            lexer.expectSymbol("binary body start", kParamsStart);
            lexer.readBinary(kParamsEnd);
        } else if (!(token.isReal() ||  // number (double)
                     token.isInteger() ||  // number (int)
                     token.isStringLiteral() ||  // string literal
                     token.isIdentifier())) {  // enum value reference.
            throw new PrettyException(token, "Unknown value token '%s'", token)
                    .setLine(token.line());
        }
    }

    private PrettyToken nextNotLineSep(PrettyLexer lexer) throws IOException {
        if (lexer.peek("message field or end").isSymbol(kListSep)) {
            lexer.next();
        }
        return lexer.expect("message field or end");
    }
}