mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 02:16:21 +00:00
* perf(search_text): precompute line offsets for O(log n) line lookup
search_for_pattern resolved each match's line number by rescanning the file from the beginning (TextUtils.get_line_from_index walks a TextStepper from index 0), making coordinate resolution O(n) per match and O(n*m) for m matches. search_text now precomputes line start offsets once per file (O(n)) and resolves each match via binary search (O(log n) per match). The precomputed table mirrors TextStepper line semantics exactly: \n, \r\n and bare \r are line separators, and an index pointing at the \n of a \r\n pair resolves to (next line, 0) — the same rule TextUtils.get_line_col_from_index implements, now pinned by regression tests (including the mixed line-ending edge cases from the insert_text_at_position fix). On a synthetic 12k-line file (494 KB) with 723 matches, match-coordinate resolution drops from ~1764 ms to ~2 ms (~1000x); the one-off precompute costs ~1.1 ms. Small files regress nothing (100 lines, 5 matches: 70 µs -> 11 µs). Results are byte-for-byte identical to the previous implementation. Note: compatible with PR #1899 (interruptible agent regexes): that PR touches the re.compile call site in search_text and the ContentReplacer helpers, this PR touches the post-match line-coordinate resolution; the two changes are complementary and rebase cleanly onto each other. * perf(search_text): add reproducible benchmark script scripts/profile_search_text.py regenerates the synthetic benchmark content with a fixed seed (no real-world code), asserts both resolution paths produce identical results, and prints the before/after timings quoted in the PR description. Default: 12k lines / ~1830 matches; optional CLI args scale the file down for quick runs. * refactor(ls): encapsulate cached text coordinates in TextCoordinates abstraction Address review feedback on the search_text line-lookup PR: the previously added module-level helpers duplicated TextStepper's line-ending logic and exposed raw offsets as a low-level data structure shared between two functions, violating the project's software design principles (each concern in exactly one home; dataclasses instead of tuples for simple data storage). Introduce in solidlsp/ls_utils.py: - LineCol: frozen, kw-only dataclass for a 0-based line/column position - TextCoordinates: encapsulates the cached table of line start offsets, built via TextStepper (step_line + line_start_idx), with a single public method line_col_at_index() resolving an index via binary search, including the CRLF rule (an index pointing at the '\n' of a '\r\n' pair denotes the beginning of the next line, column 0) and InvalidTextLocationError for out-of-range indices search_text now instantiates TextCoordinates once per file and resolves each match's coordinates through the public method; the private helpers (_compute_line_starts, _line_col_at_index) are removed. Equivalence with TextUtils.get_line_col_from_index verified across mixed line endings, empty content and boundary indices (0 mismatches). Tests are behaviour-anchored: the implementation-coupled parametrized test importing the private helpers is replaced by public-contract tests of TextCoordinates (test/solidlsp/test_ls_utils.py) and absolute line assertions through the public search_text API. The benchmark script uses the public API as well. Benchmark (synthetic 12k lines, ~723 matches, best of 5): coordinate resolution ~1764 ms -> ~2 ms; precompute ~1.1 ms; small files unchanged. Semantics are unchanged; results are identical to the previously proposed implementation and to the upstream reference implementation. * fix(test): rename test_ls_utils to test_text_coordinates to avoid basename collision The new test file collided with the pre-existing upstream test/solidlsp/util/test_ls_utils.py: pytest (prepend import mode, no __init__.py in the test dirs) requires unique basenames, and collecting both modules produced an import-file-mismatch error that aborted collection with exit code 2 across every CI matrix job. Rename to test_text_coordinates.py (unique, and matches the class under test). Full-suite collection verified locally (2252 tests, 0 errors).
This commit is contained in:
1 parent
704e8c3d19
commit
fa79a2260f
6 files changed
+329
-4
No files matched your search
@@ -190,6 +190,41 @@ class TestSearchText:
|
||||
|
||||
assert len(matches) == 0
|
||||
|
||||
def test_search_text_crlf_line_numbers(self):
|
||||
"""Matches in CRLF content report the same line numbers as with LF endings."""
|
||||
crlf = "alpha\r\nbeta\r\ngamma\r\n"
|
||||
lf = "alpha\nbeta\ngamma\n"
|
||||
crlf_matches = search_text("beta", content=crlf)
|
||||
lf_matches = search_text("beta", content=lf)
|
||||
assert len(crlf_matches) == 1
|
||||
assert len(lf_matches) == 1
|
||||
assert crlf_matches[0].start_line == lf_matches[0].start_line == 1
|
||||
assert crlf_matches[0].end_line == lf_matches[0].end_line == 1
|
||||
|
||||
def test_search_text_bare_cr_line_numbers(self):
|
||||
r"""Bare \r line endings produce separate lines, matching TextStepper semantics."""
|
||||
content = "alpha\rbeta\r"
|
||||
matches = search_text("beta", content=content)
|
||||
assert len(matches) == 1
|
||||
assert matches[0].start_line == 1
|
||||
assert matches[0].end_line == 1
|
||||
|
||||
def test_search_text_match_at_boundaries(self):
|
||||
"""Matches at the very start and very end of the content resolve to sane line numbers."""
|
||||
content = "first\nmiddle\nlast"
|
||||
first = search_text("first", content=content)
|
||||
assert first[0].start_line == 0
|
||||
last = search_text("last", content=content)
|
||||
assert last[0].start_line == 2
|
||||
|
||||
def test_search_text_multiline_match_line_range(self):
|
||||
"""A multiline match spanning several lines reports the full matched range."""
|
||||
content = "a\nTARGET_START\nb\nc\nTARGET_END\nd\n"
|
||||
matches = search_text("TARGET_START[\\s\\S]*?TARGET_END", content=content)
|
||||
assert len(matches) == 1
|
||||
assert matches[0].start_line == 1
|
||||
assert matches[0].end_line == 4
|
||||
|
||||
|
||||
# Mock file reader that always returns matching content
|
||||
def mock_reader_always_match(file_path: str) -> str:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
|
||||
from solidlsp.ls_utils import InvalidTextLocationError, LineCol, TextCoordinates
|
||||
|
||||
|
||||
class TestTextCoordinates:
|
||||
"""Tests for the public contract of TextCoordinates and LineCol."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
"hello\nworld\n",
|
||||
"hello\r\nworld\r\n",
|
||||
"hello\rworld\r",
|
||||
"a\rb\nc\r\nd\r\n",
|
||||
"",
|
||||
"no newline at end",
|
||||
"\n",
|
||||
"\r",
|
||||
"a\r\n\r\nb",
|
||||
],
|
||||
)
|
||||
def test_line_col_at_index_end_to_end(self, content):
|
||||
"""Every index within the text resolves to a position that is consistent with the line layout."""
|
||||
coordinates = TextCoordinates(content)
|
||||
for index in range(len(content) + 1):
|
||||
loc = coordinates.line_col_at_index(index)
|
||||
assert loc.line >= 0
|
||||
assert loc.col >= 0
|
||||
# a col greater than 0 implies that none of the col characters before the index is a line separator
|
||||
if loc.col > 0:
|
||||
preceding = content[index - loc.col : index]
|
||||
assert "\n" not in preceding and "\r" not in preceding
|
||||
|
||||
def test_line_col_at_index_basic_lf(self):
|
||||
"""Coordinates for LF-only content match the manually derived line layout."""
|
||||
coordinates = TextCoordinates("alpha\nbeta\ngamma\n")
|
||||
assert coordinates.line_col_at_index(0) == LineCol(line=0, col=0)
|
||||
assert coordinates.line_col_at_index(3) == LineCol(line=0, col=3)
|
||||
assert coordinates.line_col_at_index(5) == LineCol(line=0, col=5)
|
||||
assert coordinates.line_col_at_index(6) == LineCol(line=1, col=0)
|
||||
assert coordinates.line_col_at_index(8) == LineCol(line=1, col=2)
|
||||
assert coordinates.line_col_at_index(11) == LineCol(line=2, col=0)
|
||||
assert coordinates.line_col_at_index(12) == LineCol(line=2, col=1)
|
||||
assert coordinates.line_col_at_index(16) == LineCol(line=2, col=5)
|
||||
# an index at the end of the text (after the trailing newline) denotes the start of a new line
|
||||
assert coordinates.line_col_at_index(17) == LineCol(line=3, col=0)
|
||||
|
||||
def test_line_col_at_index_crlf(self):
|
||||
r"""CRLF sequences count as a single line ending. An index pointing at the "\n" of a pair denotes
|
||||
the beginning of the following line (column 0), whereas the "\r" itself still belongs to the
|
||||
preceding line.
|
||||
"""
|
||||
coordinates = TextCoordinates("alpha\r\nbeta\r\n")
|
||||
assert coordinates.line_col_at_index(0) == LineCol(line=0, col=0)
|
||||
assert coordinates.line_col_at_index(5) == LineCol(line=0, col=5)
|
||||
assert coordinates.line_col_at_index(6) == LineCol(line=1, col=0)
|
||||
assert coordinates.line_col_at_index(7) == LineCol(line=1, col=0)
|
||||
assert coordinates.line_col_at_index(8) == LineCol(line=1, col=1)
|
||||
assert coordinates.line_col_at_index(11) == LineCol(line=1, col=4)
|
||||
assert coordinates.line_col_at_index(12) == LineCol(line=2, col=0)
|
||||
assert coordinates.line_col_at_index(13) == LineCol(line=2, col=0)
|
||||
|
||||
def test_line_col_at_index_bare_cr(self):
|
||||
r"""Bare "\r" characters act as line separators."""
|
||||
coordinates = TextCoordinates("alpha\rbeta\r")
|
||||
assert coordinates.line_col_at_index(5) == LineCol(line=0, col=5)
|
||||
assert coordinates.line_col_at_index(6) == LineCol(line=1, col=0)
|
||||
assert coordinates.line_col_at_index(10) == LineCol(line=1, col=4)
|
||||
assert coordinates.line_col_at_index(11) == LineCol(line=2, col=0)
|
||||
|
||||
def test_line_col_at_index_mixed(self):
|
||||
"""Mixed line endings resolve consistently in a single text."""
|
||||
coordinates = TextCoordinates("a\r\nb\rc\nd\r\n")
|
||||
# "\r\n" ends line 0; "\r" ends line 1; "\n" ends line 2; "\r\n" ends line 3
|
||||
assert coordinates.line_col_at_index(0) == LineCol(line=0, col=0)
|
||||
assert coordinates.line_col_at_index(1) == LineCol(line=0, col=1)
|
||||
assert coordinates.line_col_at_index(2) == LineCol(line=1, col=0)
|
||||
assert coordinates.line_col_at_index(3) == LineCol(line=1, col=0)
|
||||
assert coordinates.line_col_at_index(4) == LineCol(line=1, col=1)
|
||||
assert coordinates.line_col_at_index(5) == LineCol(line=2, col=0)
|
||||
assert coordinates.line_col_at_index(6) == LineCol(line=2, col=1)
|
||||
assert coordinates.line_col_at_index(7) == LineCol(line=3, col=0)
|
||||
assert coordinates.line_col_at_index(8) == LineCol(line=3, col=1)
|
||||
assert coordinates.line_col_at_index(9) == LineCol(line=4, col=0)
|
||||
assert coordinates.line_col_at_index(10) == LineCol(line=4, col=0)
|
||||
|
||||
def test_line_col_at_index_empty_content(self):
|
||||
"""The only valid index in empty content resolves to the origin."""
|
||||
coordinates = TextCoordinates("")
|
||||
assert coordinates.line_col_at_index(0) == LineCol(line=0, col=0)
|
||||
with pytest.raises(InvalidTextLocationError):
|
||||
coordinates.line_col_at_index(1)
|
||||
|
||||
def test_line_col_at_index_out_of_range(self):
|
||||
"""Indices beyond the text length are rejected."""
|
||||
coordinates = TextCoordinates("abc")
|
||||
with pytest.raises(InvalidTextLocationError):
|
||||
coordinates.line_col_at_index(4)
|
||||
with pytest.raises(InvalidTextLocationError):
|
||||
coordinates.line_col_at_index(-1)
|
||||
Reference in new issue
Block a user