Files
serena/test/solidlsp/test_text_utils.py
xor 13ac8c5b1d SolidLSP: correct the position returned by insert_text_at_position (#1842)
insert_text_at_position returns the position of the end of the inserted text.
That position was computed by _get_updated_position_from_line_and_column_and_edit,
which steps the inserted text in isolation and adds the resulting line/column
delta to the start position.

An insertion is not independent of its surroundings. Under the LSP newline
definition adopted in 9208a3ae, a "\n" inserted directly after an existing "\r"
merges with it into a single "\r\n" sequence, so the text gains no line while the
isolated arithmetic still counts one:

    insert_text_at_position("\r", 1, 0, "\n")    -> ("\r\n", 2, 0); actual (1, 0)
    insert_text_at_position("x\ry", 1, 0, "\n")  -> ("x\r\ny", 2, 0); actual (1, 0)
    insert_text_at_position("a\r", 2, 0, "X")    -> ("a\r\nX", 2, 1); actual (1, 1)

The third case reaches the same fault through the one-line-past-EOF branch, which
prepends a "\n" that then merges with the trailing "\r". The converse interaction
was wrong too: inserting between the "\r" and the "\n" of a "\r\n" splits it, and
the isolated arithmetic reported (0, 2) -- a column on a line the split had just
emptied. In every case the resulting text was correct; only the position was wrong.

9208a3ae centralised line/column computation on TextStepper and reimplemented the
TextUtils methods on it. This helper was rebased onto TextStepper in that commit
but kept its original shape: it steps one string, and the string it steps is the
insertion rather than the result. Once a newline can be formed by two adjacent
characters, no computation over the inserted text alone can be correct.

The position is now determined from the resulting text via get_line_col_from_index,
the same function that resolves positions everywhere else, which removes the last
computation in TextUtils that did not go through the centralised path. The helper
had no other callers and is deleted, along with the line/col adjustment in the
past-EOF branch that existed only to feed it.

The column is clamped to the end of the text before both the splice and the
derivation. This is not a new tolerance: the splice already clamped implicitly
(text[:i] with i > len(text) yields the whole text), so an out-of-range column has
always appended successfully. Making the clamp explicit keeps the position derived
from the same index the splice used; without it, deriving from the result would
newly raise for input that previously worked.

Columns are offsets into the Python string (code points), which the docstring now
states. LSP columns are UTF-16 code units and nothing converts between the two at
the boundary -- a real defect, but a pre-existing and separate one: the previous
arithmetic did not preserve UTF-16 units either, since it added a code-point length
to a code-unit column and so produced a value in neither unit.

    "\U0001f600" + "\U0001f600" at column 2
        old (0, 3); UTF-16 (0, 4); code points (0, 2)

Seven regression tests cover the boundary, mid-document and past-EOF merges, the
newline split, an out-of-range column, a column past a non-BMP character, and an
unaffected "\n"-terminated control. The six that pin changed behaviour were each
verified to fail before the change; the control passes either way.
2026-09-04 13:09:55 +02:00

147 lines
7.3 KiB
Python

import pytest
from solidlsp.ls_utils import InvalidTextLocationError, TextStepper, TextUtils
class TestTextUtils:
LINE = "012"
TEXT = LINE + "\n" + LINE + "\r\n" + LINE + "\r" + LINE
def test_split_lines(self):
lines = TextUtils.split_lines(self.TEXT, with_ends=False)
assert len(lines) == 4
for line in lines:
assert line == self.LINE
def test_split_lines_with_ends(self):
lines = TextUtils.split_lines(self.TEXT, with_ends=True)
assert len(lines) == 4
for i, line in enumerate(lines):
assert line[: len(self.LINE)] == self.LINE
for i, ending in enumerate(["\n", "\r\n", "\r", ""]):
assert lines[i][len(self.LINE) :] == ending
def test_line_col_from_idx(self):
assert TextUtils.get_line_col_from_index(self.LINE, 0) == (0, 0)
assert TextUtils.get_line_col_from_index(self.LINE, 1) == (0, 1)
assert TextUtils.get_line_col_from_index(self.TEXT, 0) == (0, 0)
assert TextUtils.get_line_col_from_index(self.TEXT, 1) == (0, 1)
assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 1) == (1, 1)
assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 3 + 2 + 1) == (2, 1)
def test_idx_from_line_col(self):
assert TextUtils.get_index_from_line_col(self.TEXT, 0, 0) == 0
assert TextUtils.get_index_from_line_col(self.TEXT, 0, 1) == 1
assert TextUtils.get_index_from_line_col(self.TEXT, 1, 1) == 3 + 1 + 1
assert TextUtils.get_index_from_line_col(self.TEXT, 2, 1) == 3 + 1 + 3 + 2 + 1
def test_step_to(self):
stepper = TextStepper(self.TEXT)
stepper.step_to(2, 1)
assert stepper.line == 2
assert stepper.col == 1
assert stepper.idx == 3 + 1 + 3 + 2 + 1
def test_insert_text_at_index(self):
insertion = "XXX"
new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 0, 1, insertion)
assert (l, c) == (0, 1 + len(insertion))
assert new_text.startswith("0XXX12")
def test_insert_text_in_next_line_beyond_content(self):
"""
Test inserting text at a line index 1 beyond the actual number of lines.
This case is specifically handled as an edge case in the implementation.
"""
insertion = "XXX"
new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 4, 0, insertion)
assert (l, c) == (4, len(insertion))
assert new_text == self.TEXT + "\n" + insertion
def test_delete_text_deletes_last_line_without_trailing_newline(self) -> None:
"""Deleting the final line must work whether or not the file ends in a newline.
delete_lines(k, N-1) addresses the position one line past the last line
(line N, col 0). With no trailing newline there is no closing newline to
count, so get_index_from_line_col cannot resolve it; the delete must still
remove the last line instead of raising InvalidTextLocationError.
"""
# File with 3 lines, no trailing newline: read_file (splitlines) shows 0='a',1='b',2='c'.
text = "a\nb\nc"
new_text, deleted = TextUtils.delete_text_between_positions(text, 2, 0, 3, 0)
assert new_text == "a\nb\n"
assert deleted == "c"
def test_delete_text_last_line_matches_trailing_newline_variant(self) -> None:
"""Deleting the last line yields the same result with or without a trailing newline."""
without_nl, _ = TextUtils.delete_text_between_positions("a\nb\nc", 2, 0, 3, 0)
with_nl, _ = TextUtils.delete_text_between_positions("a\nb\nc\n", 2, 0, 3, 0)
assert without_nl == with_nl == "a\nb\n"
def test_delete_text_still_raises_for_out_of_range_end(self) -> None:
"""A genuinely out-of-range end position (beyond one-past-EOF) still raises."""
with pytest.raises(InvalidTextLocationError):
# end_line = 5 is well past the one-line-past-EOF position (3) for a 3-line file.
TextUtils.delete_text_between_positions("a\nb\nc", 0, 0, 5, 0)
def test_insert_position_accounts_for_newline_merge(self) -> None:
r"""A leading "\n" inserted directly after an existing "\r" merges with it into a single
"\r\n" newline sequence. The returned position must therefore be derived from the
resulting text; computing it from the inserted text alone reports a line too far.
"""
new_text, line, col = TextUtils.insert_text_at_position("\r", 1, 0, "\n")
assert new_text == "\r\n"
assert (line, col) == (1, 0)
def test_insert_position_after_full_text_accounts_for_newline_merge(self) -> None:
r"""Inserting one line past the last line prepends a "\n" to the inserted text; when the
text ends in "\r" the two merge into a single "\r\n". The returned position must describe
where the inserted text actually ended up.
"""
new_text, line, col = TextUtils.insert_text_at_position("a\r", 2, 0, "X")
assert new_text == "a\r\nX"
assert (line, col) == (1, 1)
def test_insert_position_after_full_text_without_merge(self) -> None:
r"""The same one-line-past-EOF insertion into "\n"-terminated text is unaffected."""
new_text, line, col = TextUtils.insert_text_at_position("a\n", 2, 0, "X")
assert new_text == "a\n\nX"
assert (line, col) == (2, 1)
def test_insert_position_accounts_for_newline_merge_mid_document(self) -> None:
r"""The merge is not specific to the end of the buffer: inserting "\n" at the start of a
line that follows a bare "\r" merges with that "\r" in the middle of the document too.
"""
new_text, line, col = TextUtils.insert_text_at_position("x\ry", 1, 0, "\n")
assert new_text == "x\r\ny"
assert (line, col) == (1, 0)
def test_insert_position_inside_newline_sequence_splits_it(self) -> None:
r"""Inserting between the "\r" and the "\n" of a "\r\n" splits it into two newline
sequences, adding a line. The position must again come from the resulting text.
"""
new_text, line, col = TextUtils.insert_text_at_position("\r\n", 0, 1, "Z")
assert new_text == "\rZ\n"
assert (line, col) == (1, 1)
def test_insert_at_column_beyond_text_appends(self) -> None:
"""A column past the end of the text appends and reports the true end position.
Callers may pass a column that exceeds the text length -- notably an LSP column, which
counts UTF-16 code units, for a line containing non-BMP characters. The insertion clamps
to the end of the text, so the reported position must too, rather than addressing an
index that does not exist in the result.
"""
new_text, line, col = TextUtils.insert_text_at_position("abc", 0, 10, "X")
assert new_text == "abcX"
assert (line, col) == (0, 4)
def test_insert_at_column_beyond_text_containing_non_bmp_character(self) -> None:
"""A non-BMP character is one code point but two UTF-16 code units, which is one way a
caller's column comes to exceed the text length. The column is clamped, and the returned
position is expressed in code points like every other position in TextUtils.
"""
new_text, line, col = TextUtils.insert_text_at_position("\U0001f600", 0, 2, "X")
assert new_text == "\U0001f600X"
assert (line, col) == (0, 2)