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)