From 844535f6236a549eb1b6f29320c5ecb30b7ca9ef Mon Sep 17 00:00:00 2001 From: Ehsan Barkhordar Date: Fri, 17 Jul 2026 07:27:22 +0000 Subject: [PATCH] fix(search): do not mark the following line as matched when a match ends at a line break `search_text` mapped the match's exclusive end index to a line number via `get_line_from_index`, which resolves an index at a line start to the following line. A match ending with a newline was therefore attributed one line too many, and `context_lines_after` was shifted by the same amount. --- CHANGELOG.md | 6 +++++ src/serena/util/text_utils.py | 4 +++ test/serena/test_text_utils.py | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cf8cfa2..ef8e53e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ Status of the `main` branch. Changes prior to the next official version change w the `line_ending` setting is meant to be the single point of line-ending translation on write. The fallback now normalizes line endings to LF, consistently with the primary path. +* Tools: + - Fix: `search_for_pattern` marked one line too many as matched whenever a match ended with a line + break, because the match's exclusive end index was mapped to a line number directly and therefore + resolved to the start of the following line. The line the match ends on is now determined correctly, + which also keeps `context_lines_after` aligned. + * JetBrains: - Allow external files from dependencies (specified via references like "") to be - read via `ReadFileTool` diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index e9aebf76..4f8f10cc 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -187,6 +187,10 @@ def search_text( # 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: + # `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 # Calculate the range of lines to include in the context context_start = max(0, start_line_num - context_lines_before) diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index ce63450d..0eb86f3c 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -103,6 +103,55 @@ class TestSearchText: assert "return a * c" in first_match.lines[1].line_content assert "elif b > a:" in first_match.lines[2].line_content + @pytest.mark.parametrize( + ("pattern", "expected_matched_lines"), + [ + # matches ending with a newline: the trailing newline terminates the last matched line + # and must not pull in the line that follows + (r"alpha\n", [0]), + (r"alpha\nbeta\n", [0, 1]), + (r"gamma\n", [2]), + # controls: matches ending mid-line were always correct + (r"alpha", [0]), + (r"beta", [1]), + (r"alpha\nbeta", [0, 1]), + ], + ) + def test_search_text_match_ending_at_line_break(self, pattern: str, expected_matched_lines: list[int]): + """The lines marked as matched are exactly the lines the matched text occupies.""" + content = "alpha\nbeta\ngamma\n" + + matches = search_text(pattern, content=content) + + assert len(matches) == 1 + matched_lines = [line.line_number for line in matches[0].lines if line.match_type == LineType.MATCH] + assert matched_lines == expected_matched_lines + assert matches[0].num_matched_lines == len(expected_matched_lines) + + @pytest.mark.parametrize( + ("pattern", "expected_matched_lines"), + [ + (r"alpha\r\n", [0]), + (r"alpha\r", [0]), + (r"alpha\r\nbeta\r\n", [0, 1]), + (r"alpha", [0]), + ], + ) + def test_search_text_match_ending_at_line_break_crlf(self, pattern: str, expected_matched_lines: list[int]): + """As above for CRLF content, where the end index can fall inside the two-character line break. + + The content is passed directly rather than read from a file, since the file read paths translate + line endings and CR would not reach this function. + """ + content = "alpha\r\nbeta\r\ngamma\r\n" + + matches = search_text(pattern, content=content) + + assert len(matches) == 1 + matched_lines = [line.line_number for line in matches[0].lines if line.match_type == LineType.MATCH] + assert matched_lines == expected_matched_lines + assert matches[0].num_matched_lines == len(expected_matched_lines) + def test_search_text_with_multiline_match(self): """Test searching with multiline pattern matching.""" content = """