diff --git a/src/core_editor/line_buffer.rs b/src/core_editor/line_buffer.rs index 2b229148..ab96a571 100644 --- a/src/core_editor/line_buffer.rs +++ b/src/core_editor/line_buffer.rs @@ -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() diff --git a/src/engine.rs b/src/engine.rs index 0cce25d3..999c1d4e 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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}; @@ -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 { + 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 { + 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. /// @@ -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, @@ -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 @@ -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) -> Reedline { @@ -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); + } }