commit b96542641a8740092c5f365f121d8aed1b5b8411
parent c8793114ea26db8b322c1553313ff0a0bbd436e0
Author: Vineet Kumar <git@vineetk.net>
Date: Thu, 22 Oct 2026 15:40:20 +0200
replace hashline editing with simple literal substitution
most if not all harnesses use either literal substitution or some
fuzzy variant of it (deepseek-harness, gemini-/qwen-code) or
patch-based (codex, claude-code?).
with hashline, I got a lot of failed tool calls as anchors were not
being provided, leading to multiple read files which causes more KV
cache to be used, hence being slower.
this rewrite is done in an attempt to get closer to how the model's
agentic stuff was RL'd.
Diffstat:
| M | llm-tools-edit-test.el | | | 800 | +++++++++++-------------------------------------------------------------------- |
| M | llm-tools-edit.el | | | 544 | +++++++++---------------------------------------------------------------------- |
| M | llm-tools-pkg.el | | | 2 | +- |
| M | llm-tools.el | | | 244 | ++++++++++++++++--------------------------------------------------------------- |
4 files changed, 218 insertions(+), 1372 deletions(-)
diff --git a/llm-tools-edit-test.el b/llm-tools-edit-test.el
@@ -2,700 +2,118 @@
(require 'ert)
(require 'llm-tools-edit)
-(ert-deftest llm-tools--edit-file-read-test ()
- "Test that 'llm-tools--edit-file-read' produces correct hashline format."
- (let* ((content "\
-;; greeting.el
-(defconst greeting-title \"Mr\")
-(defun greet (name)
- (concat
- greeting-title
- (or (string-trim name) \"guest\"))
-)")
- (file (make-temp-file nil nil nil content))
- (actual (llm-tools--edit-file-read file)))
- (unwind-protect
- (progn
- (should (= (length (string-lines actual)) 7))
- (should (string-match-p "\\`1gi|;; greeting" actual))
- (should (string-match-p "7ce|)\\'" actual)))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-anchor-to-line-num-test ()
- (should (= (llm-tools--edit-anchor-to-line-num "5ff") 5))
- (should (= (llm-tools--edit-anchor-to-line-num "123ab") 123))
- (should (= (llm-tools--edit-anchor-to-line-num "1vx") 1)))
-
-(ert-deftest llm-tools--edit-parse-section-single-replace ()
- (let ((section "= 1vx..1vx\n~(defconst greeting-title \"Mrs\")"))
- (should (= (length (llm-tools--edit-parse-section section)) 1))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (eq (hl-op-type op) 'replace))
- (should (equal (hl-op-range op) '("1vx" "1vx")))
- (should (equal (hl-op-payload op) '("(defconst greeting-title \"Mrs\")"))))))
-
-(ert-deftest llm-tools--edit-parse-section-multi-line-payload ()
- (let ((section "= 3gv..6be\n~ (concat\n~ greeting-title\n~ (or (string-trim name) \"guest\"))\n~ )"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (= (length (hl-op-payload op)) 4)))))
-
-(ert-deftest llm-tools--edit-parse-section-blank-replace ()
- "Replace with no payload should produce nil payload, not empty list."
- (let ((section "= 5ff..5ff"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (eq (hl-op-type op) 'replace))
- (should (null (hl-op-payload op))))))
-
-(ert-deftest llm-tools--edit-parse-section-insert-after ()
- (let ((section "+ 4ei\n~ greeting-title"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (eq (hl-op-type op) 'insert-after))
- (should (string= (hl-op-anchor op) "4ei"))
- (should (equal (hl-op-payload op) '(" greeting-title"))))))
-
-(ert-deftest llm-tools--edit-parse-section-insert-before ()
- (let ((section "< 5ff\n~ greeting-title"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (eq (hl-op-type op) 'insert-before))
- (should (string= (hl-op-anchor op) "5ff")))))
-
-(ert-deftest llm-tools--edit-parse-section-delete ()
- (let ((section "- 5ff..5ff"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (eq (hl-op-type op) 'delete))
- (should (equal (hl-op-range op) '("5ff" "5ff"))))))
-
-(ert-deftest llm-tools--edit-parse-section-skip-comments-and-blanks ()
- (let ((section "# comment\n\n+ 4ei\n~line"))
- (should (= (length (llm-tools--edit-parse-section section)) 1))))
-
-(ert-deftest llm-tools--edit-parse-section-multiple-ops ()
- "Two separate insert ops in one section."
- (let ((section "+ 4ei\n~first\n< 5ff\n~second"))
- (let ((ops (llm-tools--edit-parse-section section)))
- (should (= (length ops) 2))
- (should (eq (hl-op-type (car ops)) 'insert-after))
- (should (eq (hl-op-type (cadr ops)) 'insert-before)))))
-
-(ert-deftest llm-tools--edit-apply-replace-single-line ()
- (let* ((content "line1\nline2\nline3\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 2))
- (patch (format "@@ %s\n= %s..%s\n~REPLACED" file anchor anchor)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (should (string= (llm-tools--edit-file-read file)
- (string-join
- (list (format "1%s|line1" (llm-tools--edit-hash "line1"))
- (format "2%s|REPLACED" (llm-tools--edit-hash "REPLACED"))
- (format "3%s|line3" (llm-tools--edit-hash "line3")))
- "\n"))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-apply-insert-after ()
- (let* ((content "A\nB\nC\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 1))
- (patch (format "@@ %s\n+ %s\n~NEW" file anchor)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (should (equal (llm-tools--edit-file-lines file) '("A" "NEW" "B" "C"))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-apply-insert-before ()
- (let* ((content "A\nB\nC\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 3))
- (patch (format "@@ %s\n< %s\n~NEW" file anchor)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (should (equal (llm-tools--edit-file-lines file) '("A" "B" "NEW" "C"))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-apply-append-to-eof ()
- (let* ((content "A\nB\n")
- (file (make-temp-file nil nil nil content))
- (patch (format "@@ %s\n+ EOF\n~APPENDED" file)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (should (equal (llm-tools--edit-file-lines file) '("A" "B" "APPENDED"))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-apply-delete ()
- (let* ((content "A\nB\nC\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 2))
- (patch (format "@@ %s\n- %s..%s" file anchor anchor)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (should (equal (llm-tools--edit-file-lines file) '("A" "C"))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-apply-blank-line ()
- "Replace with no payload produces a blank line."
- (let* ((content "A\nB\nC\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 2))
- (patch (format "@@ %s\n= %s..%s" file anchor anchor)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (should (equal (llm-tools--edit-file-lines file) '("A" "" "C"))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-apply-offset-tracking ()
- "Multiple ops in one file: verify offset adjusts subsequent line numbers."
- (let* ((content "A\nB\nC\nD\nE\n")
- (file (make-temp-file nil nil nil content))
- (a1 (llm-tools--edit-file-line-hash file 1))
- (a3 (llm-tools--edit-file-line-hash file 3))
- (patch (format "@@ %s\n+ %s\n~X\n~Y\n- %s..%s" file a1 a3 a3)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (let ((lines (llm-tools--edit-file-lines file)))
- (should (equal lines '("A" "X" "Y" "B" "D" "E")))))
- (delete-file file))))
+(defmacro llm-tools-edit-test--with-file (content &rest body)
+ "Run BODY with FILE containing CONTENT, then delete FILE."
+ (declare (indent 1))
+ `(let ((file (make-temp-file "llm-tools-edit-test-" nil nil ,content)))
+ (unwind-protect (progn ,@body)
+ (delete-file file))))
+
+(defun llm-tools-edit-test--contents (file)
+ "Return FILE contents without decoding line endings."
+ (with-temp-buffer
+ (insert-file-contents-literally file)
+ (buffer-string)))
-(ert-deftest llm-tools--edit-apply-multi-file ()
- "Two files in one patch both get modified."
- (let* ((file1 (make-temp-file nil nil nil "A\nB\n"))
- (file2 (make-temp-file nil nil nil "X\nY\n"))
- (a1 (llm-tools--edit-file-line-hash file1 1))
- (a2 (llm-tools--edit-file-line-hash file2 2))
- (patch (format "@@ %s\n+ %s\n~NEW1\n@@ %s\n= %s..%s\n~NEW2"
- file1 a1 file2 a2 a2)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (should (equal (llm-tools--edit-file-lines file1) '("A" "NEW1" "B")))
- (should (equal (llm-tools--edit-file-lines file2) '("X" "NEW2"))))
- (delete-file file1)
- (delete-file file2))))
-
-(ert-deftest llm-tools--edit-apply-multi-line-payload ()
- "Replace with many payload lines."
- (let* ((content "A\nB\n")
- (file (make-temp-file nil nil nil content))
- (a1 (llm-tools--edit-file-line-hash file 1))
- (patch (format "@@ %s\n= %s..%s\n~ONE\n~TWO\n~THREE" file a1 a1)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (let ((lines (llm-tools--edit-file-lines file)))
- (should (equal lines '("ONE" "TWO" "THREE" "B")))))
- (delete-file file))))
+(ert-deftest llm-tools--edit-file-read-test ()
+ (llm-tools-edit-test--with-file "alpha\nbeta\n"
+ (should (equal (llm-tools--edit-file-read file) "alpha\nbeta"))
+ (should (equal (llm-tools--edit-file-read file nil nil t)
+ '("alpha" "beta")))))
-;; --- llm-tools--edit-hash ---
-(ert-deftest llm-tools--edit-hash-test ()
- "Test that hashing produces consistent, valid bigram output."
- ;; Deterministic: same input -> same output
- (should (string= (llm-tools--edit-hash "hello")
- (llm-tools--edit-hash "hello")))
- ;; Different input -> likely different output (hash collision unlikely)
- (should (not (string= (llm-tools--edit-hash "hello")
- (llm-tools--edit-hash "world"))))
- ;; Output is always a valid bigram from the table
- (let ((result (llm-tools--edit-hash "test line")))
- (should (= (length result) 2))
- (should (member result llm-tools--edit-bigrams))))
+(ert-deftest llm-tools--edit-file-read-range-test ()
+ (llm-tools-edit-test--with-file "one\ntwo\nthree\nfour\n"
+ (should (equal (llm-tools--edit-file-read file 2 3) "two\nthree"))
+ (should (equal (llm-tools--edit-file-read file 2 3 t) '("two" "three")))))
-;; --- llm-tools--edit-file-lines ---
(ert-deftest llm-tools--edit-file-lines-test ()
- "Test reading file contents as a list of strings."
- (let* ((content "alpha\nbeta\ngamma\n")
- (file (make-temp-file nil nil nil content)))
- (unwind-protect
- (progn
- (should (equal (llm-tools--edit-file-lines file)
- '("alpha" "beta" "gamma")))
- ;; With beg/end range (inclusive, 1-based)
- (should (equal (llm-tools--edit-file-lines file 1 2)
- '("alpha" "beta")))
- (should (equal (llm-tools--edit-file-lines file 2 3)
- '("beta" "gamma")))
- (should (equal (llm-tools--edit-file-lines file 2 2)
- '("beta"))))
- (delete-file file))))
+ (llm-tools-edit-test--with-file "alpha\nbeta\ngamma\n"
+ (should (equal (llm-tools--edit-file-lines file) '("alpha" "beta" "gamma")))
+ (should (equal (llm-tools--edit-file-lines file 1 2) '("alpha" "beta")))
+ (should (equal (llm-tools--edit-file-lines file 2 3) '("beta" "gamma")))
+ (should (equal (llm-tools--edit-file-lines file 2 2) '("beta")))))
(ert-deftest llm-tools--edit-file-lines-empty-file-test ()
- "Test reading an empty file."
- (let ((file (make-temp-file nil nil "")))
- (unwind-protect
- (should (equal (llm-tools--edit-file-lines file) '("")))
- (delete-file file))))
-
-;; --- llm-tools--edit-format-lines ---
-(ert-deftest llm-tools--edit-format-lines-test ()
- "Test formatting lines in hashline format."
- (let ((lines '("hello" "world")))
- ;; Without content
- (let ((no-content (llm-tools--edit-format-lines lines nil t)))
- (should (= (length (string-lines no-content)) 2))
- (should (string-match-p "\\`1.." no-content)))
- ;; With content
- (let ((with-content (llm-tools--edit-format-lines lines)))
- (should (string-match-p "\\`1..|hello" with-content))
- (should (string-match-p "2..|world\\'" with-content)))
- ;; With custom start number
- (let ((custom-start (llm-tools--edit-format-lines lines 5 t)))
- (should (string-match-p "\\`5.." custom-start))
- (should (string-match-p "6..\\'" custom-start)))))
-
-;; --- llm-tools--edit-file-read-range ---
-(ert-deftest llm-tools--edit-file-read-range-test ()
- "Test reading a range of lines in hashline format."
- (let* ((content "line1\nline2\nline3\nline4\n")
- (file (make-temp-file nil nil nil content)))
- (unwind-protect
- (let ((result (llm-tools--edit-file-read file 2 3)))
- (should (= (length (string-lines result)) 2))
- (should (string-match-p "\\`2.." result))
- (should (string-match-p "line2" result))
- (should (string-match-p "line3" result)))
- (delete-file file))))
-
-;; --- llm-tools--edit-file-read-hashes ---
-(ert-deftest llm-tools--edit-file-read-hashes-test ()
- "Test reading file as list of hash strings without content."
- (let* ((content "foo\nbar\n")
- (file (make-temp-file nil nil nil content)))
- (unwind-protect
- (let ((hashes (llm-tools--edit-file-read file nil nil t t)))
- (should (= (length hashes) 2))
- (should (string-match-p "\\`1.." (elt hashes 0)))
- (should (string-match-p "\\`2.." (elt hashes 1)))
- ;; Each hash is exactly 3 chars (1+2 for bigram, variable digit count)
- (dolist (h hashes)
- (should (>= (length h) 3))))
- (delete-file file))))
-
-;; --- llm-tools--edit-file-line-hash ---
-(ert-deftest llm-tools--edit-file-line-hash-test ()
- "Test getting the hash of a specific line in a file."
- (let* ((content "first\nsecond\nthird\n")
- (file (make-temp-file nil nil nil content)))
- (unwind-protect
- (progn
- (should (string= (llm-tools--edit-file-line-hash file 1)
- (concat "1" (llm-tools--edit-hash "first"))))
- (should (string= (llm-tools--edit-file-line-hash file 2)
- (concat "2" (llm-tools--edit-hash "second"))))
- (should (string= (llm-tools--edit-file-line-hash file 3)
- (concat "3" (llm-tools--edit-hash "third")))))
- (delete-file file))))
-
-;; --- llm-tools--split-patch-sections ---
-(ert-deftest llm-tools--split-patch-sections-single-file-test ()
- "Test splitting a patch with one file section."
- (let ((patch "@@ foo.el\n= 1vx..1vx\n~hello"))
- (let ((sections (llm-tools--split-patch-sections patch)))
- (should (= (length sections) 1))
- (should (string= (car (car sections)) "foo.el"))
- (should (string-match-p "= 1vx" (cdr (car sections)))))))
-
-(ert-deftest llm-tools--split-patch-sections-multi-file-test ()
- "Test splitting a patch with multiple file sections."
- (let ((patch "@@ a.el\n+ 1vx\n~X\n@@ b.el\n- 2in..2in"))
- (let ((sections (llm-tools--split-patch-sections patch)))
- (should (= (length sections) 2))
- (should (string= (car (car sections)) "a.el"))
- (should (string= (car (cadr sections)) "b.el")))))
-
-(ert-deftest llm-tools--split-patch-sections-empty-test ()
- "Test splitting an empty or invalid patch."
- (should (null (llm-tools--split-patch-sections "")))
- (should (null (llm-tools--split-patch-sections "no header here"))))
-
-;; --- llm-tools--edit-get-anchors ---
-(ert-deftest llm-tools--edit-get-anchors-test ()
- "Test extracting anchors from a patch."
- (let ((patch "@@ test.el\n+ 4ei\n~X\n= 3gv..6be\n~Y\n- 5ff..5ff"))
- (let ((anchors (llm-tools--edit-get-anchors patch)))
- (should (= (length anchors) 1))
- (let ((file-anchors (cdr (car anchors))))
- (should (member "4ei" file-anchors))
- (should (member "5ff" file-anchors))
- (should (member "3gv" file-anchors))
- (should (member "6be" file-anchors))))))
-
-(ert-deftest llm-tools--edit-get-anchors-eof-bof-test ()
- "Test that EOF/BOF anchors are extracted correctly."
- (let ((patch "@@ test.el\n+ EOF\n~X\n< BOF\n~Y"))
- (let ((anchors (llm-tools--edit-get-anchors patch)))
- (let ((file-anchors (cdr (car anchors))))
- (should (member "EOF" file-anchors))
- (should (member "BOF" file-anchors))))))
-
-;; --- llm-tools--edit-parse-op-line ---
-(ert-deftest llm-tools--edit-parse-op-line-insert-after-test ()
- (let ((op (llm-tools--edit-parse-op-line "+ 4ei")))
- (should (eq (hl-op-type op) 'insert-after))
- (should (string= (hl-op-anchor op) "4ei"))))
-
-(ert-deftest llm-tools--edit-parse-op-line-insert-before-test ()
- (let ((op (llm-tools--edit-parse-op-line "< 5ff")))
- (should (eq (hl-op-type op) 'insert-before))
- (should (string= (hl-op-anchor op) "5ff"))))
-
-(ert-deftest llm-tools--edit-parse-op-line-delete-test ()
- (let ((op (llm-tools--edit-parse-op-line "- 3gv..6be")))
- (should (eq (hl-op-type op) 'delete))
- (should (equal (hl-op-range op) '("3gv" "6be")))))
-
-(ert-deftest llm-tools--edit-parse-op-line-replace-test ()
- (let ((op (llm-tools--edit-parse-op-line "= 1vx..2in")))
- (should (eq (hl-op-type op) 'replace))
- (should (equal (hl-op-range op) '("1vx" "2in")))))
-
-(ert-deftest llm-tools--edit-parse-op-line-eof-anchor-test ()
- (let ((op (llm-tools--edit-parse-op-line "+ EOF")))
- (should (eq (hl-op-type op) 'insert-after))
- (should (string= (hl-op-anchor op) "EOF"))))
-
-(ert-deftest llm-tools--edit-parse-op-line-bof-anchor-test ()
- (let ((op (llm-tools--edit-parse-op-line "< BOF")))
- (should (eq (hl-op-type op) 'insert-before))
- (should (string= (hl-op-anchor op) "BOF"))))
-
-;; --- llm-tools--edit-group-by-file ---
-(ert-deftest llm-tools--edit-group-by-file-test ()
- "Test grouping sections by file path."
- (let* ((sec1 (cons "a.el" "body1"))
- (sec2 (cons "b.el" "body2"))
- (sec3 (cons "a.el" "body3"))
- (sections (list sec1 sec2 sec3)))
- (let ((grouped (llm-tools--edit-group-by-file sections)))
- (let ((total 0))
- (dolist (g grouped)
- (cl-incf total (length (cdr g))))
- (should (= total 3)))
- (let ((a-group (assoc "a.el" grouped)))
- (should a-group)
- (should (= (length (cdr a-group)) 2)))
- (let ((b-group (assoc "b.el" grouped)))
- (should b-group)
- (should (= (length (cdr b-group)) 1))))))
-
-;; --- llm-tools--edit-validate-no-payload-before-op ---
-(ert-deftest llm-tools--edit-validate-no-payload-before-op-test ()
- "Test that payload before first op is detected."
- ;; This is an edge case: payload lines at the very start with no preceding op
- ;; The parser won't actually produce this, but test the validator directly
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "4ei"
- :payload '("should not be here"))))
- (let ((errors (llm-tools--edit-validate-no-payload-before-op (list op))))
- (should (= (length errors) 1))))
- ;; Clean case
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "4ei" :payload nil)))
- (let ((errors (llm-tools--edit-validate-no-payload-before-op (list op))))
- (should (= (length errors) 0)))))
-
-;; --- llm-tools--edit-validate-insert-has-payload ---
-(ert-deftest llm-tools--edit-validate-insert-has-payload-test ()
- "Test that inserts without payload are flagged."
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "4ei" :payload nil)))
- (let ((errors (llm-tools--edit-validate-insert-has-payload (list op))))
- (should (= (length errors) 1))))
- ;; Insert with payload is fine
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "4ei" :payload '("line"))))
- (should (= (length (llm-tools--edit-validate-insert-has-payload (list op))) 0)))
- ;; Insert-before without payload
- (let ((op (make-edit-verify-op :type 'insert-before :anchor "4ei" :payload nil)))
- (let ((errors (llm-tools--edit-validate-insert-has-payload (list op))))
- (should (= (length errors) 1)))))
-
-;; --- llm-tools--edit-validate-delete-no-payload ---
-(ert-deftest llm-tools--edit-validate-delete-no-payload-test ()
- "Test that deletes with payload are flagged."
- (let ((op (make-edit-verify-op :type 'delete :anchor "1vx..2in" :payload '("bad"))))
- (let ((errors (llm-tools--edit-validate-delete-no-payload (list op))))
- (should (= (length errors) 1))))
- ;; Delete without payload is fine
- (let ((op (make-edit-verify-op :type 'delete :anchor "1vx..2in" :payload nil)))
- (should (= (length (llm-tools--edit-validate-delete-no-payload (list op))) 0))))
-
-;; --- llm-tools--edit-validate-anchors ---
-(ert-deftest llm-tools--edit-validate-anchors-insert-valid-test ()
- "Test valid insert anchors pass validation."
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "4ei" :payload '("x"))))
- (should (= (length (llm-tools--edit-validate-anchors (list op))) 0))))
-
-(ert-deftest llm-tools--edit-validate-anchors-insert-eof-test ()
- "Test that EOF is accepted as a valid insert anchor."
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "EOF" :payload '("x"))))
- (should (= (length (llm-tools--edit-validate-anchors (list op))) 0))))
-
-(ert-deftest llm-tools--edit-validate-anchors-insert-bof-test ()
- "Test that BOF is accepted as a valid insert anchor."
- (let ((op (make-edit-verify-op :type 'insert-before :anchor "BOF" :payload '("x"))))
- (should (= (length (llm-tools--edit-validate-anchors (list op))) 0))))
-
-(ert-deftest llm-tools--edit-validate-anchors-insert-invalid-test ()
- "Test that malformed insert anchors are flagged."
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "bad" :payload '("x"))))
- (let ((errors (llm-tools--edit-validate-anchors (list op))))
- (should (> (length errors) 0))))
- (let ((op (make-edit-verify-op :type 'insert-after :anchor "" :payload '("x"))))
- (let ((errors (llm-tools--edit-validate-anchors (list op))))
- (should (> (length errors) 0)))))
-
-(ert-deftest llm-tools--edit-validate-anchors-range-valid-test ()
- "Test valid range anchors pass validation."
- (let ((op (make-edit-verify-op :type 'delete :anchor "3gv..6be" :payload nil)))
- (should (= (length (llm-tools--edit-validate-anchors (list op))) 0))))
-
-(ert-deftest llm-tools--edit-validate-anchors-range-missing-dotdot-test ()
- "Test that ranges without '..' are flagged."
- (let ((op (make-edit-verify-op :type 'delete :anchor "3gv6be" :payload nil)))
- (let ((errors (llm-tools--edit-validate-anchors (list op))))
- (should (> (length errors) 0)))))
-
-(ert-deftest llm-tools--edit-validate-anchors-range-non-numeric-test ()
- "Test that range parts starting with non-numeric chars are flagged."
- (let ((op (make-edit-verify-op :type 'replace :anchor "abc..def" :payload nil)))
- (let ((errors (llm-tools--edit-validate-anchors (list op))))
- (should (> (length errors) 0)))))
-
-;; --- llm-tools--edit-verify-section ---
-(ert-deftest llm-tools--edit-verify-section-clean-test ()
- "Test that a well-formed section produces no errors."
- (let ((section (cons "test.el" "\n+ 4ei\n~payload")))
- (should (= (length (llm-tools--edit-verify-section section)) 0))))
-
-(ert-deftest llm-tools--edit-verify-section-missing-payload-test ()
- "Test that an insert without payload produces an error."
- (let ((section (cons "test.el" "\n+ 4ei")))
- (should (> (length (llm-tools--edit-verify-section section)) 0))))
-
-;; --- llm-tools--edit-verify-structure ---
-(ert-deftest llm-tools--edit-verify-structure-clean-test ()
- "Test that a structurally valid patch produces no errors."
- (let ((patch "@@ test.el\n+ 4ei\n~payload"))
- (should (= (length (llm-tools--edit-verify-structure patch)) 0))))
-
-(ert-deftest llm-tools--edit-verify-structure-errors-test ()
- "Test that structural violations are reported."
- (let* ((content "A\n")
- (file (make-temp-file nil nil nil content))
- (patch (format "@@ %s\n+ 4ei" file)))
- (unwind-protect
- (should (> (length (llm-tools--edit-verify-structure patch)) 0))
- (delete-file file))))
-
-;; --- llm-tools--edit-verify-anchors ---
-(ert-deftest llm-tools--edit-verify-anchors-matching-test ()
- "Test that anchors matching file content return t."
- (let* ((content "hello\nworld\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 1))
- (patch (format "@@ %s\n+ %s\n~new" file anchor)))
- (unwind-protect
- (let ((results (llm-tools--edit-verify-anchors patch)))
- (should (= (length results) 1))
- (let ((entry (car results)))
- (should (string= (car entry) file))
- (should (cdr entry))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-verify-anchors-stale-test ()
- "Test that stale anchors (file changed) return nil."
- (let* ((content "hello\nworld\n")
- (file (make-temp-file nil nil nil content))
- (patch (format "@@ %s\n= 1zzz..1zzz\n~new" file)))
- ;; 1zzz is unlikely to match the actual hash
- (unwind-protect
- (let ((results (llm-tools--edit-verify-anchors patch)))
- (should (= (length results) 1))
- (should-not (cdr (car results))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-verify-anchors-eof-bof-ignored-test ()
- "Test that EOF and BOF are excluded from anchor verification."
- (let* ((content "hello\n")
- (file (make-temp-file nil nil nil content)))
- (unwind-protect
- (progn
- ;; EOF anchor should not cause anchor mismatch
- (let ((patch (format "@@ %s\n+ EOF\n~appended" file)))
- (let ((results (llm-tools--edit-verify-anchors patch)))
- (should (cdr (car results)))))
- ;; BOF anchor should not cause anchor mismatch
- (let ((patch (format "@@ %s\n< BOF\n~prepended" file)))
- (let ((results (llm-tools--edit-verify-anchors patch)))
- (should (cdr (car results))))))
- (delete-file file))))
-
-;; --- llm-tools--edit-verify ---
-(ert-deftest llm-tools--edit-verify-clean-test ()
- "Test that a valid patch returns empty string."
- (let* ((content "hello\nworld\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 1))
- (patch (format "@@ %s\n+ %s\n~new" file anchor)))
- (unwind-protect
- (should (string= (llm-tools--edit-verify patch) ""))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-verify-structural-error-test ()
- "Test that structural errors are returned as a string."
- (let* ((file (make-temp-file nil nil nil "")))
- (unwind-protect
- (let ((patch (format "@@ %s\n+ 4ei" file)))
- (should-not (string= (llm-tools--edit-verify patch) "")))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-verify-anchor-error-test ()
- "Test that anchor mismatches produce a descriptive error."
- (let* ((content "hello\n")
- (file (make-temp-file nil nil nil content)))
- (unwind-protect
- (let ((patch (format "@@ %s\n= 1zzz..1zzz\n~new" file)))
- (let ((msg (llm-tools--edit-verify patch)))
- (should-not (string= msg ""))
- (should (string-match "incorrect anchors" msg))))
- (delete-file file))))
-
-;; --- llm-tools--edit-edit ---
-(ert-deftest llm-tools--edit-edit-success-test ()
- "Test that a valid patch is applied successfully via llm-tools--edit-edit."
- (let* ((content "hello\nworld\n")
- (file (make-temp-file nil nil nil content))
- (anchor (llm-tools--edit-file-line-hash file 1))
- (patch (format "@@ %s\n+ %s\n~new" file anchor)))
- (unwind-protect
- (let ((result (llm-tools--edit-edit patch)))
- (should (string-match "Finished" result))
- (should (equal (llm-tools--edit-file-lines file) '("hello" "new" "world"))))
- (delete-file file))))
-
-(ert-deftest llm-tools--edit-edit-failure-test ()
- "Test that an invalid patch is rejected via llm-tools--edit-edit."
- (let* ((content "hello\n")
- (file (make-temp-file nil nil nil content)))
- (unwind-protect
- (let ((patch (format "@@ %s\n= 1zzz..1zzz\n~new" file)))
- (let ((result (llm-tools--edit-edit patch)))
- (should-not (string-match "Finished" result))))
- (delete-file file))))
-
-;; --- llm-tools--edit-apply-one-op edge cases ---
-(ert-deftest llm-tools--edit-apply-one-op-insert-at-eof-test ()
- "Test inserting at EOF anchor."
- (let* ((content '("A" "B"))
- (op (make-edit-op :type 'insert-after :anchor "EOF" :payload '("C"))))
- (let ((result (llm-tools--edit-apply-one-op op content 0)))
- (should (equal (car result) '("A" "B" "C"))))))
-
-(ert-deftest llm-tools--edit-apply-one-op-insert-at-bof-test ()
- "Test inserting at BOF anchor."
- (let* ((content '("A" "B"))
- (op (make-edit-op :type 'insert-before :anchor "BOF" :payload '("C"))))
- (let ((result (llm-tools--edit-apply-one-op op content 0)))
- (should (equal (car result) '("C" "A" "B"))))))
-
-(ert-deftest llm-tools--edit-apply-one-op-replace-blank-payload-test ()
- "Test that replace with nil payload produces a blank line."
- (let* ((content '("A" "B" "C"))
- (op (make-edit-op :type 'replace :range '("2in" "2in") :payload nil)))
- (let ((result (llm-tools--edit-apply-one-op op content 0)))
- (should (equal (car result) '("A" "" "C"))))))
-
-(ert-deftest llm-tools--edit-apply-one-op-replace-multi-line-test ()
- "Test replace spanning multiple lines."
- (let* ((content '("A" "B" "C" "D"))
- (op (make-edit-op :type 'replace :range '("2in" "3cc") :payload '("X" "Y"))))
- (let ((result (llm-tools--edit-apply-one-op op content 0)))
- (should (equal (car result) '("A" "X" "Y" "D"))))))
-
-(ert-deftest llm-tools--edit-apply-one-op-delete-range-test ()
- "Test deleting a range of lines."
- (let* ((content '("A" "B" "C" "D" "E"))
- (op (make-edit-op :type 'delete :range '("2in" "4ei"))))
- (let ((result (llm-tools--edit-apply-one-op op content 0)))
- (should (equal (car result) '("A" "E"))))))
-
-(ert-deftest llm-tools--edit-apply-one-op-offset-tracking-test ()
- "Test that offset is updated correctly after each op."
- (let* ((content '("A" "B" "C"))
- (op (make-edit-op :type 'insert-after :anchor "1vx" :payload '("X" "Y"))))
- (let ((result (llm-tools--edit-apply-one-op op content 0)))
- (should (= (cdr result) 2)))))
-
-;; --- llm-tools--edit-parse-section edge cases ---
-(ert-deftest llm-tools--edit-parse-section-empty-payload-for-replace-test ()
- "Test that replace with no ~ lines produces nil payload."
- (let ((section "= 1vx..1vx"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (null (hl-op-payload op))))))
-
-(ert-deftest llm-tools--edit-parse-section-payload-with-tabs-test ()
- "Test that payload lines preserve leading tabs."
- (let ((section "+ 4ei\n~\tindented"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (string= (car (hl-op-payload op)) "\tindented")))))
-
-(ert-deftest llm-tools--edit-parse-section-payload-with-spaces-test ()
- "Test that payload lines preserve leading spaces."
- (let ((section "+ 4ei\n~ two spaces"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (string= (car (hl-op-payload op)) " two spaces")))))
-
-(ert-deftest llm-tools--edit-parse-section-blank-payload-line-test ()
- "Test that a ~ line with no content produces an empty string in payload."
- (let ((section "= 1vx..1vx\n~"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (equal (hl-op-payload op) '(""))))))
-
-(ert-deftest llm-tools--edit-parse-section-eof-anchor-test ()
- "Test parsing an insert with EOF anchor."
- (let ((section "+ EOF\n~appended"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (eq (hl-op-type op) 'insert-after))
- (should (string= (hl-op-anchor op) "EOF")))))
-
-(ert-deftest llm-tools--edit-parse-section-bof-anchor-test ()
- "Test parsing an insert with BOF anchor."
- (let ((section "< BOF\n~prepended"))
- (let ((op (car (llm-tools--edit-parse-section section))))
- (should (eq (hl-op-type op) 'insert-before))
- (should (string= (hl-op-anchor op) "BOF")))))
-
-(ert-deftest llm-tools--edit-get-anchors-with-payload-lines-test ()
- "Test that payload lines (~) are ignored during anchor extraction.
-This would have caught the args-out-of-range crash when anchor extraction
-tried to parse a line like \"~payload\" as an operation."
- (let ((patch "@@ test.el\n+ 4ei\n~some payload\n~another line\n= 3gv..6be\n~replacement"))
- (let ((anchors (llm-tools--edit-get-anchors patch)))
- (should (= (length anchors) 1))
- (let ((file-anchors (cdr (car anchors))))
- (should (member "4ei" file-anchors))
- (should (member "3gv" file-anchors))
- (should (member "6be" file-anchors))
- (should-not (member "some payload" file-anchors))
- (should-not (member "another line" file-anchors))
- (should-not (member "replacement" file-anchors))))))
-
-(ert-deftest llm-tools--edit-apply-replace-delete-offset-test ()
- "Replace same-count lines then delete a later line.
-The replace's offset delta should be 0 (2 payload lines replacing 2 lines).
-With the buggy formula (- b a 1) this computed to +2 instead, causing the
-delete to target past the end of the buffer."
- (let* ((content "A\nB\nC\nD\nE\n")
- (file (make-temp-file nil nil nil content))
- (a2 (llm-tools--edit-file-line-hash file 2))
- (a3 (llm-tools--edit-file-line-hash file 3))
- (a5 (llm-tools--edit-file-line-hash file 5))
- (patch (format "@@ %s\n= %s..%s\n~X\n~Y\n- %s..%s"
- file a2 a3 a5 a5)))
- (unwind-protect
- (progn
- (llm-tools--edit-apply patch)
- (let ((lines (llm-tools--edit-file-lines file)))
- (should (equal lines '("A" "X" "Y" "D")))))
- (delete-file file))))
+ (llm-tools-edit-test--with-file ""
+ (should (equal (llm-tools--edit-file-lines file) '("")))))
+
+(ert-deftest llm-tools--edit-apply-replacement-test ()
+ (should (equal (llm-tools--edit-apply "hello world\n" "world" "there")
+ "hello there\n"))
+ (should (equal (llm-tools--edit-apply "A\nB\nC\n" "A\nB" "X\nY\nZ")
+ "X\nY\nZ\nC\n")))
+
+(ert-deftest llm-tools--edit-apply-insertion-test ()
+ (should (equal (llm-tools--edit-apply "A\nB\n" "A\n" "A\nNEW\n")
+ "A\nNEW\nB\n"))
+ (should (equal (llm-tools--edit-apply "A\nB\n" "B\n" "NEW\nB\n")
+ "A\nNEW\nB\n"))
+ (should (equal (llm-tools--edit-apply "A\nB\n" "B\n" "B\nLAST\n")
+ "A\nB\nLAST\n")))
+
+(ert-deftest llm-tools--edit-apply-deletion-test ()
+ (should (equal (llm-tools--edit-apply "A\nB\nC\n" "B\n" "") "A\nC\n"))
+ (should (equal (llm-tools--edit-apply "A\nB\nC\n" "B" "") "A\n\nC\n")))
+
+(ert-deftest llm-tools--edit-apply-replace-all-test ()
+ (should-error (llm-tools--edit-apply "cat cat" "cat" "dog"))
+ (should (equal (llm-tools--edit-apply "cat cat" "cat" "dog" t) "dog dog")))
+
+(ert-deftest llm-tools--edit-apply-non-overlapping-test ()
+ (should (equal (llm-tools--edit-apply "aaa" "aa" "x") "xa"))
+ (should (equal (llm-tools--edit-apply "aaaa" "aa" "x" t) "xx")))
+
+(ert-deftest llm-tools--edit-apply-invalid-input-test ()
+ (should-error (llm-tools--edit-apply "hello" "" "x"))
+ (should-error (llm-tools--edit-apply "hello" "hello" "hello"))
+ (should-error (llm-tools--edit-apply "hello" "absent" "x"))
+ (should-error (llm-tools--edit-apply "" "absent" "x" t)))
+
+(ert-deftest llm-tools--edit-apply-exact-match-test ()
+ (let ((case-fold-search t))
+ (should-error (llm-tools--edit-apply "Hello" "hello" "x")))
+ (should-error (llm-tools--edit-apply "A\n hello\n" "A\n hello\n" "x"))
+ (should-error (llm-tools--edit-apply "hello \n" "hello\n" "x"))
+ (should-error (llm-tools--edit-apply "‘hello’" "'hello'" "x")))
+
+(ert-deftest llm-tools--edit-apply-literal-text-test ()
+ (should (equal (llm-tools--edit-apply "a.*[b]" ".*[" "\\1\\&$")
+ "a\\1\\&$b]"))
+ (should (equal (llm-tools--edit-apply "HELLO" "HELLO" "lower") "lower"))
+ (should (equal (llm-tools--edit-apply "ä½ å¥½ café" "café" "世界") "ä½ å¥½ 世界")))
+
+(ert-deftest llm-tools--edit-apply-line-endings-test ()
+ (should (equal (llm-tools--edit-apply "A\r\nB\r\n" "A\r\n" "X\r\n")
+ "X\nB\n"))
+ (should (equal (llm-tools--edit-apply "A\rB" "B" "C") "A\rC")))
+
+(ert-deftest llm-tools--edit-success-test ()
+ (dolist (content '("hello\n" "hello" "hello\r\n"))
+ (llm-tools-edit-test--with-file content
+ (should (equal (llm-tools--edit file "hello" "world")
+ (format "The file %s has been updated successfully." file)))
+ (should (equal (llm-tools-edit-test--contents file)
+ (string-replace "hello" "world" content))))))
+
+(ert-deftest llm-tools--edit-replace-all-test ()
+ (llm-tools-edit-test--with-file "hello hello\n"
+ (should (equal (llm-tools--edit file "hello" "world" t)
+ (format "The file %s has been updated. All occurrences were successfully replaced."
+ file)))
+ (should (equal (llm-tools-edit-test--contents file) "world world\n"))))
+
+(ert-deftest llm-tools--edit-failure-preserves-file-test ()
+ (llm-tools-edit-test--with-file "hello hello\n"
+ (dolist (args '(("absent" "x") ("hello" "x") ("" "x") ("hello" "hello")))
+ (should (string-prefix-p "Error:" (apply #'llm-tools--edit file args)))
+ (should (equal (llm-tools-edit-test--contents file) "hello hello\n")))))
+
+(ert-deftest llm-tools--edit-invalid-target-test ()
+ (let ((directory (make-temp-file "llm-tools-edit-test-" t)))
+ (unwind-protect
+ (let ((missing (expand-file-name "missing" directory)))
+ (should (string-prefix-p "Error:" (llm-tools--edit " " "a" "b")))
+ (should (string-prefix-p "Error:" (llm-tools--edit directory "a" "b")))
+ (should (string-prefix-p "Error:" (llm-tools--edit missing "" "new")))
+ (should-not (file-exists-p missing)))
+ (delete-directory directory))))
diff --git a/llm-tools-edit.el b/llm-tools-edit.el
@@ -22,73 +22,14 @@
;;; Commentary:
-;; Hashline-based file operations for the llm-tools package.
+;; File editing tool for the llm-tools package.
;; Provides functions for reading, editing, and verifying files using
;; a content-addressed line format.
;;; Code:
(require 'cl-lib)
-
-(defvar llm-tools--edit-bigrams
- (list "aa" "ab" "ac" "ad" "ae" "af" "ag" "ah" "ai" "aj" "ak" "al" "am" "an"
- "ao" "ap" "aq" "ar" "as" "at" "au" "av" "aw" "ax" "ay" "az" "ba" "bb"
- "bc" "bd" "be" "bf" "bg" "bh" "bi" "bj" "bk" "bl" "bm" "bn" "bo" "bp"
- "br" "bs" "bt" "bu" "bv" "bw" "bx" "by" "bz" "ca" "cb" "cc" "cd" "ce"
- "cf" "cg" "ch" "ci" "cj" "ck" "cl" "cm" "cn" "co" "cp" "cq" "cr" "cs"
- "ct" "cu" "cv" "cw" "cx" "cy" "cz" "da" "db" "dc" "dd" "de" "df" "dg"
- "dh" "di" "dj" "dk" "dl" "dm" "dn" "do" "dp" "dq" "dr" "ds" "dt" "du"
- "dv" "dw" "dx" "dy" "dz" "ea" "eb" "ec" "ed" "ee" "ef" "eg" "eh" "ei"
- "ej" "ek" "el" "em" "en" "eo" "ep" "eq" "er" "es" "et" "eu" "ev" "ew"
- "ex" "ey" "ez" "fa" "fb" "fc" "fd" "fe" "ff" "fg" "fh" "fi" "fj" "fk"
- "fl" "fm" "fn" "fo" "fp" "fq" "fr" "fs" "ft" "fu" "fv" "fw" "fx" "fy"
- "fz" "ga" "gb" "gc" "gd" "ge" "gf" "gg" "gh" "gi" "gj" "gl" "gm" "gn"
- "go" "gp" "gr" "gs" "gt" "gu" "gv" "gw" "gx" "gy" "gz" "ha" "hb" "hc"
- "hd" "he" "hf" "hg" "hh" "hi" "hj" "hk" "hl" "hm" "hn" "ho" "hp" "hq"
- "hr" "hs" "ht" "hu" "hv" "hw" "hx" "hy" "hz" "ia" "ib" "ic" "id" "ie"
- "if" "ig" "ih" "ii" "ij" "ik" "il" "im" "in" "io" "ip" "iq" "ir" "is"
- "it" "iu" "iv" "iw" "ix" "iy" "iz" "ja" "jb" "jc" "jd" "je" "jf" "jg"
- "jh" "ji" "jj" "jk" "jl" "jm" "jn" "jo" "jp" "jq" "jr" "js" "jt" "ju"
- "jw" "jx" "jy" "ka" "kb" "kc" "kd" "ke" "kf" "kg" "kh" "ki" "kj" "kk"
- "kl" "km" "kn" "ko" "kp" "kr" "ks" "kt" "ku" "kv" "kw" "kx" "ky" "la"
- "lb" "lc" "ld" "le" "lf" "lg" "lh" "li" "lj" "lk" "ll" "lm" "ln" "lo"
- "lp" "lr" "ls" "lt" "lu" "lv" "lw" "lx" "ly" "lz" "ma" "mb" "mc" "md"
- "me" "mf" "mg" "mh" "mi" "mj" "mk" "ml" "mm" "mn" "mo" "mp" "mq" "mr"
- "ms" "mt" "mu" "mv" "mw" "mx" "my" "mz" "na" "nb" "nc" "nd" "ne" "nf"
- "ng" "nh" "ni" "nj" "nk" "nl" "nm" "nn" "no" "np" "nr" "ns" "nt" "nu"
- "nv" "nw" "nx" "ny" "nz" "oa" "ob" "oc" "od" "oe" "of" "og" "oh" "oi"
- "oj" "ok" "ol" "om" "on" "oo" "op" "oq" "or" "os" "ot" "ou" "ov" "ow"
- "ox" "oy" "oz" "pa" "pb" "pc" "pd" "pe" "pf" "pg" "ph" "pi" "pj" "pk"
- "pl" "pm" "pn" "po" "pp" "pq" "pr" "ps" "pt" "pu" "pv" "pw" "px" "py"
- "pz" "qa" "qb" "qc" "qd" "qe" "qh" "qi" "ql" "qm" "qn" "qo" "qp" "qq"
- "qr" "qs" "qt" "qu" "qw" "qx" "qy" "ra" "rb" "rc" "rd" "re" "rf" "rg"
- "rh" "ri" "rk" "rl" "rm" "rn" "ro" "rp" "rq" "rr" "rs" "rt" "ru" "rv"
- "rw" "rx" "ry" "rz" "sa" "sb" "sc" "sd" "se" "sf" "sg" "sh" "si" "sj"
- "sk" "sl" "sm" "sn" "so" "sp" "sq" "sr" "ss" "st" "su" "sv" "sw" "sx"
- "sy" "sz" "ta" "tb" "tc" "td" "te" "tf" "tg" "th" "ti" "tj" "tk" "tl"
- "tm" "tn" "to" "tp" "tr" "ts" "tt" "tu" "tv" "tw" "tx" "ty" "tz" "ua"
- "ub" "uc" "ud" "ue" "uf" "ug" "uh" "ui" "uj" "uk" "ul" "um" "un" "uo"
- "up" "uq" "ur" "us" "ut" "uu" "uv" "uw" "ux" "uy" "uz" "va" "vb" "vc"
- "vd" "ve" "vf" "vg" "vh" "vi" "vj" "vk" "vl" "vm" "vn" "vo" "vp" "vq"
- "vr" "vs" "vt" "vu" "vv" "vw" "vx" "vy" "vz" "wa" "wb" "wc" "wd" "we"
- "wf" "wg" "wh" "wi" "wj" "wk" "wl" "wm" "wn" "wo" "wp" "wr" "ws" "wt"
- "wu" "wv" "ww" "wx" "wy" "xa" "xb" "xc" "xd" "xe" "xf" "xh" "xi" "xl"
- "xm" "xn" "xo" "xp" "xr" "xs" "xt" "xu" "xx" "xy" "xz" "ya" "yb" "yc"
- "yd" "ye" "yf" "yg" "yh" "yi" "yj" "yk" "yl" "ym" "yn" "yo" "yp" "yr"
- "ys" "yt" "yu" "yv" "yw" "yx" "yy" "yz" "za" "zb" "zc" "zd" "ze" "zf"
- "zg" "zh" "zi" "zk" "zl" "zm" "zn" "zo" "zp" "zr" "zs" "zt" "zu" "zw"
- "zx" "zy" "zz")
- "List of bigrams for use with hashline reads/writes.
-
-Each of the bigrams resolve to a single token in standard LLM
-vocabulary, unlike a hash's hex digits.
-
-Taken from https://github.com/can1357/oh-my-pi.
-Precisely: https://raw.githubusercontent.com/can1357/oh-my-pi/85003ca/packages/coding-agent/src/hashline/bigrams.json")
-
-(defun llm-tools--edit-hash (line)
- ;; logand is to force unsigned number
- (elt llm-tools--edit-bigrams (% (logand (sxhash line) #xffffffff) 647)))
+(require 'subr-x)
(defun llm-tools--edit-file-lines (file &optional beg end)
"Return FILE contents as a list of strings.
@@ -100,435 +41,66 @@ With optional BEG and END (inclusive, 1-based), return the lines between that ra
(cl-subseq lines (1- beg) end)
lines))))
-(defun llm-tools--edit-format-lines (lines &optional start only-anchors?)
- "Format LINES in hashline format.
-START is 1-based (default 1).
-If ONLY-ANCHORS? is non-nil, do not append `|CONTENT' after each anchor."
- (string-join
- (cl-mapcar (lambda (line i)
- (if only-anchors?
- (format "%d%2s" i (llm-tools--edit-hash line))
- (format "%d%2s|%s" i (llm-tools--edit-hash line) line)))
- lines
- (number-sequence (or start 1) (+ (or start 1) (1- (length lines)))))
- "\n"))
-
-(defun llm-tools--edit-file-read (file &optional beg end only-anchors? as-list?)
- "Return FILE contents in hashline format.
+(defun llm-tools--edit-file-read (file &optional beg end as-list?)
+ "Return FILE contents.
With optional BEG and END (inclusive, 1-based), return lines BEG to END.
-If ONLY-ANCHORS? is non-nil, do not append `|CONTENT' after each anchor.
-If AS-LIST? is non-nil, return a list of hashline strings instead of a
-single string."
+If AS-LIST? is non-nil, return a list of strings instead of a single
+string."
(let* ((lines (llm-tools--edit-file-lines file beg end))
- (formatted (llm-tools--edit-format-lines lines (or beg 1) only-anchors?)))
+ (formatted (string-join lines "\n")))
(if as-list?
- (string-lines formatted)
+ lines
formatted)))
-(defun llm-tools--edit-file-line-hash (file n)
- "Return the hash of the Nth line in FILE."
- (let* ((lines (llm-tools--edit-file-lines file))
- (bigram (llm-tools--edit-hash (elt lines (1- n)))))
- (format "%d%s" n bigram)))
-
-;;;; Diff Validation
-;; TODO this should also validate the operators (e.g. any invalid
-;; op-chars, payload given before all other operations, no payload
-;; after +/< or payload given to -, or anchors without line
-;; number). it should return a string saying it's a malformed patch
-;; and what went wrong.
-(cl-defstruct hl-verify-op
- (type nil) ; 'insert-after 'insert-before 'delete 'replace
- (anchor nil) ; string like "4ei" or "EOF" for +/-/<
- (payload nil) ; list of strings (nil is no payload)
- (raw nil)) ; raw op line text for error messages
-
-(defun llm-tools--edit-non-empty-non-comment-line-p (line)
- "Return t if LINE is not empty and not a comment (starts with #)."
- (let ((trimmed (string-trim line)))
- (and (not (string-empty-p trimmed))
- (not (string-prefix-p "#" trimmed)))))
-
-(defun llm-tools--edit-parse-op-line-for-verify (line)
- "Parse a single operation LINE (e.g. \"+ 4ei\") into a `hl-verify-op'."
- (let* ((trimmed (string-trim line))
- (ch (substring trimmed 0 1)))
- (make-edit-verify-op
- :type (pcase ch
- ("+" 'insert-after)
- ("<" 'insert-before)
- ("-" 'delete)
- ("=" 'replace))
- :anchor (string-trim (substring trimmed 1))
- :raw trimmed)))
-
-(defun llm-tools--edit-add-payload-to-verify-op (op payload-line)
- "Append PAYLOAD-LINE (without leading ~) to OP's payload list."
- (setf (hl-verify-op-payload op)
- (append (hl-verify-op-payload op) (list (substring (string-trim payload-line) 1)))))
-
-(defun llm-tools--edit-parse-section-ops (section-body)
- "Parse SECTION-BODY into a list of `hl-verify-op' structs."
- (let (ops op)
- (dolist (line (string-lines section-body))
- (when (llm-tools--edit-non-empty-non-comment-line-p line)
- (let* ((trimmed (string-trim line))
- (ch (substring trimmed 0 1)))
- (if (string= ch "~")
- (when op (llm-tools--edit-add-payload-to-verify-op op trimmed))
- (when op (push op ops))
- (setq op (llm-tools--edit-parse-op-line-for-verify trimmed))))))
- (when op (push op ops))
- (nreverse ops)))
-
-(defun llm-tools--edit-validate-no-payload-before-op (ops)
- "Check that no payload lines appear before the first op."
- (when-let ((first (car-safe ops)))
- (if (hl-verify-op-payload first)
- (list "Payload line (~) appears before any operation")
- '())))
-
-(defun llm-tools--edit-validate-insert-has-payload (ops)
- "Check that all insert ops have at least one payload line."
- (mapcan (lambda (op)
- (when (and (memq (hl-verify-op-type op) '(insert-after insert-before))
- (null (hl-verify-op-payload op)))
- (list (format "Insert operation (%s) has no payload lines following it"
- (hl-verify-op-type op)))))
- ops))
-
-(defun llm-tools--edit-validate-delete-no-payload (ops)
- "Check that delete ops have no payload lines."
- (mapcan (lambda (op)
- (when (and (eq (hl-verify-op-type op) 'delete)
- (hl-verify-op-payload op))
- (list "Delete operation (-) must not have payload lines")))
- ops))
-
-(defun llm-tools--edit-validate-insert-anchor (anchor)
- "Return error string if ANCHOR is malformed for insert, else nil."
- (when (and anchor
- (not (string= anchor "EOF"))
- (not (string= anchor "BOF")))
- (pcase anchor
- ((pred (lambda (a)
- (or (< (length a) 3)
- (not (string-match-p "^[0-9]" a)))))
- (format "Invalid anchor %S (expected LINEHASH like '5ff' or EOF/BOF)" anchor))
- (_ nil))))
-
-(defun llm-tools--edit-validate-range-anchor (anchor)
- "Return error string if ANCHOR (like \"3gv..6be\") is malformed, else nil."
- (pcase (split-string anchor "\\.\\.")
- (`(,a ,b)
- (when (or (< (length a) 3) (not (string-match-p "^[0-9]" a))
- (< (length b) 3) (not (string-match-p "^[0-9]" b)))
- (format "Invalid anchor %S in range (expected numeric line anchor like '5ff')" anchor)))
- (_
- (format "Invalid range %S (expected A..B)" anchor))))
-
-(defun llm-tools--edit-validate-anchors (ops)
- "Check that all anchors are well-formed."
- (mapcan (lambda (op)
- (pcase (hl-verify-op-type op)
- ((or 'insert-after 'insert-before)
- (when-let ((err (llm-tools--edit-validate-insert-anchor (hl-verify-op-anchor op))))
- (list err)))
- ((or 'delete 'replace)
- (when-let ((err (llm-tools--edit-validate-range-anchor (hl-verify-op-anchor op))))
- (list err)))))
- ops))
-
-(defun llm-tools--edit-verify-section (section)
- "Validate a single SECTION. Returns a list of error strings."
- (let ((ops (llm-tools--edit-parse-section-ops (cdr section))))
- (let (errors)
- ;; Inserts must have payload
- (dolist (op ops)
- (when (and (memq (hl-verify-op-type op) '(insert-after insert-before))
- (null (hl-verify-op-payload op)))
- (push (format "Insert operation (%s) has no payload lines following it"
- (hl-verify-op-type op))
- errors)))
- ;; Deletes must not have payload
- (dolist (op ops)
- (when (and (eq (hl-verify-op-type op) 'delete)
- (hl-verify-op-payload op))
- (push "Delete operation (-) must not have payload lines" errors)))
- ;; Anchor validation
- (setq errors (append errors (llm-tools--edit-validate-anchors ops)))
- (nreverse errors))))
-
-(defun llm-tools--edit-verify-structure (patch)
- "Validate the structural integrity of PATCH.
-Returns a list of error strings describing each structural violation found.
-An empty list means the patch is well-formed."
- (mapcan #'llm-tools--edit-verify-section
- (llm-tools--split-patch-sections patch)))
-
-(defun llm-tools--edit-verify (patch)
- "Verify PATCH is structurally valid and all anchors match current files.
-Returns a string with error messages, or an empty string if clean."
- (let ((struct-errors (llm-tools--edit-verify-structure patch)))
- (if struct-errors
- (string-join struct-errors "\n")
- (string-join
- (delete-dups
- (remq nil
- (mapcar (lambda (elem)
- (unless (cdr elem)
- (format "%s had incorrect anchors. Use the 'read_file' tool to get correct anchors."
- (car elem))))
- (llm-tools--edit-verify-anchors patch))))
- "\n"))))
-
-;;;; Diff Parsing
-(defun llm-tools--split-patch-sections (patch)
- "Return a list of (FILENAME . REST) cons cells from PATCH.
-Each section starts with '@@ PATH' on the first line; the car is PATH
-and the cdr is the remainder of that section."
- (let ((pos (string-match "^@@" patch)))
- (when pos
- (let (sections
- (start pos))
- (while (string-match "\n@@" patch (1+ pos))
- (let ((nl (match-beginning 0)))
- (push (substring patch start (1+ nl)) sections)
- (setq start (1+ nl)))
- (setq pos (match-end 0)))
- (push (substring patch start) sections)
- (mapcar (lambda (sec)
- (let ((end (string-match "\n" sec)))
- (cons (substring sec 3 end)
- (substring sec end))))
- (nreverse sections))))))
-
-(defun llm-tools--edit-extract-anchors-from-op-line (line)
- "Return a list of anchor strings from a single operation LINE.
-Handles ranges by returning both endpoints."
- (when (and (stringp line) (length> line 2))
- (let ((op (char-to-string (elt line 0)))
- (anchor (substring line 2)))
- (pcase op
- ((or "+" "<") (list anchor))
- ((or "-" "=") (split-string anchor "\\.\\."))))))
-
-(defun llm-tools--edit-extract-anchors-from-section (section-body)
- "Return all anchors from a section body (string).
-Only lines starting with +, <, -, or = are considered."
- (let ((ops-lines
- (cl-remove-if-not
- (lambda (line)
- (let ((trimmed (string-trim line)))
- (and (not (string-empty-p trimmed))
- (not (string-prefix-p "#" trimmed))
- (not (string-prefix-p "~" trimmed))
- (member (substring trimmed 0 1) '("+" "<" "-" "=")))))
- (string-lines section-body))))
- (flatten-list (mapcar #'llm-tools--edit-extract-anchors-from-op-line ops-lines))))
-
-(defun llm-tools--edit-get-anchors (patch)
- "Returns an alist of (FILENAME . ANCHORS) from PATCH.
-ANCHORS is a sorted, deduplicated list of all line anchors referenced by
-the operations in that file's sections. Each anchor is a string like
-\"5ff\" or a list of two strings from a range like
-(\"3gv\" \"6be\")."
- (mapcar (lambda (section)
- (cons (car section)
- (delete-dups
- (sort
- (llm-tools--edit-extract-anchors-from-section (cdr section))))))
- (llm-tools--split-patch-sections patch)))
-
-(defun llm-tools--edit-verify-anchors (patch)
- "Verify that all anchors in PATCH match the current file contents.
-Returns a list of booleans, one per file, indicating whether every
-anchor in that file's patch sections still matches the corresponding
-line in the on-disk file."
- (let ((patch-anchors (llm-tools--edit-get-anchors patch)))
- (mapcar (lambda (section)
- (let* ((file (car section))
- (anchors (delete "BOF" (delete "EOF" (cdr section))))
- (hl (llm-tools--edit-file-read file nil nil t t)))
- ;; cl-subsetp uses eql by default which compares by identity and not content
- (cons file (cl-subsetp anchors hl :test #'equal))))
- patch-anchors)))
-
-(cl-defstruct hl-op
- (type nil) ; 'insert-after 'insert-before 'delete 'replace
- (anchor nil) ; string like "4ei" or "EOF" for +/-/<
- (range nil) ; (start . end) strings for - and =
- (payload nil)) ; list of strings (nil is no payload)
-
-(defun llm-tools--edit-anchor-to-line-num (anchor)
- "Extract the line number from ANCHOR like \"5ff\"."
- (string-to-number (substring anchor 0 (- (length anchor) 2))))
-
-(defun llm-tools--edit-parse-op-line (line)
- "Parse a single operation LINE into an `hl-op' struct.
-LINE must start with `+`, `<`, `-`, or `=`, followed by a space,
-followed by an anchor (or range)."
- (string-match "^[+<=-] \\(.*\\)" line) ; returns index or nil
- (unless (match-beginning 0)
- (error "Invalid operation line: %S" line))
- (let* ((op-char (substring line 0 1))
- (anchor (match-string 1 line)))
- (pcase op-char
- ("+" (make-edit-op :type 'insert-after :anchor anchor))
- ("<" (make-edit-op :type 'insert-before :anchor anchor))
- ("-" (make-edit-op :type 'delete :range (split-string anchor "\\.\\.")))
- ("=" (make-edit-op :type 'replace :range (split-string anchor "\\.\\."))))))
-
-(defun llm-tools--edit-parse-section (section)
- "Parse SECTION (body after `@@ PATH`) into a list of `hl-op' structs.
-Skips comment lines (starting with `#`) and blank lines."
- (let (ops op)
- (dolist (line (string-lines section))
- (let ((trimmed (string-trim-left line)))
- (when (string-match "\\S-" trimmed)
- (let ((op-char (substring trimmed 0 1)))
- (pcase op-char
- ("~"
- (when op
- (setf (hl-op-payload op)
- (append (hl-op-payload op)
- (list (substring trimmed 1))))))
- ("#" nil)
- (_
- (when op (push op ops))
- (if (< (length trimmed) 3)
- (error "Malformed operation line (too short): %S" trimmed))
- (setq op (llm-tools--edit-parse-op-line trimmed))))))))
- (when op (push op ops))
- (nreverse ops)))
-
-(defun llm-tools--edit-group-by-file (sections)
- "Group SECTIONS by file path.
-Returns an alist of (FILENAME . SECTION-LIST) where SECTION-LIST
-contains all (PATH . REST) cons cells from SECTIONS that target
-FILENAME."
- (let (grouped)
- (dolist (sec sections)
- (let ((existing (assoc (car sec) grouped)))
- (if existing
- (push sec (cdr existing))
- (push (cons (car sec) (list sec)) grouped))))
- grouped))
-
-(defun llm-tools--edit-apply-insert-after (anchor payload content offset)
- "Insert PAYLOAD after the line identified by ANCHOR (or EOF)."
- (let* ((line-num (if (string= anchor "EOF")
- (length content)
- (+ (llm-tools--edit-anchor-to-line-num anchor) offset)))
- (idx (1- line-num)))
- (cons (append (cl-subseq content 0 (1+ idx))
- payload
- (cl-subseq content (1+ idx)))
- (+ offset (length payload)))))
-
-(defun llm-tools--edit-apply-insert-before (anchor payload content offset)
- "Insert PAYLOAD before the line identified by ANCHOR (or BOF)."
- (let* ((line-num (if (string= anchor "BOF") 1
- (+ (llm-tools--edit-anchor-to-line-num anchor) offset)))
- (idx (1- line-num)))
- (cons (append (cl-subseq content 0 idx)
- payload
- (cl-subseq content idx))
- (+ offset (length payload)))))
-
-(defun llm-tools--edit-apply-delete (range content offset)
- "Delete lines from (car RANGE) to (cadr RANGE)."
- (let ((a (+ (llm-tools--edit-anchor-to-line-num (car range)) offset))
- (b (+ (llm-tools--edit-anchor-to-line-num (cadr range)) offset)))
- (cons (append (cl-subseq content 0 (1- a))
- (cl-subseq content b))
- (- offset (1+ (- b a))))))
-
-(defun llm-tools--edit-apply-replace (range payload content offset)
- "Replace lines from (car RANGE) to (cadr RANGE) with PAYLOAD (nil to '(\"\"))"
- (let ((a (+ (llm-tools--edit-anchor-to-line-num (car range)) offset))
- (b (+ (llm-tools--edit-anchor-to-line-num (cadr range)) offset))
- (payload (or payload '(""))))
- (cons (append (cl-subseq content 0 (1- a))
- payload
- (cl-subseq content b))
- (+ offset (- (length payload) (1+ (- b a)))))))
-
-(defun llm-tools--edit-apply-one-op (op content offset)
- "Apply a single OP to CONTENT (list of strings) at the given OFFSET.
-Returns a cons cell (NEW-CONTENT . NEW-OFFSET)."
- (pcase (hl-op-type op)
- ('insert-after (llm-tools--edit-apply-insert-after
- (hl-op-anchor op)
- (hl-op-payload op)
- content offset))
- ('insert-before (llm-tools--edit-apply-insert-before
- (hl-op-anchor op)
- (hl-op-payload op)
- content
- offset))
- ('delete (llm-tools--edit-apply-delete
- (hl-op-range op)
- content
- offset))
- ('replace (llm-tools--edit-apply-replace
- (hl-op-range op)
- (hl-op-payload op)
- content
- offset))))
-
-(defun llm-tools--edit-apply-ops (ops content)
- "Apply a list of OPS to CONTENT (list of strings).
-Returns the modified list of strings."
- (let ((offset 0))
- (dolist (op ops content)
- (let ((result (llm-tools--edit-apply-one-op op content offset)))
- (setq content (car result))
- (setq offset (cdr result))))))
-
-(defun llm-tools--edit-apply (patch)
- "Apply all operations in PATCH to their respective files.
-Operations within a file are applied sequentially, with line numbers
-adjusted for prior insertions/deletions via a running offset."
- (mapc (lambda (file-group)
- (let* ((file (car file-group))
- (ops (mapcan (lambda (sec)
- (llm-tools--edit-parse-section (cdr sec)))
- (cdr file-group)))
- (content (llm-tools--edit-apply-ops
- ops
- (llm-tools--edit-file-lines file))))
- (with-temp-buffer
- (insert (string-join content "\n"))
- (write-region (point-min) (point-max) file))))
- (llm-tools--edit-group-by-file
- (llm-tools--split-patch-sections patch))))
-
-(defun llm-tools--edit-edit (patch)
- "Apply a hashline PATCH to the files it references.
-
-PATCH is a string containing one or more sections, each starting with
-`@@ FILEPATH' followed by operations:
- + ANCHOR Insert lines after ANCHOR (or `EOF' to append)
- < ANCHOR Insert lines before ANCHOR (or `BOF' to prepend)
- - A..B Delete lines from A to B
- = A..B Replace lines from A to B with the following payload
- ~TEXT Payload line for the preceding operation
-
-ANCHOR is a hashline string like `5ff' (line number + 2-char hash) or
-`BOF' / `EOF'. Lines are written directly to disk.
-
-Before applying, verifies that every anchor in PATCH still matches the
-current file contents. If any file has drifted, the entire operation is
-aborted and an error message is returned identifying the affected files.
-
-Returns a success message if all operations applied, or an error string
-if verification failed."
- (let ((msg (llm-tools--edit-verify patch)))
- (if (string= msg "")
- (progn
- (llm-tools--edit-apply patch)
- (format "Finished applying patch."))
- (format "%s" msg))))
+(defun llm-tools--edit-apply (content old-string new-string &optional replace-all?)
+ "Replace literal OLD-STRING with NEW-STRING in CONTENT.
+Require exactly one non-overlapping match unless REPLACE-ALL? is non-nil.
+Normalize CRLF to LF. Return the edited string, or signal an error."
+ (when (string-empty-p old-string)
+ (error "old_string must be a non-empty string"))
+ (when (string= old-string new-string)
+ (error "old_string and new_string must differ"))
+ (let* ((content (string-replace "\r\n" "\n" content))
+ (old-string (string-replace "\r\n" "\n" old-string))
+ (new-string (string-replace "\r\n" "\n" new-string))
+ (pattern (regexp-quote old-string))
+ (case-fold-search nil)
+ (position 0)
+ (count 0))
+ (while (string-match pattern content position)
+ (setq count (1+ count)
+ position (match-end 0)))
+ (cond
+ ((zerop count)
+ (error "old_string was not found"))
+ ((and (> count 1) (not replace-all?))
+ (error "old_string matched %d times; provide more context or set replace_all to true"
+ count)))
+ (replace-regexp-in-string pattern new-string content t t)))
+
+(defun llm-tools--edit (file-path old-string new-string &optional replace-all?)
+ "Replace literal OLD-STRING with NEW-STRING in FILE-PATH.
+Require a unique match unless REPLACE-ALL? is non-nil.
+Return a success message, or an error message without writing on match failure."
+ (condition-case err
+ (progn
+ (when (string-empty-p (string-trim file-path))
+ (error "file_path must be a non-empty string"))
+ (unless (file-regular-p file-path)
+ (error "Not an existing regular file: %s" file-path))
+ (with-temp-buffer
+ (insert-file-contents file-path)
+ (let* ((coding buffer-file-coding-system)
+ (edited (llm-tools--edit-apply
+ (buffer-string) old-string new-string replace-all?)))
+ (erase-buffer)
+ (insert edited)
+ (let ((coding-system-for-write coding))
+ (write-region (point-min) (point-max) file-path nil 'silent))))
+ (if replace-all?
+ (format "The file %s has been updated. All occurrences were successfully replaced."
+ file-path)
+ (format "The file %s has been updated successfully." file-path)))
+ (error (format "Error: %s" (error-message-string err)))))
(provide 'llm-tools-edit)
diff --git a/llm-tools-pkg.el b/llm-tools-pkg.el
@@ -1,4 +1,4 @@
-(define-package 'llm-tools "1.0"
+(define-package "llm-tools" "1.0"
"Opinionated tools for use by an LLM."
'((emacs "28.1") (gptel "0.9.6") (magit "3.3.0"))
:keywords '("llm" "gptel")
diff --git a/llm-tools.el b/llm-tools.el
@@ -29,70 +29,38 @@
(require 'cl-lib)
(require 'magit)
+(require 'gptel)
(defgroup llm-tools nil
"Opinionated tools for use by an LLM."
:group 'convenience)
-(require 'llm-tools-hl)
+(require 'llm-tools-edit)
(require 'llm-tools-web)
-;; read_file (hl-file-read)
+;; read_file
(gptel-make-tool
:name "read_file"
- :function (lambda (file beg end only-anchors? as-list?)
- (json-encode-string (llm-tools--hl-file-read
- file beg end only-anchors? as-list?)))
- :description "\
-Read a file (or a range of lines) and return its contents in hashline
-format. Each line is represented as LINE_NUMBER HASH|CONTENT, where
-LINE_NUMBER is the 1-based line number, HASH is a 2-character
-content-derived bigram, and CONTENT is the verbatim line text. Example
-output:
-
-1aa|const char *TITLE = \"Mr\";
-2ab|void greet(char *name) {
-3cd| return name;
-
-The LINE_NUMBER and HASH together form an anchor (e.g., 1aa, 2ab) that
-uniquely identifies a line by position and content. Copy anchors
-verbatim from the output when referencing lines in edit operations. Do
-not fabricate or modify hashes. Call this tool before editing any file
-to obtain current content and valid anchors. Re-call if anchors no
-longer match after external changes.
-
-Use optional BEG and END arguments to read only a specific range of
-lines (1-based, inclusive). Use ONLY_ANCHORS to retrieve just the line
-anchors without content. Use AS_LIST to receive output as a list rather
-than a newline-delimited string."
+ :function (lambda (file &optional beg end as-list?)
+ (llm-tools--edit-file-read file beg end as-list?))
+ :description "Read a file as plain text. Call this before editing to obtain the current text.
+Optional beg and end select an inclusive, 1-based line range.
+Set as_list to return a list of lines instead of a newline-delimited string."
:args '((:name "file"
- :type string
- :description "\
-Path to the file to read, relative to the working directory.")
- (:name "beg"
- :type integer
- :description "\
-Optional 1-based line number (inclusive) to start reading from. Omit to
-read from the beginning."
- :optional t)
- (:name "end"
- :type integer
- :description "\
-Optional 1-based line number (inclusive) to stop reading at. Omit to
-read to the end."
- :optional t)
- (:name "only_anchors"
- :type boolean
- :description "\
-Optional boolean. When true, returns only the anchor (line number +
-hash) without the content portion after the pipe."
- :optional t)
- (:name "as_list"
- :type boolean
- :description "\
-Optional boolean. When true, returns each hashline as a list element
-instead of a single newline-delimited string."
- :optional t))
+ :type string
+ :description "File path, absolute or relative to the working directory.")
+ (:name "beg"
+ :type integer
+ :description "Optional first line to read (1-based, inclusive)."
+ :optional t)
+ (:name "end"
+ :type integer
+ :description "Optional last line to read (1-based, inclusive)."
+ :optional t)
+ (:name "as_list"
+ :type boolean
+ :description "Return a list of lines instead of a string."
+ :optional t))
:async nil
:confirm nil
:include t)
@@ -129,146 +97,31 @@ final file."))
:confirm t
:include t)
-;; edit_file (hl-edit)
+;; edit_file
(gptel-make-tool
:name "edit_file"
- :function #'llm-tools--hl-edit
- :description "\
-Edit an existing file surgically using the hashline patch
-format. Call `read_file` first to obtain current content and valid
-line anchors. Use `write_file` instead for creating new files or
-completely replacing content. Returns a success message if the patch
-applied, or an error string describing what went wrong (structural
-issues or stale anchors)."
- :args '((:name "patch"
- :type string
- :description "\
-A hashline patch string containing one or more file sections. Each
-section begins with `@@ PATH` on its own line, followed by operations
-that reference lines by their anchor (line number + 2-character hash,
-e.g. `5ff`). Copy anchors verbatim from `read_file` output; never
-fabricate or modify them.
-
-CRITICAL: This is NOT a unified diff. Do NOT use `---`, `+++`, `@@ -1,3
-+1,4 @@`, `-old`, `+new`, or any unified diff syntax. The header is
-`@@ PATH`; operations use `<`, `+`, `-`, `=`. This format is purely
-textual -- the tool has NO awareness of language, indentation,
-brackets, fences, or table widths. You must emit valid syntax in
-replacements and insertions.
-
-Operations:
- + ANCHOR Insert lines AFTER the anchored line (or `EOF` to append)
- < ANCHOR Insert lines BEFORE the anchored line (or `BOF` to prepend)
- - A..B Delete the inclusive line range A through B
- = A..B Replace the inclusive range A through B with payload lines
-
-Payload lines follow the operation line and must start with `~`
-immediately followed by content -- no space after `~`. Every character
-after `~` is verbatim file content. Op lines carry no content:
-
-WRONG: + 5pg some code
-RIGHT:
-+ 5pg
-~some code
-
-One operation can have many payload lines. To insert N consecutive
-lines, write ONE op followed by N `~` lines:
-
-WRONG (one op per line, with fabricated anchors):
- + 5pg
- ~first new line
- + 6xx <- FABRICATED
- ~second new line
-
-RIGHT (one op, many payload lines):
- + 5pg
- ~first new line
- ~second new line
-
-Rules:
-- Payload is ONLY what is NEW relative to your range. Do not repeat
- existing lines or duplicate nearby content.
-- Anchors reference the file as last read via `read_file`. Do not shift
- line numbers for prior operations in the same patch.
-- Prefer narrow operations (`+`, `-`) over wide replacements (`=`).
- Two narrow ops beat one wide `=`.
-- When editing a multiline construct, widen to the whole construct
- rather than bisecting it.
-- `= A..B` deletes the range; payload is what is written. Before using
- `=`, mentally delete A..B. If that splits an unclosed brace or
- orphans a closer, you are bisecting a construct -- widen the range.
-- Every payload line must start with `~`. Raw content without `~` is
- invalid.
-- Comments (lines starting with `#`) and blank lines are ignored.
-
-Examples:
-
-Replace a single line:
-@@ greeting.el
-= 2in..2in
-~(defconst greeting-title \"Mrs\")
-
-Replace a multiline block (widen to self-contained boundary):
-@@ greeting.el
-= 4ei..7be
-~ (concat
-~ greeting-title
-~ (or (string-trim name) \"guest\"))
-~ )
-
-Insert after:
-@@ greeting.el
-+ 4ei
-~ greeting-title
-
-Insert before:
-@@ greeting.el
-< 5ff
-~ greeting-title
-
-Append to end of file:
-@@ greeting.el
-+ EOF
-~(provide 'greeting)
-
-Prepend to beginning of file:
-@@ greeting.el
-< BOF
-~;; greeting.el -- utilities
-
-Delete a single line:
-@@ greeting.el
-- 5ff..5ff
-
-Blank a line (replace with empty):
-@@ greeting.el
-= 5ff..5ff
-
-Multiple operations in one section:
-@@ greeting.el
-+ 1vx
-~(defconst DEBUG nil)
-- 5ff..5ff
-
-Multiple files in one patch:
-@@ a.el
-+ 1vx
-~(require 'b)
-@@ b.el
-= 2in..2in
-~(defconst VALUE 42)
-
-Insert multiple lines with one op:
-@@ greeting.el
-+ 1vx
-~(defconst GREETING \"Hello\")
-~(defconst FAREWELL \"Goodbye\")
-
-Replace one line with multiple lines:
-@@ greeting.el
-= 1vx..1vx
-~(defconst TITLE \"Dr\")
-~(defconst SUBTITLE \"Assistant\")"))
+ :function #'llm-tools--edit
+ :description "Edit an existing file by replacing literal text. Read the file first.
+Matching is case-sensitive and preserves whitespace, with CRLF normalized to LF.
+By default, old_string must occur exactly once. Include surrounding text in both
+old_string and new_string to uniquely identify a location while preserving context.
+Set replace_all to true only when every occurrence should change.
+Use an empty new_string to delete the matched text. To insert text, replace
+existing context with that context plus the new text. Use write_file to create files.
+Returns a success message or an error explaining why the edit could not be applied."
+ :args '((:name "file_path"
+ :type string
+ :description "File path, absolute or relative to the working directory.")
+ (:name "old_string"
+ :type string
+ :description "Non-empty literal text to replace, including exact whitespace and indentation.")
+ (:name "new_string"
+ :type string
+ :description "Literal replacement text, different from old_string. Use an empty string to delete.")
+ (:name "replace_all"
+ :type boolean
+ :description "Replace all non-overlapping matches. Defaults to false, requiring a unique match."
+ :optional t))
:async nil
:confirm t
:include t)
@@ -501,10 +354,13 @@ FORMATTER is a function (ARG-VALUES) that inserts the preview text."
" " command ")\n")))
(defun llm-tools--format-edit-file-preview (arg-values)
- (let ((patch (car arg-values)))
+ "Insert the file path, replacement scope, and literal texts from ARG-VALUES."
+ (pcase-let ((`(,file ,old-string ,new-string . ,rest) arg-values))
(insert "("
(propertize "edit_file" 'font-lock-face 'font-lock-keyword-face)
- ")\n" patch "\n")))
+ " " (prin1-to-string file)
+ (if (car rest) " replace_all=true" "")
+ ")\nOld text:\n" old-string "\nNew text:\n" new-string "\n")))
(defun llm-tools--format-write-file-preview (arg-values)
(let ((file (car arg-values))