ConfigUtil.java

/*
 * Copyright 2016,2017 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.config.parser;

import net.morimekta.providence.PMessage;
import net.morimekta.providence.descriptor.PDescriptor;
import net.morimekta.providence.util.MessageUtil;
import net.morimekta.util.collect.UnmodifiableSet;

import javax.annotation.Nonnull;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;

import static net.morimekta.providence.util.MessageUtil.keyPathToFields;
import static net.morimekta.util.FileUtil.readCanonicalPath;

/**
 * Utilities for helping with providence config handling.
 */
public class ConfigUtil {
    /**
     * Simple stage separation. The content *must* come in this order.
     */
    enum Stage {
        INCLUDES,
        DEFINES,
        MESSAGE
    }

    static final String IDENTIFIER_SEP = ".";

    static final        String FALSE     = "false";
    static final        String TRUE      = "true";
    static final        String DEF       = "def";
    public static final String UNDEFINED = "undefined";
    static final        String INCLUDE   = "include";
    static final        String AS        = "as";

    static final Set<String> RESERVED_WORDS = UnmodifiableSet.setOf(
            TRUE,
            FALSE,
            UNDEFINED,
            DEF,
            AS,
            INCLUDE);

    /**
     * Look up a key in the message structure. If the key is not found, return null.
     *
     * @param message The message to look up into.
     * @param key The key to look up.
     * @return The value found or null.
     * @throws ConfigException When unable to get value from message.
     */
    static Object getInMessage(PMessage message, String key) throws ConfigException {
        return getInMessage(message, key, null);
    }

    /**
     * Look up a key in the message structure. If the key is not found, return
     * the default value. Note that the default value will be converted to the
     * type of the declared field, not returned verbatim.
     *
     * @param message The message to look up into.
     * @param key The key to look up.
     * @param defValue The default value.
     * @return The value found or the default.
     * @throws ConfigException When unable to get value from message.
     */
    static Object getInMessage(PMessage message, final String key, Object defValue)
            throws ConfigException {
        if (message == null) {
            return null;
        }
        try {
            return MessageUtil.getInMessage(message, keyPathToFields(message.descriptor(), key))
                              .orElse(defValue);
        } catch (IllegalArgumentException e) {
            throw new ConfigException(e, e.getMessage());
        }
    }

    static Object asType(PDescriptor descriptor, Object o) throws ConfigException {
        try {
            return MessageUtil.coerce(descriptor, o).orElse(null);
        } catch (IllegalArgumentException e) {
            throw new ConfigException(e, e.getMessage());
        }
    }

    static void consumeValue(@Nonnull ConfigLexer lexer,
                             @Nonnull ConfigToken token) throws IOException {
        boolean isMessage = false;

        if (UNDEFINED.equals(token.toString())) {
            // ignore undefined.
            return;
        } else if (token.toString().equals(ConfigToken.B64)) {
            lexer.expectSymbol("b64 body start", ConfigToken.kParamsStart);
            lexer.readBinary(ConfigToken.kParamsEnd);
        } else if (token.toString().equals(ConfigToken.HEX)) {
            lexer.expectSymbol("hex body start", ConfigToken.kParamsStart);
            lexer.readBinary(ConfigToken.kParamsEnd);
        } else if (token.isReferenceIdentifier()) {
            if (!lexer.peek("message start").isSymbol(ConfigToken.kMessageStart)) {
                // just a reference.
                return;
            }
            // reference + message.
            isMessage = true;
            token = lexer.expect("start of message");
        }

        if (token.isSymbol(ConfigToken.kMessageStart)) {
            // message or map.
            token = lexer.expect("map or message first entry");
            if (token.isSymbol(ConfigToken.kMessageEnd)) {
                return;
            }

            ConfigToken firstSep = lexer.peek("First separator");

            if (!isMessage &&
                !firstSep.isSymbol(ConfigToken.kFieldValueSep) &&
                !firstSep.isSymbol(ConfigToken.kMessageStart)) {
                // assume map.
                while (!token.isSymbol(ConfigToken.kMessageEnd)) {
                    if (!token.isIdentifier() && token.isReferenceIdentifier()) {
                        throw new ConfigException(token, "Invalid map key: " + token.toString());
                    }
                    consumeValue(lexer, token);
                    lexer.expectSymbol("key value sep.", ConfigToken.kKeyValueSep);
                    consumeValue(lexer, lexer.expect("map value"));

                    // maps do *not* require separator, but allows ',' separator, and separator after last.
                    token = nextNotSeparator(lexer, "map key, sep or end");
                }
            } else {
                // assume message.
                while (!token.isSymbol(ConfigToken.kMessageEnd)) {
                    if (!token.isIdentifier()) {
                        throw new ConfigException(token, "Invalid field name: " + token.toString());
                    }
                    token = lexer.expect("field value sep");
                    if (token.isSymbol(ConfigToken.kMessageStart)) {
                        // direct inheritance of message field.
                        consumeValue(lexer, token);
                    } else if (token.isSymbol(ConfigToken.kFieldValueSep)) {
                        consumeValue(lexer, lexer.expect("field value"));
                    } else {
                        throw new ConfigException(token, "Unknown field value sep: " + token.toString());
                    }
                    token = nextNotSeparator(lexer, "message field or end");
                }
            }
        } else if (token.isSymbol(ConfigToken.kListStart)) {
            token = lexer.expect("list value or end");
            while (!token.isSymbol(ConfigToken.kListEnd)) {
                consumeValue(lexer, token);
                // lists and sets require list separator (,), and allows trailing separator.
                if (lexer.expectSymbol("list separator or end", ConfigToken.kEntrySep, ConfigToken.kListEnd).isSymbol(ConfigToken.kListEnd)) {
                    break;
                }
                token = lexer.expect("list value or end");
            }
       }
    }

    @Nonnull
    static ConfigToken nextNotSeparator(ConfigLexer tokenizer, String message) throws IOException {
        if (tokenizer.peek("optional sep").isSymbol(ConfigToken.kEntrySep) ||
            tokenizer.peek("optional sep").isSymbol(ConfigToken.kLineSep)) {
            tokenizer.expect(message);
        }
        return tokenizer.expect(message);
    }

    public static Path canonicalFileLocation(@Nonnull Path file) throws ConfigException {
        if (!file.isAbsolute()) {
            file = file.toAbsolutePath();
        }
        if (file.getParent() == null) {
            throw new ConfigException("Trying to read root directory");
        }
        try {
            Path dir = readCanonicalPath(file.getParent());
            return dir.resolve(file.getFileName());
        } catch (IOException e) {
            throw new ConfigException(e, e.getMessage());
        }
    }

    /**
     * Resolve a file path within the source roots.
     *
     * @param reference A file or directory reference
     * @param path The file reference to resolve.
     * @return The resolved file.
     * @throws FileNotFoundException When the file is not found.
     * @throws IOException When unable to make canonical path.
     */
    static Path resolveFile(Path reference, String path) throws IOException {
        if (reference == null) {
            Path file = canonicalFileLocation(Paths.get(path));
            if (Files.exists(file)) {
                if (Files.isRegularFile(file)) {
                    return file;
                }
                throw new FileNotFoundException(path + " is a directory, expected file");
            }
            throw new FileNotFoundException("File " + path + " not found");
        } else if (path.startsWith("/")) {
            throw new FileNotFoundException("Absolute path includes not allowed: " + path);
        } else {
            // Referenced files are referenced from the real file,
            // not from symlink location, in case of sym-linked files.
            // this way include references are always consistent, but
            // files can be referenced via symlinks if needed.
            reference = readCanonicalPath(reference);
            if (!Files.isDirectory(reference)) {
                reference = reference.getParent();
            }
            Path file = canonicalFileLocation(reference.resolve(path));

            if (Files.exists(file)) {
                if (Files.isRegularFile(file)) {
                    return file;
                }
                throw new FileNotFoundException(path + " is a directory, expected file");
            }
            throw new FileNotFoundException("Included file " + path + " not found");
        }
    }

    private ConfigUtil() {}
}