From cb38f6f82aac111971e0bc96c542dbcd30eef946 Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Fri, 10 Jul 2026 18:51:08 -0600 Subject: [PATCH] fix(editing): delete last line of a file without a trailing newline delete_lines/replace_lines address the position one line past the last line (line == number of lines, col 0). get_index_from_line_col cannot resolve that position when the file has no trailing newline (no closing newline to count), so delete_text_between_positions raised InvalidTextLocationError and the delete crashed on a very common file shape. Clamp that past-EOF end position to end-of-file in delete_text_between_positions, mirroring the guard insert_text_at_position already has. A file with no trailing newline now deletes its last line identically to one with a trailing newline. Adds TextUtils unit tests and a CHANGELOG entry. --- CHANGELOG.md | 4 ++++ src/solidlsp/ls_utils.py | 16 +++++++++++++++- test/solidlsp/test_text_utils.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edeb6bcd..1f74d656 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks); previously only "\n" considered - Fix: Apply consistent line splitting across tools, uniformly applying the LSP splitting semantics; affects `search_text` used by `search_for_pattern` tool (reported in #1684) + - Fix in `TextUtils` (used by editors): Deleting up to the end of the file, referencing the line one past + the end of the file (particularly for files with no newline at end of file) raised `InvalidTextLocationError` + instead of accepting the deletion (change in `TextUtils.delete_text_between_positions`, + which now accepts the end position similar to `insert_text_at_position`). - Fix: glob pattern expansion in `expand_braces` did not terminate with empty or unbalanced braces #1690 * CLI: diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index eafc3b37..d95723ad 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -237,7 +237,21 @@ class TextUtils: :return: a tuple containing the modified text and the deleted text """ del_start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col) - del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) + try: + del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) + except InvalidTextLocationError: + # Deleting through the final line addresses the position one line past the + # last line (line == number of lines, col 0). When the file has no trailing + # newline there is no closing newline to count, so get_index_from_line_col + # cannot resolve it; that position means "end of file". Clamp to len(text). + # (insert_text_at_position handles the same past-EOF position.) + text_stepper = TextStepper(text) + text_stepper.process_all() + num_lines_in_text = text_stepper.line + 1 + if end_line == num_lines_in_text and end_col == 0: + del_end_idx = len(text) + else: + raise deleted_text = text[del_start_idx:del_end_idx] new_text = text[:del_start_idx] + text[del_end_idx:] diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py index 062125c4..0d0fca42 100644 --- a/test/solidlsp/test_text_utils.py +++ b/test/solidlsp/test_text_utils.py @@ -1,4 +1,6 @@ -from solidlsp.ls_utils import TextUtils +import pytest + +from solidlsp.ls_utils import InvalidTextLocationError, TextUtils class TestTextUtils: @@ -48,3 +50,29 @@ class TestTextUtils: 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)