Only in /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19: .distro diff -U2 -r /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/README.md /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/README.md --- /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/README.md 2026-10-07 17:05:25.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/README.md 2026-10-24 04:04:24.000000000 +0000 @@ -8,6 +8,8 @@ - 📄 Present matches in a clean, linear, easy-to-read format - ✏️ Edit all matched code snippets in a single temporary file -- 💾 Apply changes back to original files -- 🛠️ Works with any editor via shell command +- 💾 Apply changes back to the original files +- 🛠️ Work with any editor via a shell command + +If the temporary file is not saved by the editor, no changes are applied. ## Demo @@ -29,5 +31,7 @@ ### Fedora Copr + A Fedora Copr package is available: + ```bash sudo dnf copr enable vittyvk/ripgrep-edit @@ -40,24 +44,24 @@ - `-e, --regexp `: Regex pattern to search for -- `-E, --editor `: Editor command (e.g., `vim`, `emacs`) -- `-C, --context `: Number of context lines -- `-A, --after-context `: Lines of context to show after each match -- `-B, --before-context `: Lines of context to show before each match -- `--require-all-files`: Require all files in ripgrep output to be present in the edited file -- `--context-separator `: Separator string between snippets -- `--filename-prefix `: Prefix for filename lines +- `-E, --editor `: Editor command (e.g., `vim`, `emacsclient`) +- `-C, --context `: Number of context lines (default: 5, minimum: 1) +- `-A, --after-context `: Lines of context to show after each match (the larger of `-A` and `-C` is used) +- `-B, --before-context `: Lines of context to show before each match (the larger of `-B` and `-C` is used) +- `--require-all-files`: Require all files in the ripgrep output to be present in the edited file (by default, files removed from the edited file are left unchanged) +- `--context-separator `: Separator string between snippets (default: `-- DO NOT DELETE THIS SEPARATOR`) +- `--filename-prefix `: Prefix for filename lines (default: `-- `) - `-S, --smart-case`: Enable smart case search - `-i, --ignore-case`: Ignore case distinctions -- `-w, --word-regexp`: Force pattern to match only whole words +- `-w, --word-regexp`: Force the pattern to match only whole words - `-U, --multiline`: Enable multiline matching - `--multiline-dotall`: Enable dotall mode for multiline matching -- `--sort `: Sort results by path, modified, accessed, or created time -- `--sortr `: Sort results in reverse order by path, modified, accessed, or created time -- `--follow`: Follow symlinks -- `-j, --threads `: Number of threads to use -- `--dump-on-error`: Dump processed tempfile to stderr on error -- `--output-size-limit `: Limit total ripgrep output size (e.g., 1024, 1k, 1m, 1g). If the limit is exceeded, the tool exits with code 3. -- `--gbnf`: Generate dynamic GBNF grammar file -- `--gbnf-control-lines`: Max number of GBNF control lines added as extra context +- `--sort `: Sort results by `path`, `modified`, `accessed`, or `created` time +- `--sortr `: Sort results in reverse order by `path`, `modified`, `accessed`, or `created` time (cannot be combined with `--sort`) +- `-L, --follow`: Follow symlinks +- `-j, --threads `: Number of threads to use (default: 0, automatic) +- `--dump-on-error`: Dump the processed temporary file to stderr on error +- `--output-size-limit `: Limit the total ripgrep output size (e.g., `1024`, `1k`, `1m`, `1g`). If the limit is exceeded, the tool exits with code 3. +- `--gbnf`: Generate a dynamic GBNF grammar file +- `-G, --gbnf-control-lines `: Maximum number of GBNF control lines added as extra context (default: 10) ## Examples @@ -66,13 +70,13 @@ rg-edit [OPTIONS] --regexp --editor [PATHS...] rg-edit -e "function\s*\(" -E vim src/ -rg-edit -e "function\s*\(" -E "emacsclient" -C 8 src/ +rg-edit -e "function\s*\(" -E emacsclient -C 8 src/ rg-edit -E vim -C10 -U -e '(?s)^<<<<<<<+ .*?^\|\|\|\|\|\|\|+ .*?^>>>>>>>+ ' # resolve git conflicts rg-edit -E emacsclient -C10 -U -e '(?s)^<<<<<<<+ .*?^\|\|\|\|\|\|\|+ .*?^>>>>>>>+ ' -rg-edit -e '.*' -E vim src/ --output-size-limit 1m # fail if output larger than 1 megabyte (exits with code 3) +rg-edit -e '.*' -E vim src/ --output-size-limit 1m # fail if the output exceeds 1 megabyte (exits with code 3) ``` ## Why It Was Built -Large codebases challenge LLMs due to context limits. Traditional vibe coding tools require reading snippets of multiple large files through the LLM agentic workflow. +Large codebases challenge LLMs due to context limits. Traditional vibe coding tools require the LLM agentic workflow to read snippets of multiple large files. `ripgrep-edit` solves this by: @@ -80,7 +84,7 @@ - 📌 **Preserving context** around each match - 🧠 **Enabling precise edits** via LLMs or editors -- 🔄 **Reapplying changes to original files** +- 💾 **Reapplying changes** to the original files -`ripgrep-edit` lets the programmer define the exact context needed, presenting it in a structured, linear format optimized for the LLM's key-value input. The resulting gain in efficiency enables real-time visibility into what the LLM is generating — allowing to interrupt, refine input, adjust directives, and eliminate unnecessary context, computation, and cost. +`ripgrep-edit` lets the programmer define the exact context needed, presenting it in a structured, linear format optimized for the LLM's key-value input. The resulting gain in efficiency provides real-time visibility into what the LLM is generating, making it possible to interrupt, refine the input, adjust directives, and eliminate unnecessary context, computation, and cost. The goal is to optimize the LLM's most demanding task — writing code — by minimizing perplexity and maximizing efficiency. @@ -102,7 +106,12 @@ ``` +Every file kept in the edited output must retain all of its snippets and separators; otherwise the changes are rejected. + ## rg-edit.el -- Provides `rg-edit`, `rg-edit-buffer`, `rg-edit-git`, `rg-edit-git-conflicts` functions +- Provides the `rg-edit`, `rg-edit-buffer`, `rg-edit-git` and `rg-edit-git-conflicts` commands +- Requires a running Emacs server (`M-x server-start`), since `emacsclient` is used as the editor +- Enabling `global-auto-revert-mode` is recommended so modified files are reloaded automatically +- Automatically passes `--gbnf` when a configured gptel backend supports the `gbnf` or `ebnf` capability - Keybindings: - `rg-edit-git` **C-c r** @@ -110,6 +119,13 @@ - `rg-edit-buffer` **C-- C-c r** - `rg-edit-git-conflicts` **C-u C-c r** - - `rg-edit--commit` **C-c C-c** (in rg-edit buffer) - - `rg-edit--abort` **C-c C-k** (in rg-edit buffer) + - `rg-edit--commit` **C-c C-c** (in the rg-edit buffer, via `rg-edit-mode-map`) + - `rg-edit--abort` **C-c C-k** (in the rg-edit buffer, via `rg-edit-mode-map`) + - `rg-edit--terminate` **C-c C-k** (in the `*rg-edit*` buffer, terminates any outstanding session) +- Customization variables: + - `rg-edit-executable`: path to the `rg-edit` executable + - `rg-edit-auto-mark-whole-buffer`: mark the whole buffer when entering `rg-edit-mode` + - `rg-edit-prefill-extra-args`: default extra arguments (default: `-U --multiline-dotall --smart-case`) + - `rg-edit-gptel-system-message`: gptel directive used in rg-edit buffers + - `rg-edit-mode-hook`: hook run when entering the `rg-edit-mode` major mode (derived from `prog-mode`) ```elisp @@ -122,7 +138,7 @@ ``` -## GBNF EBNF Grammar Support +## GBNF/EBNF Grammar Support -ripgrep-edit integrates with [GBNF](https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md) to constrain LLM outputs when using llama.cpp or vllm. When the `--gbnf` flag is used, a `*.gbnf` grammar file is generated in the same directory as the temporary rg-edit buffer. The `rg-edit.el` plugin automatically detects this file and injects its contents into the JSON request as the `grammar` field when the gptel backend supports the `gbnf` or `ebnf` capability. +ripgrep-edit integrates with [GBNF](https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md) to constrain LLM outputs when using llama.cpp or vLLM. When the `--gbnf` flag is used, a `.gbnf` grammar file is generated next to the temporary `.rg-edit` file. The `rg-edit.el` plugin automatically detects this file and injects its contents into the JSON request: as the `grammar` field when the gptel model supports the `gbnf` capability, or as `structured_outputs.grammar` when it supports the `ebnf` capability. ```elisp @@ -146,7 +162,7 @@ ``` -The GBNF grammar enforces deterministic structure around the metadata markers of the rg-edit format. The LLM retains the ability to drop irrelevant files or reorder the file sequence, but the filename and its prefix are enforced to have no typos. Once inside the snippets, the GBNF grammar emits the "before context" control line (if available) and then grants the LLM free reign to modify snippet content until the LLM emits the "after context" control line or the separator. At that point, the GBNF grammar regains control, emits the separator (if not already emitted), and advances to the next snippet. +The GBNF grammar enforces a deterministic structure around the metadata markers of the rg-edit format. The LLM retains the ability to drop irrelevant files or reorder the file sequence, but the filename and its prefix are enforced verbatim, preventing typos. Inside each snippet, the grammar emits the "before context" control line (if available) and then grants the LLM free rein to modify the snippet content until it emits the "after context" control line or the separator. At that point, the grammar regains control, emits the separator (if not already emitted), and advances to the next snippet. -When the grammar is enabled during inference, a notification appears in the `*rg-edit*` buffer. +When the grammar is enabled, a notification appears in the `*rg-edit*` buffer. > ![ripgrep-edit `"max_line_1 with GBNF"` commit](https://gitlab.com/aarcange/ripgrep-edit-assets/-/raw/main/demo-max_line_1-GBNF.webm) @@ -155,10 +171,9 @@ ## Why Not wgrep? -- Emacs' [`wgrep`](https://github.com/mhayashi1120/Emacs-wgrep) offers similar functionality but relies on interactive edits, such as `C-c C-d` to delete a line, which are incompatible with the full rewrites required in LLM-driven workflows. +- Emacs' [`wgrep`](https://github.com/mhayashi1120/Emacs-wgrep) offers similar functionality but relies on interactive edits, such as `C-c C-d` to delete a line, which are incompatible with the full rewrites required by LLM-driven workflows. -- Unlike wgrep, `ripgrep-edit` is editor-agnostic and LLM-agnostic: any editor can be used, making it ideal for diverse environments, or simply to avoid opening multiple files with vim. +- Unlike wgrep, `ripgrep-edit` is editor-agnostic and LLM-agnostic: any editor can be used, making it ideal for diverse environments, or simply to avoid opening multiple files in vim. ## License -[![License: GPL-3.0-or-later](https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.html) -[![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.html) +[![License: GPL-3.0-or-later](https://img.shields.io/badge/License-GPL--3.0--or--later-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.html) ![OR](https://img.shields.io/badge/OR-lightgrey.svg) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](https://www.gnu.org/licenses/agpl-3.0.html) diff -U2 -r /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/emacs/rg-edit.el /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/emacs/rg-edit.el --- /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/emacs/rg-edit.el 2026-10-07 17:05:25.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/emacs/rg-edit.el 2026-10-24 04:04:24.000000000 +0000 @@ -22,4 +22,9 @@ ;;; Code: +(defgroup rg-edit nil + "Edit ripgrep search results across multiple files." + :group 'tools + :prefix "rg-edit-") + (defcustom rg-edit-executable "rg-edit" "The rg-edit executable to use." @@ -56,4 +61,5 @@ (define-key global-map (kbd "C-c r") #'rg-edit-git) +(require 'server) (defun rg-edit--check-server () "Check if the Emacs server is running and signal an error if not." @@ -82,5 +88,4 @@ (unless (current-local-map) (let ((map (make-sparse-keymap))) - (set-keymap-parent map (current-local-map)) (define-key map (kbd "C-c C-k") #'rg-edit--terminate) (use-local-map map))) @@ -91,18 +96,28 @@ (visual-line-mode))) +(require 'cl-lib) (defun rg-edit--gptel-gbnf-enabled-p () - "Return non-nil if at least one configured gptel backend supports GBNF." - (when (fboundp 'gptel--model-capable-p) + "Return non-nil if at least one model of a configured gptel backend supports GBNF or EBNF." + (when (and (fboundp 'gptel--model-capable-p) + (boundp 'gptel--known-backends)) (cl-loop for backend-entry in gptel--known-backends for backend-value = (cdr backend-entry) - if (gptel-backend-p backend-value) - if (or (gptel--model-capable-p 'gbnf (intern (car backend-entry))) - (gptel--model-capable-p 'ebnf (intern (car backend-entry)))) - return t))) + thereis (and (gptel-backend-p backend-value) + (cl-loop for model in (gptel-backend-models backend-value) + thereis (or (gptel--model-capable-p 'gbnf model) + (gptel--model-capable-p 'ebnf model))))))) (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)) - (dir-name (file-name-nondirectory path-dir)) + (base-name (file-name-nondirectory path-dir)) + ;; When the search path is the filesystem root, the base + ;; name is empty: search "." from within the root instead of + ;; passing an empty path argument to rg + (root-p (string-empty-p base-name)) + (dir-name (if root-p "." base-name)) + (work-dir (if root-p + (file-name-as-directory path-dir) + (file-name-directory path-dir))) (rg-buffer (get-buffer-create "*rg-edit*" t)) (gbnf (when (rg-edit--gptel-gbnf-enabled-p) @@ -112,8 +127,12 @@ extra-args))) (save-some-buffers - nil (lambda () (string-prefix-p (file-truename path-dir) - (file-truename (buffer-file-name))))) + nil (lambda () + (when-let* ((file (buffer-file-name))) + (let ((root (file-truename path-dir)) + (file (file-truename file))) + (or (string= root file) + (string-prefix-p (file-name-as-directory root) file)))))) (rg-edit--setup-buffer rg-buffer) - (cd (file-name-directory path-dir)) + (cd work-dir) (apply #'start-process "rg-edit" rg-buffer @@ -123,5 +142,5 @@ "--dump-on-error" "--output-size-limit=100m" - (shell-quote-argument dir-name) + dir-name (split-string-shell-command (string-trim extra-args))))) @@ -150,5 +169,7 @@ (defun rg-edit--invoke (regexp path extra-args get-path-fn refine-path) - "Invoke rg-edit with REGEXP, PATH, EXTRA-ARGS using GET-PATH-FN to determine the path." + "Invoke rg-edit with REGEXP, PATH, EXTRA-ARGS using GET-PATH-FN to determine the path. +If REFINE-PATH is non-nil, or the current buffer is not visiting a file, +prompt the user to confirm or refine the search path." (rg-edit--check-server) (rg-edit--warn-if-auto-revert-disabled) @@ -187,11 +208,17 @@ "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." +ARG is the numeric prefix argument: +- no prefix (1): invoke rg-edit with the search path set in the git root. +- C-0 (0): invoke `rg-edit' instead. +- C-- (-1): invoke `rg-edit-buffer' instead. +- C-u (4): invoke `rg-edit-git-conflicts' instead." (interactive "p") - (cond - ((= arg 1) (rg-edit--invoke nil nil nil #'rg-edit--get-git-path nil)) - ((= arg 0) (rg-edit)) - ((= arg -1) (rg-edit-buffer)) - ((= arg 4) (rg-edit-git-conflicts)))) + (let ((arg (prefix-numeric-value arg))) + (cond + ((= arg 1) (rg-edit--invoke nil nil nil #'rg-edit--get-git-path nil)) + ((= arg 0) (rg-edit)) + ((= arg -1) (rg-edit-buffer)) + ((= arg 4) (rg-edit-git-conflicts)) + (t (user-error "Unsupported prefix argument: %s" arg))))) (defun rg-edit--warn-if-auto-revert-disabled () @@ -203,6 +230,7 @@ (defun rg-edit--gptel-rewrite () - (when (and (boundp 'gptel-version) - (string-match-p "\\.rg.edit\\'" (buffer-name))) + (when-let* (((boundp 'gptel-version)) + (file (buffer-file-name)) + ((string-match-p "\\.rg-edit\\'" file))) rg-edit-gptel-system-message)) (add-hook 'gptel-rewrite-directives-hook #'rg-edit--gptel-rewrite) @@ -228,4 +256,6 @@ (gptel-abort (current-buffer)))) +(defvar-local rg-edit--gbnf-content nil) + (defun rg-edit--gbnf () "Read the .gbnf file and inject its contents into the JSON as the 'grammar' field." @@ -249,8 +279,8 @@ ((gptel--model-capable-p 'gbnf) (setq-local gptel--request-params - (plist-put gptel--request-params :grammar content))) + (plist-put (copy-sequence gptel--request-params) :grammar content))) ((gptel--model-capable-p 'ebnf) (setq-local gptel--request-params - (plist-put gptel--request-params :structured_outputs + (plist-put (copy-sequence gptel--request-params) :structured_outputs (list :grammar content))))))) @@ -267,9 +297,21 @@ (rg-edit--commit)) -(defun rg-edit-mode () +(defvar rg-edit-mode-map + (let ((map (make-sparse-keymap))) + (define-key map (kbd "C-c C-c") #'rg-edit--commit) + (define-key map (kbd "C-c C-k") #'rg-edit--abort) + map) + "Keymap for `rg-edit-mode'.") + +;;;###autoload +(define-derived-mode rg-edit-mode prog-mode "rg-edit" + "Major mode for editing rg-edit temporary files. + +\\{rg-edit-mode-map}" + :group 'rg-edit (let ((rg-buffer (get-buffer "*rg-edit*"))) - (with-current-buffer rg-buffer - (bury-buffer))) - (prog-mode) + (when rg-buffer + (with-current-buffer rg-buffer + (bury-buffer)))) (rg-edit--setup-gptel-directives) (rg-edit--setup-gptel--rewrite-directive) @@ -278,6 +320,4 @@ (when rg-edit-auto-mark-whole-buffer (mark-whole-buffer)) - (define-key (current-local-map) (kbd "C-c C-c") #'rg-edit--commit) - (define-key (current-local-map) (kbd "C-c C-k") #'rg-edit--abort) (setq-local server-client-instructions nil) (message "When done C-c C-c to commit, C-c C-k to abort or C-x # to manually close the session")) diff -U2 -r /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/src/gbnf.rs /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/src/gbnf.rs --- /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/src/gbnf.rs 2026-10-07 17:05:25.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/src/gbnf.rs 2026-10-24 04:04:24.000000000 +0000 @@ -364,5 +364,5 @@ file_rule.push_str("prefix \""); - file_rule.push_str(filename); + file_rule.push_str(&filename.chars().map(quote_char).collect::()); file_rule.push_str("\" \"\\n\"\n"); diff -U2 -r /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/src/main.rs /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/src/main.rs --- /var/lib/copr-rpmbuild/results/ripgrep-edit/upstream-unpacked/Source0/ripgrep-edit-0.3.19/src/main.rs 2026-10-07 17:05:25.000000000 +0000 +++ /var/lib/copr-rpmbuild/results/ripgrep-edit/srpm-unpacked/ripgrep-edit-0.3.19.tar.gz-extract/ripgrep-edit-0.3.19/src/main.rs 2026-10-24 04:04:24.000000000 +0000 @@ -99,15 +99,11 @@ } - if let Some(ref current) = current_file { - if current != &line { - if let Some(range) = current_range { - file_ranges.add(current, range); - } - current_file = Some(line.to_string()); - } - } else { - current_file = Some(line.to_string()); + if let (Some(current), Some(range)) = (¤t_file, current_range.take()) { + file_ranges.add(current, range); + } + if file_ranges.contains_key(&line) { + anyhow::bail!("Duplicate file found in rg output: {}", line); } - current_range = None; + current_file = Some(line.to_string()); context_is_before = true; prev_line_empty = false; @@ -121,8 +117,7 @@ if line == context_separator { let current = current_file.as_ref().unwrap(); - if let Some(range) = current_range { + if let Some(range) = current_range.take() { file_ranges.add(current, range); } - current_range = None; context_is_before = true; prev_line_empty = false; @@ -178,5 +173,5 @@ ); } - if let Some(range) = current_range { + if let Some(range) = current_range.take() { file_ranges.add(current, range); } @@ -349,6 +344,7 @@ num_str .parse::() - .map(|n| n * multiplier) - .map_err(|_| anyhow::anyhow!("Invalid size limit format: {}", size_str)) + .map_err(|_| anyhow::anyhow!("Invalid size limit format: {}", size_str))? + .checked_mul(multiplier) + .ok_or_else(|| anyhow::anyhow!("Size limit too large: {}", size_str)) } @@ -368,7 +364,15 @@ let mut stderr = child.stderr.take().unwrap(); + // Drain stderr in a separate thread to avoid a deadlock if rg + // fills the stderr pipe buffer before closing stdout + let stderr_thread = std::thread::spawn(move || { + let mut stderr_bytes = Vec::new(); + let _ = std::io::Read::read_to_end(&mut stderr, &mut stderr_bytes); + stderr_bytes + }); + let mut buf = [0u8; 65536]; let mut total_bytes = 0u64; - let mut rg_output = String::new(); + let mut rg_output_bytes: Vec = Vec::new(); loop { @@ -388,5 +392,5 @@ } } - rg_output.push_str(&String::from_utf8_lossy(&buf[..n])); + rg_output_bytes.extend_from_slice(&buf[..n]); } Err(e) => { @@ -394,4 +398,6 @@ continue; } + let _ = child.kill(); + let _ = child.wait(); anyhow::bail!("Error reading rg output: {}", e); } @@ -400,12 +406,15 @@ let status = child.wait()?; + let stderr_bytes = stderr_thread.join().unwrap_or_default(); if !status.success() && status.code() != Some(1) { - let mut stderr_bytes = Vec::new(); - let _ = std::io::Read::read_to_end(&mut stderr, &mut stderr_bytes); let stderr_str = String::from_utf8_lossy(&stderr_bytes); - assert!(status.code().unwrap() == 2); - anyhow::bail!("rg exited with {}: {}", status, stderr_str); + match status.code() { + Some(_) => anyhow::bail!("rg exited with {}: {}", status, stderr_str), + None => anyhow::bail!("rg was terminated by a signal ({}): {}", status, stderr_str), + } } + let rg_output = String::from_utf8_lossy(&rg_output_bytes).into_owned(); + Ok(rg_output) } @@ -441,4 +450,5 @@ .arg(format!("--field-match-separator={}", RG_MATCH_SEPARATOR)) .arg(format!("--context-separator={context_separator}")) + .arg("--encoding=none") .arg("-e") .arg(&args.regexp); @@ -555,5 +565,7 @@ editor_cmd .arg("-c") - .arg(format!("{} {}", args.editor, temp_path.display())); + .arg(format!("{} \"$@\"", args.editor)) + .arg("sh") // $0 + .arg(&temp_path); // $1, passed verbatim let editor_status = editor_cmd.status()?; @@ -593,4 +605,12 @@ } +// Strip the trailing "\n" or "\r\n" line terminator, matching the +// behavior of str::lines() used to parse the ripgrep output +#[inline] +fn strip_line_terminator(line: &str) -> &str { + let line = line.strip_suffix('\n').unwrap_or(line); + line.strip_suffix('\r').unwrap_or(line) +} + fn apply_changes_to_file_ranges( changes: &FileChanges, @@ -620,5 +640,9 @@ } - let mut changed_files = false; + // Compute and verify all modifications first, then write them + // back only if every file still matches the original ripgrep + // output, so a concurrent modification never results in a + // partial or misplaced write + let mut pending_writes: Vec<(&String, String)> = Vec::new(); for (file_path, file_changes) in changes { @@ -626,5 +650,34 @@ // Read the original file let content = std::fs::read_to_string(file_path)?; - let mut lines: Vec = content.lines().map(|s| s.to_string()).collect(); + // Split keeping the line terminators so that + // lines.concat() reconstructs the original content + // exactly, including any '\r' before the '\n' and a + // missing trailing newline + let mut lines: Vec = content + .split_inclusive('\n') + .map(|s| s.to_string()) + .collect(); + + // Verify the file was not modified while the editor was open + for range in ranges { + let start = range.start.checked_sub(1).ok_or_else(|| { + anyhow::anyhow!("Invalid range start 0 in file: {}", file_path) + })?; + let end = range.end; + if start >= end + || end > lines.len() + || !lines[start..end] + .iter() + .zip(range.lines.iter()) + .all(|(line, orig)| strip_line_terminator(line) == orig) + { + return Err(anyhow::anyhow!( + "File {} was modified while editing: lines {}-{} no longer match the original content", + file_path, + range.start, + range.end + )); + } + } // Check if any changes are actually different from original @@ -637,23 +690,28 @@ // Replace the lines in the file - if let Some(snippet) = file_changes.get(i) { - let original_snippet: Vec = lines[start..end].to_vec(); - if *snippet != original_snippet { - // Replace the range with new content - lines.splice(start..end, snippet.clone()); - has_changes = true; - changed_files = true; - } + if let Some(snippet) = file_changes.get(i) + && *snippet != range.lines + { + // Always terminate the modified lines with a newline + let new_lines: Vec = + snippet.iter().map(|line| format!("{}\n", line)).collect(); + // Replace the range with new content + lines.splice(start..end, new_lines); + has_changes = true; } } if has_changes { - // Write back the modified content - std::fs::write(file_path, lines.join("\n") + "\n")?; + pending_writes.push((file_path, lines.concat())); } } } - if !changed_files { + for (file_path, content) in &pending_writes { + // Write back the modified content + std::fs::write(file_path, content)?; + } + + if pending_writes.is_empty() { eprintln!("No changes detected in any file."); } @@ -676,5 +734,5 @@ let mut prev_line_empty = false; let mut pprev_line_separator = false; - let mut first = true; + let mut header = true; for line in reader.lines() { @@ -682,6 +740,5 @@ processed_lines.push(line.clone()); - if first && line == "```" { - first = false; + if header && line == "```" { continue; } @@ -711,4 +768,5 @@ && file_ranges.contains_key(&normalized_line) { + header = false; if !current_file.is_empty() { if !pprev_line_separator { @@ -764,5 +822,10 @@ } else { assert!(!prev_line_empty); - assert!(pprev_line_separator); + if !pprev_line_separator { + return Err(anyhow::anyhow!( + "Missing separator at the end of file: {}", + current_file + )); + } } if changes