Implement DSL error pretty-printing

This commit is contained in:
Riccardo Azzolini 2019-08-06 19:01:00 +02:00
parent 788f9663e2
commit aeb0388925
15 changed files with 794 additions and 6 deletions

View File

@ -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);
}
try {
RulesDsl.makeRules(source).forEach(RulesManager::addRule);
} catch (DslAggregateException e) {
fileErrors.addFileErrors(file, source, e.getErrors());
}
}
if (fileErrors.hasErrors()) {
throw fileErrors;
}
}

View File

@ -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<DslError> 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) {
if (errors.isEmpty()) {
throw new IllegalArgumentException("The list of errors can't be empty");
}
this.errors = errors;
}

View File

@ -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 <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);
}
}

View File

@ -25,6 +25,11 @@ public class UndefinedSubFunction implements DslError {
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.
*/

View File

@ -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");
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}

View File

@ -28,6 +28,11 @@ public class IncompleteNumberLiteral implements DslError {
return literal.length();
}
@Override
public <T> T accept(final DslError.Visitor<T> visitor) {
return visitor.visit(this);
}
/**
* @return The incomplete number literal.
*/

View File

@ -26,6 +26,11 @@ public class UnexpectedCharacters implements DslError {
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.
*/

View File

@ -32,6 +32,11 @@ public class UnexpectedToken implements DslError {
return unexpected.lexeme.length();
}
@Override
public <T> T accept(final DslError.Visitor<T> visitor) {
return visitor.visit(this);
}
/**
* @return The unexpected token.
*/

View File

@ -24,6 +24,11 @@ public class UnterminatedComment implements DslError {
return 2; // Length of comment start marker: "/*"
}
@Override
public <T> T accept(final DslError.Visitor<T> visitor) {
return visitor.visit(this);
}
@Override
public boolean equals(final Object o) {
if (!(o instanceof UnterminatedComment)) {

View File

@ -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()));
}
}