ValueUtil.java

/*
 * Copyright 2020 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.proto.utils;

import static java.util.Objects.requireNonNull;
import static net.morimekta.collect.UnmodifiableList.listOf;
import static net.morimekta.proto.ProtoEnum.getEnumDescriptor;
import static net.morimekta.proto.utils.ByteStringUtil.toBase64;
import static net.morimekta.proto.utils.FieldUtil.getDefaultTypeValue;
import static net.morimekta.proto.utils.FieldUtil.getMapKeyDescriptor;
import static net.morimekta.proto.utils.FieldUtil.getMapValueDescriptor;
import static net.morimekta.strings.EscapeUtil.javaEscape;

import com.google.protobuf.ByteString;
import com.google.protobuf.Descriptors;
import com.google.protobuf.MapEntry;
import com.google.protobuf.Message;
import com.google.protobuf.MessageOrBuilder;
import com.google.protobuf.ProtocolMessageEnum;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;

import net.morimekta.collect.UnmodifiableList;
import net.morimekta.collect.UnmodifiableMap;
import net.morimekta.proto.ProtoMessage;
import net.morimekta.strings.Stringable;

/**
 * Most of these are pretty trivial methods, but here to vastly simplify generated code when making builder, mutable
 * values or build a message.
 */
public final class ValueUtil {
    /**
     * @param message The message to check.
     * @return Ture if the message it it's default instance.
     */
    public static boolean isDefault(Message message) {
        requireNonNull(message, "message == null");
        return message.equals(message.getDefaultInstanceForType());
    }

    /**
     * @param message The message to check.
     * @return Ture if the message it it's default instance.
     */
    public static boolean isNullOrDefault(Message message) {
        return message == null || isDefault(message);
    }

    /**
     * @param message The message to check.
     * @return True if the message is not it's default instance.
     */
    public static boolean isNotDefault(Message message) {
        return !isNullOrDefault(message);
    }

    /**
     * @param message Message to check.
     * @param <T>     The message type.
     * @return The message, or null if the message it it's own default value.
     */
    public static <T extends Message> T defaultToNull(T message) {
        if (isNullOrDefault(message)) {
            return null;
        }
        return message;
    }

    // --- Conversion ---

    /**
     * Convert a proto value to a java value. The input is expected to be the
     * result of a call to {@link Message#getField(Descriptors.FieldDescriptor)}.
     * The result of the method should be the equivalent to <code>MessageType.getMyField()</code>
     * or it's list variant for repeated fields.
     *
     * @param field The proto field describing the value.
     * @param value The proto value.
     * @return The java value.
     * @see ValueUtil#toProtoValue(Descriptors.FieldDescriptor, Object) for the opposite conversion.
     */
    public static Object toJavaValue(Descriptors.FieldDescriptor field, Object value) {
        if (value == null) {
            return null;
        } else if (field.isRepeated() && value instanceof Collection) {
            if (field.isMapField()) {
                var keyType = getMapKeyDescriptor(field);
                var valueType = getMapValueDescriptor(field);
                @SuppressWarnings("unchecked")
                var list = (List<MapEntry<?, ?>>) value;
                var builder = UnmodifiableMap.newBuilder(list.size());
                list.forEach(o -> builder.put(
                        toJavaValue(keyType, o.getKey()),
                        toJavaValue(valueType, o.getValue())));
                return builder.build();
            } else {
                var list = (List<?>) value;
                var builder = UnmodifiableList.newBuilder(list.size());
                list.forEach(item -> builder.add(toJavaValue(field, item)));
                return builder.build();
            }
        } else if (field.getType() == Descriptors.FieldDescriptor.Type.ENUM) {
            if (value instanceof Descriptors.EnumValueDescriptor) {
                var desc = (Descriptors.EnumValueDescriptor) value;
                return getEnumDescriptor(desc.getType()).valueFor(desc);
            } else if (value instanceof Integer) {
                // TODO: Fail if no value?
                return getEnumDescriptor(field.getEnumType()).findByNumber((Integer) value);
            }
        }
        return value;
    }

    /**
     * Convert a java value to a proto value. The input is expected to be the
     * result of a call to <code>MessageType.getMyField()</code> or it's list
     * variant for repeated fields. The output of the method should be the value
     * compatible with the value param when calling
     * {@link Message.Builder#setField(Descriptors.FieldDescriptor, Object)}.
     *
     * @param field The proto field describing the value.
     * @param value The java value.
     * @return The proto value.
     * @see ValueUtil#toJavaValue(Descriptors.FieldDescriptor, Object) for the opposite conversion.
     */
    public static Object toProtoValue(Descriptors.FieldDescriptor field, Object value) {
        if (value == null) {
            return null;
        } else if (value instanceof Map) {
            var map = (Map<?, ?>) value;
            if (map.isEmpty()) {
                return listOf();
            }
            var keyType = getMapKeyDescriptor(field);
            var valueType = getMapValueDescriptor(field);
            var defaultEntry = MapEntry.newDefaultInstance(field.getMessageType(),
                                                           keyType.getLiteType(),
                                                           getDefaultTypeValue(keyType),
                                                           valueType.getLiteType(),
                                                           getDefaultTypeValue(valueType));
            var builder = UnmodifiableList.newBuilder();
            map.forEach((k, v) -> builder.add(defaultEntry.toBuilder()
                                                          .setKey(toProtoValue(keyType, k))
                                                          .setValue(toProtoValue(valueType, v))
                                                          .build()));
            return builder.build();
        } else if (value instanceof Collection) {
            var list = (Collection<?>) value;
            var builder = UnmodifiableList.newBuilder(list.size());
            list.forEach(i -> builder.add(toProtoValue(field, i)));
            return builder.build();
        } else if (value instanceof ProtocolMessageEnum) {
            return ((ProtocolMessageEnum) value).getValueDescriptor();
        }
        return value;
    }

    /**
     * Get a java like <code>toString()</code> value, but that is meant to be used in
     * displaying the value in a safe manner. This means strings are quoted and escaped,
     * optionals are unwrapped etc.
     * <p>
     * Messages are also formatted differently from the default {@link Message#toString()},
     * where it follows the list in {@link #toDebugString(Message)}, but without newlines,
     * indent, and much fewer spaces.
     *
     * @param o The value to stringify.
     * @return The string value.
     * @see #toDebugString(Message) for main differences with {@link Message#toString()}.
     */
    public static String asString(Object o) {
        if (o == null) {
            return "null";
        }
        if (o instanceof Optional) {
            return ((Optional<?>) o).map(ValueUtil::asString).orElse("null");
        }

        if (o instanceof Message) {
            var message = (Message) o;
            var builder = new StringBuilder("{");
            for (var field : message.getAllFields().entrySet()) {
                if (builder.length() > 1) {
                    builder.append(", ");
                }
                if (field.getKey().isExtension()) {
                    builder.append('(')
                           .append(field.getKey().getFullName())
                           .append(')');
                } else {
                    builder.append(field.getKey().getName());
                }
                builder.append("=");
                var value = toJavaValue(field.getKey(), field.getValue());
                if (value instanceof MessageOrBuilder) {
                    builder.append(new ProtoMessage((MessageOrBuilder) value).asString());
                } else {
                    builder.append(ValueUtil.asString(value));
                }
            }
            builder.append('}');
            return builder.toString();
        }
        if (o instanceof ProtocolMessageEnum) {
            return ((ProtocolMessageEnum) o).getValueDescriptor().getName();
        }
        if (o instanceof CharSequence) {
            return "\"" + javaEscape((CharSequence) o) + "\"";
        }
        if (o instanceof ByteString) {
            return toBase64((ByteString) o);
        }
        if (o instanceof Map) {
            Map<?, ?> map = (Map<?, ?>) o;

            AtomicBoolean val = new AtomicBoolean();
            StringBuilder builder = new StringBuilder();

            builder.append('{');
            for (Map.Entry<?, ?> entry : map.entrySet()) {
                if (val.getAndSet(true)) {
                    builder.append(", ");
                }
                builder.append(asString(entry.getKey()))
                       .append(": ")
                       .append(asString(entry.getValue()));
            }
            builder.append('}');

            return builder.toString();
        }
        if (o instanceof Collection) {
            Collection<?> collection = (Collection<?>) o;

            AtomicBoolean val = new AtomicBoolean();
            StringBuilder builder = new StringBuilder();

            builder.append('[');
            for (Object item : collection) {
                if (val.getAndSet(true)) {
                    builder.append(", ");
                }
                builder.append(asString(item));
            }
            builder.append(']');

            return builder.toString();
        }
        return Stringable.asString(o);
    }

    /**
     * Make an easily readable debug string from the message. This differs from the
     * standard text format of proto with:
     *
     * <ul>
     *     <li>
     *         The entire message is encapsulated with '{...}' and named to show which message type
     *         is shown.
     *     </li>
     *     <li>
     *         Field / Value separator for messages is '=', not ':' (':' is used for maps).
     *     </li>
     *     <li>
     *         <code>bytes</code> fields are encoded with base64 end encapsulated with
     *         "b64(...)".
     *     </li>
     *     <li>
     *         Lists are encoded as lists, not as repeated fields. This is much easier to read.
     *     </li>
     *     <li>
     *         Short lists of non-message, non-strings are written on a single line. Max 5 elements.
     *     </li>
     *     <li>
     *         Maps are encoded as maps, not as repeated messages. This is much easier to read.
     *     </li>
     *     <li>
     *         Extension names are encapsulated in '(...)', not '[...]', Which looks more similar
     *         to how custom extensions are named when setting them in .proto files.
     *     </li>
     * </ul>
     *
     * @param message The message to get debug string from.
     * @return The debug string.
     */
    public static String toDebugString(Message message) {
        StringBuilder builder = new StringBuilder();
        builder.append(message.getDescriptorForType().getFullName());
        appendDebugString("", message, builder);
        return builder.toString();
    }

    // --- Private ---

    private static void appendDebugString(String prefix, Message message, StringBuilder builder) {
        var all = message.getAllFields();
        if (all.isEmpty()) {
            builder.append("{}");
            return;
        }
        builder.append("{\n");
        for (var field : message.getAllFields().entrySet()) {
            builder.append(prefix)
                   .append("  ");
            if (field.getKey().isExtension()) {
                builder.append("(").append(field.getKey().getFullName()).append(")");
            } else {
                builder.append(field.getKey().getName());
            }
            builder.append(" = ");
            var value = toJavaValue(field.getKey(), field.getValue());
            appendDebugValue(prefix + "  ", value, builder);
            builder.append("\n");
        }
        builder.append(prefix).append("}");
    }

    private static void appendDebugValue(String prefix, Object value, StringBuilder builder) {
        if (value instanceof Message) {
            appendDebugString(prefix, (Message) value, builder);
        } else if (value instanceof Map) {
            var map = (Map<?, ?>) value;
            builder.append("{");
            boolean first = true;
            for (var entry : map.entrySet()) {
                if (first) {
                    first = false;
                } else {
                    builder.append(",");
                }
                builder.append("\n")
                       .append(prefix)
                       .append("  ")
                       .append(asString(entry.getKey()))
                       .append(": ");
                appendDebugValue(prefix + "  ", entry.getValue(), builder);
            }
            builder.append("\n")
                   .append(prefix).append("}");
        } else if (value instanceof List) {
            var list = (List<?>) value;
            var first = list.get(0);
            if (list.size() > 5 || first instanceof Message || first instanceof ByteString || first instanceof String) {
                builder.append("[");
                for (var item : list) {
                    if (first != null) {
                        first = null;
                    } else {
                        builder.append(",");
                    }
                    builder.append("\n")
                           .append(prefix).append("  ");
                    appendDebugValue(prefix + "  ", item, builder);
                }
                builder.append("\n")
                       .append(prefix).append("]");
            } else {
                builder.append("[");
                for (var item : list) {
                    if (first != null) {
                        first = null;
                    } else {
                        builder.append(", ");
                    }
                    builder.append(asString(item));
                }
                builder.append("]");
            }
        } else if (value instanceof ByteString) {
            builder.append("b64(")
                   .append(ByteStringUtil.toBase64((ByteString) value))
                   .append(")");
        } else {
            builder.append(asString(value));
        }
    }

    private ValueUtil() {}
}