SimpleTupleFormat.java

/*
 * 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 org.apache.commons.geometry.core.internal;

import java.text.ParsePosition;

/** Class for performing simple formatting and parsing of real number tuples.
 */
public class SimpleTupleFormat {

    /** Default value separator string. */
    private static final String DEFAULT_SEPARATOR = ",";

    /** Space character. */
    private static final String SPACE = " ";

    /** Static instance configured with default values. Tuples in this format
     * are enclosed by parentheses and separated by commas.
     */
    private static final SimpleTupleFormat DEFAULT_INSTANCE =
            new SimpleTupleFormat(",", "(", ")");

    /** String separating tuple values. */
    private final String separator;

    /** String used to signal the start of a tuple; may be null. */
    private final String prefix;

    /** String used to signal the end of a tuple; may be null. */
    private final String suffix;

    /** Constructs a new instance with the default string separator (a comma)
     * and the given prefix and suffix.
     * @param prefix String used to signal the start of a tuple; if null, no
     *      string is expected at the start of the tuple
     * @param suffix String used to signal the end of a tuple; if null, no
     *      string is expected at the end of the tuple
     */
    public SimpleTupleFormat(final String prefix, final String suffix) {
        this(DEFAULT_SEPARATOR, prefix, suffix);
    }

    /** Simple constructor.
     * @param separator String used to separate tuple values; must not be null.
     * @param prefix String used to signal the start of a tuple; if null, no
     *      string is expected at the start of the tuple
     * @param suffix String used to signal the end of a tuple; if null, no
     *      string is expected at the end of the tuple
     */
    protected SimpleTupleFormat(final String separator, final String prefix, final String suffix) {
        this.separator = separator;
        this.prefix = prefix;
        this.suffix = suffix;
    }

    /** Return the string used to separate tuple values.
     * @return the value separator string
     */
    public String getSeparator() {
        return separator;
    }

    /** Return the string used to signal the start of a tuple. This value may be null.
     * @return the string used to begin each tuple or null
     */
    public String getPrefix() {
        return prefix;
    }

    /** Returns the string used to signal the end of a tuple. This value may be null.
     * @return the string used to end each tuple or null
     */
    public String getSuffix() {
        return suffix;
    }

    /** Return a tuple string with the given value.
     * @param a value
     * @return 1-tuple string
     */
    public String format(final double a) {
        final StringBuilder sb = new StringBuilder();

        if (prefix != null) {
            sb.append(prefix);
        }

        sb.append(a);

        if (suffix != null) {
            sb.append(suffix);
        }

        return sb.toString();
    }

    /** Return a tuple string with the given values.
     * @param a1 first value
     * @param a2 second value
     * @return 2-tuple string
     */
    public String format(final double a1, final double a2) {
        final StringBuilder sb = new StringBuilder();

        if (prefix != null) {
            sb.append(prefix);
        }

        sb.append(a1)
            .append(separator)
            .append(SPACE)
            .append(a2);

        if (suffix != null) {
            sb.append(suffix);
        }

        return sb.toString();
    }

    /** Return a tuple string with the given values.
     * @param a1 first value
     * @param a2 second value
     * @param a3 third value
     * @return 3-tuple string
     */
    public String format(final double a1, final double a2, final double a3) {
        final StringBuilder sb = new StringBuilder();

        if (prefix != null) {
            sb.append(prefix);
        }

        sb.append(a1)
            .append(separator)
            .append(SPACE)
            .append(a2)
            .append(separator)
            .append(SPACE)
            .append(a3);

        if (suffix != null) {
            sb.append(suffix);
        }

        return sb.toString();
    }

    /** Return a tuple string with the given values.
     * @param a1 first value
     * @param a2 second value
     * @param a3 third value
     * @param a4 fourth value
     * @return 4-tuple string
     */
    public String format(final double a1, final double a2, final double a3, final double a4) {
        final StringBuilder sb = new StringBuilder();

        if (prefix != null) {
            sb.append(prefix);
        }

        sb.append(a1)
            .append(separator)
            .append(SPACE)
            .append(a2)
            .append(separator)
            .append(SPACE)
            .append(a3)
            .append(separator)
            .append(SPACE)
            .append(a4);

        if (suffix != null) {
            sb.append(suffix);
        }

        return sb.toString();
    }

    /** Parse the given string as a 1-tuple and passes the tuple values to the
     * given function. The function output is returned.
     * @param <T> function return type
     * @param str the string to be parsed
     * @param fn function that will be passed the parsed tuple values
     * @return object returned by {@code fn}
     * @throws IllegalArgumentException if the input string format is invalid
     */
    public <T> T parse(final String str, final DoubleFunction1N<T> fn) {
        final ParsePosition pos = new ParsePosition(0);

        readPrefix(str, pos);
        final double v = readTupleValue(str, pos);
        readSuffix(str, pos);
        endParse(str, pos);

        return fn.apply(v);
    }

    /** Parse the given string as a 2-tuple and passes the tuple values to the
     * given function. The function output is returned.
     * @param <T> function return type
     * @param str the string to be parsed
     * @param fn function that will be passed the parsed tuple values
     * @return object returned by {@code fn}
     * @throws IllegalArgumentException if the input string format is invalid
     */
    public <T> T parse(final String str, final DoubleFunction2N<T> fn) {
        final ParsePosition pos = new ParsePosition(0);

        readPrefix(str, pos);
        final double v1 = readTupleValue(str, pos);
        final double v2 = readTupleValue(str, pos);
        readSuffix(str, pos);
        endParse(str, pos);

        return fn.apply(v1, v2);
    }

    /** Parse the given string as a 3-tuple and passes the parsed values to the
     * given function. The function output is returned.
     * @param <T> function return type
     * @param str the string to be parsed
     * @param fn function that will be passed the parsed tuple values
     * @return object returned by {@code fn}
     * @throws IllegalArgumentException if the input string format is invalid
     */
    public <T> T parse(final String str, final DoubleFunction3N<T> fn) {
        final ParsePosition pos = new ParsePosition(0);

        readPrefix(str, pos);
        final double v1 = readTupleValue(str, pos);
        final double v2 = readTupleValue(str, pos);
        final double v3 = readTupleValue(str, pos);
        readSuffix(str, pos);
        endParse(str, pos);

        return fn.apply(v1, v2, v3);
    }

    /** Read the configured prefix from the current position in the given string, ignoring any preceding
     * whitespace, and advance the parsing position past the prefix sequence. An exception is thrown if the
     * prefix is not found. Does nothing if the prefix is null.
     * @param str the string being parsed
     * @param pos the current parsing position
     * @throws IllegalArgumentException if the configured prefix is not null and is not found at the current
     *      parsing position, ignoring preceding whitespace
     */
    private void readPrefix(final String str, final ParsePosition pos) {
        if (prefix != null) {
            consumeWhitespace(str, pos);
            readSequence(str, prefix, pos);
        }
    }

    /** Read and return a tuple value from the current position in the given string. An exception is thrown if a
     * valid number is not found. The parsing position is advanced past the parsed number and any trailing separator.
     * @param str the string being parsed
     * @param pos the current parsing position
     * @return the tuple value
     * @throws IllegalArgumentException if the configured prefix is not null and is not found at the current
     *      parsing position, ignoring preceding whitespace
     */
    private double readTupleValue(final String str, final ParsePosition pos) {
        final int startIdx = pos.getIndex();

        int endIdx = str.indexOf(separator, startIdx);
        if (endIdx < 0) {
            if (suffix != null) {
                endIdx = str.indexOf(suffix, startIdx);
            }

            if (endIdx < 0) {
                endIdx = str.length();
            }
        }

        final String substr = str.substring(startIdx, endIdx);
        try {
            final double value = Double.parseDouble(substr);

            // advance the position and move past any terminating separator
            pos.setIndex(endIdx);
            matchSequence(str, separator, pos);

            return value;
        } catch (final NumberFormatException exc) {
            throw parseFailure(String.format("unable to parse number from string \"%s\"", substr), str, pos, exc);
        }
    }

    /** Read the configured suffix from the current position in the given string, ignoring any preceding
     * whitespace, and advance the parsing position past the suffix sequence. An exception is thrown if the
     * suffix is not found. Does nothing if the suffix is null.
     * @param str the string being parsed
     * @param pos the current parsing position
     * @throws IllegalArgumentException if the configured suffix is not null and is not found at the current
     *      parsing position, ignoring preceding whitespace
     */
    private void readSuffix(final String str, final ParsePosition pos) {
        if (suffix != null) {
            consumeWhitespace(str, pos);
            readSequence(str, suffix, pos);
        }
    }

    /** End a parse operation by ensuring that all non-whitespace characters in the string have been parsed. An
     * exception is thrown if extra content is found.
     * @param str the string being parsed
     * @param pos the current parsing position
     * @throws IllegalArgumentException if extra non-whitespace content is found past the current parsing position
     */
    private void endParse(final String str, final ParsePosition pos) {
        consumeWhitespace(str, pos);
        if (pos.getIndex() != str.length()) {
            throw parseFailure("unexpected content", str, pos);
        }
    }

    /** Advance {@code pos} past any whitespace characters in {@code str},
     * starting at the current parse position index.
     * @param str the input string
     * @param pos the current parse position
     */
    private void consumeWhitespace(final String str, final ParsePosition pos) {
        int idx = pos.getIndex();
        final int len = str.length();

        for (; idx < len; ++idx) {
            if (!Character.isWhitespace(str.codePointAt(idx))) {
                break;
            }
        }

        pos.setIndex(idx);
    }

    /** Return a boolean indicating whether or not the input string {@code str}
     * contains the string {@code seq} at the given parse index. If the match succeeds,
     * the index of {@code pos} is moved to the first character after the match. If
     * the match does not succeed, the parse position is left unchanged.
     * @param str the string to match against
     * @param seq the sequence to look for in {@code str}
     * @param pos the parse position indicating the index in {@code str}
     *      to attempt the match
     * @return true if {@code str} contains exactly the same characters as {@code seq}
     *      at {@code pos}; otherwise, false
     */
    private boolean matchSequence(final String str, final String seq, final ParsePosition pos) {
        final int idx = pos.getIndex();
        final int inputLength = str.length();
        final int seqLength = seq.length();

        int i = idx;
        int s = 0;
        for (; i < inputLength && s < seqLength; ++i, ++s) {
            if (str.codePointAt(i) != seq.codePointAt(s)) {
                break;
            }
        }

        if (i <= inputLength && s == seqLength) {
            pos.setIndex(idx + seqLength);
            return true;
        }
        return false;
    }

    /** Read the string given by {@code seq} from the given position in {@code str}.
     * Throws an IllegalArgumentException if the sequence is not found at that position.
     * @param str the string to match against
     * @param seq the sequence to look for in {@code str}
     * @param pos the parse position indicating the index in {@code str}
     *      to attempt the match
     * @throws IllegalArgumentException if {@code str} does not contain the characters from
     *      {@code seq} at position {@code pos}
     */
    private void readSequence(final String str, final String seq, final ParsePosition pos) {
        if (!matchSequence(str, seq, pos)) {
            final int idx = pos.getIndex();
            final String actualSeq = str.substring(idx, Math.min(str.length(), idx + seq.length()));

            throw parseFailure(String.format("expected \"%s\" but found \"%s\"", seq, actualSeq), str, pos);
        }
    }

    /** Return an instance configured with default values. Tuples in this format
     * are enclosed by parentheses and separated by commas.
     *
     * Ex:
     * <pre>
     * "(1.0)"
     * "(1.0, 2.0)"
     * "(1.0, 2.0, 3.0)"
     * </pre>
     * @return instance configured with default values
     */
    public static SimpleTupleFormat getDefault() {
        return DEFAULT_INSTANCE;
    }

    /** Return an {@link IllegalArgumentException} representing a parsing failure.
     * @param msg the error message
     * @param str the string being parsed
     * @param pos the current parse position
     * @return an exception signaling a parse failure
     */
    private static IllegalArgumentException parseFailure(final String msg, final String str, final ParsePosition pos) {
        return parseFailure(msg, str, pos, null);
    }

    /** Return an {@link IllegalArgumentException} representing a parsing failure.
     * @param msg the error message
     * @param str the string being parsed
     * @param pos the current parse position
     * @param cause the original cause of the error
     * @return an exception signaling a parse failure
     */
    private static IllegalArgumentException parseFailure(final String msg, final String str, final ParsePosition pos,
                                                         final Throwable cause) {
        final String fullMsg = String.format("Failed to parse string \"%s\" at index %d: %s",
                str, pos.getIndex(), msg);

        return new TupleParseException(fullMsg, cause);
    }

    /** Exception class for errors occurring during tuple parsing.
     */
    private static class TupleParseException extends IllegalArgumentException {

        /** Serializable version identifier. */
        private static final long serialVersionUID = 20180629;

        /** Simple constructor.
         * @param msg the exception message
         * @param cause the exception root cause
         */
        TupleParseException(final String msg, final Throwable cause) {
            super(msg, cause);
        }
    }
}