llm-tools-hl.el (23387B)
1 ;; llm-tools-hl.el --- Hashline file operations for llm-tools -*- lexical-binding: t; -*- 2 3 ;; Copyright (C) 2026 Vineet K 4 5 ;; Author: Vineet K <git@vineetk.net> 6 ;; Version: 1.0 7 ;; Keywords: llm, gptel 8 ;; Package-Requires: ((emacs "28.1")) 9 10 ;; This program is free software; you can redistribute it and/or modify 11 ;; it under the terms of the GNU General Public License as published by 12 ;; the Free Software Foundation, either version 3 of the License, or 13 ;; (at your option) any later version. 14 15 ;; This program is distributed in the hope that it will be useful, 16 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 ;; GNU General Public License for more details. 19 20 ;; You should have received a copy of the GNU General Public License 21 ;; along with this program. If not, see <https://www.gnu.org/licenses/>. 22 23 ;;; Commentary: 24 25 ;; Hashline-based file operations for the llm-tools package. 26 ;; Provides functions for reading, editing, and verifying files using 27 ;; a content-addressed line format. 28 29 ;;; Code: 30 31 (require 'cl-lib) 32 33 (defvar llm-tools--hl-bigrams 34 (list "aa" "ab" "ac" "ad" "ae" "af" "ag" "ah" "ai" "aj" "ak" "al" "am" "an" 35 "ao" "ap" "aq" "ar" "as" "at" "au" "av" "aw" "ax" "ay" "az" "ba" "bb" 36 "bc" "bd" "be" "bf" "bg" "bh" "bi" "bj" "bk" "bl" "bm" "bn" "bo" "bp" 37 "br" "bs" "bt" "bu" "bv" "bw" "bx" "by" "bz" "ca" "cb" "cc" "cd" "ce" 38 "cf" "cg" "ch" "ci" "cj" "ck" "cl" "cm" "cn" "co" "cp" "cq" "cr" "cs" 39 "ct" "cu" "cv" "cw" "cx" "cy" "cz" "da" "db" "dc" "dd" "de" "df" "dg" 40 "dh" "di" "dj" "dk" "dl" "dm" "dn" "do" "dp" "dq" "dr" "ds" "dt" "du" 41 "dv" "dw" "dx" "dy" "dz" "ea" "eb" "ec" "ed" "ee" "ef" "eg" "eh" "ei" 42 "ej" "ek" "el" "em" "en" "eo" "ep" "eq" "er" "es" "et" "eu" "ev" "ew" 43 "ex" "ey" "ez" "fa" "fb" "fc" "fd" "fe" "ff" "fg" "fh" "fi" "fj" "fk" 44 "fl" "fm" "fn" "fo" "fp" "fq" "fr" "fs" "ft" "fu" "fv" "fw" "fx" "fy" 45 "fz" "ga" "gb" "gc" "gd" "ge" "gf" "gg" "gh" "gi" "gj" "gl" "gm" "gn" 46 "go" "gp" "gr" "gs" "gt" "gu" "gv" "gw" "gx" "gy" "gz" "ha" "hb" "hc" 47 "hd" "he" "hf" "hg" "hh" "hi" "hj" "hk" "hl" "hm" "hn" "ho" "hp" "hq" 48 "hr" "hs" "ht" "hu" "hv" "hw" "hx" "hy" "hz" "ia" "ib" "ic" "id" "ie" 49 "if" "ig" "ih" "ii" "ij" "ik" "il" "im" "in" "io" "ip" "iq" "ir" "is" 50 "it" "iu" "iv" "iw" "ix" "iy" "iz" "ja" "jb" "jc" "jd" "je" "jf" "jg" 51 "jh" "ji" "jj" "jk" "jl" "jm" "jn" "jo" "jp" "jq" "jr" "js" "jt" "ju" 52 "jw" "jx" "jy" "ka" "kb" "kc" "kd" "ke" "kf" "kg" "kh" "ki" "kj" "kk" 53 "kl" "km" "kn" "ko" "kp" "kr" "ks" "kt" "ku" "kv" "kw" "kx" "ky" "la" 54 "lb" "lc" "ld" "le" "lf" "lg" "lh" "li" "lj" "lk" "ll" "lm" "ln" "lo" 55 "lp" "lr" "ls" "lt" "lu" "lv" "lw" "lx" "ly" "lz" "ma" "mb" "mc" "md" 56 "me" "mf" "mg" "mh" "mi" "mj" "mk" "ml" "mm" "mn" "mo" "mp" "mq" "mr" 57 "ms" "mt" "mu" "mv" "mw" "mx" "my" "mz" "na" "nb" "nc" "nd" "ne" "nf" 58 "ng" "nh" "ni" "nj" "nk" "nl" "nm" "nn" "no" "np" "nr" "ns" "nt" "nu" 59 "nv" "nw" "nx" "ny" "nz" "oa" "ob" "oc" "od" "oe" "of" "og" "oh" "oi" 60 "oj" "ok" "ol" "om" "on" "oo" "op" "oq" "or" "os" "ot" "ou" "ov" "ow" 61 "ox" "oy" "oz" "pa" "pb" "pc" "pd" "pe" "pf" "pg" "ph" "pi" "pj" "pk" 62 "pl" "pm" "pn" "po" "pp" "pq" "pr" "ps" "pt" "pu" "pv" "pw" "px" "py" 63 "pz" "qa" "qb" "qc" "qd" "qe" "qh" "qi" "ql" "qm" "qn" "qo" "qp" "qq" 64 "qr" "qs" "qt" "qu" "qw" "qx" "qy" "ra" "rb" "rc" "rd" "re" "rf" "rg" 65 "rh" "ri" "rk" "rl" "rm" "rn" "ro" "rp" "rq" "rr" "rs" "rt" "ru" "rv" 66 "rw" "rx" "ry" "rz" "sa" "sb" "sc" "sd" "se" "sf" "sg" "sh" "si" "sj" 67 "sk" "sl" "sm" "sn" "so" "sp" "sq" "sr" "ss" "st" "su" "sv" "sw" "sx" 68 "sy" "sz" "ta" "tb" "tc" "td" "te" "tf" "tg" "th" "ti" "tj" "tk" "tl" 69 "tm" "tn" "to" "tp" "tr" "ts" "tt" "tu" "tv" "tw" "tx" "ty" "tz" "ua" 70 "ub" "uc" "ud" "ue" "uf" "ug" "uh" "ui" "uj" "uk" "ul" "um" "un" "uo" 71 "up" "uq" "ur" "us" "ut" "uu" "uv" "uw" "ux" "uy" "uz" "va" "vb" "vc" 72 "vd" "ve" "vf" "vg" "vh" "vi" "vj" "vk" "vl" "vm" "vn" "vo" "vp" "vq" 73 "vr" "vs" "vt" "vu" "vv" "vw" "vx" "vy" "vz" "wa" "wb" "wc" "wd" "we" 74 "wf" "wg" "wh" "wi" "wj" "wk" "wl" "wm" "wn" "wo" "wp" "wr" "ws" "wt" 75 "wu" "wv" "ww" "wx" "wy" "xa" "xb" "xc" "xd" "xe" "xf" "xh" "xi" "xl" 76 "xm" "xn" "xo" "xp" "xr" "xs" "xt" "xu" "xx" "xy" "xz" "ya" "yb" "yc" 77 "yd" "ye" "yf" "yg" "yh" "yi" "yj" "yk" "yl" "ym" "yn" "yo" "yp" "yr" 78 "ys" "yt" "yu" "yv" "yw" "yx" "yy" "yz" "za" "zb" "zc" "zd" "ze" "zf" 79 "zg" "zh" "zi" "zk" "zl" "zm" "zn" "zo" "zp" "zr" "zs" "zt" "zu" "zw" 80 "zx" "zy" "zz") 81 "List of bigrams for use with hashline reads/writes. 82 83 Each of the bigrams resolve to a single token in standard LLM 84 vocabulary, unlike a hash's hex digits. 85 86 Taken from https://github.com/can1357/oh-my-pi. 87 Precisely: https://raw.githubusercontent.com/can1357/oh-my-pi/85003ca/packages/coding-agent/src/hashline/bigrams.json") 88 89 (defun llm-tools--hl-hash (line) 90 ;; logand is to force unsigned number 91 (elt llm-tools--hl-bigrams (% (logand (sxhash line) #xffffffff) 647))) 92 93 (defun llm-tools--hl-file-lines (file &optional beg end) 94 "Return FILE contents as a list of strings. 95 With optional BEG and END (inclusive, 1-based), return the lines between that range." 96 (with-temp-buffer 97 (insert-file-contents-literally file) 98 (let ((lines (string-lines (buffer-string)))) 99 (if beg 100 (cl-subseq lines (1- beg) end) 101 lines)))) 102 103 (defun llm-tools--hl-format-lines (lines &optional start only-anchors?) 104 "Format LINES in hashline format. 105 START is 1-based (default 1). 106 If ONLY-ANCHORS? is non-nil, do not append `|CONTENT' after each anchor." 107 (string-join 108 (cl-mapcar (lambda (line i) 109 (if only-anchors? 110 (format "%d%2s" i (llm-tools--hl-hash line)) 111 (format "%d%2s|%s" i (llm-tools--hl-hash line) line))) 112 lines 113 (number-sequence (or start 1) (+ (or start 1) (1- (length lines))))) 114 "\n")) 115 116 (defun llm-tools--hl-file-read (file &optional beg end only-anchors? as-list?) 117 "Return FILE contents in hashline format. 118 With optional BEG and END (inclusive, 1-based), return lines BEG to END. 119 If ONLY-ANCHORS? is non-nil, do not append `|CONTENT' after each anchor. 120 If AS-LIST? is non-nil, return a list of hashline strings instead of a 121 single string." 122 (let* ((lines (llm-tools--hl-file-lines file beg end)) 123 (formatted (llm-tools--hl-format-lines lines (or beg 1) only-anchors?))) 124 (if as-list? 125 (string-lines formatted) 126 formatted))) 127 128 (defun llm-tools--hl-file-line-hash (file n) 129 "Return the hash of the Nth line in FILE." 130 (let* ((lines (llm-tools--hl-file-lines file)) 131 (bigram (llm-tools--hl-hash (elt lines (1- n))))) 132 (format "%d%s" n bigram))) 133 134 ;;;; Diff Validation 135 ;; TODO this should also validate the operators (e.g. any invalid 136 ;; op-chars, payload given before all other operations, no payload 137 ;; after +/< or payload given to -, or anchors without line 138 ;; number). it should return a string saying it's a malformed patch 139 ;; and what went wrong. 140 (cl-defstruct hl-verify-op 141 (type nil) ; 'insert-after 'insert-before 'delete 'replace 142 (anchor nil) ; string like "4ei" or "EOF" for +/-/< 143 (payload nil) ; list of strings (nil is no payload) 144 (raw nil)) ; raw op line text for error messages 145 146 (defun llm-tools--hl-non-empty-non-comment-line-p (line) 147 "Return t if LINE is not empty and not a comment (starts with #)." 148 (let ((trimmed (string-trim line))) 149 (and (not (string-empty-p trimmed)) 150 (not (string-prefix-p "#" trimmed))))) 151 152 (defun llm-tools--hl-parse-op-line-for-verify (line) 153 "Parse a single operation LINE (e.g. \"+ 4ei\") into a `hl-verify-op'." 154 (let* ((trimmed (string-trim line)) 155 (ch (substring trimmed 0 1))) 156 (make-hl-verify-op 157 :type (pcase ch 158 ("+" 'insert-after) 159 ("<" 'insert-before) 160 ("-" 'delete) 161 ("=" 'replace)) 162 :anchor (string-trim (substring trimmed 1)) 163 :raw trimmed))) 164 165 (defun llm-tools--hl-add-payload-to-verify-op (op payload-line) 166 "Append PAYLOAD-LINE (without leading ~) to OP's payload list." 167 (setf (hl-verify-op-payload op) 168 (append (hl-verify-op-payload op) (list (substring (string-trim payload-line) 1))))) 169 170 (defun llm-tools--hl-parse-section-ops (section-body) 171 "Parse SECTION-BODY into a list of `hl-verify-op' structs." 172 (let (ops op) 173 (dolist (line (string-lines section-body)) 174 (when (llm-tools--hl-non-empty-non-comment-line-p line) 175 (let* ((trimmed (string-trim line)) 176 (ch (substring trimmed 0 1))) 177 (if (string= ch "~") 178 (when op (llm-tools--hl-add-payload-to-verify-op op trimmed)) 179 (when op (push op ops)) 180 (setq op (llm-tools--hl-parse-op-line-for-verify trimmed)))))) 181 (when op (push op ops)) 182 (nreverse ops))) 183 184 (defun llm-tools--hl-validate-no-payload-before-op (ops) 185 "Check that no payload lines appear before the first op." 186 (when-let ((first (car-safe ops))) 187 (if (hl-verify-op-payload first) 188 (list "Payload line (~) appears before any operation") 189 '()))) 190 191 (defun llm-tools--hl-validate-insert-has-payload (ops) 192 "Check that all insert ops have at least one payload line." 193 (mapcan (lambda (op) 194 (when (and (memq (hl-verify-op-type op) '(insert-after insert-before)) 195 (null (hl-verify-op-payload op))) 196 (list (format "Insert operation (%s) has no payload lines following it" 197 (hl-verify-op-type op))))) 198 ops)) 199 200 (defun llm-tools--hl-validate-delete-no-payload (ops) 201 "Check that delete ops have no payload lines." 202 (mapcan (lambda (op) 203 (when (and (eq (hl-verify-op-type op) 'delete) 204 (hl-verify-op-payload op)) 205 (list "Delete operation (-) must not have payload lines"))) 206 ops)) 207 208 (defun llm-tools--hl-validate-insert-anchor (anchor) 209 "Return error string if ANCHOR is malformed for insert, else nil." 210 (when (and anchor 211 (not (string= anchor "EOF")) 212 (not (string= anchor "BOF"))) 213 (pcase anchor 214 ((pred (lambda (a) 215 (or (< (length a) 3) 216 (not (string-match-p "^[0-9]" a))))) 217 (format "Invalid anchor %S (expected LINEHASH like '5ff' or EOF/BOF)" anchor)) 218 (_ nil)))) 219 220 (defun llm-tools--hl-validate-range-anchor (anchor) 221 "Return error string if ANCHOR (like \"3gv..6be\") is malformed, else nil." 222 (pcase (split-string anchor "\\.\\.") 223 (`(,a ,b) 224 (when (or (< (length a) 3) (not (string-match-p "^[0-9]" a)) 225 (< (length b) 3) (not (string-match-p "^[0-9]" b))) 226 (format "Invalid anchor %S in range (expected numeric line anchor like '5ff')" anchor))) 227 (_ 228 (format "Invalid range %S (expected A..B)" anchor)))) 229 230 (defun llm-tools--hl-validate-anchors (ops) 231 "Check that all anchors are well-formed." 232 (mapcan (lambda (op) 233 (pcase (hl-verify-op-type op) 234 ((or 'insert-after 'insert-before) 235 (when-let ((err (llm-tools--hl-validate-insert-anchor (hl-verify-op-anchor op)))) 236 (list err))) 237 ((or 'delete 'replace) 238 (when-let ((err (llm-tools--hl-validate-range-anchor (hl-verify-op-anchor op)))) 239 (list err))))) 240 ops)) 241 242 (defun llm-tools--hl-verify-section (section) 243 "Validate a single SECTION. Returns a list of error strings." 244 (let ((ops (llm-tools--hl-parse-section-ops (cdr section)))) 245 (let (errors) 246 ;; Inserts must have payload 247 (dolist (op ops) 248 (when (and (memq (hl-verify-op-type op) '(insert-after insert-before)) 249 (null (hl-verify-op-payload op))) 250 (push (format "Insert operation (%s) has no payload lines following it" 251 (hl-verify-op-type op)) 252 errors))) 253 ;; Deletes must not have payload 254 (dolist (op ops) 255 (when (and (eq (hl-verify-op-type op) 'delete) 256 (hl-verify-op-payload op)) 257 (push "Delete operation (-) must not have payload lines" errors))) 258 ;; Anchor validation 259 (setq errors (append errors (llm-tools--hl-validate-anchors ops))) 260 (nreverse errors)))) 261 262 (defun llm-tools--hl-verify-structure (patch) 263 "Validate the structural integrity of PATCH. 264 Returns a list of error strings describing each structural violation found. 265 An empty list means the patch is well-formed." 266 (mapcan #'llm-tools--hl-verify-section 267 (llm-tools--split-patch-sections patch))) 268 269 (defun llm-tools--hl-verify (patch) 270 "Verify PATCH is structurally valid and all anchors match current files. 271 Returns a string with error messages, or an empty string if clean." 272 (let ((struct-errors (llm-tools--hl-verify-structure patch))) 273 (if struct-errors 274 (string-join struct-errors "\n") 275 (string-join 276 (delete-dups 277 (remq nil 278 (mapcar (lambda (elem) 279 (unless (cdr elem) 280 (format "%s had incorrect anchors. Use the 'read_file' tool to get correct anchors." 281 (car elem)))) 282 (llm-tools--hl-verify-anchors patch)))) 283 "\n")))) 284 285 ;;;; Diff Parsing 286 (defun llm-tools--split-patch-sections (patch) 287 "Return a list of (FILENAME . REST) cons cells from PATCH. 288 Each section starts with '@@ PATH' on the first line; the car is PATH 289 and the cdr is the remainder of that section." 290 (let ((pos (string-match "^@@" patch))) 291 (when pos 292 (let (sections 293 (start pos)) 294 (while (string-match "\n@@" patch (1+ pos)) 295 (let ((nl (match-beginning 0))) 296 (push (substring patch start (1+ nl)) sections) 297 (setq start (1+ nl))) 298 (setq pos (match-end 0))) 299 (push (substring patch start) sections) 300 (mapcar (lambda (sec) 301 (let ((end (string-match "\n" sec))) 302 (cons (substring sec 3 end) 303 (substring sec end)))) 304 (nreverse sections)))))) 305 306 (defun llm-tools--hl-extract-anchors-from-op-line (line) 307 "Return a list of anchor strings from a single operation LINE. 308 Handles ranges by returning both endpoints." 309 (when (and (stringp line) (length> line 2)) 310 (let ((op (char-to-string (elt line 0))) 311 (anchor (substring line 2))) 312 (pcase op 313 ((or "+" "<") (list anchor)) 314 ((or "-" "=") (split-string anchor "\\.\\.")))))) 315 316 (defun llm-tools--hl-extract-anchors-from-section (section-body) 317 "Return all anchors from a section body (string). 318 Only lines starting with +, <, -, or = are considered." 319 (let ((ops-lines 320 (cl-remove-if-not 321 (lambda (line) 322 (let ((trimmed (string-trim line))) 323 (and (not (string-empty-p trimmed)) 324 (not (string-prefix-p "#" trimmed)) 325 (not (string-prefix-p "~" trimmed)) 326 (member (substring trimmed 0 1) '("+" "<" "-" "="))))) 327 (string-lines section-body)))) 328 (flatten-list (mapcar #'llm-tools--hl-extract-anchors-from-op-line ops-lines)))) 329 330 (defun llm-tools--hl-get-anchors (patch) 331 "Returns an alist of (FILENAME . ANCHORS) from PATCH. 332 ANCHORS is a sorted, deduplicated list of all line anchors referenced by 333 the operations in that file's sections. Each anchor is a string like 334 \"5ff\" or a list of two strings from a range like 335 (\"3gv\" \"6be\")." 336 (mapcar (lambda (section) 337 (cons (car section) 338 (delete-dups 339 (sort 340 (llm-tools--hl-extract-anchors-from-section (cdr section)))))) 341 (llm-tools--split-patch-sections patch))) 342 343 (defun llm-tools--hl-verify-anchors (patch) 344 "Verify that all anchors in PATCH match the current file contents. 345 Returns a list of booleans, one per file, indicating whether every 346 anchor in that file's patch sections still matches the corresponding 347 line in the on-disk file." 348 (let ((patch-anchors (llm-tools--hl-get-anchors patch))) 349 (mapcar (lambda (section) 350 (let* ((file (car section)) 351 (anchors (delete "BOF" (delete "EOF" (cdr section)))) 352 (hl (llm-tools--hl-file-read file nil nil t t))) 353 ;; cl-subsetp uses eql by default which compares by identity and not content 354 (cons file (cl-subsetp anchors hl :test #'equal)))) 355 patch-anchors))) 356 357 (cl-defstruct hl-op 358 (type nil) ; 'insert-after 'insert-before 'delete 'replace 359 (anchor nil) ; string like "4ei" or "EOF" for +/-/< 360 (range nil) ; (start . end) strings for - and = 361 (payload nil)) ; list of strings (nil is no payload) 362 363 (defun llm-tools--hl-anchor-to-line-num (anchor) 364 "Extract the line number from ANCHOR like \"5ff\"." 365 (string-to-number (substring anchor 0 (- (length anchor) 2)))) 366 367 (defun llm-tools--hl-parse-op-line (line) 368 "Parse a single operation LINE into an `hl-op' struct. 369 LINE must start with `+`, `<`, `-`, or `=`, followed by a space, 370 followed by an anchor (or range)." 371 (string-match "^[+<=-] \\(.*\\)" line) ; returns index or nil 372 (unless (match-beginning 0) 373 (error "Invalid operation line: %S" line)) 374 (let* ((op-char (substring line 0 1)) 375 (anchor (match-string 1 line))) 376 (pcase op-char 377 ("+" (make-hl-op :type 'insert-after :anchor anchor)) 378 ("<" (make-hl-op :type 'insert-before :anchor anchor)) 379 ("-" (make-hl-op :type 'delete :range (split-string anchor "\\.\\."))) 380 ("=" (make-hl-op :type 'replace :range (split-string anchor "\\.\\.")))))) 381 382 (defun llm-tools--hl-parse-section (section) 383 "Parse SECTION (body after `@@ PATH`) into a list of `hl-op' structs. 384 Skips comment lines (starting with `#`) and blank lines." 385 (let (ops op) 386 (dolist (line (string-lines section)) 387 (let ((trimmed (string-trim-left line))) 388 (when (string-match "\\S-" trimmed) 389 (let ((op-char (substring trimmed 0 1))) 390 (pcase op-char 391 ("~" 392 (when op 393 (setf (hl-op-payload op) 394 (append (hl-op-payload op) 395 (list (substring trimmed 1)))))) 396 ("#" nil) 397 (_ 398 (when op (push op ops)) 399 (if (< (length trimmed) 3) 400 (error "Malformed operation line (too short): %S" trimmed)) 401 (setq op (llm-tools--hl-parse-op-line trimmed)))))))) 402 (when op (push op ops)) 403 (nreverse ops))) 404 405 (defun llm-tools--hl-group-by-file (sections) 406 "Group SECTIONS by file path. 407 Returns an alist of (FILENAME . SECTION-LIST) where SECTION-LIST 408 contains all (PATH . REST) cons cells from SECTIONS that target 409 FILENAME." 410 (let (grouped) 411 (dolist (sec sections) 412 (let ((existing (assoc (car sec) grouped))) 413 (if existing 414 (push sec (cdr existing)) 415 (push (cons (car sec) (list sec)) grouped)))) 416 grouped)) 417 418 (defun llm-tools--hl-apply-insert-after (anchor payload content offset) 419 "Insert PAYLOAD after the line identified by ANCHOR (or EOF)." 420 (let* ((line-num (if (string= anchor "EOF") 421 (length content) 422 (+ (llm-tools--hl-anchor-to-line-num anchor) offset))) 423 (idx (1- line-num))) 424 (cons (append (cl-subseq content 0 (1+ idx)) 425 payload 426 (cl-subseq content (1+ idx))) 427 (+ offset (length payload))))) 428 429 (defun llm-tools--hl-apply-insert-before (anchor payload content offset) 430 "Insert PAYLOAD before the line identified by ANCHOR (or BOF)." 431 (let* ((line-num (if (string= anchor "BOF") 1 432 (+ (llm-tools--hl-anchor-to-line-num anchor) offset))) 433 (idx (1- line-num))) 434 (cons (append (cl-subseq content 0 idx) 435 payload 436 (cl-subseq content idx)) 437 (+ offset (length payload))))) 438 439 (defun llm-tools--hl-apply-delete (range content offset) 440 "Delete lines from (car RANGE) to (cadr RANGE)." 441 (let ((a (+ (llm-tools--hl-anchor-to-line-num (car range)) offset)) 442 (b (+ (llm-tools--hl-anchor-to-line-num (cadr range)) offset))) 443 (cons (append (cl-subseq content 0 (1- a)) 444 (cl-subseq content b)) 445 (- offset (1+ (- b a)))))) 446 447 (defun llm-tools--hl-apply-replace (range payload content offset) 448 "Replace lines from (car RANGE) to (cadr RANGE) with PAYLOAD (nil to '(\"\"))" 449 (let ((a (+ (llm-tools--hl-anchor-to-line-num (car range)) offset)) 450 (b (+ (llm-tools--hl-anchor-to-line-num (cadr range)) offset)) 451 (payload (or payload '("")))) 452 (cons (append (cl-subseq content 0 (1- a)) 453 payload 454 (cl-subseq content b)) 455 (+ offset (- (length payload) (1+ (- b a))))))) 456 457 (defun llm-tools--hl-apply-one-op (op content offset) 458 "Apply a single OP to CONTENT (list of strings) at the given OFFSET. 459 Returns a cons cell (NEW-CONTENT . NEW-OFFSET)." 460 (pcase (hl-op-type op) 461 ('insert-after (llm-tools--hl-apply-insert-after 462 (hl-op-anchor op) 463 (hl-op-payload op) 464 content offset)) 465 ('insert-before (llm-tools--hl-apply-insert-before 466 (hl-op-anchor op) 467 (hl-op-payload op) 468 content 469 offset)) 470 ('delete (llm-tools--hl-apply-delete 471 (hl-op-range op) 472 content 473 offset)) 474 ('replace (llm-tools--hl-apply-replace 475 (hl-op-range op) 476 (hl-op-payload op) 477 content 478 offset)))) 479 480 (defun llm-tools--hl-apply-ops (ops content) 481 "Apply a list of OPS to CONTENT (list of strings). 482 Returns the modified list of strings." 483 (let ((offset 0)) 484 (dolist (op ops content) 485 (let ((result (llm-tools--hl-apply-one-op op content offset))) 486 (setq content (car result)) 487 (setq offset (cdr result)))))) 488 489 (defun llm-tools--hl-apply (patch) 490 "Apply all operations in PATCH to their respective files. 491 Operations within a file are applied sequentially, with line numbers 492 adjusted for prior insertions/deletions via a running offset." 493 (mapc (lambda (file-group) 494 (let* ((file (car file-group)) 495 (ops (mapcan (lambda (sec) 496 (llm-tools--hl-parse-section (cdr sec))) 497 (cdr file-group))) 498 (content (llm-tools--hl-apply-ops 499 ops 500 (llm-tools--hl-file-lines file)))) 501 (with-temp-buffer 502 (insert (string-join content "\n")) 503 (write-region (point-min) (point-max) file)))) 504 (llm-tools--hl-group-by-file 505 (llm-tools--split-patch-sections patch)))) 506 507 (defun llm-tools--hl-edit (patch) 508 "Apply a hashline PATCH to the files it references. 509 510 PATCH is a string containing one or more sections, each starting with 511 `@@ FILEPATH' followed by operations: 512 + ANCHOR Insert lines after ANCHOR (or `EOF' to append) 513 < ANCHOR Insert lines before ANCHOR (or `BOF' to prepend) 514 - A..B Delete lines from A to B 515 = A..B Replace lines from A to B with the following payload 516 ~TEXT Payload line for the preceding operation 517 518 ANCHOR is a hashline string like `5ff' (line number + 2-char hash) or 519 `BOF' / `EOF'. Lines are written directly to disk. 520 521 Before applying, verifies that every anchor in PATCH still matches the 522 current file contents. If any file has drifted, the entire operation is 523 aborted and an error message is returned identifying the affected files. 524 525 Returns a success message if all operations applied, or an error string 526 if verification failed." 527 (let ((msg (llm-tools--hl-verify patch))) 528 (if (string= msg "") 529 (progn 530 (llm-tools--hl-apply patch) 531 (format "Finished applying patch.")) 532 (format "%s" msg)))) 533 534 (provide 'llm-tools-hl)