llm-tools.el

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

llm-tools-edit.el (4586B)


      1 ;; llm-tools-edit.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 ;; File editing tool 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 (require 'subr-x)
     33 
     34 (defun llm-tools--edit-file-lines (file &optional beg end)
     35   "Return FILE contents as a list of strings.
     36 With optional BEG and END (inclusive, 1-based), return the lines between that range."
     37   (with-temp-buffer
     38     (insert-file-contents-literally file)
     39     (let ((lines (string-lines (buffer-string))))
     40       (if beg
     41 	  (cl-subseq lines (1- beg) end)
     42 	lines))))
     43 
     44 (defun llm-tools--edit-file-read (file &optional beg end as-list? tail)
     45   "Return FILE contents.
     46 With optional BEG and END (inclusive, 1-based), return lines BEG to END.
     47 If AS-LIST? is non-nil, return a list of strings instead of a single
     48 string.
     49 TAIL reads the last N lines and cannot be combined with BEG or END."
     50   (when (and tail (or beg end (not (and (integerp tail) (> tail 0)))))
     51     (error "tail must be positive and cannot be combined with beg or end"))
     52   (let* ((lines (llm-tools--edit-file-lines file beg end))
     53          (lines (if tail (last lines tail) lines))
     54 	 (formatted (string-join lines "\n")))
     55     (if as-list?
     56 	lines
     57       formatted)))
     58 
     59 (defun llm-tools--edit-apply (content old-string new-string &optional replace-all?)
     60   "Replace literal OLD-STRING with NEW-STRING in CONTENT.
     61 Require exactly one non-overlapping match unless REPLACE-ALL? is non-nil.
     62 Normalize CRLF to LF. Return the edited string, or signal an error."
     63   (when (string-empty-p old-string)
     64     (error "old_string must be a non-empty string"))
     65   (when (string= old-string new-string)
     66     (error "old_string and new_string must differ"))
     67   (let* ((content (string-replace "\r\n" "\n" content))
     68          (old-string (string-replace "\r\n" "\n" old-string))
     69          (new-string (string-replace "\r\n" "\n" new-string))
     70          (pattern (regexp-quote old-string))
     71          (case-fold-search nil)
     72          (position 0)
     73          (count 0))
     74     (while (string-match pattern content position)
     75       (setq count (1+ count)
     76             position (match-end 0)))
     77     (cond
     78      ((zerop count)
     79       (error "old_string was not found"))
     80      ((and (> count 1) (not replace-all?))
     81       (error "old_string matched %d times; provide more context or set replace_all to true"
     82              count)))
     83     (replace-regexp-in-string pattern new-string content t t)))
     84 
     85 (defun llm-tools--edit (file-path old-string new-string &optional replace-all?)
     86   "Replace literal OLD-STRING with NEW-STRING in FILE-PATH.
     87 Require a unique match unless REPLACE-ALL? is non-nil.
     88 Return a success message, or an error message without writing on match failure."
     89   (condition-case err
     90       (progn
     91         (when (string-empty-p (string-trim file-path))
     92           (error "file_path must be a non-empty string"))
     93         (unless (file-regular-p file-path)
     94           (error "Not an existing regular file: %s" file-path))
     95         (with-temp-buffer
     96           (insert-file-contents file-path)
     97           (let* ((coding buffer-file-coding-system)
     98                  (edited (llm-tools--edit-apply
     99                           (buffer-string) old-string new-string replace-all?)))
    100             (erase-buffer)
    101             (insert edited)
    102             (let ((coding-system-for-write coding))
    103               (write-region (point-min) (point-max) file-path nil 'silent))))
    104         (if replace-all?
    105             (format "The file %s has been updated. All occurrences were successfully replaced."
    106                     file-path)
    107           (format "The file %s has been updated successfully." file-path)))
    108     (error (format "Error: %s" (error-message-string err)))))
    109 
    110 (provide 'llm-tools-edit)