From aeb03889251c7def914d14b8bf78edf14ee9fc1b Mon Sep 17 00:00:00 2001 From: Riccardo Azzolini Date: Tue, 6 Aug 2019 19:01:00 +0200 Subject: [PATCH] Implement DSL error pretty-printing --- .../warppi/math/rules/RulesManager.java | 20 +- .../math/rules/dsl/DslAggregateException.java | 8 +- .../warppi/math/rules/dsl/DslError.java | 27 +++ .../math/rules/dsl/UndefinedSubFunction.java | 5 + .../errorutils/DslErrorMessageFormatter.java | 124 ++++++++++++ .../dsl/errorutils/DslFilesException.java | 47 +++++ .../math/rules/dsl/errorutils/FileErrors.java | 55 +++++ .../dsl/errorutils/FilesErrorsFormatter.java | 102 ++++++++++ .../math/rules/dsl/errorutils/LineMap.java | 191 ++++++++++++++++++ .../dsl/errorutils/TabExpandedString.java | 60 ++++++ .../dsl/frontend/IncompleteNumberLiteral.java | 5 + .../dsl/frontend/UnexpectedCharacters.java | 5 + .../rules/dsl/frontend/UnexpectedToken.java | 5 + .../dsl/frontend/UnterminatedComment.java | 5 + .../rules/dsl/errorutils/LineMapTest.java | 141 +++++++++++++ 15 files changed, 794 insertions(+), 6 deletions(-) create mode 100644 core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslErrorMessageFormatter.java create mode 100644 core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslFilesException.java create mode 100644 core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FileErrors.java create mode 100644 core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FilesErrorsFormatter.java create mode 100644 core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMap.java create mode 100644 core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/TabExpandedString.java create mode 100644 core/src/test/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMapTest.java diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/RulesManager.java b/core/src/main/java/it/cavallium/warppi/math/rules/RulesManager.java index df56714b..ab8ce069 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/RulesManager.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/RulesManager.java @@ -15,6 +15,7 @@ import it.cavallium.warppi.math.functions.Variable; import it.cavallium.warppi.math.functions.Variable.V_TYPE; import it.cavallium.warppi.math.rules.dsl.DslAggregateException; import it.cavallium.warppi.math.rules.dsl.RulesDsl; +import it.cavallium.warppi.math.rules.dsl.errorutils.DslFilesException; import it.cavallium.warppi.math.rules.functions.*; import it.cavallium.warppi.math.solver.MathSolver; import it.cavallium.warppi.util.Error; @@ -41,8 +42,11 @@ public class RulesManager { } else { try { loadDslRules(); - } catch (IOException | DslAggregateException e) { + } catch (IOException | DslFilesException e) { e.printStackTrace(); + if (e instanceof DslFilesException) { + System.err.print(((DslFilesException) e).format()); + } Engine.getPlatform().exit(1); } } @@ -66,14 +70,14 @@ public class RulesManager { ).forEach(RulesManager::addRule); } - private static void loadDslRules() throws IOException, DslAggregateException { + private static void loadDslRules() throws IOException, DslFilesException { final StorageUtils storageUtils = Engine.getPlatform().getStorageUtils(); - final File dslRulesPath = storageUtils.get("rules/dsl/"); if (!dslRulesPath.exists()) { return; } + final DslFilesException fileErrors = new DslFilesException(); for (final File file : storageUtils.walk(dslRulesPath)) { if (!file.toString().endsWith(".rules")) { continue; @@ -90,7 +94,15 @@ public class RulesManager { source = storageUtils.read(resource); } - RulesDsl.makeRules(source).forEach(RulesManager::addRule); + try { + RulesDsl.makeRules(source).forEach(RulesManager::addRule); + } catch (DslAggregateException e) { + fileErrors.addFileErrors(file, source, e.getErrors()); + } + } + + if (fileErrors.hasErrors()) { + throw fileErrors; } } diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslAggregateException.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslAggregateException.java index 38d5c05b..ea4cb2d7 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslAggregateException.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslAggregateException.java @@ -6,16 +6,20 @@ import java.util.Objects; /** * Thrown when processing DSL code which contains one or more errors. * - * Contains a list of {@link DslError}s, which should not be empty. + * Contains a non-empty list of {@link DslError}s. */ public class DslAggregateException extends Exception { private final List errors; /** * Constructs a DslAggregateException containing the specified list of errors. - * @param errors The list of errors. Should not be empty. + * @param errors The (non-empty) list of errors. + * @throws IllegalArgumentException If the list of errors is empty. */ public DslAggregateException(final List errors) { + if (errors.isEmpty()) { + throw new IllegalArgumentException("The list of errors can't be empty"); + } this.errors = errors; } diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslError.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslError.java index a3c16ae6..841f2509 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslError.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/DslError.java @@ -1,5 +1,10 @@ package it.cavallium.warppi.math.rules.dsl; +import it.cavallium.warppi.math.rules.dsl.frontend.IncompleteNumberLiteral; +import it.cavallium.warppi.math.rules.dsl.frontend.UnexpectedCharacters; +import it.cavallium.warppi.math.rules.dsl.frontend.UnexpectedToken; +import it.cavallium.warppi.math.rules.dsl.frontend.UnterminatedComment; + /** * Represents an error in DSL code. */ @@ -13,4 +18,26 @@ public interface DslError { * @return The length of the error in the source string. */ int getLength(); + + /** + * Accepts a DslError.Visitor by calling the correct overload of visit. + * + * @param visitor The visitor to be accepted. + * @param The return type of the visit method. + * @return The value returned by visit. + */ + T accept(Visitor visitor); + + /** + * Executes a different overload of a method for each DslError implementation. + * + * @param The return type of all visit method overloads. + */ + interface Visitor { + T visit(IncompleteNumberLiteral incompleteNumberLiteral); + T visit(UndefinedSubFunction undefinedSubFunction); + T visit(UnexpectedCharacters unexpectedCharacters); + T visit(UnexpectedToken unexpectedToken); + T visit(UnterminatedComment unterminatedComment); + } } diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/UndefinedSubFunction.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/UndefinedSubFunction.java index 955b29df..58d42611 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/UndefinedSubFunction.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/UndefinedSubFunction.java @@ -25,6 +25,11 @@ public class UndefinedSubFunction implements DslError { return identifier.lexeme.length(); } + @Override + public T accept(final DslError.Visitor visitor) { + return visitor.visit(this); + } + /** * @return The name of the undefined sub-function. */ diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslErrorMessageFormatter.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslErrorMessageFormatter.java new file mode 100644 index 00000000..d6efee78 --- /dev/null +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslErrorMessageFormatter.java @@ -0,0 +1,124 @@ +package it.cavallium.warppi.math.rules.dsl.errorutils; + +import it.cavallium.warppi.math.rules.dsl.DslError; +import it.cavallium.warppi.math.rules.dsl.UndefinedSubFunction; +import it.cavallium.warppi.math.rules.dsl.frontend.*; + +import java.util.stream.Collectors; + +/** + * Creates human-readable error messages from instances of {@link DslError}. + */ +public class DslErrorMessageFormatter implements DslError.Visitor { + /** + * Formats the given DslError as a human-readable message, according to its type. + * + * @param error The error to format. + * @return One or more lines of text which describe the error (without a trailing newline). + */ + public String format(final DslError error) { + return error.accept(this); + } + + @Override + public String visit(final IncompleteNumberLiteral incompleteNumberLiteral) { + return String.format( + "Number has a decimal point but no digits following it: %s", + incompleteNumberLiteral.getLiteral() + ); + } + + @Override + public String visit(final UndefinedSubFunction undefinedSubFunction) { + return String.format( + "Sub-function %s is used in a replacement pattern,\nbut not defined in the target pattern", + undefinedSubFunction.getName() + ); + } + + @Override + public String visit(final UnexpectedCharacters unexpectedCharacters) { + final String plural = unexpectedCharacters.getLength() > 1 ? "s" : ""; + return String.format("Unexpected character%s: %s", plural, unexpectedCharacters.getUnexpectedCharacters()); + } + + @Override + public String visit(final UnexpectedToken unexpectedToken) { + final String suggestions; + if (unexpectedToken.getSuggested().isEmpty()) { + suggestions = ""; + } else { + suggestions = "\nSome suggestions are: " + unexpectedToken.getSuggested().stream() + .map(DslErrorMessageFormatter::tokenTypeName) + .collect(Collectors.joining(", ")); + } + + return String.format( + "Unexpected %s%s", + tokenTypeName(unexpectedToken.getUnexpected().type), + suggestions + ); + } + + @Override + public String visit(final UnterminatedComment unterminatedComment) { + return "Unterminated comment"; + } + + private static String tokenTypeName(final TokenType type) { + switch (type) { + case EOF: + return "end of input"; + case COLON: + return "\":\""; + case ARROW: + return "\"->\""; + case COMMA: + return "\",\""; + case LEFT_PAREN: + return "\"(\""; + case RIGHT_PAREN: + return "\")\""; + case LEFT_BRACKET: + return "\"[\""; + case RIGHT_BRACKET: + return "\"]\""; + case EQUALS: + return "\"=\""; + case PLUS: + return "\"+\""; + case MINUS: + return "\"-\""; + case PLUS_MINUS: + return "\"+-\""; + case TIMES: + return "\"*\""; + case DIVIDE: + return "\"/\""; + case POWER: + return "\"^\""; + case REDUCTION: + case EXPANSION: + case CALCULATION: + case EXISTENCE: + case ARCCOS: + case ARCSIN: + case ARCTAN: + case COS: + case SIN: + case TAN: + case ROOT: + case SQRT: + case LOG: + case UNDEFINED: + case PI: + case E: + return '"' + type.name().toLowerCase() + '"'; + case NUMBER: + return "number"; + case IDENTIFIER: + return "identifier"; + } + throw new RuntimeException("unknown token type"); + } +} diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslFilesException.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslFilesException.java new file mode 100644 index 00000000..d0325281 --- /dev/null +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/DslFilesException.java @@ -0,0 +1,47 @@ +package it.cavallium.warppi.math.rules.dsl.errorutils; + +import it.cavallium.warppi.math.rules.dsl.DslError; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Thrown when one or more DSL source files contain errors. + */ +public class DslFilesException extends Exception { + private final List filesErrors = new ArrayList<>(); + + /** + * Registers errors which have been found in the specified DSL source file. + * + * @param file The path of the DSL source file in which the errors occurred. + * @param source The entire contents of the DSL source file in which the errors occurred. + * @param errors The (non-empty) list of errors found in the DSL source file. + * @throws IllegalArgumentException If the list of errors is empty. + */ + public void addFileErrors(final File file, final String source, final List errors) { + filesErrors.add(new FileErrors(file, source, errors)); + } + + /** + * Checks if any errors have been registered. + *

+ * Instances of this class should only be thrown as exceptions if they actually contain errors. + * + * @return true if at least one error has been added, otherwise false. + */ + public boolean hasErrors() { + return !filesErrors.isEmpty(); + } + + /** + * Formats all errors using a {@link FilesErrorsFormatter}. + * + * @return A formatted representation of all errors for display to the user. + * @see FilesErrorsFormatter#format(List) + */ + public String format() { + return new FilesErrorsFormatter().format(filesErrors); + } +} diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FileErrors.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FileErrors.java new file mode 100644 index 00000000..f06a5e13 --- /dev/null +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FileErrors.java @@ -0,0 +1,55 @@ +package it.cavallium.warppi.math.rules.dsl.errorutils; + +import it.cavallium.warppi.math.rules.dsl.DslError; + +import java.io.File; +import java.util.List; + +/** + * Groups one or more errors from the same DSL source file. + *

+ * Also stores the file's path and contents (for error reporting). + */ +public class FileErrors { + private final File file; + private final String source; + private final List errors; + + /** + * Constructs a FileErrors instance with the given file and error data. + * + * @param file The path of the DSL source file in which the errors occurred. + * @param source The entire contents of the DSL source file in which the errors occurred. + * @param errors The (non-empty) list of errors found in the DSL source file. + * @throws IllegalArgumentException If the list of errors is empty. + */ + public FileErrors(final File file, final String source, final List errors) { + if (errors.isEmpty()) { + throw new IllegalArgumentException("The list of errors can't be empty"); + } + this.file = file; + this.source = source; + this.errors = errors; + } + + /** + * @return The path of the DSL source file in which the errors occurred. + */ + public File getFile() { + return file; + } + + /** + * @return The entire contents of the DSL source file in which the errors occurred. + */ + public String getSource() { + return source; + } + + /** + * @return The list of errors found in the DSL source file. + */ + public List getErrors() { + return errors; + } +} diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FilesErrorsFormatter.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FilesErrorsFormatter.java new file mode 100644 index 00000000..74bf46f9 --- /dev/null +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/FilesErrorsFormatter.java @@ -0,0 +1,102 @@ +package it.cavallium.warppi.math.rules.dsl.errorutils; + +import it.cavallium.warppi.math.rules.dsl.DslError; +import org.apache.commons.lang3.StringUtils; + +import java.io.File; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Formats DSL errors from (potentially) multiple files for display to the user. + */ +public class FilesErrorsFormatter { + private static final int INDENT = 2; + private static final int TAB_WIDTH = 4; + + private final DslErrorMessageFormatter messageFormatter = new DslErrorMessageFormatter(); + + /** + * Formats all errors in the given list. + * + * @param filesErrors The list of errors to format. + * @return A human-readable textual representation of all errors (with a trailing newline). + */ + public String format(final List filesErrors) { + return filesErrors.stream() + .sorted(Comparator.comparing(FileErrors::getFile)) + .flatMap(this::formatFileErrors) + .collect(Collectors.joining(System.lineSeparator())); + } + + private Stream formatFileErrors(final FileErrors fileErrors) { + final LineMap lines = new LineMap(fileErrors.getSource()); + return fileErrors.getErrors().stream() + .sorted(Comparator.comparing(DslError::getPosition).thenComparing(DslError::getLength)) + .map(error -> formatError(fileErrors.getFile(), lines, error)); + } + + private String formatError(final File file, final LineMap lines, final DslError error) { + final StringBuilder builder = new StringBuilder(); + + final List spannedLines = lines.getSpannedLines(error.getPosition(), error.getLength()); + final LineMap.Line firstLine = spannedLines.get(0); + final int column = error.getPosition() - firstLine.getStartPosition() + 1; + + builder.append(file.toString()).append(":") + .append(firstLine.getNumber()).append(":") + .append(column).append(":") + .append(System.lineSeparator()); + + final int lastLineNum = spannedLines.get(spannedLines.size() - 1).getNumber(); + final int numberWidth = String.valueOf(lastLineNum).length(); + final int padding = INDENT + numberWidth; + + // Preceding line with just separator + builder.append(StringUtils.repeat(' ', padding)) + .append(" |") + .append(System.lineSeparator()); + + for (final LineMap.Line line : spannedLines) { + // Error text line + final TabExpandedString expanded = new TabExpandedString(line.getText(), TAB_WIDTH); + builder.append(StringUtils.leftPad(String.valueOf(line.getNumber()), padding)) + .append(" | ") + .append(expanded.getExpanded()) + .append(System.lineSeparator()); + + // Error underlining line + builder.append(StringUtils.repeat(' ', padding)).append(" | "); + underline(builder, line, expanded, error); + builder.append(System.lineSeparator()); + } + + builder.append(messageFormatter.format(error)).append(System.lineSeparator()); + + return builder.toString(); + } + + private void underline( + final StringBuilder builder, + final LineMap.Line line, + final TabExpandedString expanded, + final DslError error + ) { + final int errorStartInLine = Math.max(line.getStartPosition(), error.getPosition()); + final int charsBeforeError = errorStartInLine - line.getStartPosition(); + final int spacesBeforeError = expanded.substringLength(0, charsBeforeError); + builder.append(StringUtils.repeat(' ', spacesBeforeError)); + + final int underlineLength; + if (error.getLength() == 0) { + underlineLength = 1; // Special case for "unexpected EOF" error + } else { + final int errorEnd = error.getPosition() + error.getLength(); + final int errorLengthInLine = Math.min(line.getText().length() - charsBeforeError, errorEnd - errorStartInLine); + underlineLength = expanded.substringLength(charsBeforeError, charsBeforeError + errorLengthInLine); + } + builder.append(StringUtils.repeat('^', underlineLength)); + } +} diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMap.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMap.java new file mode 100644 index 00000000..31ff36e0 --- /dev/null +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMap.java @@ -0,0 +1,191 @@ +package it.cavallium.warppi.math.rules.dsl.errorutils; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Splits a string into lines and associates positions within the string to the lines they belong to. + *

+ * For each line, the number (starting from 1), start position and content are stored. + *

+ * A line can end at the end of the string, or with a line terminator ("\r", "\n" or "\r\n"). + * The terminator defines the end of a line, but not necessarily the beginning of a new one: it's considered to be part + * of the line (however, for convenience, it's not included in the content), and a terminator at the end of the string + * doesn't start a new empty line. + * For example, the string "abc\n\n" contains two lines: + *

    + *
  • line 1 starts at position 0, and its content is "abc"; + *
  • line 2 starts at position 4 (the index of the second '\n'), and its content is "" (the empty string). + *
+ * As a consequence of these criteria, an empty string has no lines. + */ +public class LineMap { + private final String text; + private final NavigableMap lines; + + /** + * Constructs a LineMap for the given string. + * + * @param text The string to split into lines. + */ + public LineMap(final String text) { + this.text = text; + this.lines = splitLines(text); + } + + /** + * Gets all lines which the specified substring spans. + *

+ * A substring spans a line if it contains at least one of the characters which belong to the line, + * including the terminator ("\r", "\n" or "\r\n"), within the original string. + * However, as a special case, an empty substring (length == 0) still spans the line corresponding to + * its startPosition, even though it doesn't contain any characters. + * Therefore, any substring spans at least one line, unless there are no lines at all (because the original string + * is empty). + * + * @param startPosition The index at which the substring starts within the original string. + * @param length The length of the substring within the original string. + * @return The (potentially empty) list of spanned lines (each one without the terminator characters). + * @throws StringIndexOutOfBoundsException If the specified substring isn't valid, because: + *

    + *
  • startPosition is negative, or + *
  • startPosition is larger than the length of the original string, or + *
  • length is negative, or + *
  • there are less than length characters from startPosition + * to the end of the original string. + *
+ */ + public List getSpannedLines(final int startPosition, final int length) { + if (startPosition < 0 || startPosition > text.length()) { + throw new StringIndexOutOfBoundsException("Substring start position out of range: " + startPosition); + } + int endPosition = startPosition + length; + if (endPosition < startPosition || endPosition > text.length()) { + throw new StringIndexOutOfBoundsException("Substring length out of range: " + length); + } + + if (lines.isEmpty()) { + return Collections.emptyList(); + } + + final Map.Entry firstSpannedLine = lines.floorEntry(startPosition); + if (length == 0) { + // For empty substrings, firstSpannedLine.getKey() may be equal to endPosition. + // In this case, the submap would be empty (because the upper bound is exclusive), + // so the single spanned line has to be returned manually. + return Collections.singletonList(lineFromMapEntry(firstSpannedLine)); + } + final SortedMap spannedLines = lines.subMap(firstSpannedLine.getKey(), endPosition); + return spannedLines.entrySet().stream() + .map(this::lineFromMapEntry) + .collect(Collectors.toList()); + } + + private static NavigableMap splitLines(final String string) { + final TreeMap lines = new TreeMap<>(); + + int lineNum = 1; + int lineStart = 0; + int pos = 0; + while (pos < string.length()) { + final char cur = string.charAt(pos); + int nextPos = pos + 1; + if (nextPos < string.length() && cur == '\r' && string.charAt(nextPos) == '\n') { + nextPos++; // Skip \n after \r because \r\n is a single line separator + } + if (cur == '\r' || cur == '\n') { + lines.put(lineStart, new LineInfo(lineNum, pos)); + lineNum++; + lineStart = nextPos; + } + pos = nextPos; + } + // If the last line has no trailing separator, the loop won't add it to the map + if (lineStart < string.length()) { + lines.put(lineStart, new LineInfo(lineNum, string.length())); + } + + return lines; + } + + private Line lineFromMapEntry(final Map.Entry entry) { + final int start = entry.getKey(); + final LineInfo lineInfo = entry.getValue(); + return new Line( + lineInfo.number, + start, + text.substring(start, lineInfo.end) + ); + } + + private static class LineInfo { + public final int number; + public final int end; + + LineInfo(final int number, final int end) { + this.number = number; + this.end = end; + } + } + + /** + * Represents a line of text within a string. + */ + public static class Line { + private final int number; + private final int startPosition; + private final String text; + + Line(final int number, final int startPosition, final String text) { + this.number = number; + this.startPosition = startPosition; + this.text = text; + } + + /** + * @return The line number (starting from 1). + */ + public int getNumber() { + return number; + } + + /** + * @return The index at which this line starts within the original string. + * If the line is empty, this is the index of the terminator characters ("\r", "\n" or "\r\n"). + */ + public int getStartPosition() { + return startPosition; + } + + /** + * @return The contents of this line, without the terminator characters ("\r", "\n" or "\r\n"). + */ + public String getText() { + return text; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Line line = (Line) o; + return number == line.number && + startPosition == line.startPosition && + Objects.equals(text, line.text); + } + + @Override + public int hashCode() { + return Objects.hash(number, startPosition, text); + } + + @Override + public String toString() { + return new StringJoiner(", ", "Line{", "}") + .add("number=" + number) + .add("startPosition=" + startPosition) + .add("text='" + text + "'") + .toString(); + } + } +} diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/TabExpandedString.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/TabExpandedString.java new file mode 100644 index 00000000..4263d588 --- /dev/null +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/errorutils/TabExpandedString.java @@ -0,0 +1,60 @@ +package it.cavallium.warppi.math.rules.dsl.errorutils; + +import java.util.Arrays; + +/** + * Represents a string in which tabs have been expanded (replaced with spaces). + *

+ * Each tab character is replaced with the number of spaces required to get to the next tab stop + * (that is, the next column which is a multiple of the tab stop width). + */ +public class TabExpandedString { + private final String expanded; + private final int[] charWidths; + + /** + * Constructs a tab-expanded string with the given tab stop width. + * + * @param string The string to expand. + * @param tabWidth The tab stop width. + */ + public TabExpandedString(final String string, final int tabWidth) { + final StringBuilder builder = new StringBuilder(); + charWidths = new int[string.length()]; + + for (int i = 0; i < string.length(); i++) { + final char c = string.charAt(i); + charWidths[i] = 1; + + if (c == '\t') { + builder.append(' '); + while (builder.length() % tabWidth != 0) { + builder.append(' '); + charWidths[i]++; + } + } else { + builder.append(c); + } + } + + expanded = builder.toString(); + } + + /** + * @return The tab-expanded string. + */ + public String getExpanded() { + return expanded; + } + + /** + * Computes the length of a substring of the original string after tab expansion. + * + * @param beginIndex The beginning index (inclusive) within the original string. + * @param endIndex The ending index (exclusive) within the original string. + * @return The length of the specified substring, after tabs have been expanded. + */ + public int substringLength(final int beginIndex, final int endIndex) { + return Arrays.stream(charWidths, beginIndex, endIndex).sum(); + } +} diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/IncompleteNumberLiteral.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/IncompleteNumberLiteral.java index fd9966d2..44e61b10 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/IncompleteNumberLiteral.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/IncompleteNumberLiteral.java @@ -28,6 +28,11 @@ public class IncompleteNumberLiteral implements DslError { return literal.length(); } + @Override + public T accept(final DslError.Visitor visitor) { + return visitor.visit(this); + } + /** * @return The incomplete number literal. */ diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedCharacters.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedCharacters.java index 1d9e1b06..92766b13 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedCharacters.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedCharacters.java @@ -26,6 +26,11 @@ public class UnexpectedCharacters implements DslError { return unexpectedCharacters.length(); } + @Override + public T accept(final DslError.Visitor visitor) { + return visitor.visit(this); + } + /** * @return The string of one or more consecutive unexpected characters. */ diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedToken.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedToken.java index 17a35e67..c701b2a6 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedToken.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnexpectedToken.java @@ -32,6 +32,11 @@ public class UnexpectedToken implements DslError { return unexpected.lexeme.length(); } + @Override + public T accept(final DslError.Visitor visitor) { + return visitor.visit(this); + } + /** * @return The unexpected token. */ diff --git a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnterminatedComment.java b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnterminatedComment.java index 2ff9ce12..4bf2a691 100644 --- a/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnterminatedComment.java +++ b/core/src/main/java/it/cavallium/warppi/math/rules/dsl/frontend/UnterminatedComment.java @@ -24,6 +24,11 @@ public class UnterminatedComment implements DslError { return 2; // Length of comment start marker: "/*" } + @Override + public T accept(final DslError.Visitor visitor) { + return visitor.visit(this); + } + @Override public boolean equals(final Object o) { if (!(o instanceof UnterminatedComment)) { diff --git a/core/src/test/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMapTest.java b/core/src/test/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMapTest.java new file mode 100644 index 00000000..3f020611 --- /dev/null +++ b/core/src/test/java/it/cavallium/warppi/math/rules/dsl/errorutils/LineMapTest.java @@ -0,0 +1,141 @@ +package it.cavallium.warppi.math.rules.dsl.errorutils; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.*; + +public class LineMapTest { + @Test + public void emptyText() { + String text = ""; + LineMap map = new LineMap(text); + + assertEquals(Collections.emptyList(), map.getSpannedLines(0, text.length())); + } + + @Test + public void noLineSeparators() { + String text = "single line"; + LineMap map = new LineMap(text); + + List expected = Collections.singletonList( + new LineMap.Line(1, 0, text) + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void trailingLf() { + String text = "single line\n"; + LineMap map = new LineMap(text); + + List expected = Collections.singletonList( + new LineMap.Line(1, 0, "single line") + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void trailingCr() { + String text = "single line\r"; + LineMap map = new LineMap(text); + + List expected = Collections.singletonList( + new LineMap.Line(1, 0, "single line") + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void trailingCrLf() { + String text = "single line\r\n"; + LineMap map = new LineMap(text); + + List expected = Collections.singletonList( + new LineMap.Line(1, 0, "single line") + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void multipleNonEmptyLines() { + String text = "line 1\nline 2\rline 3\r\nline 4"; + LineMap map = new LineMap(text); + + List expected = Arrays.asList( + new LineMap.Line(1, 0, "line 1"), + new LineMap.Line(2, 7, "line 2"), + new LineMap.Line(3, 14, "line 3"), + new LineMap.Line(4, 22, "line 4") + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void singleEmptyLine() { + String text = "\n"; + LineMap map = new LineMap(text); + + List expected = Collections.singletonList( + new LineMap.Line(1, 0, "") + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void multipleEmptyLines() { + String text = "\r\n\n\r"; + LineMap map = new LineMap(text); + + List expected = Arrays.asList( + new LineMap.Line(1, 0, ""), // Terminated by \r\n + new LineMap.Line(2, 2, ""), // Terminated by \n + new LineMap.Line(3, 3, "") // Terminated by \r + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void mixedEmptyAndNonEmptyLines() { + String text = "line 1\nline 2\r\r\nline 4\n\n"; + LineMap map = new LineMap(text); + + List expected = Arrays.asList( + new LineMap.Line(1, 0, "line 1"), + new LineMap.Line(2, 7, "line 2"), + new LineMap.Line(3, 14, ""), + new LineMap.Line(4, 16, "line 4"), + new LineMap.Line(5, 23, "") + ); + assertEquals(expected, map.getSpannedLines(0, text.length())); + } + + @Test + public void emptySubstrings() { + String text = "single line\n"; + LineMap map = new LineMap(text); + + List expected = Collections.singletonList( + new LineMap.Line(1, 0, "single line") + ); + for (int start = 0; start <= text.length(); start++) { + assertEquals(expected, map.getSpannedLines(start, 0)); + } + } + + @Test + public void substringIsJustLineSeparator() { + String separator = "\n"; + String text = "line 1" + separator + "line 2"; + LineMap map = new LineMap(text); + + List expected = Collections.singletonList( + new LineMap.Line(1, 0, "line 1") + ); + assertEquals(expected, map.getSpannedLines(text.indexOf(separator), separator.length())); + } +} \ No newline at end of file