Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/core_editor/line_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ impl LineBuffer {
self.lines[..self.cursor.head()].matches('\n').count()
}

/// Calculates the grapheme index in the line the cursor head is on.
///
/// Zero-based index
pub fn col(&self) -> usize {
self.lines[self.line_start_index()..self.cursor.head()]
.grapheme_indices(true)
.count()
}

/// Counts the number of lines in the buffer
pub fn num_lines(&self) -> usize {
self.lines.split('\n').count()
Expand Down
204 changes: 153 additions & 51 deletions src/engine.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::{collections::HashMap, ops::ControlFlow, path::PathBuf};
use std::{collections::HashMap, ffi::OsStr, ops::ControlFlow, path::PathBuf};

use itertools::Itertools;
use nu_ansi_term::{Color, Style};

use crate::{enums::ReedlineRawEvent, CursorConfig};
Expand Down Expand Up @@ -241,6 +240,60 @@ struct BufferEditor {
temp_file: PathBuf,
}

impl BufferEditor {
/// renders the editor command template,
/// substituting `{file}`, `{line}`, and `{col}` where present.
pub(crate) fn render_command(&self, line_buffer: &LineBuffer) -> Command {

@pickx pickx Aug 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right now we do this even if the command is not templated.
is this is considered wasteful, we can avoid it by determining ahead of time if it's templated or not
please tell me if you think it matters.

let mut rendered = Command::new(self.command.get_program());

for (key, value) in self.command.get_envs() {
match value {
Some(value) => rendered.env(key, value),
None => rendered.env_remove(key),
};
}

let file = self.temp_file.to_string_lossy();
let line = (line_buffer.line() + 1).to_string();
let col = (line_buffer.col() + 1).to_string();

let mut has_file_placeholder = false;

for arg in self.command.get_args().map(OsStr::to_string_lossy) {
has_file_placeholder |= arg.contains("{file}");

let arg = arg
.replace("{file}", &file)
.replace("{line}", &line)
.replace("{col}", &col);

rendered.arg(arg);
}

if !has_file_placeholder {
rendered.arg(&self.temp_file);
}

rendered
}

/// writes the buffer to the temp file,
/// in preparation for spawning the buffer editor
pub(crate) fn write_current_buffer(&self, buffer_contents: &str) -> Result<()> {
let mut file = File::create(&self.temp_file)?;
write!(file, "{buffer_contents}")
}

/// reads the buffer from the temp file,
/// expected to be called after the buffer editor exits
pub(crate) fn get_edited_buffer(&mut self) -> Result<String> {
let mut res = std::fs::read_to_string(&self.temp_file)?;
let content_len = res.trim_end().len();
res.truncate(content_len);
Ok(res)
}
}

/// The completions the [`Menu`](ReedlineEvent::Menu) event could not decide, because the
/// completer had not answered yet.
///
Expand Down Expand Up @@ -685,20 +738,27 @@ impl Reedline {
/// use std::env::temp_dir;
/// use std::process::Command;
///
/// let temp_file = std::env::temp_dir().join("my-random-unique.file");
/// let temp = std::env::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);
/// command.arg("-p"); // open in a new vim tab
/// // ...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 be 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 {
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,
Expand Down Expand Up @@ -2348,50 +2408,45 @@ 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())?;
}
// Capture the prompt's screen range so that an editor
// that leaves the cursor untouched (e.g. an editor that
// uses the alternate screen only) re-uses the existing
// prompt rows instead of starting a new prompt a row
// below the old one.
let suspended_state = self.painter.state_before_suspension();
{
let mut child = command.spawn()?;
// The child owns the tty now; invalidate eagerly so
// any `?` early-return below still leaves the
// painter in a safe state.
self.painter.invalidate_prompt_start_row();
child.wait()?;
}
let Some(buffer_editor) = &mut self.buffer_editor else {
return Ok(());
};

// On the success path, re-initialize position and size
// (covers a resize-during-editor with no SIGWINCH). If
// the editor moved the cursor out of the prompt's rows
// (it printed output), a fresh prompt starts below that
// output. On query failure, the eager invalidate above
// is our floor — losing the size refresh is acceptable;
// losing the user's edited buffer below is not.
let _ = self
.painter
.initialize_prompt_position(Some(&suspended_state));
buffer_editor.write_current_buffer(self.editor.get_buffer())?;

let res = std::fs::read_to_string(temp_file)?;
let res = res.trim_end().to_string();
// Capture the prompt's screen range so that an editor
// that leaves the cursor untouched (e.g. an editor that
// uses the alternate screen only) re-uses the existing
// prompt rows instead of starting a new prompt a row
// below the old one.
let suspended_state = self.painter.state_before_suspension();
{
let mut child = buffer_editor
.render_command(self.editor.line_buffer())
.spawn()?;
// The child owns the tty now; invalidate eagerly so
// any `?` early-return below still leaves the
// painter in a safe state.
self.painter.invalidate_prompt_start_row();
child.wait()?;
}

self.editor.set_buffer(res, UndoBehavior::CreateUndoPoint);
// On the success path, re-initialize position and size
// (covers a resize-during-editor with no SIGWINCH). If
// the editor moved the cursor out of the prompt's rows
// (it printed output), a fresh prompt starts below that
// output. On query failure, the eager invalidate above
// is our floor — losing the size refresh is acceptable;
// losing the user's edited buffer below is not.
let _ = self
.painter
.initialize_prompt_position(Some(&suspended_state));

Ok(())
}
_ => Ok(()),
}
let res = buffer_editor.get_edited_buffer()?;

self.editor.set_buffer(res, UndoBehavior::CreateUndoPoint);

Ok(())
}

/// Repaint logic for the history reverse search
Expand Down Expand Up @@ -2713,6 +2768,7 @@ mod tests {
ColumnarMenu, CompletionOrigin, CompletionResult, DefaultPrompt, MenuBuilder, PromptViMode,
Span, Suggestion,
};
use itertools::Itertools;
use rstest::rstest;

fn seam_engine(edit_mode: Box<dyn EditMode>) -> Reedline {
Expand Down Expand Up @@ -4786,4 +4842,50 @@ mod tests {
drive(&mut rl, &[ch('l')]); // crosses down to 'c' (start of line 2)
assert_eq!(rl.editor.insertion_point(), 3);
}

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 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")]
#[case(&["emacs", "+{line}:{col}"], "emacs +2:4 foo.rs")]
fn render_editor_command_with_pattern(#[case] command: &[&str], #[case] expected: &str) {
let buffer_editor = BufferEditor {
command: command_from_strs(command),
temp_file: PathBuf::from("foo.rs"),
};

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_left_before(' ', false);
line_buffer
};

let actual = buffer_editor.render_command(&line_buffer);
assert_eq!(command_into_string(actual), expected);
}
}
Loading