llm-tools.el

Opinionated tools for use by a (local) LLM
Log | Files | Refs | LICENSE

commit c8793114ea26db8b322c1553313ff0a0bbd436e0
parent 0b3f0a363d107fb39cc226583111214f454e668f
Author: Vineet Kumar <git@vineetk.net>
Date:   Thu, 22 Oct 2026 14:41:26 +0200

rename hashline files to edit

Diffstat:
Allm-tools-edit-test.el | 701+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Allm-tools-edit.el | 534+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dllm-tools-hl-test.el | 701-------------------------------------------------------------------------------
Dllm-tools-hl.el | 534-------------------------------------------------------------------------------
4 files changed, 1235 insertions(+), 1235 deletions(-)

diff --git a/llm-tools-edit-test.el b/llm-tools-edit-test.el @@ -0,0 +1,701 @@ +;; -*- lexical-binding: t; -*- +(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)))) + +(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)))) + +;; --- 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)))) + +;; --- 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)))) + +(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)))) diff --git a/llm-tools-edit.el b/llm-tools-edit.el @@ -0,0 +1,534 @@ +;; llm-tools-edit.el --- Hashline file operations for llm-tools -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Vineet K + +;; Author: Vineet K <git@vineetk.net> +;; Version: 1.0 +;; Keywords: llm, gptel +;; Package-Requires: ((emacs "28.1")) + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <https://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Hashline-based file operations 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))) + +(defun llm-tools--edit-file-lines (file &optional beg end) + "Return FILE contents as a list of strings. +With optional BEG and END (inclusive, 1-based), return the lines between that range." + (with-temp-buffer + (insert-file-contents-literally file) + (let ((lines (string-lines (buffer-string)))) + (if beg + (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. +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." + (let* ((lines (llm-tools--edit-file-lines file beg end)) + (formatted (llm-tools--edit-format-lines lines (or beg 1) only-anchors?))) + (if as-list? + (string-lines formatted) + 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)))) + +(provide 'llm-tools-edit) diff --git a/llm-tools-hl-test.el b/llm-tools-hl-test.el @@ -1,701 +0,0 @@ -;; -*- lexical-binding: t; -*- -(require 'ert) -(require 'llm-tools-hl) - -(ert-deftest llm-tools--hl-file-read-test () - "Test that 'llm-tools--hl-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--hl-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--hl-anchor-to-line-num-test () - (should (= (llm-tools--hl-anchor-to-line-num "5ff") 5)) - (should (= (llm-tools--hl-anchor-to-line-num "123ab") 123)) - (should (= (llm-tools--hl-anchor-to-line-num "1vx") 1))) - -(ert-deftest llm-tools--hl-parse-section-single-replace () - (let ((section "= 1vx..1vx\n~(defconst greeting-title \"Mrs\")")) - (should (= (length (llm-tools--hl-parse-section section)) 1)) - (let ((op (car (llm-tools--hl-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--hl-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--hl-parse-section section)))) - (should (= (length (hl-op-payload op)) 4))))) - -(ert-deftest llm-tools--hl-parse-section-blank-replace () - "Replace with no payload should produce nil payload, not empty list." - (let ((section "= 5ff..5ff")) - (let ((op (car (llm-tools--hl-parse-section section)))) - (should (eq (hl-op-type op) 'replace)) - (should (null (hl-op-payload op)))))) - -(ert-deftest llm-tools--hl-parse-section-insert-after () - (let ((section "+ 4ei\n~ greeting-title")) - (let ((op (car (llm-tools--hl-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--hl-parse-section-insert-before () - (let ((section "< 5ff\n~ greeting-title")) - (let ((op (car (llm-tools--hl-parse-section section)))) - (should (eq (hl-op-type op) 'insert-before)) - (should (string= (hl-op-anchor op) "5ff"))))) - -(ert-deftest llm-tools--hl-parse-section-delete () - (let ((section "- 5ff..5ff")) - (let ((op (car (llm-tools--hl-parse-section section)))) - (should (eq (hl-op-type op) 'delete)) - (should (equal (hl-op-range op) '("5ff" "5ff")))))) - -(ert-deftest llm-tools--hl-parse-section-skip-comments-and-blanks () - (let ((section "# comment\n\n+ 4ei\n~line")) - (should (= (length (llm-tools--hl-parse-section section)) 1)))) - -(ert-deftest llm-tools--hl-parse-section-multiple-ops () - "Two separate insert ops in one section." - (let ((section "+ 4ei\n~first\n< 5ff\n~second")) - (let ((ops (llm-tools--hl-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--hl-apply-replace-single-line () - (let* ((content "line1\nline2\nline3\n") - (file (make-temp-file nil nil nil content)) - (anchor (llm-tools--hl-file-line-hash file 2)) - (patch (format "@@ %s\n= %s..%s\n~REPLACED" file anchor anchor))) - (unwind-protect - (progn - (llm-tools--hl-apply patch) - (should (string= (llm-tools--hl-file-read file) - (string-join - (list (format "1%s|line1" (llm-tools--hl-hash "line1")) - (format "2%s|REPLACED" (llm-tools--hl-hash "REPLACED")) - (format "3%s|line3" (llm-tools--hl-hash "line3"))) - "\n")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-apply-insert-after () - (let* ((content "A\nB\nC\n") - (file (make-temp-file nil nil nil content)) - (anchor (llm-tools--hl-file-line-hash file 1)) - (patch (format "@@ %s\n+ %s\n~NEW" file anchor))) - (unwind-protect - (progn - (llm-tools--hl-apply patch) - (should (equal (llm-tools--hl-file-lines file) '("A" "NEW" "B" "C")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-apply-insert-before () - (let* ((content "A\nB\nC\n") - (file (make-temp-file nil nil nil content)) - (anchor (llm-tools--hl-file-line-hash file 3)) - (patch (format "@@ %s\n< %s\n~NEW" file anchor))) - (unwind-protect - (progn - (llm-tools--hl-apply patch) - (should (equal (llm-tools--hl-file-lines file) '("A" "B" "NEW" "C")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-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--hl-apply patch) - (should (equal (llm-tools--hl-file-lines file) '("A" "B" "APPENDED")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-apply-delete () - (let* ((content "A\nB\nC\n") - (file (make-temp-file nil nil nil content)) - (anchor (llm-tools--hl-file-line-hash file 2)) - (patch (format "@@ %s\n- %s..%s" file anchor anchor))) - (unwind-protect - (progn - (llm-tools--hl-apply patch) - (should (equal (llm-tools--hl-file-lines file) '("A" "C")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-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--hl-file-line-hash file 2)) - (patch (format "@@ %s\n= %s..%s" file anchor anchor))) - (unwind-protect - (progn - (llm-tools--hl-apply patch) - (should (equal (llm-tools--hl-file-lines file) '("A" "" "C")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-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--hl-file-line-hash file 1)) - (a3 (llm-tools--hl-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--hl-apply patch) - (let ((lines (llm-tools--hl-file-lines file))) - (should (equal lines '("A" "X" "Y" "B" "D" "E"))))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-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--hl-file-line-hash file1 1)) - (a2 (llm-tools--hl-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--hl-apply patch) - (should (equal (llm-tools--hl-file-lines file1) '("A" "NEW1" "B"))) - (should (equal (llm-tools--hl-file-lines file2) '("X" "NEW2")))) - (delete-file file1) - (delete-file file2)))) - -(ert-deftest llm-tools--hl-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--hl-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--hl-apply patch) - (let ((lines (llm-tools--hl-file-lines file))) - (should (equal lines '("ONE" "TWO" "THREE" "B"))))) - (delete-file file)))) - -;; --- llm-tools--hl-hash --- -(ert-deftest llm-tools--hl-hash-test () - "Test that hashing produces consistent, valid bigram output." - ;; Deterministic: same input -> same output - (should (string= (llm-tools--hl-hash "hello") - (llm-tools--hl-hash "hello"))) - ;; Different input -> likely different output (hash collision unlikely) - (should (not (string= (llm-tools--hl-hash "hello") - (llm-tools--hl-hash "world")))) - ;; Output is always a valid bigram from the table - (let ((result (llm-tools--hl-hash "test line"))) - (should (= (length result) 2)) - (should (member result llm-tools--hl-bigrams)))) - -;; --- llm-tools--hl-file-lines --- -(ert-deftest llm-tools--hl-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--hl-file-lines file) - '("alpha" "beta" "gamma"))) - ;; With beg/end range (inclusive, 1-based) - (should (equal (llm-tools--hl-file-lines file 1 2) - '("alpha" "beta"))) - (should (equal (llm-tools--hl-file-lines file 2 3) - '("beta" "gamma"))) - (should (equal (llm-tools--hl-file-lines file 2 2) - '("beta")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-file-lines-empty-file-test () - "Test reading an empty file." - (let ((file (make-temp-file nil nil ""))) - (unwind-protect - (should (equal (llm-tools--hl-file-lines file) '(""))) - (delete-file file)))) - -;; --- llm-tools--hl-format-lines --- -(ert-deftest llm-tools--hl-format-lines-test () - "Test formatting lines in hashline format." - (let ((lines '("hello" "world"))) - ;; Without content - (let ((no-content (llm-tools--hl-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--hl-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--hl-format-lines lines 5 t))) - (should (string-match-p "\\`5.." custom-start)) - (should (string-match-p "6..\\'" custom-start))))) - -;; --- llm-tools--hl-file-read-range --- -(ert-deftest llm-tools--hl-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--hl-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--hl-file-read-hashes --- -(ert-deftest llm-tools--hl-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--hl-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--hl-file-line-hash --- -(ert-deftest llm-tools--hl-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--hl-file-line-hash file 1) - (concat "1" (llm-tools--hl-hash "first")))) - (should (string= (llm-tools--hl-file-line-hash file 2) - (concat "2" (llm-tools--hl-hash "second")))) - (should (string= (llm-tools--hl-file-line-hash file 3) - (concat "3" (llm-tools--hl-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--hl-get-anchors --- -(ert-deftest llm-tools--hl-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--hl-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--hl-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--hl-get-anchors patch))) - (let ((file-anchors (cdr (car anchors)))) - (should (member "EOF" file-anchors)) - (should (member "BOF" file-anchors)))))) - -;; --- llm-tools--hl-parse-op-line --- -(ert-deftest llm-tools--hl-parse-op-line-insert-after-test () - (let ((op (llm-tools--hl-parse-op-line "+ 4ei"))) - (should (eq (hl-op-type op) 'insert-after)) - (should (string= (hl-op-anchor op) "4ei")))) - -(ert-deftest llm-tools--hl-parse-op-line-insert-before-test () - (let ((op (llm-tools--hl-parse-op-line "< 5ff"))) - (should (eq (hl-op-type op) 'insert-before)) - (should (string= (hl-op-anchor op) "5ff")))) - -(ert-deftest llm-tools--hl-parse-op-line-delete-test () - (let ((op (llm-tools--hl-parse-op-line "- 3gv..6be"))) - (should (eq (hl-op-type op) 'delete)) - (should (equal (hl-op-range op) '("3gv" "6be"))))) - -(ert-deftest llm-tools--hl-parse-op-line-replace-test () - (let ((op (llm-tools--hl-parse-op-line "= 1vx..2in"))) - (should (eq (hl-op-type op) 'replace)) - (should (equal (hl-op-range op) '("1vx" "2in"))))) - -(ert-deftest llm-tools--hl-parse-op-line-eof-anchor-test () - (let ((op (llm-tools--hl-parse-op-line "+ EOF"))) - (should (eq (hl-op-type op) 'insert-after)) - (should (string= (hl-op-anchor op) "EOF")))) - -(ert-deftest llm-tools--hl-parse-op-line-bof-anchor-test () - (let ((op (llm-tools--hl-parse-op-line "< BOF"))) - (should (eq (hl-op-type op) 'insert-before)) - (should (string= (hl-op-anchor op) "BOF")))) - -;; --- llm-tools--hl-group-by-file --- -(ert-deftest llm-tools--hl-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--hl-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--hl-validate-no-payload-before-op --- -(ert-deftest llm-tools--hl-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-hl-verify-op :type 'insert-after :anchor "4ei" - :payload '("should not be here")))) - (let ((errors (llm-tools--hl-validate-no-payload-before-op (list op)))) - (should (= (length errors) 1)))) - ;; Clean case - (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload nil))) - (let ((errors (llm-tools--hl-validate-no-payload-before-op (list op)))) - (should (= (length errors) 0))))) - -;; --- llm-tools--hl-validate-insert-has-payload --- -(ert-deftest llm-tools--hl-validate-insert-has-payload-test () - "Test that inserts without payload are flagged." - (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload nil))) - (let ((errors (llm-tools--hl-validate-insert-has-payload (list op)))) - (should (= (length errors) 1)))) - ;; Insert with payload is fine - (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload '("line")))) - (should (= (length (llm-tools--hl-validate-insert-has-payload (list op))) 0))) - ;; Insert-before without payload - (let ((op (make-hl-verify-op :type 'insert-before :anchor "4ei" :payload nil))) - (let ((errors (llm-tools--hl-validate-insert-has-payload (list op)))) - (should (= (length errors) 1))))) - -;; --- llm-tools--hl-validate-delete-no-payload --- -(ert-deftest llm-tools--hl-validate-delete-no-payload-test () - "Test that deletes with payload are flagged." - (let ((op (make-hl-verify-op :type 'delete :anchor "1vx..2in" :payload '("bad")))) - (let ((errors (llm-tools--hl-validate-delete-no-payload (list op)))) - (should (= (length errors) 1)))) - ;; Delete without payload is fine - (let ((op (make-hl-verify-op :type 'delete :anchor "1vx..2in" :payload nil))) - (should (= (length (llm-tools--hl-validate-delete-no-payload (list op))) 0)))) - -;; --- llm-tools--hl-validate-anchors --- -(ert-deftest llm-tools--hl-validate-anchors-insert-valid-test () - "Test valid insert anchors pass validation." - (let ((op (make-hl-verify-op :type 'insert-after :anchor "4ei" :payload '("x")))) - (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) - -(ert-deftest llm-tools--hl-validate-anchors-insert-eof-test () - "Test that EOF is accepted as a valid insert anchor." - (let ((op (make-hl-verify-op :type 'insert-after :anchor "EOF" :payload '("x")))) - (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) - -(ert-deftest llm-tools--hl-validate-anchors-insert-bof-test () - "Test that BOF is accepted as a valid insert anchor." - (let ((op (make-hl-verify-op :type 'insert-before :anchor "BOF" :payload '("x")))) - (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) - -(ert-deftest llm-tools--hl-validate-anchors-insert-invalid-test () - "Test that malformed insert anchors are flagged." - (let ((op (make-hl-verify-op :type 'insert-after :anchor "bad" :payload '("x")))) - (let ((errors (llm-tools--hl-validate-anchors (list op)))) - (should (> (length errors) 0)))) - (let ((op (make-hl-verify-op :type 'insert-after :anchor "" :payload '("x")))) - (let ((errors (llm-tools--hl-validate-anchors (list op)))) - (should (> (length errors) 0))))) - -(ert-deftest llm-tools--hl-validate-anchors-range-valid-test () - "Test valid range anchors pass validation." - (let ((op (make-hl-verify-op :type 'delete :anchor "3gv..6be" :payload nil))) - (should (= (length (llm-tools--hl-validate-anchors (list op))) 0)))) - -(ert-deftest llm-tools--hl-validate-anchors-range-missing-dotdot-test () - "Test that ranges without '..' are flagged." - (let ((op (make-hl-verify-op :type 'delete :anchor "3gv6be" :payload nil))) - (let ((errors (llm-tools--hl-validate-anchors (list op)))) - (should (> (length errors) 0))))) - -(ert-deftest llm-tools--hl-validate-anchors-range-non-numeric-test () - "Test that range parts starting with non-numeric chars are flagged." - (let ((op (make-hl-verify-op :type 'replace :anchor "abc..def" :payload nil))) - (let ((errors (llm-tools--hl-validate-anchors (list op)))) - (should (> (length errors) 0))))) - -;; --- llm-tools--hl-verify-section --- -(ert-deftest llm-tools--hl-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--hl-verify-section section)) 0)))) - -(ert-deftest llm-tools--hl-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--hl-verify-section section)) 0)))) - -;; --- llm-tools--hl-verify-structure --- -(ert-deftest llm-tools--hl-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--hl-verify-structure patch)) 0)))) - -(ert-deftest llm-tools--hl-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--hl-verify-structure patch)) 0)) - (delete-file file)))) - -;; --- llm-tools--hl-verify-anchors --- -(ert-deftest llm-tools--hl-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--hl-file-line-hash file 1)) - (patch (format "@@ %s\n+ %s\n~new" file anchor))) - (unwind-protect - (let ((results (llm-tools--hl-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--hl-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--hl-verify-anchors patch))) - (should (= (length results) 1)) - (should-not (cdr (car results)))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-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--hl-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--hl-verify-anchors patch))) - (should (cdr (car results)))))) - (delete-file file)))) - -;; --- llm-tools--hl-verify --- -(ert-deftest llm-tools--hl-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--hl-file-line-hash file 1)) - (patch (format "@@ %s\n+ %s\n~new" file anchor))) - (unwind-protect - (should (string= (llm-tools--hl-verify patch) "")) - (delete-file file)))) - -(ert-deftest llm-tools--hl-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--hl-verify patch) ""))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-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--hl-verify patch))) - (should-not (string= msg "")) - (should (string-match "incorrect anchors" msg)))) - (delete-file file)))) - -;; --- llm-tools--hl-edit --- -(ert-deftest llm-tools--hl-edit-success-test () - "Test that a valid patch is applied successfully via llm-tools--hl-edit." - (let* ((content "hello\nworld\n") - (file (make-temp-file nil nil nil content)) - (anchor (llm-tools--hl-file-line-hash file 1)) - (patch (format "@@ %s\n+ %s\n~new" file anchor))) - (unwind-protect - (let ((result (llm-tools--hl-edit patch))) - (should (string-match "Finished" result)) - (should (equal (llm-tools--hl-file-lines file) '("hello" "new" "world")))) - (delete-file file)))) - -(ert-deftest llm-tools--hl-edit-failure-test () - "Test that an invalid patch is rejected via llm-tools--hl-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--hl-edit patch))) - (should-not (string-match "Finished" result)))) - (delete-file file)))) - -;; --- llm-tools--hl-apply-one-op edge cases --- -(ert-deftest llm-tools--hl-apply-one-op-insert-at-eof-test () - "Test inserting at EOF anchor." - (let* ((content '("A" "B")) - (op (make-hl-op :type 'insert-after :anchor "EOF" :payload '("C")))) - (let ((result (llm-tools--hl-apply-one-op op content 0))) - (should (equal (car result) '("A" "B" "C")))))) - -(ert-deftest llm-tools--hl-apply-one-op-insert-at-bof-test () - "Test inserting at BOF anchor." - (let* ((content '("A" "B")) - (op (make-hl-op :type 'insert-before :anchor "BOF" :payload '("C")))) - (let ((result (llm-tools--hl-apply-one-op op content 0))) - (should (equal (car result) '("C" "A" "B")))))) - -(ert-deftest llm-tools--hl-apply-one-op-replace-blank-payload-test () - "Test that replace with nil payload produces a blank line." - (let* ((content '("A" "B" "C")) - (op (make-hl-op :type 'replace :range '("2in" "2in") :payload nil))) - (let ((result (llm-tools--hl-apply-one-op op content 0))) - (should (equal (car result) '("A" "" "C")))))) - -(ert-deftest llm-tools--hl-apply-one-op-replace-multi-line-test () - "Test replace spanning multiple lines." - (let* ((content '("A" "B" "C" "D")) - (op (make-hl-op :type 'replace :range '("2in" "3cc") :payload '("X" "Y")))) - (let ((result (llm-tools--hl-apply-one-op op content 0))) - (should (equal (car result) '("A" "X" "Y" "D")))))) - -(ert-deftest llm-tools--hl-apply-one-op-delete-range-test () - "Test deleting a range of lines." - (let* ((content '("A" "B" "C" "D" "E")) - (op (make-hl-op :type 'delete :range '("2in" "4ei")))) - (let ((result (llm-tools--hl-apply-one-op op content 0))) - (should (equal (car result) '("A" "E")))))) - -(ert-deftest llm-tools--hl-apply-one-op-offset-tracking-test () - "Test that offset is updated correctly after each op." - (let* ((content '("A" "B" "C")) - (op (make-hl-op :type 'insert-after :anchor "1vx" :payload '("X" "Y")))) - (let ((result (llm-tools--hl-apply-one-op op content 0))) - (should (= (cdr result) 2))))) - -;; --- llm-tools--hl-parse-section edge cases --- -(ert-deftest llm-tools--hl-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--hl-parse-section section)))) - (should (null (hl-op-payload op)))))) - -(ert-deftest llm-tools--hl-parse-section-payload-with-tabs-test () - "Test that payload lines preserve leading tabs." - (let ((section "+ 4ei\n~\tindented")) - (let ((op (car (llm-tools--hl-parse-section section)))) - (should (string= (car (hl-op-payload op)) "\tindented"))))) - -(ert-deftest llm-tools--hl-parse-section-payload-with-spaces-test () - "Test that payload lines preserve leading spaces." - (let ((section "+ 4ei\n~ two spaces")) - (let ((op (car (llm-tools--hl-parse-section section)))) - (should (string= (car (hl-op-payload op)) " two spaces"))))) - -(ert-deftest llm-tools--hl-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--hl-parse-section section)))) - (should (equal (hl-op-payload op) '("")))))) - -(ert-deftest llm-tools--hl-parse-section-eof-anchor-test () - "Test parsing an insert with EOF anchor." - (let ((section "+ EOF\n~appended")) - (let ((op (car (llm-tools--hl-parse-section section)))) - (should (eq (hl-op-type op) 'insert-after)) - (should (string= (hl-op-anchor op) "EOF"))))) - -(ert-deftest llm-tools--hl-parse-section-bof-anchor-test () - "Test parsing an insert with BOF anchor." - (let ((section "< BOF\n~prepended")) - (let ((op (car (llm-tools--hl-parse-section section)))) - (should (eq (hl-op-type op) 'insert-before)) - (should (string= (hl-op-anchor op) "BOF"))))) - -(ert-deftest llm-tools--hl-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--hl-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--hl-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--hl-file-line-hash file 2)) - (a3 (llm-tools--hl-file-line-hash file 3)) - (a5 (llm-tools--hl-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--hl-apply patch) - (let ((lines (llm-tools--hl-file-lines file))) - (should (equal lines '("A" "X" "Y" "D"))))) - (delete-file file)))) diff --git a/llm-tools-hl.el b/llm-tools-hl.el @@ -1,534 +0,0 @@ -;; llm-tools-hl.el --- Hashline file operations for llm-tools -*- lexical-binding: t; -*- - -;; Copyright (C) 2026 Vineet K - -;; Author: Vineet K <git@vineetk.net> -;; Version: 1.0 -;; Keywords: llm, gptel -;; Package-Requires: ((emacs "28.1")) - -;; This program is free software; you can redistribute it and/or modify -;; it under the terms of the GNU General Public License as published by -;; the Free Software Foundation, either version 3 of the License, or -;; (at your option) any later version. - -;; This program is distributed in the hope that it will be useful, -;; but WITHOUT ANY WARRANTY; without even the implied warranty of -;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -;; GNU General Public License for more details. - -;; You should have received a copy of the GNU General Public License -;; along with this program. If not, see <https://www.gnu.org/licenses/>. - -;;; Commentary: - -;; Hashline-based file operations 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--hl-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--hl-hash (line) - ;; logand is to force unsigned number - (elt llm-tools--hl-bigrams (% (logand (sxhash line) #xffffffff) 647))) - -(defun llm-tools--hl-file-lines (file &optional beg end) - "Return FILE contents as a list of strings. -With optional BEG and END (inclusive, 1-based), return the lines between that range." - (with-temp-buffer - (insert-file-contents-literally file) - (let ((lines (string-lines (buffer-string)))) - (if beg - (cl-subseq lines (1- beg) end) - lines)))) - -(defun llm-tools--hl-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--hl-hash line)) - (format "%d%2s|%s" i (llm-tools--hl-hash line) line))) - lines - (number-sequence (or start 1) (+ (or start 1) (1- (length lines))))) - "\n")) - -(defun llm-tools--hl-file-read (file &optional beg end only-anchors? as-list?) - "Return FILE contents in hashline format. -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." - (let* ((lines (llm-tools--hl-file-lines file beg end)) - (formatted (llm-tools--hl-format-lines lines (or beg 1) only-anchors?))) - (if as-list? - (string-lines formatted) - formatted))) - -(defun llm-tools--hl-file-line-hash (file n) - "Return the hash of the Nth line in FILE." - (let* ((lines (llm-tools--hl-file-lines file)) - (bigram (llm-tools--hl-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--hl-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--hl-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-hl-verify-op - :type (pcase ch - ("+" 'insert-after) - ("<" 'insert-before) - ("-" 'delete) - ("=" 'replace)) - :anchor (string-trim (substring trimmed 1)) - :raw trimmed))) - -(defun llm-tools--hl-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--hl-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--hl-non-empty-non-comment-line-p line) - (let* ((trimmed (string-trim line)) - (ch (substring trimmed 0 1))) - (if (string= ch "~") - (when op (llm-tools--hl-add-payload-to-verify-op op trimmed)) - (when op (push op ops)) - (setq op (llm-tools--hl-parse-op-line-for-verify trimmed)))))) - (when op (push op ops)) - (nreverse ops))) - -(defun llm-tools--hl-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--hl-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--hl-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--hl-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--hl-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--hl-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--hl-validate-insert-anchor (hl-verify-op-anchor op)))) - (list err))) - ((or 'delete 'replace) - (when-let ((err (llm-tools--hl-validate-range-anchor (hl-verify-op-anchor op)))) - (list err))))) - ops)) - -(defun llm-tools--hl-verify-section (section) - "Validate a single SECTION. Returns a list of error strings." - (let ((ops (llm-tools--hl-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--hl-validate-anchors ops))) - (nreverse errors)))) - -(defun llm-tools--hl-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--hl-verify-section - (llm-tools--split-patch-sections patch))) - -(defun llm-tools--hl-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--hl-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--hl-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--hl-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--hl-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--hl-extract-anchors-from-op-line ops-lines)))) - -(defun llm-tools--hl-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--hl-extract-anchors-from-section (cdr section)))))) - (llm-tools--split-patch-sections patch))) - -(defun llm-tools--hl-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--hl-get-anchors patch))) - (mapcar (lambda (section) - (let* ((file (car section)) - (anchors (delete "BOF" (delete "EOF" (cdr section)))) - (hl (llm-tools--hl-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--hl-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--hl-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-hl-op :type 'insert-after :anchor anchor)) - ("<" (make-hl-op :type 'insert-before :anchor anchor)) - ("-" (make-hl-op :type 'delete :range (split-string anchor "\\.\\."))) - ("=" (make-hl-op :type 'replace :range (split-string anchor "\\.\\.")))))) - -(defun llm-tools--hl-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--hl-parse-op-line trimmed)))))))) - (when op (push op ops)) - (nreverse ops))) - -(defun llm-tools--hl-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--hl-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--hl-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--hl-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--hl-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--hl-apply-delete (range content offset) - "Delete lines from (car RANGE) to (cadr RANGE)." - (let ((a (+ (llm-tools--hl-anchor-to-line-num (car range)) offset)) - (b (+ (llm-tools--hl-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--hl-apply-replace (range payload content offset) - "Replace lines from (car RANGE) to (cadr RANGE) with PAYLOAD (nil to '(\"\"))" - (let ((a (+ (llm-tools--hl-anchor-to-line-num (car range)) offset)) - (b (+ (llm-tools--hl-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--hl-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--hl-apply-insert-after - (hl-op-anchor op) - (hl-op-payload op) - content offset)) - ('insert-before (llm-tools--hl-apply-insert-before - (hl-op-anchor op) - (hl-op-payload op) - content - offset)) - ('delete (llm-tools--hl-apply-delete - (hl-op-range op) - content - offset)) - ('replace (llm-tools--hl-apply-replace - (hl-op-range op) - (hl-op-payload op) - content - offset)))) - -(defun llm-tools--hl-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--hl-apply-one-op op content offset))) - (setq content (car result)) - (setq offset (cdr result)))))) - -(defun llm-tools--hl-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--hl-parse-section (cdr sec))) - (cdr file-group))) - (content (llm-tools--hl-apply-ops - ops - (llm-tools--hl-file-lines file)))) - (with-temp-buffer - (insert (string-join content "\n")) - (write-region (point-min) (point-max) file)))) - (llm-tools--hl-group-by-file - (llm-tools--split-patch-sections patch)))) - -(defun llm-tools--hl-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--hl-verify patch))) - (if (string= msg "") - (progn - (llm-tools--hl-apply patch) - (format "Finished applying patch.")) - (format "%s" msg)))) - -(provide 'llm-tools-hl)