diff --git a/commonmark/src/main/java/org/commonmark/parser/beta/Scanner.java b/commonmark/src/main/java/org/commonmark/parser/beta/Scanner.java index 1acf39ced..91aaa6015 100644 --- a/commonmark/src/main/java/org/commonmark/parser/beta/Scanner.java +++ b/commonmark/src/main/java/org/commonmark/parser/beta/Scanner.java @@ -13,7 +13,8 @@ public class Scanner { * "previous" methods). * *

Note that we can use NULL to represent this because CommonMark does not allow those in the - * input (we replace them in the beginning of parsing). + * input (we replace them in the beginning of parsing). It is not a searchable source character: + * {@link #find(char)} and {@link #findPrev(char)} skip it instead of matching it. */ public static final char END = '\0'; @@ -119,6 +120,30 @@ public void next() { } } + /** + * Move back by one UTF-16 code unit. + * + *

This reverses {@link #next()}. Lines yield a synthetic newline between lines and no + * newline after the final line: from index zero the position moves to the previous line's + * length (that newline), and from the end of input it moves to the last character, or across + * an empty final line to the preceding newline. At the beginning of the input, including an + * empty input, the position is left unchanged. + */ + public void prev() { + if (index > 0) { + index--; + } else if (lineIndex > 0) { + lineIndex--; + if (lineIndex < lines.size()) { + setLine(lines.get(lineIndex)); + } else { + // Landed past the end (after next() at EOF); same representation as next(). + setLine(SourceLine.of("", null)); + } + index = lineLength; + } + } + /** * Check if the specified char is next and advance the position. * @@ -221,6 +246,100 @@ public int find(CharMatcher matcher) { } } + /** + * Find the nearest occurrence of {@code c} at or before the current position. + * + *

The current character is included, matching {@link #find(char)}, so an immediate match + * returns {@code 0}. {@link #END} is skipped and never matches. From the end of input a match + * on the character immediately before the end returns {@code 1}. A newline between lines can + * be matched. If {@code c} does not occur, the scanner is left at the beginning of the input + * and {@code -1} is returned. + * + * @param c character to find (including a newline) + * @return the number of UTF-16 code units moved backward, or {@code -1} if not found + */ + public int findPrev(char c) { + return findPrev(c, null, 0, 0); + } + + /** + * Find the nearest character matched by {@code matcher} at or before the current position. + * + *

See {@link #findPrev(char)} for the current position, {@link #END}, and a search that + * reaches the beginning of the input. {@link #END} is not passed to {@code matcher}. + * + * @param matcher matcher applied to each character + * @return the number of UTF-16 code units moved backward, or {@code -1} if not found + */ + public int findPrev(CharMatcher matcher) { + return findPrev(END, matcher, 0, 0); + } + + /** + * Find the nearest occurrence of {@code c} at or before the current position and at or after + * {@code bound}. + * + *

{@code bound} is inclusive: the character there is examined, and the search never moves + * before it. If {@code bound} is the current position, only that character is checked. If there + * is no match, the scanner is left at {@code bound} and {@code -1} is returned. {@link #END} is + * skipped and never matches. On an empty input the search returns {@code -1}; the position at + * the beginning of that input is a valid bound. + * + *

The bound is checked before the scanner moves. It must have valid coordinates for this + * scanner and must not be after the current position. As with {@link #setPosition(Position)}, + * positions are expected to come from the same scanner. + * + * @param c character to find (including a newline) + * @param bound inclusive lower bound + * @return the number of UTF-16 code units moved backward, or {@code -1} if not found + * @throws IllegalArgumentException if {@code bound} is after the current position or outside + * the input + */ + public int findPrev(char c, Position bound) { + checkLowerBound(bound); + return findPrev(c, null, bound.lineIndex, bound.index); + } + + /** + * Find the nearest character matched by {@code matcher} at or before the current position and + * at or after {@code bound}. + * + *

See {@link #findPrev(char, Position)} for the bound, {@link #END}, and a search with no + * match. {@link #END} is not passed to {@code matcher}. + * + * @param matcher matcher applied to each character + * @param bound inclusive lower bound + * @return the number of UTF-16 code units moved backward, or {@code -1} if not found + * @throws IllegalArgumentException if {@code bound} is after the current position or outside + * the input + */ + public int findPrev(CharMatcher matcher, Position bound) { + checkLowerBound(bound); + return findPrev(END, matcher, bound.lineIndex, bound.index); + } + + // matcher == null matches c exactly, so the char overloads allocate nothing per character. + private int findPrev(char c, CharMatcher matcher, int boundLine, int boundIndex) { + int count = 0; + while (true) { + char cur = peek(); + if (cur != END && (matcher != null ? matcher.matches(cur) : cur == c)) { + return count; + } + if (lineIndex == boundLine && index == boundIndex) { + return -1; + } + int lineBefore = lineIndex; + int indexBefore = index; + prev(); + // Beginning of input: prev() is a no-op, so stop instead of spinning. + if (lineIndex == lineBefore && index == indexBefore) { + return -1; + } + count++; + } + } + // Don't expose the int index, because it would be good if we could switch input to a // List of lines later instead of one contiguous String. public Position position() { @@ -280,4 +399,35 @@ private void checkPosition(int lineIndex, int index) { "Index " + index + " out of range, line length: " + line.getContent().length()); } } + + private void checkLowerBound(Position position) { + // Empty input has no lines; its only position is the canonical (0, 0) cursor. + if (lines.isEmpty()) { + if (position.lineIndex != 0) { + throw new IllegalArgumentException( + "Line index " + + position.lineIndex + + " out of range, number of lines: " + + lines.size()); + } + if (position.index != 0) { + throw new IllegalArgumentException( + "Index " + position.index + " out of range, line length: 0"); + } + } else { + checkPosition(position.lineIndex, position.index); + } + if (position.lineIndex > lineIndex + || (position.lineIndex == lineIndex && position.index > index)) { + throw new IllegalArgumentException( + "Bound line " + + position.lineIndex + + " index " + + position.index + + " is after the current position line " + + lineIndex + + " index " + + index); + } + } } diff --git a/commonmark/src/test/java/org/commonmark/parser/InlineContentParserTest.java b/commonmark/src/test/java/org/commonmark/parser/InlineContentParserTest.java index 167f74f25..fe4d03a17 100644 --- a/commonmark/src/test/java/org/commonmark/parser/InlineContentParserTest.java +++ b/commonmark/src/test/java/org/commonmark/parser/InlineContentParserTest.java @@ -9,6 +9,8 @@ import org.commonmark.parser.beta.InlineParserState; import org.commonmark.parser.beta.ParsedInline; import org.commonmark.test.Nodes; +import org.commonmark.text.AsciiMatcher; +import org.commonmark.text.CharMatcher; import org.junit.jupiter.api.Test; class InlineContentParserTest { @@ -55,6 +57,46 @@ void bangInlineContentParser() { assertThat(((Text) image.getNext().getNext().getNext()).getLiteral()).isEqualTo("notimage"); } + @Test + void backwardSearchInlineContentParser() { + var parser = + Parser.builder() + .customInlineContentParserFactory(new DestinationInlineParser.Factory()) + .build(); + + var doc = parser.parse("img(not-a-link-)more-text) more text"); + var inline = Nodes.find(doc, DestinationInline.class); + assertThat(((Text) inline.getPrevious()).getLiteral()).isEqualTo("img"); + assertThat(inline.getDestination()).isEqualTo("not-a-link-)more-text"); + assertThat(((Text) inline.getNext()).getLiteral()).isEqualTo(" more text"); + + doc = parser.parse("img(not-a-link-)more-text)"); + inline = Nodes.find(doc, DestinationInline.class); + assertThat(inline.getDestination()).isEqualTo("not-a-link-)more-text"); + assertThat(inline.getNext()).isNull(); + + doc = parser.parse("img(not-a-link) more text"); + inline = Nodes.find(doc, DestinationInline.class); + assertThat(inline.getDestination()).isEqualTo("not-a-link"); + assertThat(((Text) inline.getNext()).getLiteral()).isEqualTo(" more text"); + + doc = parser.parse("img() more text"); + inline = Nodes.find(doc, DestinationInline.class); + assertThat(inline.getDestination()).isEqualTo(""); + assertThat(((Text) inline.getNext()).getLiteral()).isEqualTo(" more text"); + + doc = parser.parse("img(not-a-link more text"); + assertThat(Nodes.tryFind(doc, DestinationInline.class)).isNull(); + assertThat(((Text) doc.getFirstChild().getFirstChild()).getLiteral()) + .isEqualTo("img(not-a-link more text"); + + // The ')' before the saved opening position is outside the inclusive bound. + doc = parser.parse("before)img(not-a-link more text"); + assertThat(Nodes.tryFind(doc, DestinationInline.class)).isNull(); + assertThat(((Text) doc.getFirstChild().getFirstChild()).getLiteral()) + .isEqualTo("before)img(not-a-link more text"); + } + private static class DollarInline extends CustomNode { private final String literal; private final int index; @@ -105,6 +147,55 @@ public InlineContentParser create() { } } + /** + * Custom image-like syntax {@code (destination)} whose destination may itself contain {@code + * )}. The closer is the last {@code )} before whitespace or the end of input, and it must not + * be before the opening parenthesis. + */ + private static class DestinationInline extends CustomNode { + private final String destination; + + public DestinationInline(String destination) { + this.destination = destination; + } + + public String getDestination() { + return destination; + } + } + + private static class DestinationInlineParser implements InlineContentParser { + + private static final CharMatcher WHITESPACE = + AsciiMatcher.builder().anyOf(" \t\n\u000B\f\r").build(); + + @Override + public ParsedInline tryParse(InlineParserState inlineParserState) { + var scanner = inlineParserState.scanner(); + scanner.next(); + var contentStart = scanner.position(); + scanner.find(WHITESPACE); + if (scanner.findPrev(')', contentStart) == -1) { + return ParsedInline.none(); + } + var destination = scanner.getSource(contentStart, scanner.position()).getContent(); + scanner.next(); + return ParsedInline.of(new DestinationInline(destination), scanner.position()); + } + + static class Factory implements InlineContentParserFactory { + @Override + public Set getTriggerCharacters() { + return Set.of('('); + } + + @Override + public InlineContentParser create() { + return new DestinationInlineParser(); + } + } + } + private static class BangInline extends CustomNode {} private static class BangInlineParser implements InlineContentParser { diff --git a/commonmark/src/test/java/org/commonmark/parser/beta/ScannerTest.java b/commonmark/src/test/java/org/commonmark/parser/beta/ScannerTest.java index 437812866..44f94c957 100644 --- a/commonmark/src/test/java/org/commonmark/parser/beta/ScannerTest.java +++ b/commonmark/src/test/java/org/commonmark/parser/beta/ScannerTest.java @@ -1,11 +1,13 @@ package org.commonmark.parser.beta; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; import org.commonmark.node.SourceSpan; import org.commonmark.parser.SourceLine; import org.commonmark.parser.SourceLines; +import org.commonmark.text.CharMatcher; import org.junit.jupiter.api.Test; class ScannerTest { @@ -144,6 +146,471 @@ private void assertSourceLines( assertThat(sourceLines.getSourceSpans()).isEqualTo(List.of(expectedSourceSpans)); } + @Test + void testPrev() { + Scanner scanner = Scanner.of(SourceLines.of(SourceLine.of("abc", null))); + Position start = scanner.position(); + scanner.next(); + Position b = scanner.position(); + assertThat(scanner.peek()).isEqualTo('b'); + scanner.next(); + Position c = scanner.position(); + scanner.next(); + Position eof = scanner.position(); + assertThat(scanner.peek()).isEqualTo('\0'); + assertThat(scanner.hasNext()).isFalse(); + + scanner.prev(); + assertThat(scanner.position()).isEqualTo(c); + assertThat(scanner.peek()).isEqualTo('c'); + scanner.prev(); + assertThat(scanner.position()).isEqualTo(b); + assertThat(scanner.peek()).isEqualTo('b'); + scanner.prev(); + assertThat(scanner.position()).isEqualTo(start); + assertThat(scanner.peek()).isEqualTo('a'); + + // No-op at the beginning. + scanner.prev(); + assertThat(scanner.position()).isEqualTo(start); + assertThat(scanner.peek()).isEqualTo('a'); + + scanner.next(); + scanner.prev(); + assertThat(scanner.position()).isEqualTo(start); + assertThat(scanner.peek()).isEqualTo('a'); + + scanner.setPosition(eof); + scanner.prev(); + scanner.next(); + assertThat(scanner.position()).isEqualTo(eof); + assertThat(scanner.peek()).isEqualTo('\0'); + + Scanner empty = Scanner.of(SourceLines.empty()); + Position emptyPos = empty.position(); + empty.prev(); + assertThat(empty.position()).isEqualTo(emptyPos); + assertThat(empty.peek()).isEqualTo('\0'); + assertThat(empty.hasNext()).isFalse(); + + Scanner emptyLine = Scanner.of(SourceLines.of(SourceLine.of("", null))); + Position emptyLinePos = emptyLine.position(); + assertThat(emptyLine.peek()).isEqualTo('\0'); + assertThat(emptyLine.hasNext()).isFalse(); + emptyLine.prev(); + assertThat(emptyLine.position()).isEqualTo(emptyLinePos); + assertThat(emptyLine.peek()).isEqualTo('\0'); + } + + @Test + void testPrevAcrossLines() { + Scanner scanner = + Scanner.of( + SourceLines.of( + List.of(SourceLine.of("ab", null), SourceLine.of("c", null)))); + Position start = scanner.position(); + scanner.next(); + assertThat(scanner.peek()).isEqualTo('b'); + scanner.next(); + Position newline = scanner.position(); + assertThat(scanner.peek()).isEqualTo('\n'); + scanner.next(); + assertThat(scanner.peek()).isEqualTo('c'); + scanner.next(); + assertThat(scanner.peek()).isEqualTo('\0'); + assertThat(scanner.hasNext()).isFalse(); + + scanner.prev(); + assertThat(scanner.peek()).isEqualTo('c'); + scanner.prev(); + assertThat(scanner.position()).isEqualTo(newline); + assertThat(scanner.peek()).isEqualTo('\n'); + scanner.prev(); + assertThat(scanner.peek()).isEqualTo('b'); + scanner.next(); + assertThat(scanner.position()).isEqualTo(newline); + assertThat(scanner.peek()).isEqualTo('\n'); + scanner.prev(); + scanner.prev(); + assertThat(scanner.position()).isEqualTo(start); + assertThat(scanner.peek()).isEqualTo('a'); + scanner.prev(); + assertThat(scanner.position()).isEqualTo(start); + + // Unequal lengths: cached line content/length must follow the line we step onto. + Scanner uneven = + Scanner.of( + SourceLines.of( + List.of(SourceLine.of("a", null), SourceLine.of("bbb", null)))); + uneven.next(); + uneven.next(); + assertThat(uneven.peek()).isEqualTo('b'); + uneven.prev(); + assertThat(uneven.peek()).isEqualTo('\n'); + uneven.prev(); + assertThat(uneven.peek()).isEqualTo('a'); + uneven.next(); + assertThat(uneven.peek()).isEqualTo('\n'); + uneven.next(); + uneven.next(); + uneven.next(); + uneven.next(); + assertThat(uneven.peek()).isEqualTo('\0'); + uneven.prev(); + assertThat(uneven.peek()).isEqualTo('b'); + + // Consecutive empty lines, including an empty final line: no newline after the last line. + Scanner blanks = + Scanner.of( + SourceLines.of( + List.of( + SourceLine.of("", null), + SourceLine.of("", null), + SourceLine.of("", null)))); + Position blanksStart = blanks.position(); + assertThat(blanks.peek()).isEqualTo('\n'); + blanks.next(); + assertThat(blanks.peek()).isEqualTo('\n'); + blanks.next(); + assertThat(blanks.peek()).isEqualTo('\0'); + assertThat(blanks.hasNext()).isFalse(); + blanks.prev(); + assertThat(blanks.peek()).isEqualTo('\n'); + blanks.prev(); + assertThat(blanks.position()).isEqualTo(blanksStart); + assertThat(blanks.peek()).isEqualTo('\n'); + blanks.next(); + blanks.next(); + assertThat(blanks.peek()).isEqualTo('\0'); + + // Empty final line steps back onto the preceding newline, not a made-up terminal one. + Scanner trailingBlank = + Scanner.of( + SourceLines.of( + List.of(SourceLine.of("a", null), SourceLine.of("", null)))); + Position trailingStart = trailingBlank.position(); + trailingBlank.next(); + Position precedingNewline = trailingBlank.position(); + assertThat(trailingBlank.peek()).isEqualTo('\n'); + trailingBlank.next(); + assertThat(trailingBlank.peek()).isEqualTo('\0'); + assertThat(trailingBlank.hasNext()).isFalse(); + trailingBlank.prev(); + assertThat(trailingBlank.position()).isEqualTo(precedingNewline); + assertThat(trailingBlank.peek()).isEqualTo('\n'); + trailingBlank.next(); + assertThat(trailingBlank.peek()).isEqualTo('\0'); + trailingBlank.prev(); + trailingBlank.prev(); + assertThat(trailingBlank.position()).isEqualTo(trailingStart); + assertThat(trailingBlank.peek()).isEqualTo('a'); + } + + @Test + void testFindPrev() { + Scanner scanner = Scanner.of(SourceLines.of(SourceLine.of("abXcdX", null))); + Position start = scanner.position(); + assertThat(scanner.findPrev('a')).isEqualTo(0); + assertThat(scanner.position()).isEqualTo(start); + assertThat(scanner.findPrev(c -> c == 'a')).isEqualTo(0); + assertThat(scanner.position()).isEqualTo(start); + + scanner.next(); + scanner.next(); + Position firstX = scanner.position(); + assertThat(scanner.peek()).isEqualTo('X'); + assertThat(scanner.findPrev('X')).isEqualTo(0); + assertThat(scanner.position()).isEqualTo(firstX); + + scanner.next(); + scanner.next(); + // Between the two matches: the nearer (earlier) X, not one after the cursor. + assertThat(scanner.peek()).isEqualTo('d'); + assertThat(scanner.findPrev('X')).isEqualTo(2); + assertThat(scanner.position()).isEqualTo(firstX); + + scanner.next(); + scanner.next(); + scanner.next(); + Position secondX = scanner.position(); + assertThat(scanner.peek()).isEqualTo('X'); + scanner.next(); + assertThat(scanner.peek()).isEqualTo('\0'); + // Match immediately before EOF counts the step off the sentinel. + assertThat(scanner.findPrev('X')).isEqualTo(1); + assertThat(scanner.position()).isEqualTo(secondX); + assertThat(scanner.peek()).isEqualTo('X'); + + scanner.next(); + assertThat(scanner.findPrev(c -> c == 'X')).isEqualTo(1); + assertThat(scanner.position()).isEqualTo(secondX); + + assertThat(scanner.findPrev('z')).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(start); + assertThat(scanner.peek()).isEqualTo('a'); + + scanner.setPosition(secondX); + scanner.next(); + assertThat(scanner.findPrev(Scanner.END)).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(start); + scanner.setPosition(secondX); + scanner.next(); + assertThat(scanner.findPrev(c -> c == Scanner.END)).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(start); + + CharMatcher any = c -> true; + scanner.setPosition(secondX); + assertThat(scanner.findPrev(any)).isEqualTo(0); + assertThat(scanner.position()).isEqualTo(secondX); + scanner.next(); + assertThat(scanner.findPrev(any)).isEqualTo(1); + assertThat(scanner.position()).isEqualTo(secondX); + assertThat(scanner.peek()).isEqualTo('X'); + + Scanner lines = + Scanner.of( + SourceLines.of( + List.of(SourceLine.of("ab", null), SourceLine.of("c", null)))); + lines.next(); + lines.next(); + Position newline = lines.position(); + lines.next(); + lines.next(); + assertThat(lines.peek()).isEqualTo('\0'); + assertThat(lines.findPrev('\n')).isEqualTo(2); + assertThat(lines.position()).isEqualTo(newline); + assertThat(lines.peek()).isEqualTo('\n'); + assertThat(lines.findPrev(c -> c == '\n')).isEqualTo(0); + assertThat(lines.position()).isEqualTo(newline); + + Scanner empty = Scanner.of(SourceLines.empty()); + Position emptyPos = empty.position(); + assertThat(empty.findPrev('a')).isEqualTo(-1); + assertThat(empty.findPrev(Scanner.END)).isEqualTo(-1); + assertThat(empty.findPrev(any)).isEqualTo(-1); + assertThat(empty.findPrev(c -> c == Scanner.END)).isEqualTo(-1); + assertThat(empty.position()).isEqualTo(emptyPos); + assertThat(empty.peek()).isEqualTo('\0'); + + Scanner text = Scanner.of(SourceLines.of(SourceLine.of("a", null))); + text.next(); + assertThat(text.findPrev(any)).isEqualTo(1); + assertThat(text.peek()).isEqualTo('a'); + } + + @Test + void testFindPrevBounds() { + Scanner scanner = Scanner.of(SourceLines.of(SourceLine.of("aXbYc", null))); + Position start = scanner.position(); + scanner.next(); + Position x = scanner.position(); + scanner.next(); + scanner.next(); + Position y = scanner.position(); + scanner.next(); + scanner.next(); + Position eof = scanner.position(); + assertThat(scanner.peek()).isEqualTo('\0'); + + // Inclusive bound on the same line, including a match sitting on the bound. + assertThat(scanner.findPrev('Y', x)).isEqualTo(2); + assertThat(scanner.position()).isEqualTo(y); + scanner.setPosition(eof); + assertThat(scanner.findPrev('Y', y)).isEqualTo(2); + assertThat(scanner.position()).isEqualTo(y); + assertThat(scanner.peek()).isEqualTo('Y'); + + // A match before the bound is excluded; a miss stays on the bound. + scanner.setPosition(eof); + assertThat(scanner.findPrev('X', y)).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(y); + assertThat(scanner.peek()).isEqualTo('Y'); + scanner.setPosition(eof); + assertThat(scanner.findPrev('z', y)).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(y); + + // Start and bound are the same position: only that character is checked. + assertThat(scanner.findPrev('Y', y)).isEqualTo(0); + assertThat(scanner.position()).isEqualTo(y); + assertThat(scanner.findPrev('X', y)).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(y); + scanner.setPosition(eof); + assertThat(scanner.findPrev('c', eof)).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(eof); + assertThat(scanner.peek()).isEqualTo('\0'); + + scanner.setPosition(eof); + assertThat(scanner.findPrev(c -> c == 'Y', x)).isEqualTo(2); + assertThat(scanner.position()).isEqualTo(y); + scanner.setPosition(eof); + assertThat(scanner.findPrev(c -> c == 'X', y)).isEqualTo(-1); + assertThat(scanner.position()).isEqualTo(y); + assertThat(scanner.findPrev(c -> c == 'Y', y)).isEqualTo(0); + + // Across lines the bound is still inclusive and stops the search. + Scanner lines = + Scanner.of( + SourceLines.of( + List.of(SourceLine.of("aXb", null), SourceLine.of("cYd", null)))); + Position lineStart = lines.position(); + lines.next(); + Position lineX = lines.position(); + lines.next(); + lines.next(); + Position newline = lines.position(); + assertThat(lines.peek()).isEqualTo('\n'); + lines.next(); + Position secondStart = lines.position(); + lines.next(); + Position lineY = lines.position(); + lines.next(); + lines.next(); + Position linesEof = lines.position(); + + assertThat(lines.findPrev('Y', newline)).isEqualTo(2); + assertThat(lines.position()).isEqualTo(lineY); + lines.setPosition(linesEof); + assertThat(lines.findPrev('\n', lineStart)).isEqualTo(4); + assertThat(lines.position()).isEqualTo(newline); + assertThat(lines.peek()).isEqualTo('\n'); + + lines.setPosition(linesEof); + assertThat(lines.findPrev('X', newline)).isEqualTo(-1); + assertThat(lines.position()).isEqualTo(newline); + assertThat(lines.peek()).isEqualTo('\n'); + lines.setPosition(linesEof); + assertThat(lines.findPrev('a', secondStart)).isEqualTo(-1); + assertThat(lines.position()).isEqualTo(secondStart); + + lines.setPosition(linesEof); + assertThat(lines.findPrev('a', lineStart)).isEqualTo(7); + assertThat(lines.position()).isEqualTo(lineStart); + lines.setPosition(linesEof); + assertThat(lines.findPrev(c -> c == '\n', newline)).isEqualTo(4); + assertThat(lines.position()).isEqualTo(newline); + lines.setPosition(linesEof); + assertThat(lines.findPrev(c -> c == 'X', secondStart)).isEqualTo(-1); + assertThat(lines.position()).isEqualTo(secondStart); + + // Future and out-of-range bounds throw before the cursor moves. + lines.setPosition(lineX); + assertThat(lines.peek()).isEqualTo('X'); + assertThatThrownBy(() -> lines.findPrev('a', lineY)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> lines.findPrev(c -> true, linesEof)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> lines.findPrev('a', new Position(-1, 0))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> lines.findPrev('a', new Position(0, -1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> lines.findPrev('a', new Position(0, 4))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> lines.findPrev('a', new Position(2, 0))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(lines.position()).isEqualTo(lineX); + assertThat(lines.peek()).isEqualTo('X'); + + Scanner empty = Scanner.of(SourceLines.empty()); + Position origin = empty.position(); + assertThat(empty.findPrev('x', origin)).isEqualTo(-1); + assertThat(empty.findPrev(c -> true, origin)).isEqualTo(-1); + assertThat(empty.findPrev(Scanner.END, origin)).isEqualTo(-1); + assertThat(empty.position()).isEqualTo(origin); + assertThatThrownBy(() -> empty.findPrev('x', new Position(0, 1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> empty.findPrev(c -> true, new Position(1, 0))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(empty.position()).isEqualTo(origin); + assertThat(empty.peek()).isEqualTo('\0'); + + // Same-line bound equal to the start still accepts the character there. + scanner.setPosition(start); + assertThat(scanner.findPrev('a', start)).isEqualTo(0); + assertThat(scanner.position()).isEqualTo(start); + } + + @Test + void testCodeUnitMovement() { + Scanner scanner = Scanner.of(SourceLines.of(SourceLine.of("A\uD83D\uDE0AZ", null))); + assertThat(scanner.peek()).isEqualTo('A'); + assertThat(scanner.peekCodePoint()).isEqualTo('A'); + assertThat(scanner.peekPreviousCodePoint()).isEqualTo('\0'); + + scanner.next(); + assertThat(scanner.peek()).isEqualTo('\uD83D'); + assertThat(scanner.peekCodePoint()).isEqualTo(128522); + assertThat(scanner.peekPreviousCodePoint()).isEqualTo('A'); + + scanner.next(); + assertThat(scanner.peek()).isEqualTo('\uDE0A'); + assertThat(scanner.peekCodePoint()).isEqualTo('\uDE0A'); + assertThat(scanner.peekPreviousCodePoint()).isEqualTo('\uD83D'); + + scanner.next(); + assertThat(scanner.peek()).isEqualTo('Z'); + // Previous code point reassembles the surrogate pair behind this code unit. + assertThat(scanner.peekPreviousCodePoint()).isEqualTo(128522); + scanner.next(); + assertThat(scanner.hasNext()).isFalse(); + assertThat(scanner.peek()).isEqualTo('\0'); + assertThat(scanner.peekCodePoint()).isEqualTo('\0'); + assertThat(scanner.peekPreviousCodePoint()).isEqualTo('Z'); + + scanner.prev(); + assertThat(scanner.peek()).isEqualTo('Z'); + scanner.prev(); + assertThat(scanner.peek()).isEqualTo('\uDE0A'); + assertThat(scanner.peekCodePoint()).isEqualTo('\uDE0A'); + assertThat(scanner.peekPreviousCodePoint()).isEqualTo('\uD83D'); + scanner.prev(); + assertThat(scanner.peek()).isEqualTo('\uD83D'); + assertThat(scanner.peekCodePoint()).isEqualTo(128522); + assertThat(scanner.peekPreviousCodePoint()).isEqualTo('A'); + + scanner.next(); + assertThat(scanner.peek()).isEqualTo('\uDE0A'); + scanner.prev(); + assertThat(scanner.peek()).isEqualTo('\uD83D'); + assertThat(scanner.peekCodePoint()).isEqualTo(128522); + + while (scanner.hasNext()) { + scanner.next(); + } + assertThat(scanner.findPrev('\uDE0A')).isEqualTo(2); + assertThat(scanner.peek()).isEqualTo('\uDE0A'); + assertThat(scanner.peekCodePoint()).isEqualTo('\uDE0A'); + while (scanner.hasNext()) { + scanner.next(); + } + assertThat(scanner.findPrev('\uD83D')).isEqualTo(3); + assertThat(scanner.peek()).isEqualTo('\uD83D'); + assertThat(scanner.peekCodePoint()).isEqualTo(128522); + assertThat(scanner.peekPreviousCodePoint()).isEqualTo('A'); + } + + @Test + void testFindPrevSource() { + Scanner scanner = + new Scanner( + List.of( + SourceLine.of("ab", SourceSpan.of(10, 3, 13, 2)), + SourceLine.of("cde", SourceSpan.of(11, 4, 20, 3))), + 0, + 0); + while (scanner.hasNext()) { + scanner.next(); + } + Position end = scanner.position(); + assertThat(scanner.peek()).isEqualTo('\0'); + assertThat(scanner.findPrev('b')).isEqualTo(5); + assertThat(scanner.peek()).isEqualTo('b'); + assertSourceLines( + scanner.getSource(scanner.position(), end), + "b\ncde", + SourceSpan.of(10, 4, 14, 1), + SourceSpan.of(11, 4, 20, 3)); + } + @Test void nextString() { Scanner scanner =