From fa79a2260fc568f07d1bd1146ea5fad4dd88e52e Mon Sep 17 00:00:00 2001 From: Guillermo <1905197+davalillo@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:18:23 +0200 Subject: [PATCH 1/2] * perf(search_text): precompute line offsets for O(log n) line lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 3 + scripts/profile_search_text.py | 110 +++++++++++++++++++++++++ src/serena/util/text_utils.py | 13 ++- src/solidlsp/ls_utils.py | 71 ++++++++++++++++ test/serena/test_text_utils.py | 35 ++++++++ test/solidlsp/test_text_coordinates.py | 101 +++++++++++++++++++++++ 6 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 scripts/profile_search_text.py create mode 100644 test/solidlsp/test_text_coordinates.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 258ad2fa..9eeb6567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: MCP `initialize` now reports Serena's version instead of the installed mcp SDK version (#1889) - Fix: Parallel agents auto-registering projects could overwrite each other's changes to the global project list in `serena_config.yml` + - Perf: `search_for_pattern` resolved each match's line number by rescanning the file from the + beginning (O(n) per match, O(n*m) total for m matches); coordinates are now resolved via the new + `TextCoordinates` abstraction (cached line starts + binary search) - Fix: `TextUtils.insert_text_at_position` returned a wrong position when the inserted text merged with an adjacent character into a single newline sequence (e.g. a `\n` inserted directly after an existing `\r`); the position is now determined from the resulting text diff --git a/scripts/profile_search_text.py b/scripts/profile_search_text.py new file mode 100644 index 00000000..47bdce5f --- /dev/null +++ b/scripts/profile_search_text.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Benchmark for search_text line-coordinate resolution (see PR "perf(search_text): precompute line offsets"). + +Reproduces the before/after numbers quoted in the PR description: + +- "before" resolves each match's line number via ``TextUtils.get_line_from_index``, + which walks a TextStepper from index 0 (O(n) per match, O(n*m) for m matches) +- "after" precomputes line start offsets once (O(n)) and resolves each match via + binary search (O(log n) per match) + +The content is fully synthetic and generated with a fixed seed: it contains no +code from any real project. Both paths must produce identical results; the script +asserts that before timing anything. + +Usage: uv run python scripts/profile_search_text.py [num_lines] [num_matches] +""" + +import random +import re +import sys +import time + +import solidlsp # noqa: F401 # imported first: solidlsp must resolve before serena.util.text_utils (known circular-import window) +from solidlsp.ls_utils import TextCoordinates, TextUtils + + +def generate_synthetic_content(num_lines: int, match_identifier: str, match_frequency: float = 0.15) -> str: + """ + Generates synthetic pseudo-code content with a fixed seed. + + :param num_lines: number of lines to generate + :param match_identifier: the identifier the benchmark pattern will search for + :param match_frequency: fraction of lines that reference the match identifier + :return: the synthetic content as a single string + """ + rng = random.Random(1234) # fixed seed for reproducibility + filler_identifiers = ["var_global_contador", "otra_funcion_aleatoria", "gestion_operaciones", "ajuste_niveles"] + lines = [] + for i in range(num_lines): + kind = rng.random() + if kind < match_frequency: + lines.append(f" double resultado_{i} = {match_identifier}({rng.randint(1, 999)});") + elif kind < match_frequency + 0.15: + lines.append(f" if ({filler_identifiers[rng.randrange(len(filler_identifiers))]} > {rng.randint(0, 100)}) {{") + elif kind < match_frequency + 0.25: + lines.append(" }") + elif kind < match_frequency + 0.40: + lines.append(f"// comment line {i} with filler text {rng.randint(1000, 9999)}") + else: + lines.append(f" int local_variable_{i} = {rng.randint(0, 5000)};") + return "\n".join(lines) + "\n" + + +def resolve_before(content: str, compiled_pattern: re.Pattern) -> list[tuple[int, int]]: + """Resolves line coordinates the way upstream currently does (TextStepper from index 0 per match).""" + results = [] + for match in compiled_pattern.finditer(content): + start_pos, end_pos = match.start(), match.end() + start_line_num = TextUtils.get_line_from_index(content, start_pos) + end_line_num = TextUtils.get_line_from_index(content, end_pos) + if end_line_num > start_line_num and TextUtils.get_line_col_from_index(content, end_pos)[1] == 0: + end_line_num -= 1 + results.append((start_line_num, end_line_num)) + return results + + +def resolve_after(content: str, compiled_pattern: re.Pattern) -> list[tuple[int, int]]: + """Resolves line coordinates the way this PR proposes (cached line starts + binary search).""" + coordinates = TextCoordinates(content) + results = [] + for match in compiled_pattern.finditer(content): + start_pos, end_pos = match.start(), match.end() + s = coordinates.line_col_at_index(start_pos).line + end_loc = coordinates.line_col_at_index(end_pos) + e = end_loc.line + if e > s and end_loc.col == 0: + e -= 1 + results.append((s, e)) + return results + + +def main() -> None: + num_lines = int(sys.argv[1]) if len(sys.argv) > 1 else 12000 + match_identifier = "funcion_calcula_parametro" + content = generate_synthetic_content(num_lines, match_identifier) + compiled_pattern = re.compile(match_identifier) + + num_matches = len(list(compiled_pattern.finditer(content))) + print(f"synthetic file: {content.count(chr(10)):,} lines, {len(content):,} chars, {num_matches} matches") + + before = resolve_before(content, compiled_pattern) + after = resolve_after(content, compiled_pattern) + assert before == after, "implementations disagree; this script only measures identical work" + print(f"results identical: OK ({len(before)} matches)") + + for name, fn in [ + ("before (TextStepper, O(n) per match)", lambda: resolve_before(content, compiled_pattern)), + ("after (bisect, O(log n) per match)", lambda: resolve_after(content, compiled_pattern)), + ]: + times = [] + for _ in range(5): + t0 = time.perf_counter() + fn() + times.append(time.perf_counter() - t0) + print(f"{name}: {min(times) * 1000:.2f} ms (best of 5)") + + +if __name__ == "__main__": + main() diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index e3ee0d1e..79000bf3 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -13,7 +13,7 @@ from joblib import Parallel, delayed from sensai.util.string import ToStringMixin from serena.util.file_proxy import FileCollection, FileProxy -from solidlsp.ls_utils import TextUtils +from solidlsp.ls_utils import TextCoordinates, TextUtils log = logging.getLogger(__name__) @@ -150,6 +150,10 @@ def search_text( lines = TextUtils.split_lines(content) total_lines = len(lines) + # precompute line start offsets once so that each match's coordinates can be resolved via binary search + # instead of re-scanning the text from the beginning for every match + coordinates = TextCoordinates(content) + # For multiline matches, optionally use DOTALL so '.' matches newlines flags = (re.MULTILINE | re.DOTALL) if multiline else 0 compiled_pattern = re.compile(pattern, flags) @@ -159,9 +163,10 @@ def search_text( end_pos = match.end() # Find the line numbers for the start and end positions - start_line_num = TextUtils.get_line_from_index(content, start_pos) - end_line_num = TextUtils.get_line_from_index(content, end_pos) - if end_line_num > start_line_num and TextUtils.get_line_col_from_index(content, end_pos)[1] == 0: + start_loc = coordinates.line_col_at_index(start_pos) + end_loc = coordinates.line_col_at_index(end_pos) + start_line_num, end_line_num = start_loc.line, end_loc.line + if end_line_num > start_line_num and end_loc.col == 0: # `end_pos` is exclusive, so if it is at the start of a line, the match ends with the # preceding line's newline and does not extend into the line that `end_pos` points to end_line_num -= 1 diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 1fe7e0e8..99520c28 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -3,6 +3,7 @@ This file contains various utility functions like I/O operations, handling paths """ # SPDX-License-Identifier: MIT +import bisect import gzip import hashlib import logging @@ -15,6 +16,7 @@ import tempfile import uuid import zipfile from collections.abc import Sequence +from dataclasses import dataclass from enum import Enum from pathlib import Path, PurePath from typing import Literal, cast @@ -179,6 +181,75 @@ class TextStepper: return lines +@dataclass(frozen=True, kw_only=True) +class LineCol: + """ + Represents a position in a text as a pair of 0-based line and column numbers. + """ + + line: int + """the 0-based line number""" + + col: int + """the 0-based column number""" + + +class TextCoordinates: + r""" + Provides line/column coordinates for character indices in a text, backed by a privately cached table of line start + offsets. + + The table is built once upon construction and mirrors the line semantics of :class:`TextStepper`: "\n", "\r\n" + and a bare "\r" are all treated as line separators (as defined by the Language Server Protocol). Resolving a + coordinate then requires only a binary search, independent of the text length. + """ + + def __init__(self, text: str): + """ + :param text: the text in which character indices are to be located + """ + self._text = text + self._line_starts = self._compute_line_starts() + + def line_col_at_index(self, index: int) -> LineCol: + r""" + Returns the line/column coordinates corresponding to the given character index. + + An index pointing at the "\n" of a "\r\n" sequence denotes the beginning of the following line + (column 0), in the same way as a cursor insertion position between "\r" and "\n" does. + + :param index: the 0-based index in the text; must not exceed the text length + :return: the coordinates corresponding to the index + :raises InvalidTextLocationError: if the index is negative or greater than the text length + """ + # determine the line containing the index, which is the last line whose start does not exceed the index + if index < 0 or index > len(self._text): + raise InvalidTextLocationError(f"{index=}") + line_num = bisect.bisect_right(self._line_starts, index) - 1 + line_start = self._line_starts[line_num] + + # an index pointing at the "\n" of a "\r\n" pair maps to the beginning of the following line + if index > 0 and self._text[index - 1] == "\r" and self._text[index : index + 1] == "\n": + return LineCol(line=line_num + 1, col=0) + + return LineCol(line=line_num, col=index - line_start) + + def _compute_line_starts(self) -> list[int]: + """ + Computes the character offsets at which the lines of the text begin, using a TextStepper to process + the text line by line. + + :return: a list where entry i is the 0-based character offset at which line i starts; + entry 0 is always 0 + """ + line_starts = [0] + text_stepper = TextStepper(self._text) + while text_stepper.step_line(): + if text_stepper.is_newline: + line_starts.append(text_stepper.line_start_idx) + return line_starts + + class TextUtils: """ Utilities for text operations. diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 865b71b4..991aca9c 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -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: diff --git a/test/solidlsp/test_text_coordinates.py b/test/solidlsp/test_text_coordinates.py new file mode 100644 index 00000000..04733da5 --- /dev/null +++ b/test/solidlsp/test_text_coordinates.py @@ -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) From 26939c447f268cb287afa67e07016ccadf775bc5 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 23 Sep 2026 13:40:37 +0200 Subject: [PATCH 2/2] Refactoring: Improve class naming and consolidate text coordinate representations * Rename TextCoordinates -> TextCoordinateProvider * Consolidate ls_util.LineCol, text_utils.TextCoords -> ls_util.TextCoordinates --- scripts/profile_search_text.py | 8 +-- src/serena/util/text_utils.py | 24 ++----- src/solidlsp/ls_utils.py | 21 +++--- test/solidlsp/test_text_coordinates.py | 90 +++++++++++++------------- 4 files changed, 63 insertions(+), 80 deletions(-) diff --git a/scripts/profile_search_text.py b/scripts/profile_search_text.py index 47bdce5f..00f9f254 100644 --- a/scripts/profile_search_text.py +++ b/scripts/profile_search_text.py @@ -22,7 +22,7 @@ import sys import time import solidlsp # noqa: F401 # imported first: solidlsp must resolve before serena.util.text_utils (known circular-import window) -from solidlsp.ls_utils import TextCoordinates, TextUtils +from solidlsp.ls_utils import TextCoordinateProvider, TextUtils def generate_synthetic_content(num_lines: int, match_identifier: str, match_frequency: float = 0.15) -> str: @@ -67,12 +67,12 @@ def resolve_before(content: str, compiled_pattern: re.Pattern) -> list[tuple[int def resolve_after(content: str, compiled_pattern: re.Pattern) -> list[tuple[int, int]]: """Resolves line coordinates the way this PR proposes (cached line starts + binary search).""" - coordinates = TextCoordinates(content) + coordinates = TextCoordinateProvider(content) results = [] for match in compiled_pattern.finditer(content): start_pos, end_pos = match.start(), match.end() - s = coordinates.line_col_at_index(start_pos).line - end_loc = coordinates.line_col_at_index(end_pos) + s = coordinates.compute_coordinates(start_pos).line + end_loc = coordinates.compute_coordinates(end_pos) e = end_loc.line if e > s and end_loc.col == 0: e -= 1 diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 79000bf3..9c030cf7 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -13,7 +13,7 @@ from joblib import Parallel, delayed from sensai.util.string import ToStringMixin from serena.util.file_proxy import FileCollection, FileProxy -from solidlsp.ls_utils import TextCoordinates, TextUtils +from solidlsp.ls_utils import TextCoordinateProvider, TextCoordinates, TextUtils log = logging.getLogger(__name__) @@ -152,7 +152,7 @@ def search_text( # precompute line start offsets once so that each match's coordinates can be resolved via binary search # instead of re-scanning the text from the beginning for every match - coordinates = TextCoordinates(content) + coordinates = TextCoordinateProvider(content) # For multiline matches, optionally use DOTALL so '.' matches newlines flags = (re.MULTILINE | re.DOTALL) if multiline else 0 @@ -163,8 +163,8 @@ def search_text( end_pos = match.end() # Find the line numbers for the start and end positions - start_loc = coordinates.line_col_at_index(start_pos) - end_loc = coordinates.line_col_at_index(end_pos) + start_loc = coordinates.compute_coordinates(start_pos) + end_loc = coordinates.compute_coordinates(end_pos) start_line_num, end_line_num = start_loc.line, end_loc.line if end_line_num > start_line_num and end_loc.col == 0: # `end_pos` is exclusive, so if it is at the start of a line, the match ends with the @@ -643,19 +643,7 @@ class MultiFileContentReplacer: return "\n".join(diff_lines) -@dataclass -class TextCoords: - line: int - """ - 0-based line number - """ - col: int - """ - 0-based column number - """ - - -def find_text_coordinates(content: str, regex: str, require_unique: bool = False) -> TextCoords | None: +def find_text_coordinates(content: str, regex: str, require_unique: bool = False) -> TextCoordinates | None: """ Finds the line and column number of the first match of a regex pattern in the given content. @@ -680,4 +668,4 @@ def find_text_coordinates(content: str, regex: str, require_unique: bool = False raise ValueError(f"Regex must contain exactly one group to capture the position, but found {len(match.groups())} groups.") index_in_content = match.start(1) line, col = TextUtils.get_line_col_from_index(content, index_in_content) - return TextCoords(line, col) + return TextCoordinates(line, col) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 99520c28..ae292e6c 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -181,8 +181,8 @@ class TextStepper: return lines -@dataclass(frozen=True, kw_only=True) -class LineCol: +@dataclass +class TextCoordinates: """ Represents a position in a text as a pair of 0-based line and column numbers. """ @@ -194,14 +194,9 @@ class LineCol: """the 0-based column number""" -class TextCoordinates: - r""" - Provides line/column coordinates for character indices in a text, backed by a privately cached table of line start - offsets. - - The table is built once upon construction and mirrors the line semantics of :class:`TextStepper`: "\n", "\r\n" - and a bare "\r" are all treated as line separators (as defined by the Language Server Protocol). Resolving a - coordinate then requires only a binary search, independent of the text length. +class TextCoordinateProvider: + """ + Accelerates multiple computations of line/column coordinates in a given text by precomputing the text's line start indices. """ def __init__(self, text: str): @@ -211,7 +206,7 @@ class TextCoordinates: self._text = text self._line_starts = self._compute_line_starts() - def line_col_at_index(self, index: int) -> LineCol: + def compute_coordinates(self, index: int) -> TextCoordinates: r""" Returns the line/column coordinates corresponding to the given character index. @@ -230,9 +225,9 @@ class TextCoordinates: # an index pointing at the "\n" of a "\r\n" pair maps to the beginning of the following line if index > 0 and self._text[index - 1] == "\r" and self._text[index : index + 1] == "\n": - return LineCol(line=line_num + 1, col=0) + return TextCoordinates(line=line_num + 1, col=0) - return LineCol(line=line_num, col=index - line_start) + return TextCoordinates(line=line_num, col=index - line_start) def _compute_line_starts(self) -> list[int]: """ diff --git a/test/solidlsp/test_text_coordinates.py b/test/solidlsp/test_text_coordinates.py index 04733da5..8510bba3 100644 --- a/test/solidlsp/test_text_coordinates.py +++ b/test/solidlsp/test_text_coordinates.py @@ -1,6 +1,6 @@ import pytest -from solidlsp.ls_utils import InvalidTextLocationError, LineCol, TextCoordinates +from solidlsp.ls_utils import InvalidTextLocationError, TextCoordinateProvider, TextCoordinates class TestTextCoordinates: @@ -22,9 +22,9 @@ class TestTextCoordinates: ) 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) + coordinates = TextCoordinateProvider(content) for index in range(len(content) + 1): - loc = coordinates.line_col_at_index(index) + loc = coordinates.compute_coordinates(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 @@ -34,68 +34,68 @@ class TestTextCoordinates: 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) + coordinates = TextCoordinateProvider("alpha\nbeta\ngamma\n") + assert coordinates.compute_coordinates(0) == TextCoordinates(line=0, col=0) + assert coordinates.compute_coordinates(3) == TextCoordinates(line=0, col=3) + assert coordinates.compute_coordinates(5) == TextCoordinates(line=0, col=5) + assert coordinates.compute_coordinates(6) == TextCoordinates(line=1, col=0) + assert coordinates.compute_coordinates(8) == TextCoordinates(line=1, col=2) + assert coordinates.compute_coordinates(11) == TextCoordinates(line=2, col=0) + assert coordinates.compute_coordinates(12) == TextCoordinates(line=2, col=1) + assert coordinates.compute_coordinates(16) == TextCoordinates(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) + assert coordinates.compute_coordinates(17) == TextCoordinates(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) + coordinates = TextCoordinateProvider("alpha\r\nbeta\r\n") + assert coordinates.compute_coordinates(0) == TextCoordinates(line=0, col=0) + assert coordinates.compute_coordinates(5) == TextCoordinates(line=0, col=5) + assert coordinates.compute_coordinates(6) == TextCoordinates(line=1, col=0) + assert coordinates.compute_coordinates(7) == TextCoordinates(line=1, col=0) + assert coordinates.compute_coordinates(8) == TextCoordinates(line=1, col=1) + assert coordinates.compute_coordinates(11) == TextCoordinates(line=1, col=4) + assert coordinates.compute_coordinates(12) == TextCoordinates(line=2, col=0) + assert coordinates.compute_coordinates(13) == TextCoordinates(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) + coordinates = TextCoordinateProvider("alpha\rbeta\r") + assert coordinates.compute_coordinates(5) == TextCoordinates(line=0, col=5) + assert coordinates.compute_coordinates(6) == TextCoordinates(line=1, col=0) + assert coordinates.compute_coordinates(10) == TextCoordinates(line=1, col=4) + assert coordinates.compute_coordinates(11) == TextCoordinates(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") + coordinates = TextCoordinateProvider("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) + assert coordinates.compute_coordinates(0) == TextCoordinates(line=0, col=0) + assert coordinates.compute_coordinates(1) == TextCoordinates(line=0, col=1) + assert coordinates.compute_coordinates(2) == TextCoordinates(line=1, col=0) + assert coordinates.compute_coordinates(3) == TextCoordinates(line=1, col=0) + assert coordinates.compute_coordinates(4) == TextCoordinates(line=1, col=1) + assert coordinates.compute_coordinates(5) == TextCoordinates(line=2, col=0) + assert coordinates.compute_coordinates(6) == TextCoordinates(line=2, col=1) + assert coordinates.compute_coordinates(7) == TextCoordinates(line=3, col=0) + assert coordinates.compute_coordinates(8) == TextCoordinates(line=3, col=1) + assert coordinates.compute_coordinates(9) == TextCoordinates(line=4, col=0) + assert coordinates.compute_coordinates(10) == TextCoordinates(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) + coordinates = TextCoordinateProvider("") + assert coordinates.compute_coordinates(0) == TextCoordinates(line=0, col=0) with pytest.raises(InvalidTextLocationError): - coordinates.line_col_at_index(1) + coordinates.compute_coordinates(1) def test_line_col_at_index_out_of_range(self): """Indices beyond the text length are rejected.""" - coordinates = TextCoordinates("abc") + coordinates = TextCoordinateProvider("abc") with pytest.raises(InvalidTextLocationError): - coordinates.line_col_at_index(4) + coordinates.compute_coordinates(4) with pytest.raises(InvalidTextLocationError): - coordinates.line_col_at_index(-1) + coordinates.compute_coordinates(-1)