LexerException.java

/*
 * Copyright (c) 2015-2020, Stein Eldar Johnsen
 *
 * 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.lexer;

import net.morimekta.strings.ConsoleUtil;
import net.morimekta.strings.Displayable;

import java.io.IOException;

import static java.util.Objects.requireNonNull;
import static net.morimekta.strings.ConsoleUtil.isConsolePrintable;
import static net.morimekta.strings.ConsoleUtil.replaceNonPrintable;

/**
 * Exception representing problems parsing tokens or other problems with
 * returned tokens from the {@link Lexer}, or how a token interacts with
 * other in the whole structure. This exception is not meant to represent
 * problems that solely comes from the interaction between tokens in an
 * otherwise sound document, e.g. where the problem can not be pinned
 * to a single token causing the problem.
 */
public class LexerException extends IOException implements Displayable {
    private static final long serialVersionUID = 883627013596058366L;

    private CharSequence line;
    private int          lineNo = -1;
    private int          linePos = -1;
    private int          length = 0;
    private Token<?>     token;

    /**
     * Make exception cause by secondary problems.
     *
     * @param message Exception message.
     */
    protected LexerException(String message) {
        super(requireNonNull(message));
    }

    /**
     * Make exception cause by secondary problems.
     *
     * @param cause The cause of the exception.
     * @param message Exception message.
     */
    protected LexerException(Throwable cause, String message) {
        super(requireNonNull(message, "message == null"), requireNonNull(cause, "cause == null"));
        if (cause instanceof LexerException) {
            LexerException e = (LexerException) cause;
            if (e.token != null) {
                this.token = e.token;
            } else {
                this.lineNo = e.getLineNo();
                this.linePos = e.getLinePos();
                this.length = e.getLength();
                this.line = e.getLine();
            }
        }
    }

    /**
     * Make exception representing problems with a parsed token.
     *
     * @param token The problematic token.
     * @param message The exception message.
     */
    public LexerException(Token<?> token, String message) {
        super(requireNonNull(message, "message == null"));
        this.token = requireNonNull(token, "token == null");
    }

    /**
     * Make exception representing a tokenizing problem at a specific position
     * in the input.
     *
     * @param line The line the problem is located.
     * @param lineNo The line number where the problem is (1-N).
     * @param linePos The position in the line where the problem is (0-N).
     * @param length Length of the error.
     * @param message The exception message.
     */
    public LexerException(CharSequence line, int lineNo, int linePos, int length, String message) {
        super(requireNonNull(message, "message == null"));
        this.line = requireNonNull(line, "line == null");
        this.length = length;
        this.lineNo = lineNo;
        this.linePos = linePos;
    }

    @Override
    public LexerException initCause(Throwable cause) {
        super.initCause(cause);
        return this;
    }

    /**
     * @return The line string where the problem occurred.
     */
    public CharSequence getLine() {
        if (token != null) {
            return token.line();
        }
        return line;
    }

    /**
     * @return The line number where the problem occurred.
     */
    public int getLineNo() {
        if (token != null) {
            return token.lineNo();
        }
        return lineNo;
    }

    /**
     * @return The line position where the problem occurred.
     */
    public int getLinePos() {
        if (token != null) {
            return token.linePos();
        }
        return linePos;
    }

    /**
     * @return Length of match where fault lies.
     */
    public int getLength() {
        if (token != null) {
            return token.length();
        }
        return length;
    }

    @Override
    public String toString() {
        return getClass().getSimpleName() + "\n" + displayString();
    }

    /**
     * Replace non-printable chars in a string with something else. The
     * replacement is static and only meant as a place-holder. It is advised
     * to use a non-standard char as the replacement, as otherwise it will
     * not be distinguishable from the standard "printable".
     *
     * @param str The string to escape.
     * @param replacement Char to replace non-printable with.
     * @return The escaped char string.
     * @deprecated Use {@link ConsoleUtil#replaceNonPrintable(CharSequence, char)} in
     *             <code>utils-string</code> instead.
     */
    @Deprecated(forRemoval = true)
    public static String replaceNonPrintable(CharSequence str, char replacement) {
        return ConsoleUtil.replaceNonPrintable(str, replacement);
    }

    /**
     * @return Get the initial error string of the exception message. Defaults to 'Error'.
     */
    protected String getError() {
        return "Error";
    }

    @Override
    public String displayString() {
        if (token != null) {
            return toStringInternal(
                    getError(),
                    token.lineNo(),
                    token.linePos(),
                    token.length(),
                    getMessage(),
                    token.line());
        } else if (line != null) {
            return toStringInternal(
                    getError(),
                    lineNo,
                    linePos,
                    length,
                    getMessage(),
                    line);
        } else {
            return String.format("%s: %s", getError(), getMessage());
        }
    }

    /**
     * Generate the toString of the exception based on various parts.
     *
     * @param error The initial 'error' string.
     * @param lineNo The line number (1 indexed).
     * @param linePos The line position (0 indexed).
     * @param tokenLen The token length (in characters).
     * @param message The error message.
     * @param line The full line text.
     * @return The formatted exception string.
     */
    protected static String toStringInternal(
            final String error,
            final int lineNo,
            final int linePos,
            final int tokenLen,
            final String message,
            CharSequence line) {
        int start = linePos;
        int end = start + tokenLen;

        if (line.length() > 120 && tokenLen < 100) {
            if (end > 115) {
                // cut at least 10 characters at beginning....
                int remove = (end - 105);
                start -= remove;
                line = "..." + line.subSequence(remove + 3, line.length());
            }

            if (line.length() > 120) {
                line = line.subSequence(0, 117) + "...";
            }
        }

        if (tokenLen > 1) {
            return String.format(
                    "%s on line %d row %d-%d: %s%n" +
                    "%s%n" +
                    "%s%s",
                    error,
                    lineNo,
                    linePos,
                    linePos + tokenLen - 1,
                    message,
                    ConsoleUtil.replaceNonPrintable(line, '·'),
                    "-".repeat(start - 1),
                    "^".repeat(tokenLen));
        } else {
            return String.format(
                    "%s on line %d row %d: %s%n" +
                    "%s%n" +
                    "%s^",
                    error,
                    lineNo,
                    linePos,
                    message,
                    ConsoleUtil.replaceNonPrintable(line, '·'),
                    "-".repeat(start - 1));

        }
    }
}