LineIndex.java
/*
* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 Nikolas Charalambidis
*/
package dev.nichar.facepalm.engine.extractor;
import jakarta.annotation.Nonnull;
import java.util.Arrays;
import java.util.stream.IntStream;
/**
* Maps character offsets of a text to 1-based line numbers.
* Line break offsets are computed once, so each lookup is a binary search instead of a scan of the preceding text.
*
* <p>
* Line breaks are exactly those of the {@code \R} regex used to split files into lines: CR LF as one break, and
* each of LF, CR, VT (U+000B), FF (U+000C), NEL (U+0085), LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029).
* Multi-line findings therefore get the same line numbers as single-line ones.
*/
final class LineIndex {
/**
* Sorted offsets of the last character of every line break in the text (the {@code \n} of {@code \r\n}).
*/
private final int[] newlineOffsets;
LineIndex(@Nonnull final String content) {
this.newlineOffsets = IntStream.range(0, content.length())
.filter(i -> endsLineBreak(content, i))
.toArray();
}
/**
* Returns whether the character at {@code i} is the last character of a line break.
*/
private static boolean endsLineBreak(@Nonnull final String content, final int i) {
return switch (content.charAt(i)) {
// A \r followed by \n is part of one \r\n break, which ends at the \n.
case '\r' -> i + 1 >= content.length() || content.charAt(i + 1) != '\n';
case '\n', '\u000B', '\u000C', '\u0085', '\u2028', '\u2029' -> true;
default -> false;
};
}
/**
* Returns the 1-based line number containing the given character offset,
* i.e. the number of line breaks ending strictly before the offset, plus one.
*/
int lineNumberAt(final int offset) {
final int index = Arrays.binarySearch(newlineOffsets, offset);
// Found: the offset ends a line break, which belongs to its line. Not found: insertion point = breaks before.
final int newlinesBefore = index >= 0 ? index : -index - 1;
return newlinesBefore + 1;
}
}