summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--llm-tools-hl-test.el180
-rw-r--r--llm-tools-hl.el168
2 files changed, 334 insertions, 14 deletions
diff --git a/llm-tools-hl-test.el b/llm-tools-hl-test.el
index 4bc0fa0..85777c8 100644
--- a/llm-tools-hl-test.el
+++ b/llm-tools-hl-test.el
@@ -2,7 +2,7 @@
2(require 'llm-tools-hl) 2(require 'llm-tools-hl)
3 3
4(ert-deftest llm-tools--hl-file-read-test () 4(ert-deftest llm-tools--hl-file-read-test ()
5 "Test that 'llm-tools--hl-file-read' produces correct hashline format." 5 "Test that 'llm-tools--hl-file-read' produces correct hashline format."
6 (let* ((content "\ 6 (let* ((content "\
7const TITLE = \"Mr\"; 7const TITLE = \"Mr\";
8export function greet(name) { 8export function greet(name) {
@@ -23,3 +23,181 @@ export function greet(name) {
23 (unwind-protect 23 (unwind-protect
24 (should (equal actual expected)) 24 (should (equal actual expected))
25 (delete-file file)))) 25 (delete-file file))))
26
27(ert-deftest llm-tools--hl-anchor-to-line-num-test ()
28 (should (= (llm-tools--hl-anchor-to-line-num "5ff") 5))
29 (should (= (llm-tools--hl-anchor-to-line-num "123ab") 123))
30 (should (= (llm-tools--hl-anchor-to-line-num "1vx") 1)))
31
32(ert-deftest llm-tools--hl-parse-section-single-replace ()
33 (let ((section "= 1vx..1vx\n~const TITLE = \"Mrs\";"))
34 (should (= (length (llm-tools--hl-parse-section section)) 1))
35 (let ((op (car (llm-tools--hl-parse-section section))))
36 (should (eq (hl-op-type op) 'replace))
37 (should (equal (hl-op-range op) '("1vx" "1vx")))
38 (should (equal (hl-op-payload op) '("const TITLE = \"Mrs\";"))))))
39
40(ert-deftest llm-tools--hl-parse-section-multi-line-payload ()
41 (let ((section "= 3gv..6be\n~\treturn [\n~\t\t\"Mrs\",\n~\t\tname?.trim() || \"guest\",\n~\t].join(\" \");"))
42 (let ((op (car (llm-tools--hl-parse-section section))))
43 (should (= (length (hl-op-payload op)) 4)))))
44
45(ert-deftest llm-tools--hl-parse-section-blank-replace ()
46 "Replace with no payload should produce nil payload, not empty list."
47 (let ((section "= 5ff..5ff"))
48 (let ((op (car (llm-tools--hl-parse-section section))))
49 (should (eq (hl-op-type op) 'replace))
50 (should (null (hl-op-payload op))))))
51
52(ert-deftest llm-tools--hl-parse-section-insert-after ()
53 (let ((section "+ 4ei\n~\t\t\"Dr\","))
54 (let ((op (car (llm-tools--hl-parse-section section))))
55 (should (eq (hl-op-type op) 'insert-after))
56 (should (string= (hl-op-anchor op) "4ei"))
57 (should (equal (hl-op-payload op) '("\t\t\"Dr\","))))))
58
59(ert-deftest llm-tools--hl-parse-section-insert-before ()
60 (let ((section "< 5ff\n~\t\t\"Dr\","))
61 (let ((op (car (llm-tools--hl-parse-section section))))
62 (should (eq (hl-op-type op) 'insert-before))
63 (should (string= (hl-op-anchor op) "5ff")))))
64
65(ert-deftest llm-tools--hl-parse-section-delete ()
66 (let ((section "- 5ff..5ff"))
67 (let ((op (car (llm-tools--hl-parse-section section))))
68 (should (eq (hl-op-type op) 'delete))
69 (should (equal (hl-op-range op) '("5ff" "5ff"))))))
70
71(ert-deftest llm-tools--hl-parse-section-skip-comments-and-blanks ()
72 (let ((section "# comment\n\n+ 4ei\n~line"))
73 (should (= (length (llm-tools--hl-parse-section section)) 1))))
74
75(ert-deftest llm-tools--hl-parse-section-multiple-ops ()
76 "Two separate insert ops in one section."
77 (let ((section "+ 4ei\n~first\n< 5ff\n~second"))
78 (let ((ops (llm-tools--hl-parse-section section)))
79 (should (= (length ops) 2))
80 (should (eq (hl-op-type (car ops)) 'insert-after))
81 (should (eq (hl-op-type (cadr ops)) 'insert-before)))))
82
83(ert-deftest llm-tools--hl-apply-replace-single-line ()
84 (let* ((content "line1\nline2\nline3\n")
85 (file (make-temp-file nil nil nil content))
86 (patch (format "@@ test\n= 2%s..2%s\n~REPLACED" (elt 1 (llm-tools--hl-hash (string-lines content))))))
87 (unwind-protect
88 (progn
89 (llm-tools--hl-apply patch)
90 (should (string= (llm-tools--hl-file-read file)
91 (string-join
92 (list (format "1%s|line1" (llm-tools--hl-hash "line1"))
93 (format "2%s|REPLACED" (llm-tools--hl-hash "REPLACED"))
94 (format "3%s|line3" (llm-tools--hl-hash "line3")))
95 "\n"))))
96 (delete-file file))))
97
98(ert-deftest llm-tools--hl-apply-insert-after ()
99 (let* ((content "A\nB\nC\n")
100 (file (make-temp-file nil nil nil content))
101 (anchor (format "+ %s" (llm-tools--hl-file-line-hash file 1)))
102 (patch (concat "@@ test\n" anchor "\n~NEW")))
103 (unwind-protect
104 (progn
105 (llm-tools--hl-apply patch)
106 (let ((lines (llm-tools--hl-file-lines file)))
107 (should (equal lines '("A" "NEW" "B" "C")))))
108 (delete-file file))))
109
110(ert-deftest llm-tools--hl-apply-insert-before ()
111 (let* ((content "A\nB\nC\n")
112 (file (make-temp-file nil nil nil content))
113 (anchor (format "< %s" (llm-tools--hl-file-line-hash file 3)))
114 (patch (concat "@@ test\n" anchor "\n~NEW")))
115 (unwind-protect
116 (progn
117 (llm-tools--hl-apply patch)
118 (let ((lines (llm-tools--hl-file-lines file)))
119 (should (equal lines '("A" "B" "NEW" "C")))))
120 (delete-file file))))
121
122(ert-deftest llm-tools--hl-apply-append-to-eof ()
123 (let* ((content "A\nB\n")
124 (file (make-temp-file nil nil nil content))
125 (patch "@@ test\n+ EOF\n~APPENDED"))
126 (unwind-protect
127 (progn
128 (llm-tools--hl-apply patch)
129 (let ((lines (llm-tools--hl-file-lines file)))
130 (should (equal lines '("A" "B" "APPENDED")))))
131 (delete-file file))))
132
133(ert-deftest llm-tools--hl-apply-delete ()
134 (let* ((content "A\nB\nC\n")
135 (file (make-temp-file nil nil nil content))
136 (anchor (llm-tools--hl-file-line-hash file 2))
137 (patch (format "@@ test\n- 2%s..2%s" anchor anchor)))
138 (unwind-protect
139 (progn
140 (llm-tools--hl-apply patch)
141 (let ((lines (llm-tools--hl-file-lines file)))
142 (should (equal lines '("A" "C")))))
143 (delete-file file))))
144
145(ert-deftest llm-tools--hl-apply-blank-line ()
146 "Replace with no payload produces a blank line."
147 (let* ((content "A\nB\nC\n")
148 (file (make-temp-file nil nil nil content))
149 (anchor (llm-tools--hl-file-line-hash file 2))
150 (patch (format "@@ test\n= 2%s..2%s" anchor anchor)))
151 (unwind-protect
152 (progn
153 (llm-tools--hl-apply patch)
154 (let ((lines (llm-tools--hl-file-lines file)))
155 (should (equal lines '("A" "" "C")))))
156 (delete-file file))))
157
158(ert-deftest llm-tools--hl-apply-offset-tracking ()
159 "Multiple ops in one file: verify offset adjusts subsequent line numbers."
160 (let* ((content "A\nB\nC\nD\nE\n")
161 (file (make-temp-file nil nil nil content))
162 (a1 (llm-tools--hl-file-line-hash file 1))
163 (a3 (llm-tools--hl-file-line-hash file 3))
164 ;; Insert 2 lines after line 1, then delete original line 3
165 (patch (format "@@ test\n+ 1%s\n~X\n~Y\n- 3%s..3%s" a1 a3 a3)))
166 (unwind-protect
167 (progn
168 (llm-tools--hl-apply patch)
169 ;; After insert: A X Y B C D E (offset = +2)
170 ;; Original line 3 (C) is now at index 5 (1-based). Delete it.
171 ;; Result: A X Y B D E
172 (let ((lines (llm-tools--hl-file-lines file)))
173 (should (equal lines '("A" "X" "Y" "B" "D" "E")))))
174 (delete-file file))))
175
176(ert-deftest llm-tools--hl-apply-multi-file ()
177 "Two files in one patch both get modified."
178 (let* ((file1 (make-temp-file nil nil nil "A\nB\n"))
179 (file2 (make-temp-file nil nil nil "X\nY\n"))
180 (a1 (llm-tools--hl-file-line-hash file1 1))
181 (a2 (llm-tools--hl-file-line-hash file2 2))
182 (patch (format "@@ %s\n+ 1%s\n~NEW1\n@@ %s\n= 2%s..2%s\n~NEW2"
183 file1 a1 file2 a2 a2)))
184 (unwind-protect
185 (progn
186 (llm-tools--hl-apply patch)
187 (should (equal (llm-tools--hl-file-lines file1) '("A" "NEW1" "B")))
188 (should (equal (llm-tools--hl-file-lines file2) '("X" "NEW2"))))
189 (delete-file file1)
190 (delete-file file2))))
191
192(ert-deftest llm-tools--hl-apply-multi-line-payload ()
193 "Replace with many payload lines."
194 (let* ((content "A\nB\n")
195 (file (make-temp-file nil nil nil content))
196 (a1 (llm-tools--hl-file-line-hash file 1))
197 (patch (format "@@ test\n= 1%s..1%s\n~ONE\n~TWO\n~THREE" a1 a1)))
198 (unwind-protect
199 (progn
200 (llm-tools--hl-apply patch)
201 (let ((lines (llm-tools--hl-file-lines file)))
202 (should (equal lines '("ONE" "TWO" "THREE" "B")))))
203 (delete-file file))))
diff --git a/llm-tools-hl.el b/llm-tools-hl.el
index b4ca40a..808b837 100644
--- a/llm-tools-hl.el
+++ b/llm-tools-hl.el
@@ -97,7 +97,7 @@ Each element is a line number and two-character hash, like `1vx'."
97(defun llm-tools--hl-file-line-hash (file n) 97(defun llm-tools--hl-file-line-hash (file n)
98 "Return the hash of the Nth line in FILE." 98 "Return the hash of the Nth line in FILE."
99 (let ((lines (llm-tools--hl-file-lines file))) 99 (let ((lines (llm-tools--hl-file-lines file)))
100 (llm-tools--hl-hash (elt lines (1- n))))) 100 (llm-tools--hl-hash (elt lines (1- n)))))
101 101
102;;;; Diff Parsing 102;;;; Diff Parsing
103(defun llm-tools--split-patch-sections (patch) 103(defun llm-tools--split-patch-sections (patch)
@@ -126,7 +126,7 @@ ANCHORS is a sorted, deduplicated list of all line anchors referenced by
126the operations in that file's sections. Each anchor is a string like 126the operations in that file's sections. Each anchor is a string like
127\"5ff\" or a list of two strings from a range like 127\"5ff\" or a list of two strings from a range like
128(\"3gv\" \"6be\")." 128(\"3gv\" \"6be\")."
129 (let ((sections (split-patch-sections patch))) 129 (let ((sections (llm-tools--split-patch-sections patch)))
130 (delete-dups 130 (delete-dups
131 (mapcar (lambda (patch) 131 (mapcar (lambda (patch)
132 (cons (car patch) 132 (cons (car patch)
@@ -151,12 +151,17 @@ line in the on-disk file."
151 (let ((patch-anchors (llm-tools--hl-get-anchors patch))) 151 (let ((patch-anchors (llm-tools--hl-get-anchors patch)))
152 (mapcar (lambda (section) 152 (mapcar (lambda (section)
153 (let* ((file (car section)) 153 (let* ((file (car section))
154 (anchors (delete "EOF" (cdr section))) 154 (anchors (delete "BOF" (delete "EOF" (cdr section))))
155 (hl (llm-tools--hl-file-read-hashes file))) 155 (hl (llm-tools--hl-file-read-hashes file)))
156 ;; cl-subsetp uses eql by default which compares by identity and not content 156 ;; cl-subsetp uses eql by default which compares by identity and not content
157 (cons file (cl-subsetp anchors hl :test #'equal)))) 157 (cons file (cl-subsetp anchors hl :test #'equal))))
158 patch-anchors))) 158 patch-anchors)))
159 159
160;; TODO this should also validate the operators (e.g. any invalid
161;; op-chars, payload given before all other operations, no payload
162;; after +/< or payload given to -, or anchors without line
163;; number). it should return a string saying it's a malformed patch
164;; and what went wrong.
160(defun llm-tools--hl-verify (patch) 165(defun llm-tools--hl-verify (patch)
161 "Verify if the files in PATCH need to be re-read. 166 "Verify if the files in PATCH need to be re-read.
162Returns a string message that states which files need to be re-read. 167Returns a string message that states which files need to be re-read.
@@ -171,21 +176,158 @@ Empty string means no files need to be re-read."
171 (llm-tools--hl-verify-anchors patch)))) 176 (llm-tools--hl-verify-anchors patch))))
172 "\n")) 177 "\n"))
173 178
179(cl-defstruct hl-op
180 (type nil) ; 'insert-after 'insert-before 'delete 'replace
181 (anchor nil) ; string like "4ei" or "EOF" for +/-/<
182 (range nil) ; (start . end) strings for - and =
183 (payload nil)) ; list of strings (nil is no payload)
184
185(defun llm-tools--hl-anchor-to-line-num (anchor)
186 "Extract the line number from ANCHOR like \"5ff\"."
187 (string-to-number (substring anchor 0 (- (length anchor) 2))))
188
189(defun llm-tools--hl-parse-op-line (line)
190 "Parse a single operation LINE into a `hl-op` struct.
191LINE should start with `+`, `<`, `-`, or `=`."
192 (pcase (substring line 0 1)
193 ("+"
194 (make-hl-op :type 'insert-after
195 :anchor (substring line 2)))
196 ("<"
197 (make-hl-op :type 'insert-before
198 :anchor (substring line 2)))
199 ("-"
200 (make-hl-op :type 'delete
201 :range (split-string (substring line 2) "\\.\\.")))
202 ("="
203 (make-hl-op :type 'replace
204 :range (split-string (substring line 2) "\\.\\.")))))
205
174(defun llm-tools--hl-parse-section (section) 206(defun llm-tools--hl-parse-section (section)
175 "Parses operation and optional payload from SECTION." 207 "Parse SECTION (body after `@@ PATH`) into a list of `hl-op` structs.
176 ) 208Skips comment lines (starting with `#`) and blank lines."
209 (let (ops op)
210 (dolist (line (string-lines section))
211 (when (string-match "\\S-" line)
212 (let ((op-char (substring line 0 1)))
213 (pcase op-char
214 ("~"
215 (when op
216 (setf (hl-op-payload op)
217 (append (hl-op-payload op)
218 (list (substring line 1))))))
219 ("#" nil)
220 (_
221 (when op (push op ops))
222 (setq op (llm-tools--hl-parse-op-line line))))))
223 (when op (push op ops))
224 (nreverse ops))))
225
226(defun llm-tools--hl-group-by-file (sections)
227 "Group SECTIONS by file path.
228Returns an alist of (FILENAME . SECTION-LIST) where SECTION-LIST
229contains all (PATH . REST) cons cells from SECTIONS that target
230FILENAME."
231 (let (grouped)
232 (dolist (sec sections)
233 (let ((existing (assoc (car sec) grouped)))
234 (if existing
235 (push sec (cdr existing))
236 (push (cons (car sec) (list sec)) grouped))))
237 grouped))
238
239(defun llm-tools--hl-apply-one-op (op content offset)
240 "Apply a single OP to CONTENT (list of strings) at the given OFFSET.
241Returns a cons cell (NEW-CONTENT . NEW-OFFSET)."
242 (pcase (hl-op-type op)
243 ('insert-after
244 (let* ((anchor (hl-op-anchor op))
245 (payload (hl-op-payload op))
246 (line-num (if (string= anchor "EOF")
247 (length content)
248 (+ (llm-tools--hl-anchor-to-line-num anchor)
249 offset)))
250 (idx (1- line-num)))
251 (cons (append (cl-subseq content 0 (1+ idx))
252 payload
253 (cl-subseq content (1+ idx)))
254 (+ offset (length payload)))))
255 ('insert-before
256 (let* ((anchor (hl-op-anchor op))
257 (payload (hl-op-payload op))
258 (line-num (if (string= anchor "BOF")
259 1
260 (+ (llm-tools--hl-anchor-to-line-num anchor)
261 offset)))
262 (idx (1- line-num)))
263 (cons (append (cl-subseq content 0 idx)
264 payload
265 (cl-subseq content idx))
266 (+ offset (length payload)))))
267 ('delete
268 (let* ((range (hl-op-range op))
269 (a (+ (llm-tools--hl-anchor-to-line-num (car range)) offset))
270 (b (+ (llm-tools--hl-anchor-to-line-num (cadr range)) offset)))
271 (cons (append (cl-subseq content 0 (1- a))
272 (cl-subseq content b))
273 (- offset (- b a 1)))))
274 ('replace
275 (let* ((range (hl-op-range op))
276 (a (+ (llm-tools--hl-anchor-to-line-num (car range)) offset))
277 (b (+ (llm-tools--hl-anchor-to-line-num (cadr range)) offset))
278 (payload (or (hl-op-payload op)
279 '(""))))
280 (cons (append (cl-subseq content 0 (1- a))
281 payload
282 (cl-subseq content b))
283 (+ offset (- (length payload) (- b a 1))))))))
284
285(defun llm-tools--hl-apply-ops (ops content)
286 "Apply a list of OPS to CONTENT (list of strings).
287Returns the modified list of strings."
288 (let ((offset 0))
289 (dolist (op ops content)
290 (let ((result (llm-tools--hl-apply-one-op op content offset)))
291 (setq content (car result))
292 (setq offset (cdr result))))))
177 293
178(defun llm-tools--hl-apply (patch) 294(defun llm-tools--hl-apply (patch)
179 "TODO rewrite docstring. Applies a patch." 295 "Apply all operations in PATCH to their respective files.
180 ;; when performing each operation, I need to make sure that the line 296Operations within a file are applied sequentially, with line numbers
181 ;; numbers of subsequent operations in same file also get updated. 297adjusted for prior insertions/deletions via a running offset."
182 (let ((sections (split-patch-sections patch))) 298 (dolist (file-group (llm-tools--hl-group-by-file
183 (llm-tools--hl-parse sections))) 299 (llm-tools--split-patch-sections patch)))
300 (let* ((file (car file-group))
301 (ops (mapcan (lambda (sec)
302 (llm-tools--hl-parse-section (cdr sec)))
303 (cdr file-group)))
304 (content (llm-tools--hl-apply-ops
305 ops
306 (llm-tools--hl-file-lines file))))
307 (with-temp-buffer
308 (insert (string-join content "\n"))
309 (write-region (point-min) (point-max) file)))))
184 310
185(defun llm-tools--hl-edit (patch) 311(defun llm-tools--hl-edit (patch)
186 "TODO write better docstring. First verifies if any files in PATCH needs 312 "Apply a hashline PATCH to the files it references.
187to be re-read. Then, applies the changes in PATCH. If any files need to 313
188be re-read, the edit operation is cancelled for all files." 314PATCH is a string containing one or more sections, each starting with
315`@@ FILEPATH' followed by operations:
316 + ANCHOR Insert lines after ANCHOR (or `EOF' to append)
317 < ANCHOR Insert lines before ANCHOR (or `BOF' to prepend)
318 - A..B Delete lines from A to B
319 = A..B Replace lines from A to B with the following payload
320 ~TEXT Payload line for the preceding operation
321
322ANCHOR is a hashline string like `5ff' (line number + 2-char hash) or
323`BOF' / `EOF'. Lines are written directly to disk.
324
325Before applying, verifies that every anchor in PATCH still matches the
326current file contents. If any file has drifted, the entire operation is
327aborted and an error message is returned identifying the affected files.
328
329Returns a success message if all operations applied, or an error string
330if verification failed."
189 (let ((msg (llm-tools--hl-verify patch))) 331 (let ((msg (llm-tools--hl-verify patch)))
190 (if (string= msg "") 332 (if (string= msg "")
191 (progn 333 (progn