diff --git a/src/plyngent/tools/file/edit_lineno.py b/src/plyngent/tools/file/edit_lineno.py index 6dad80d..6860a77 100644 --- a/src/plyngent/tools/file/edit_lineno.py +++ b/src/plyngent/tools/file/edit_lineno.py @@ -11,6 +11,9 @@ if TYPE_CHECKING: # How many unread line numbers to preview in the error message. _UNREAD_PREVIEW = 8 +# Lines of context a suggested read covers on each side of the edit range. +_READ_HINT_MARGIN = 3 + def _detect_newline(lines: list[str]) -> str: for sample in lines: @@ -68,6 +71,19 @@ def _unread_lines_in_range( return sorted(set(range(start_line, end_line + 1)) - read_lines) +def _read_hint(path: str, start_line: int, end_line: int, n: int) -> str: + """A ready-to-copy ``read_file`` call covering the edit range plus a margin. + + ``read_file`` counts ``offset`` from 0 while ``edit_lineno`` counts lines + from 1, so the call is spelled out instead of asking the model to convert. + The window is clipped to the file (``_validate_range`` already accepted the + edit) and the margin keeps neighbouring lines editable too. + """ + first = max(1, start_line - _READ_HINT_MARGIN) + last = max(first, min(n, end_line + _READ_HINT_MARGIN)) + return f"read_file(path={path!r}, offset={first - 1}, limit={last - first + 1}, with_lineno=true)" + + def _append_after(target: Path, path: str, text: str, lines: list[str], new_content: str) -> str: n = len(lines) newline = _detect_newline(lines[-1:] if lines else []) @@ -135,8 +151,9 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st if read_lines: window = f" (this turn read lines {min(read_lines)}-{max(read_lines)})" return ( - f"error: lines {head} not read{window}; call read_file(path, with_lineno=true) " - "first — read with a small margin around the lines you plan to edit" + f"error: lines {head} not read{window}; re-read with " + f"{_read_hint(path, start_line, end_line, len(lines))} " + "— offset is 0-based; the window keeps a margin around the edit" ) recorded_mtime = lineno_read_mtime(str(target)) @@ -148,7 +165,8 @@ async def edit_lineno(path: str, start_line: int, end_line: int, new_content: st if current_mtime is not None and current_mtime != recorded_mtime: return ( f"error: {path} changed since it was read (line numbers may be stale); " - "re-read with read_file(path, with_lineno=true)" + f"re-read with {_read_hint(path, start_line, end_line, len(lines))} " + "— offset is 0-based" ) if start_line == len(lines) + 1: diff --git a/tests/test_tools/test_edit_lineno.py b/tests/test_tools/test_edit_lineno.py index 6bd95e3..946f063 100644 --- a/tests/test_tools/test_edit_lineno.py +++ b/tests/test_tools/test_edit_lineno.py @@ -49,6 +49,24 @@ def test_edit_lineno_error_shows_read_window(workspace: object) -> None: assert "not read" in out assert "read lines 2-3" in out assert "margin" in out + # Ready to copy: 1-based line 1 is offset 0, and n=4 clamps the limit. + assert "read_file(path='j.txt', offset=0, limit=4, with_lineno=true)" in out + + +def test_edit_lineno_error_suggests_the_read_file_call(workspace: object) -> None: + """Both rejections hand back a ready-to-copy call with a 0-based ``offset``.""" + del workspace + _ = call_sync(write_file, "s.txt", "".join(f"line {i}\n" for i in range(1, 21))) + _ = reset_lineno_tracker() + _ = call_sync(read_file, "s.txt", offset=0, limit=10, with_lineno=True) # lines 1-10 + # Line 15 was never shown; the hint reads it back with a margin (line 12 → offset 11). + out = call_sync(edit_lineno, "s.txt", 15, 15, "X\n") + assert "read_file(path='s.txt', offset=11, limit=7, with_lineno=true)" in out + # A stale read gets a fresh call for the same range after an external write. + _ = call_sync(write_file, "s.txt", "changed\n") + out = call_sync(edit_lineno, "s.txt", 1, 1, "X\n") + assert "changed since it was read" in out + assert "read_file(path='s.txt', offset=0, limit=1, with_lineno=true)" in out def test_edit_lineno_requires_all_lines_read(workspace: object) -> None: @@ -84,6 +102,8 @@ def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None: _ = call_sync(read_file, "ap.txt", offset=0, limit=2, with_lineno=True) out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n") assert "not read" in out and "3" in out + # Appending needs the last line, so the suggested window ends at n. + assert "read_file(path='ap.txt', offset=0, limit=3, with_lineno=true)" in out # Read the last line, then append succeeds. _ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True) out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")