llm-tools.el

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

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)