feat: Use reedline for basic input implementation

This commit is contained in:
Florent Vilmart 2025-03-20 16:47:51 -04:00
parent f33a26123c
commit f0d3489b79
6 changed files with 234 additions and 78 deletions

1
Cargo.lock generated
View File

@ -3704,6 +3704,7 @@ dependencies = [
"rand 0.8.5",
"rand_chacha 0.3.1",
"rayon",
"reedline",
"rmp",
"roxmltree",
"rstest",

View File

@ -85,6 +85,7 @@ quick-xml = { workspace = true }
rand = { workspace = true, optional = true }
getrandom = { workspace = true, optional = true }
rayon = { workspace = true }
reedline = { workspace = true }
roxmltree = { workspace = true }
rusqlite = { workspace = true, features = [
"bundled",

View File

@ -1,19 +1,16 @@
use crossterm::{
cursor,
event::{Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
style::Print,
terminal::{self, ClearType},
};
use itertools::Itertools;
use crate::platform::input::legacy_input::LegacyInput;
use crate::platform::input::reedline_prompt::ReedlinePrompt;
use nu_engine::command_prelude::*;
use nu_protocol::shell_error::io::IoError;
use reedline::{Reedline, Signal};
use std::{io::Write, time::Duration};
use std::io::Write;
#[derive(Clone)]
pub struct Input;
impl LegacyInput for Input {}
impl Command for Input {
fn name(&self) -> &str {
"input"
@ -61,11 +58,11 @@ impl Command for Input {
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
let prompt: Option<String> = call.opt(engine_state, stack, 0)?;
let prompt_str: Option<String> = call.opt(engine_state, stack, 0)?;
let bytes_until: Option<String> = call.get_flag(engine_state, stack, "bytes-until-any")?;
let suppress_output = call.has_flag(engine_state, stack, "suppress-output")?;
let numchar: Option<Spanned<i64>> = call.get_flag(engine_state, stack, "numchar")?;
let numchar: Spanned<i64> = numchar.unwrap_or(Spanned {
let numchar_flag: Option<Spanned<i64>> = call.get_flag(engine_state, stack, "numchar")?;
let numchar: Spanned<i64> = numchar_flag.unwrap_or(Spanned {
item: i64::MAX,
span: call.head,
});
@ -81,8 +78,15 @@ impl Command for Input {
});
}
// Those 2 options are not supported by reedline, default to the legacy
// implementation
if suppress_output || bytes_until.is_some() || numchar_flag.is_some() {
return self.legacy_input(engine_state, stack, call, _input);
}
let default_val: Option<String> = call.get_flag(engine_state, stack, "default")?;
if let Some(prompt) = &prompt {
if let Some(prompt) = &prompt_str {
match &default_val {
None => print!("{prompt}"),
Some(val) => print!("{prompt} (default: {val})"),
@ -91,82 +95,35 @@ impl Command for Input {
}
let mut buf = String::new();
crossterm::terminal::enable_raw_mode().map_err(&from_io_error)?;
// clear terminal events
while crossterm::event::poll(Duration::from_secs(0)).map_err(&from_io_error)? {
// If there's an event, read it to remove it from the queue
let _ = crossterm::event::read().map_err(&from_io_error)?;
}
let prompt = ReedlinePrompt {
left_prompt: prompt_str.unwrap_or("".to_string()),
indicator: "".to_string(), // TODO: Add support for custom prompt indicators
// for now, and backwards compat, we just use the empty
// string
};
let mut line_editor = Reedline::create();
// TODO handle options
loop {
if i64::try_from(buf.len()).unwrap_or(0) >= numchar.item {
break;
}
match crossterm::event::read() {
Ok(Event::Key(k)) => match k.kind {
KeyEventKind::Press | KeyEventKind::Repeat => {
match k.code {
// TODO: maintain keycode parity with existing command
KeyCode::Char(c) => {
if k.modifiers == KeyModifiers::ALT
|| k.modifiers == KeyModifiers::CONTROL
{
if k.modifiers == KeyModifiers::CONTROL && c == 'c' {
crossterm::terminal::disable_raw_mode()
.map_err(&from_io_error)?;
return Err(IoError::new(
std::io::ErrorKind::Interrupted,
call.head,
None,
)
.into());
}
continue;
}
if let Some(bytes_until) = bytes_until.as_ref() {
if bytes_until.bytes().contains(&(c as u8)) {
break;
}
}
buf.push(c);
}
KeyCode::Backspace => {
let _ = buf.pop();
}
KeyCode::Enter => {
break;
}
_ => continue,
}
}
_ => continue,
},
match line_editor.read_line(&prompt) {
Ok(Signal::Success(buffer)) => {
buf.push_str(&buffer);
break;
}
Ok(Signal::CtrlC) => {
return Err(
IoError::new(std::io::ErrorKind::Interrupted, call.head, None).into(),
);
}
Ok(_) => continue,
Err(event_error) => {
crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?;
return Err(from_io_error(event_error).into());
}
}
if !suppress_output {
// clear the current line and print the current buffer
execute!(
std::io::stdout(),
terminal::Clear(ClearType::CurrentLine),
cursor::MoveToColumn(0),
)
.map_err(|err| IoError::new(err.kind(), call.head, None))?;
if let Some(prompt) = &prompt {
execute!(std::io::stdout(), Print(prompt.to_string()))
.map_err(&from_io_error)?;
}
execute!(std::io::stdout(), Print(buf.to_string())).map_err(&from_io_error)?;
}
}
crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?;
if !suppress_output {
std::io::stdout().write_all(b"\n").map_err(&from_io_error)?;
}
match default_val {
Some(val) if buf.is_empty() => Ok(Value::string(val, call.head).into_pipeline_data()),

View File

@ -0,0 +1,134 @@
use crossterm::{
cursor,
event::{Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
style::Print,
terminal::{self, ClearType},
};
use itertools::Itertools;
use nu_engine::command_prelude::*;
use nu_protocol::shell_error::io::IoError;
use std::{io::Write, time::Duration};
pub trait LegacyInput {
fn legacy_input(
&self,
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
_input: PipelineData,
) -> Result<PipelineData, ShellError> {
let prompt: Option<String> = call.opt(engine_state, stack, 0)?;
let bytes_until: Option<String> = call.get_flag(engine_state, stack, "bytes-until-any")?;
let suppress_output = call.has_flag(engine_state, stack, "suppress-output")?;
let numchar: Option<Spanned<i64>> = call.get_flag(engine_state, stack, "numchar")?;
let numchar: Spanned<i64> = numchar.unwrap_or(Spanned {
item: i64::MAX,
span: call.head,
});
let from_io_error = IoError::factory(call.head, None);
if numchar.item < 1 {
return Err(ShellError::UnsupportedInput {
msg: "Number of characters to read has to be positive".to_string(),
input: "value originated from here".to_string(),
msg_span: call.head,
input_span: numchar.span,
});
}
let default_val: Option<String> = call.get_flag(engine_state, stack, "default")?;
if let Some(prompt) = &prompt {
match &default_val {
None => print!("{prompt}"),
Some(val) => print!("{prompt} (default: {val})"),
}
let _ = std::io::stdout().flush();
}
let mut buf = String::new();
crossterm::terminal::enable_raw_mode().map_err(&from_io_error)?;
// clear terminal events
while crossterm::event::poll(Duration::from_secs(0)).map_err(&from_io_error)? {
// If there's an event, read it to remove it from the queue
let _ = crossterm::event::read().map_err(&from_io_error)?;
}
loop {
if i64::try_from(buf.len()).unwrap_or(0) >= numchar.item {
break;
}
match crossterm::event::read() {
Ok(Event::Key(k)) => match k.kind {
KeyEventKind::Press | KeyEventKind::Repeat => {
match k.code {
// TODO: maintain keycode parity with existing command
KeyCode::Char(c) => {
if k.modifiers == KeyModifiers::ALT
|| k.modifiers == KeyModifiers::CONTROL
{
if k.modifiers == KeyModifiers::CONTROL && c == 'c' {
crossterm::terminal::disable_raw_mode()
.map_err(&from_io_error)?;
return Err(IoError::new(
std::io::ErrorKind::Interrupted,
call.head,
None,
)
.into());
}
continue;
}
if let Some(bytes_until) = bytes_until.as_ref() {
if bytes_until.bytes().contains(&(c as u8)) {
break;
}
}
buf.push(c);
}
KeyCode::Backspace => {
let _ = buf.pop();
}
KeyCode::Enter => {
break;
}
_ => continue,
}
}
_ => continue,
},
Ok(_) => continue,
Err(event_error) => {
crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?;
return Err(from_io_error(event_error).into());
}
}
if !suppress_output {
// clear the current line and print the current buffer
execute!(
std::io::stdout(),
terminal::Clear(ClearType::CurrentLine),
cursor::MoveToColumn(0),
)
.map_err(|err| IoError::new(err.kind(), call.head, None))?;
if let Some(prompt) = &prompt {
execute!(std::io::stdout(), Print(prompt.to_string()))
.map_err(&from_io_error)?;
}
execute!(std::io::stdout(), Print(buf.to_string())).map_err(&from_io_error)?;
}
}
crossterm::terminal::disable_raw_mode().map_err(&from_io_error)?;
if !suppress_output {
std::io::stdout().write_all(b"\n").map_err(&from_io_error)?;
}
match default_val {
Some(val) if buf.is_empty() => Ok(Value::string(val, call.head).into_pipeline_data()),
_ => Ok(Value::string(buf, call.head).into_pipeline_data()),
}
}
}

View File

@ -1,6 +1,8 @@
mod input_;
mod input_listen;
mod legacy_input;
mod list;
mod reedline_prompt;
pub use input_::Input;
pub use input_listen::InputListen;

View File

@ -0,0 +1,61 @@
use reedline::{
Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode,
};
use std::borrow::Cow;
/// The default prompt indicator
pub static DEFAULT_VI_INSERT_PROMPT_INDICATOR: &str = ": ";
pub static DEFAULT_VI_NORMAL_PROMPT_INDICATOR: &str = "";
pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: ";
/// Simple [`Prompt`] displaying a configurable left and a right prompt.
/// For more fine-tuned configuration, implement the [`Prompt`] trait.
/// For the default configuration, use [`DefaultPrompt::default()`]
#[derive(Clone)]
pub struct ReedlinePrompt {
/// What segment should be rendered in the left (main) prompt
pub left_prompt: String,
pub indicator: String,
}
impl Prompt for ReedlinePrompt {
fn render_prompt_left(&self) -> Cow<str> {
Cow::Borrowed(&self.left_prompt)
}
fn render_prompt_right(&self) -> Cow<str> {
Cow::Borrowed("")
}
fn render_prompt_indicator(&self, edit_mode: PromptEditMode) -> Cow<str> {
match edit_mode {
PromptEditMode::Default | PromptEditMode::Emacs => self.indicator.as_str().into(),
PromptEditMode::Vi(vi_mode) => match vi_mode {
PromptViMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(),
PromptViMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(),
},
PromptEditMode::Custom(str) => format!("({str})").into(),
}
}
fn render_prompt_multiline_indicator(&self) -> Cow<str> {
Cow::Borrowed(DEFAULT_MULTILINE_INDICATOR)
}
fn render_prompt_history_search_indicator(
&self,
history_search: PromptHistorySearch,
) -> Cow<str> {
let prefix = match history_search.status {
PromptHistorySearchStatus::Passing => "",
PromptHistorySearchStatus::Failing => "failing ",
};
// NOTE: magic strings, given there is logic on how these compose I am not sure if it
// is worth extracting in to static constant
Cow::Owned(format!(
"({}reverse-search: {}) ",
prefix, history_search.term
))
}
}