Implement DSL error pretty-printing
This commit is contained in:
parent
788f9663e2
commit
aeb0388925
@ -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.functions.Variable.V_TYPE;
|
||||||
import it.cavallium.warppi.math.rules.dsl.DslAggregateException;
|
import it.cavallium.warppi.math.rules.dsl.DslAggregateException;
|
||||||
import it.cavallium.warppi.math.rules.dsl.RulesDsl;
|
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.rules.functions.*;
|
||||||
import it.cavallium.warppi.math.solver.MathSolver;
|
import it.cavallium.warppi.math.solver.MathSolver;
|
||||||
import it.cavallium.warppi.util.Error;
|
import it.cavallium.warppi.util.Error;
|
||||||
@ -41,8 +42,11 @@ public class RulesManager {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
loadDslRules();
|
loadDslRules();
|
||||||
} catch (IOException | DslAggregateException e) {
|
} catch (IOException | DslFilesException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
|
if (e instanceof DslFilesException) {
|
||||||
|
System.err.print(((DslFilesException) e).format());
|
||||||
|
}
|
||||||
Engine.getPlatform().exit(1);
|
Engine.getPlatform().exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,14 +70,14 @@ public class RulesManager {
|
|||||||
).forEach(RulesManager::addRule);
|
).forEach(RulesManager::addRule);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void loadDslRules() throws IOException, DslAggregateException {
|
private static void loadDslRules() throws IOException, DslFilesException {
|
||||||
final StorageUtils storageUtils = Engine.getPlatform().getStorageUtils();
|
final StorageUtils storageUtils = Engine.getPlatform().getStorageUtils();
|
||||||
|
|
||||||
final File dslRulesPath = storageUtils.get("rules/dsl/");
|
final File dslRulesPath = storageUtils.get("rules/dsl/");
|
||||||
if (!dslRulesPath.exists()) {
|
if (!dslRulesPath.exists()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final DslFilesException fileErrors = new DslFilesException();
|
||||||
for (final File file : storageUtils.walk(dslRulesPath)) {
|
for (final File file : storageUtils.walk(dslRulesPath)) {
|
||||||
if (!file.toString().endsWith(".rules")) {
|
if (!file.toString().endsWith(".rules")) {
|
||||||
continue;
|
continue;
|
||||||
@ -90,7 +94,15 @@ public class RulesManager {
|
|||||||
source = storageUtils.read(resource);
|
source = storageUtils.read(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
RulesDsl.makeRules(source).forEach(RulesManager::addRule);
|
RulesDsl.makeRules(source).forEach(RulesManager::addRule);
|
||||||
|
} catch (DslAggregateException e) {
|
||||||
|
fileErrors.addFileErrors(file, source, e.getErrors());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileErrors.hasErrors()) {
|
||||||
|
throw fileErrors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,16 +6,20 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* Thrown when processing DSL code which contains one or more errors.
|
* 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 {
|
public class DslAggregateException extends Exception {
|
||||||
private final List<DslError> errors;
|
private final List<DslError> errors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a <code>DslAggregateException</code> containing the specified list of errors.
|
* Constructs a <code>DslAggregateException</code> 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<DslError> errors) {
|
public DslAggregateException(final List<DslError> errors) {
|
||||||
|
if (errors.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("The list of errors can't be empty");
|
||||||
|
}
|
||||||
this.errors = errors;
|
this.errors = errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
package it.cavallium.warppi.math.rules.dsl;
|
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.
|
* Represents an error in DSL code.
|
||||||
*/
|
*/
|
||||||
@ -13,4 +18,26 @@ public interface DslError {
|
|||||||
* @return The length of the error in the source string.
|
* @return The length of the error in the source string.
|
||||||
*/
|
*/
|
||||||
int getLength();
|
int getLength();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts a <code>DslError.Visitor</code> by calling the correct overload of <code>visit</code>.
|
||||||
|
*
|
||||||
|
* @param visitor The visitor to be accepted.
|
||||||
|
* @param <T> The return type of the <code>visit</code> method.
|
||||||
|
* @return The value returned by <code>visit</code>.
|
||||||
|
*/
|
||||||
|
<T> T accept(Visitor<T> visitor);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a different overload of a method for each <code>DslError</code> implementation.
|
||||||
|
*
|
||||||
|
* @param <T> The return type of all <code>visit</code> method overloads.
|
||||||
|
*/
|
||||||
|
interface Visitor<T> {
|
||||||
|
T visit(IncompleteNumberLiteral incompleteNumberLiteral);
|
||||||
|
T visit(UndefinedSubFunction undefinedSubFunction);
|
||||||
|
T visit(UnexpectedCharacters unexpectedCharacters);
|
||||||
|
T visit(UnexpectedToken unexpectedToken);
|
||||||
|
T visit(UnterminatedComment unterminatedComment);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,11 @@ public class UndefinedSubFunction implements DslError {
|
|||||||
return identifier.lexeme.length();
|
return identifier.lexeme.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T accept(final DslError.Visitor<T> visitor) {
|
||||||
|
return visitor.visit(this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return The name of the undefined sub-function.
|
* @return The name of the undefined sub-function.
|
||||||
*/
|
*/
|
||||||
|
@ -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<String> {
|
||||||
|
/**
|
||||||
|
* Formats the given <code>DslError</code> 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");
|
||||||
|
}
|
||||||
|
}
|
@ -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<FileErrors> 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<DslError> errors) {
|
||||||
|
filesErrors.add(new FileErrors(file, source, errors));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if any errors have been registered.
|
||||||
|
* <p>
|
||||||
|
* Instances of this class should only be thrown as exceptions if they actually contain errors.
|
||||||
|
*
|
||||||
|
* @return <code>true</code> if at least one error has been added, otherwise <code>false</code>.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
@ -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.
|
||||||
|
* <p>
|
||||||
|
* 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<DslError> errors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a <code>FileErrors</code> 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<DslError> 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<DslError> getErrors() {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
}
|
@ -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<FileErrors> filesErrors) {
|
||||||
|
return filesErrors.stream()
|
||||||
|
.sorted(Comparator.comparing(FileErrors::getFile))
|
||||||
|
.flatMap(this::formatFileErrors)
|
||||||
|
.collect(Collectors.joining(System.lineSeparator()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Stream<String> 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<LineMap.Line> 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));
|
||||||
|
}
|
||||||
|
}
|
@ -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.
|
||||||
|
* <p>
|
||||||
|
* For each line, the number (starting from 1), start position and content are stored.
|
||||||
|
* <p>
|
||||||
|
* 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 <code>"abc\n\n"</code> contains two lines:
|
||||||
|
* <ul>
|
||||||
|
* <li> line 1 starts at position 0, and its content is <code>"abc"</code>;
|
||||||
|
* <li> line 2 starts at position 4 (the index of the second '\n'), and its content is <code>""</code> (the empty string).
|
||||||
|
* </ul>
|
||||||
|
* As a consequence of these criteria, an empty string has no lines.
|
||||||
|
*/
|
||||||
|
public class LineMap {
|
||||||
|
private final String text;
|
||||||
|
private final NavigableMap<Integer, LineInfo> lines;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a <code>LineMap</code> 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.
|
||||||
|
* <p>
|
||||||
|
* 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 (<code>length == 0</code>) still spans the line corresponding to
|
||||||
|
* its <code>startPosition</code>, 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:
|
||||||
|
* <ul>
|
||||||
|
* <li> <code>startPosition</code> is negative, or
|
||||||
|
* <li> <code>startPosition</code> is larger than the length of the original string, or
|
||||||
|
* <li> <code>length</code> is negative, or
|
||||||
|
* <li> there are less than <code>length</code> characters from <code>startPosition</code>
|
||||||
|
* to the end of the original string.
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public List<Line> 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<Integer, LineInfo> 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<Integer, LineInfo> spannedLines = lines.subMap(firstSpannedLine.getKey(), endPosition);
|
||||||
|
return spannedLines.entrySet().stream()
|
||||||
|
.map(this::lineFromMapEntry)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NavigableMap<Integer, LineInfo> splitLines(final String string) {
|
||||||
|
final TreeMap<Integer, LineInfo> 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<Integer, LineInfo> 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, <em>without</em> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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).
|
||||||
|
* <p>
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
@ -28,6 +28,11 @@ public class IncompleteNumberLiteral implements DslError {
|
|||||||
return literal.length();
|
return literal.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T accept(final DslError.Visitor<T> visitor) {
|
||||||
|
return visitor.visit(this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return The incomplete number literal.
|
* @return The incomplete number literal.
|
||||||
*/
|
*/
|
||||||
|
@ -26,6 +26,11 @@ public class UnexpectedCharacters implements DslError {
|
|||||||
return unexpectedCharacters.length();
|
return unexpectedCharacters.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T accept(final DslError.Visitor<T> visitor) {
|
||||||
|
return visitor.visit(this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return The string of one or more consecutive unexpected characters.
|
* @return The string of one or more consecutive unexpected characters.
|
||||||
*/
|
*/
|
||||||
|
@ -32,6 +32,11 @@ public class UnexpectedToken implements DslError {
|
|||||||
return unexpected.lexeme.length();
|
return unexpected.lexeme.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T accept(final DslError.Visitor<T> visitor) {
|
||||||
|
return visitor.visit(this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return The unexpected token.
|
* @return The unexpected token.
|
||||||
*/
|
*/
|
||||||
|
@ -24,6 +24,11 @@ public class UnterminatedComment implements DslError {
|
|||||||
return 2; // Length of comment start marker: "/*"
|
return 2; // Length of comment start marker: "/*"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> T accept(final DslError.Visitor<T> visitor) {
|
||||||
|
return visitor.visit(this);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean equals(final Object o) {
|
public boolean equals(final Object o) {
|
||||||
if (!(o instanceof UnterminatedComment)) {
|
if (!(o instanceof UnterminatedComment)) {
|
||||||
|
@ -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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> 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<LineMap.Line> expected = Collections.singletonList(
|
||||||
|
new LineMap.Line(1, 0, "line 1")
|
||||||
|
);
|
||||||
|
assertEquals(expected, map.getSpannedLines(text.indexOf(separator), separator.length()));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user