blob: f4c4cabd36407a7c0b7261d2a0797778d60ad812 [file] [log] [blame]
;;; rg-edit.el --- Invoke ripgrep-edit
;;; SPDX-License-Identifier: GPL-3.0-or-later OR AGPL-3.0-or-later
;; Copyright (C) 2025 Red Hat Inc.
;;; Commentary:
;; This module provides helper functions to invoke the `rg-edit` executable
;; with the current region or word as the search pattern. It requires the `rg-edit`
;; binary to be installed and accessible in the system PATH or configured via
;; `rg-edit-executable`.
;;; Example configuration:
;; (add-to-list 'load-path "~/.../ripgrep-edit/emacs/")
;; (use-package rg-edit
;; :ensure nil
;; :config
;; (setq-default rg-edit-auto-mark-whole-buffer t)
;; (setq-default rg-edit-executable "~/.../ripgrep-edit/target/release/rg-edit"))
;;; Code:
(defcustom rg-edit-executable "rg-edit"
"The rg-edit executable to use."
:type 'string
:group 'rg-edit)
(defcustom rg-edit-gptel-system-message
(concat "You are a careful programmer. Rewrite cross-file snippets.\n"
"Rewrite everything exactly the same except: the required change.\n"
"Keep the filenames at the start of the files.\n"
"Keep the separators at the end of the snippets.\n"
"Do not delete the filenames and the separators.\n"
"Do not add markdown fences.\n"
"Do not ask clarification.")
"The rg-edit gptel-system-message to use when in a rg-edit buffer."
:type 'string
:group 'rg-edit)
(defcustom rg-edit-auto-mark-whole-buffer nil
"If non-nil, mark the whole buffer when entering rg-edit-mode.
This prepares the buffer for AI rewriting by selecting all content."
:type 'boolean
:group 'rg-edit)
(define-key global-map (kbd "C-c r") #'rg-edit-git)
(defun rg-edit--check-server ()
"Check if the Emacs server is running and signal an error if not."
(unless (server-running-p)
(user-error "Emacs server is not running. Please start it with 'M-x server-start'")))
(defun rg-edit--collect-extra-args (extra-args)
"Collect extra arguments for rg-edit command."
(let ((extra-args (read-string "Extra args: " extra-args)))
(unless (string-empty-p extra-args)
extra-args)))
(defun rg-edit--cleanup-buffer (buffer)
"Clean up the *rg-edit* buffer before running rg-edit."
(with-current-buffer buffer
(read-only-mode -1)
(erase-buffer)
(when (and (fboundp 'gptel--model-capable-p)
(gptel--model-capable-p 'gbnf))
(insert "GBNF Grammar Enabled\n"))
(goto-char (point-min))))
(defun rg-edit--run-command (regexp path extra-args)
"Run rg-edit with REGEXP, PATH, and EXTRA-ARGS."
(let* ((path-dir (directory-file-name path))
(default-directory (file-name-directory path-dir))
(dir-name (file-name-nondirectory path-dir))
(process-buffer (get-buffer-create "*rg-edit*" t)))
(save-some-buffers
nil (lambda () (string-prefix-p (file-truename dir-name)
(file-truename (buffer-file-name)))))
(rg-edit--cleanup-buffer process-buffer)
(apply #'start-process "rg-edit"
process-buffer
rg-edit-executable
"-e" regexp
"-E" "emacsclient"
"--dump-on-error"
(if (and (fboundp 'gptel--model-capable-p)
(gptel--model-capable-p 'gbnf))
"--gbnf" "")
(shell-quote-argument dir-name)
(when extra-args
(split-string-shell-command extra-args)))))
(defun rg-edit--get-path (path buffer-file)
"Get the search path for rg-edit, using PATH or buffer file location."
(or path
(if buffer-file
(file-name-directory buffer-file)
default-directory)))
(defun rg-edit--get-git-path (path buffer-file)
"Get the git repository root as search path for rg-edit, using PATH or buffer file location."
(or path
(if buffer-file
(let ((git-root (locate-dominating-file buffer-file ".git")))
(if git-root
(file-name-directory git-root)
(file-name-directory buffer-file)))
default-directory)))
(defun rg-edit--invoke (regexp path extra-args get-path-fn)
"Invoke rg-edit with REGEXP, PATH, EXTRA-ARGS using GET-PATH-FN to determine the path."
(rg-edit--check-server)
(rg-edit--warn-if-auto-revert-disabled)
(let ((search-regexp (or regexp
(if (use-region-p)
(buffer-substring-no-properties (region-beginning) (region-end))
(or (current-word) "")))))
(unless search-regexp
(user-error "No regexp provided or region/word to use"))
(let ((search-regexp (read-string "Regexp: " search-regexp))
(buffer-file (buffer-file-name)))
(let ((search-path (funcall get-path-fn path buffer-file)))
(let ((search-path (expand-file-name (read-directory-name "Path: " search-path)))
(extra-args (rg-edit--collect-extra-args extra-args)))
(rg-edit--run-command search-regexp search-path extra-args))))))
(defun rg-edit ()
"Invoke rg-edit with the path in the current directory."
(interactive)
(rg-edit--invoke nil nil nil #'rg-edit--get-path))
(defun rg-edit-git-conflicts ()
"Invoke rg-edit-git with regex and extra-args preset to edit git conflicts."
(interactive)
(rg-edit--invoke "(?s)^<<<<<<<+ .*?^>>>>>>>+ " nil "-U" #'rg-edit--get-git-path))
(defun rg-edit-git (&optional arg)
"Invoke rg-edit-git with the search path set in the git root.
With a C-u prefix argument invoke rg-edit-git-conflicts instead."
(interactive "p")
(cond
((= arg 1) (rg-edit--invoke nil nil nil #'rg-edit--get-git-path))
((= arg 4) (rg-edit-git-conflicts))))
(defun rg-edit--warn-if-auto-revert-disabled ()
"Warn if automatic file revert is not enabled."
(unless (bound-and-true-p global-auto-revert-mode)
(display-warning 'rg-edit
"Automatic file revert is not enabled. Consider enabling it with `M-x global-auto-revert-mode`."
:warning)))
(defun rg-edit--gptel-rewrite ()
(when (and (boundp 'gptel-version)
(string-match-p "\\.rg.edit\\'" (buffer-name)))
rg-edit-gptel-system-message))
(add-hook 'gptel-rewrite-directives-hook #'rg-edit--gptel-rewrite)
(defun rg-edit--setup-gptel-directives ()
"Set up gptel directives for rg-edit."
(when (boundp 'gptel-directives)
(setf (alist-get 'rg-edit gptel-directives)
rg-edit-gptel-system-message)))
(defun rg-edit--setup-gptel--rewrite-directive ()
;; workaround for gptel issue in pull request #1095
;; fixed in 129032fca88f29c20343e973c98fa17db3000405 and
;; the following commit f4344b8a7950fd6b969b32f84f0fe427a9bc925b
;; introduced gptel-version
(unless (boundp 'gptel-version)
(setq-local gptel--rewrite-directive
rg-edit-gptel-system-message)))
(defun rg-edit--kill-buffer-hook ()
"Interrupt any in-flight gptel-send request when the buffer is closed."
(when (fboundp 'gptel-abort)
(gptel-abort (current-buffer))))
(defun rg-edit--gbnf ()
"Read the .gbnf file and inject its contents into the JSON as the 'gbnf' field."
(when (and (fboundp 'gptel--model-capable-p)
(gptel--model-capable-p 'gbnf))
(when-let* ((buffer-file (buffer-file-name))
(gbnf-path (car (file-expand-wildcards (concat buffer-file "*.gbnf"))))
(gbnf-content (when (file-exists-p gbnf-path)
(with-temp-buffer
(insert-file-contents gbnf-path)
(buffer-string)))))
(when gbnf-content
(setq-local gptel--request-params
(plist-put gptel--request-params :grammar gbnf-content))))))
(defun rg-edit-mode ()
(prog-mode)
(rg-edit--setup-gptel-directives)
(rg-edit--setup-gptel--rewrite-directive)
(add-hook 'kill-buffer-hook #'rg-edit--kill-buffer-hook nil t)
(rg-edit--gbnf)
(when rg-edit-auto-mark-whole-buffer
(mark-whole-buffer)))
;;;###autoload
(add-to-list 'auto-mode-alist '("\\.rg-edit\\'" . rg-edit-mode))
(provide 'rg-edit)
;;; rg-edit.el ends here