From 222e60366a8074cbbf2283830e6ff570937c14a0 Mon Sep 17 00:00:00 2001 From: pickx Date: Thu, 19 Mar 2026 14:59:57 +0200 Subject: [PATCH 1/8] DRY --- src/core_editor/line_buffer.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 416e13fe..350f5f8f 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -83,7 +83,7 @@ impl LineBuffer { self.insertion_point = self.lines.len(); } - /// Calculates the current the user is on + /// Calculates the current line the user is on /// /// Zero-based index pub fn line(&self) -> usize { @@ -105,20 +105,21 @@ impl LineBuffer { self.insertion_point = 0; } - /// Move the cursor before the first character of the line - pub fn move_to_line_start(&mut self) { - self.insertion_point = self.lines[..self.insertion_point] + fn line_start(&self) -> usize { + self.lines[..self.insertion_point] .rfind('\n') - .map_or(0, |offset| offset + 1); + .map_or(0, |offset| offset + 1) // str is guaranteed to be utf8, thus \n is safe to assume 1 byte long } + /// Move the cursor before the first character of the line + pub fn move_to_line_start(&mut self) { + self.insertion_point = self.line_start(); + } + /// Move the cursor before the first non whitespace character of the line pub fn move_to_line_non_blank_start(&mut self) { - let line_start = self.lines[..self.insertion_point] - .rfind('\n') - .map_or(0, |offset| offset + 1); - // str is guaranteed to be utf8, thus \n is safe to assume 1 byte long + let line_start = self.line_start(); self.insertion_point = self.lines[line_start..] .find(|c: char| !c.is_whitespace() || c == '\n') @@ -535,9 +536,7 @@ impl LineBuffer { /// extending beyond the potential carriage return and line feed characters /// terminating the line pub fn current_line_range(&self) -> Range { - let left_index = self.lines[..self.insertion_point] - .rfind('\n') - .map_or(0, |offset| offset + 1); + let left_index = self.line_start(); let right_index = self.lines[self.insertion_point..] .find('\n') .map_or_else(|| self.lines.len(), |i| i + self.insertion_point + 1); From fd70e937a2954c5c8a4e17be11793176eceb1f18 Mon Sep 17 00:00:00 2001 From: pickx Date: Thu, 19 Mar 2026 00:08:55 +0200 Subject: [PATCH 2/8] take template as command, fetch pos --- src/core_editor/line_buffer.rs | 9 ++++ src/engine.rs | 98 ++++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 350f5f8f..dbaa19d8 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -90,6 +90,15 @@ impl LineBuffer { self.lines[..self.insertion_point].matches('\n').count() } + /// Calculates the character index in the line the user is on + /// + /// Zero-based index + pub fn col(&self) -> usize { + self.lines[self.line_start()..self.insertion_point] + .chars() + .count() + } + /// Counts the number of lines in the buffer pub fn num_lines(&self) -> usize { self.lines.split('\n').count() diff --git a/src/engine.rs b/src/engine.rs index 0364acf4..8191ca59 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; - use itertools::Itertools; use nu_ansi_term::{Color, Style}; +use std::ffi::OsStr; +use std::path::PathBuf; use crate::{enums::ReedlineRawEvent, CursorConfig}; #[cfg(feature = "bashisms")] @@ -47,6 +47,7 @@ use { terminal, QueueableCommand, }, std::{ + ffi::OsString, fs::File, io, io::Result, @@ -559,10 +560,6 @@ impl Reedline { /// ``` #[must_use] pub fn with_buffer_editor(mut self, editor: Command, temp_file: PathBuf) -> Self { - let mut editor = editor; - if !editor.get_args().contains(&temp_file.as_os_str()) { - editor.arg(&temp_file); - } self.buffer_editor = Some(BufferEditor { command: editor, temp_file, @@ -1359,7 +1356,15 @@ impl Reedline { } Ok(EventStatus::Handled) } - ReedlineEvent::OpenEditor => self.open_editor().map(|_| EventStatus::Handled), + ReedlineEvent::OpenEditor => { + if let Some(buffer_editor) = &self.buffer_editor { + let new_buffer = self.open_editor(buffer_editor)?; + self.editor + .set_buffer(new_buffer, UndoBehavior::CreateUndoPoint); + } + + Ok(EventStatus::Handled) + } ReedlineEvent::Resize(width, height) => { self.last_render_snapshot = None; self.painter.handle_resize(width, height); @@ -1872,30 +1877,69 @@ impl Reedline { } } - fn open_editor(&mut self) -> Result<()> { - match &mut self.buffer_editor { - Some(BufferEditor { - ref mut command, - ref temp_file, - }) => { - { - let mut file = File::create(temp_file)?; - write!(file, "{}", self.editor.get_buffer())?; - } - { - let mut child = command.spawn()?; - child.wait()?; - } + /// opens the current buffer in the editor described in [`buffer_editor`] + /// returns the new buffer, after processing the changes via the editor + fn open_editor(&self, buffer_editor: &BufferEditor) -> Result { + let mut command = Self::render_editor_command(buffer_editor, self.editor.line_buffer()); + + // flush buffer to temp file, so it can be read by the editor + { + let mut file = File::create(&buffer_editor.temp_file)?; + write!(file, "{}", self.editor.get_buffer())?; + } - let res = std::fs::read_to_string(temp_file)?; - let res = res.trim_end().to_string(); + command.spawn()?.wait()?; - self.editor.set_buffer(res, UndoBehavior::CreateUndoPoint); + // fetch contents of buffer after editor is done + let mut buffer = std::fs::read_to_string(&buffer_editor.temp_file)?; + let content_len = buffer.trim_end().len(); + buffer.truncate(content_len); - Ok(()) - } - _ => Ok(()), + Ok(buffer) + } + + /// renders the template command described in [`buffer_editor`], + /// by substituting the placeholders in the pattern, if any + fn render_editor_command(buffer_editor: &BufferEditor, line_buffer: &LineBuffer) -> Command { + use std::ops::Add as _; + + let mut cmd = Command::new(buffer_editor.command.get_program()); + + const FILE: &str = "{file}"; + const LINE: &str = "{line}"; + const COL: &str = "{col}"; + + // kind of a wonky check, but it's enough to know + // that we have somewhere to stick that temp_file path in + let is_template = buffer_editor + .command + .get_args() + .map(OsStr::to_string_lossy) + .any(|arg| arg.contains(FILE)); + + if is_template { + // TODO: there are more efficient ways to do this. + // e.g. "format args"-style structs + + let file = buffer_editor.temp_file.to_string_lossy(); + let line = line_buffer.line().add(1).to_string(); + let col = line_buffer.col().add(1).to_string(); + + let actual_args = buffer_editor + .command + .get_args() + .map(OsStr::to_string_lossy) + .map(|arg| arg.replace(FILE, &file)) + .map(|arg| arg.replace(LINE, &line)) + .map(|arg| arg.replace(COL, &col)); + + cmd.args(actual_args); + } else { + cmd.args(buffer_editor.command.get_args()); + cmd.arg(&buffer_editor.temp_file); } + + cmd } /// Repaint logic for the history reverse search From 2d462007089bab336eddadd9b506929531b03161 Mon Sep 17 00:00:00 2001 From: pickx Date: Thu, 19 Mar 2026 15:30:40 +0200 Subject: [PATCH 3/8] optimize imports --- src/engine.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 8191ca59..ee87adad 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,7 +1,5 @@ use itertools::Itertools; use nu_ansi_term::{Color, Style}; -use std::ffi::OsStr; -use std::path::PathBuf; use crate::{enums::ReedlineRawEvent, CursorConfig}; #[cfg(feature = "bashisms")] @@ -47,11 +45,12 @@ use { terminal, QueueableCommand, }, std::{ - ffi::OsString, + ffi::OsStr, fs::File, io, io::Result, io::Write, + path::PathBuf, process::Command, sync::{atomic::AtomicBool, Arc}, time::Duration, From 12841281125f1c83e4d5ee1d1a42a307bb4a8cda Mon Sep 17 00:00:00 2001 From: pickx Date: Sun, 22 Mar 2026 16:41:19 +0200 Subject: [PATCH 4/8] clippy --- src/engine.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/engine.rs b/src/engine.rs index ee87adad..3b08b16b 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,4 +1,3 @@ -use itertools::Itertools; use nu_ansi_term::{Color, Style}; use crate::{enums::ReedlineRawEvent, CursorConfig}; From 044bb10fcbd32a33d0ce830452be0bac00db16da Mon Sep 17 00:00:00 2001 From: pickx Date: Sun, 22 Mar 2026 19:09:30 +0200 Subject: [PATCH 5/8] add test --- src/engine.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/engine.rs b/src/engine.rs index 3b08b16b..5586715b 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -2214,6 +2214,7 @@ mod tests { use super::*; use crate::terminal_extensions::semantic_prompt::PromptKind; use crate::DefaultPrompt; + use rstest::rstest; #[test] fn test_cursor_position_after_multiline_history_navigation() { @@ -2395,4 +2396,59 @@ mod tests { _ => panic!("Expected Signal::ExternalBreak"), } } + + fn command_from_strs(command: &[&str]) -> Command { + let (program, args) = command.split_first().unwrap(); + + let mut command = Command::new(program); + command.args(args); + command + } + + fn command_into_string(command: Command) -> String { + use itertools::Itertools; + use std::iter::once; + + once(command.get_program()) + .chain(command.get_args()) + .map(|os_str| os_str.to_str().unwrap()) + .join(" ") + } + + #[rstest] + #[case(&["nano"], "nano foo.rs")] + #[case(&["code", "--goto", "{file}:{line}:{col}"], "code --goto foo.rs:2:4")] + #[case(&["hx", "{file}:{line}:{col}"], "hx foo.rs:2:4")] + #[case(&["nvim", "{file}", "\"call cursor({line}, {col})\""], "nvim foo.rs \"call cursor(2, 4)\"")] + #[case(&["vim", "+{line}", "{file}"], "vim +2 foo.rs")] + #[case(&["emacs", "+{line}:{col}", "{file}"], "emacs +2:4 foo.rs")] + fn render_editor_command_with_pattern(#[case] command: &[&str], #[case] expected: &str) { + // we're not actually spawning anything, + // so no need to create an actual file + let temp_file = PathBuf::from("foo.rs"); + + let buffer_editor = BufferEditor { + command: command_from_strs(command), + temp_file, + }; + + let line_buffer = { + let mut line_buffer = LineBuffer::new(); + + line_buffer.insert_str("a mulatto\n"); + line_buffer.insert_str("an albino\n"); + line_buffer.insert_str("a mosquito\n"); + line_buffer.insert_str("my libido\n"); + line_buffer.move_line_up(); + line_buffer.move_line_up(); + line_buffer.move_word_left(); + + line_buffer + }; + + let actual = Reedline::render_editor_command(&buffer_editor, &line_buffer); + let actual = command_into_string(actual); + + assert_eq!(actual, expected); + } } From 6af0734064767ff9698656fc810c087744436613 Mon Sep 17 00:00:00 2001 From: pickx Date: Sun, 22 Mar 2026 19:44:11 +0200 Subject: [PATCH 6/8] do not require file placholder --- src/engine.rs | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 5586715b..84e56fe8 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1907,33 +1907,30 @@ impl Reedline { const LINE: &str = "{line}"; const COL: &str = "{col}"; - // kind of a wonky check, but it's enough to know - // that we have somewhere to stick that temp_file path in - let is_template = buffer_editor + let has_file_placholder = buffer_editor .command .get_args() .map(OsStr::to_string_lossy) .any(|arg| arg.contains(FILE)); - if is_template { - // TODO: there are more efficient ways to do this. - // e.g. "format args"-style structs + // there are more efficient ways to do this. + // e.g. "format args"-style structs - let file = buffer_editor.temp_file.to_string_lossy(); - let line = line_buffer.line().add(1).to_string(); - let col = line_buffer.col().add(1).to_string(); + let file = buffer_editor.temp_file.to_string_lossy(); + let line = line_buffer.line().add(1).to_string(); + let col = line_buffer.col().add(1).to_string(); - let actual_args = buffer_editor - .command - .get_args() - .map(OsStr::to_string_lossy) - .map(|arg| arg.replace(FILE, &file)) - .map(|arg| arg.replace(LINE, &line)) - .map(|arg| arg.replace(COL, &col)); + let args = buffer_editor + .command + .get_args() + .map(OsStr::to_string_lossy) + .map(|arg| arg.replace(FILE, &file)) + .map(|arg| arg.replace(LINE, &line)) + .map(|arg| arg.replace(COL, &col)); - cmd.args(actual_args); - } else { - cmd.args(buffer_editor.command.get_args()); + cmd.args(args); + + if !has_file_placholder { cmd.arg(&buffer_editor.temp_file); } @@ -2422,6 +2419,7 @@ mod tests { #[case(&["nvim", "{file}", "\"call cursor({line}, {col})\""], "nvim foo.rs \"call cursor(2, 4)\"")] #[case(&["vim", "+{line}", "{file}"], "vim +2 foo.rs")] #[case(&["emacs", "+{line}:{col}", "{file}"], "emacs +2:4 foo.rs")] + #[case(&["emacs", "+{line}:{col}"], "emacs +2:4 foo.rs")] fn render_editor_command_with_pattern(#[case] command: &[&str], #[case] expected: &str) { // we're not actually spawning anything, // so no need to create an actual file From a8d04e69ea895d6f8da6d26bd9e2cef478a52362 Mon Sep 17 00:00:00 2001 From: pickx Date: Sun, 22 Mar 2026 20:05:21 +0200 Subject: [PATCH 7/8] update example --- src/engine.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/engine.rs b/src/engine.rs index 84e56fe8..b8d32c4a 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -542,19 +542,29 @@ impl Reedline { /// /// # Example /// ```rust,no_run - /// // Create a reedline object with vim as editor - /// /// use reedline::Reedline; /// use std::env::temp_dir; /// use std::process::Command; /// - /// let temp_file = std::env::temp_dir().join("my-random-unique.file"); + /// let temp = temp_dir().join("my-random-unique.file"); + /// /// let mut command = Command::new("vim"); /// // you can provide additional flags: /// command.arg("-p"); // open in a vim tab (just for demonstration) - /// // you don't have to pass the filename to the command - /// let mut line_editor = - /// Reedline::create().with_buffer_editor(command, temp_file); + /// // ...and the filename will be appended at the end of the command + /// let mut line_editor = Reedline::create().with_buffer_editor(command, temp.clone()); + /// + /// // optionally, {file}, {line}, and {col} placeholders can used. + /// // they will be replaced with the corresponding filename and current cursor position + /// let mut command = Command::new("hx"); + /// command.args(["+{line}:{col}", "{file}"]); + /// let mut line_editor = Reedline::create().with_buffer_editor(command, temp.clone()); + /// + /// // if {file} is omitted, the filename is still appended at the end, + /// // as in the above example + /// let mut command = Command::new("emacs"); + /// command.arg("+{line}:{col}"); + /// let mut line_editor = Reedline::create().with_buffer_editor(command, temp); /// ``` #[must_use] pub fn with_buffer_editor(mut self, editor: Command, temp_file: PathBuf) -> Self { From 64b6bcdff5598ad1560220f37d7d4d63abdae705 Mon Sep 17 00:00:00 2001 From: pickx Date: Mon, 23 Mar 2026 16:53:29 +0200 Subject: [PATCH 8/8] use graphemes --- src/core_editor/line_buffer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index dbaa19d8..eea506eb 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -95,7 +95,7 @@ impl LineBuffer { /// Zero-based index pub fn col(&self) -> usize { self.lines[self.line_start()..self.insertion_point] - .chars() + .grapheme_indices(true) .count() }