feat: Use reedline for input implementation (#15369)

<!--
if this PR closes one or more issues, you can automatically link the PR
with
them by using one of the [*linking
keywords*](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword),
e.g.
- this PR should close #xxxx
- fixes #xxxx

you can also mention related issues, PRs or discussions!
-->

# Description
<!--
Thank you for improving Nushell. Please, check our [contributing
guide](../CONTRIBUTING.md) and talk to the core team before making major
changes.

Description of your pull request goes here. **Provide examples and/or
screenshots** if your changes affect the user experience.
-->

This PR replaces the default `input` implementation with `reedline`. It
provides a fully backwards compatible implementation, by leveraging left
prompt provided through `input "my-prompt> "` provided by reedline.

The default indicator is hidden to be fully backwards compatible, the
multiline indicator is kept.

The legacy implementation will be used when the user passes options
truncating input such as `--bytes-until` or `--numchar` or
`--suppress-output` which I didn't find a straightforward implementation
through reedline.

# User-Facing Changes
No breaking change. 

- Adds ability to enter multi-line input with reedline.
- Adds ability to pass a command history through the pipe `["command",
"history"] | input`- Adds ability to pass a history file through the
params `input --history-file path/to/history`


# Tests + Formatting
<!--
Don't forget to add tests that cover your changes.

Make sure you've run and fixed any issues with these commands:

- `cargo fmt --all -- --check` to check standard code formatting (`cargo
fmt --all` applies these changes)
- `cargo clippy --workspace -- -D warnings -D clippy::unwrap_used` to
check that you're using the standard code style
- `cargo test --workspace` to check that all tests pass (on Windows make
sure to [enable developer
mode](https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging))
- `cargo run -- -c "use toolkit.nu; toolkit test stdlib"` to run the
tests for the standard library

> **Note**
> from `nushell` you can also use the `toolkit` as follows
> ```bash
> use toolkit.nu # or use an `env_change` hook to activate it
automatically
> toolkit check pr
> ```
-->

# After Submitting
<!-- If your PR had any user-facing changes, update [the
documentation](https://github.com/nushell/nushell.github.io) after the
PR is merged, if necessary. This will help us keep the docs up to date.
-->

---------

Co-authored-by: Darren Schroeder <343840+fdncred@users.noreply.github.com>
This commit is contained in:
Florent Vilmart 2025-05-28 17:31:49 -04:00 committed by GitHub
parent 90afb65329
commit a8c49857d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 347 additions and 109 deletions

1
Cargo.lock generated
View File

@ -3715,6 +3715,7 @@ dependencies = [
"rand 0.9.0",
"rand_chacha 0.9.0",
"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, optional = true }
roxmltree = { workspace = true }
rusqlite = { workspace = true, features = [
"bundled",
@ -180,6 +181,7 @@ os = [
"notify-debouncer-full",
"open",
"os_pipe",
"reedline",
"uu_cp",
"uu_mkdir",
"uu_mktemp",

View File

@ -1,19 +1,14 @@
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::{self, io::IoError};
use std::{io::Write, time::Duration};
use reedline::{FileBackedHistory, HISTORY_SIZE, History, HistoryItem, Reedline, Signal};
#[derive(Clone)]
pub struct Input;
impl LegacyInput for Input {}
impl Command for Input {
fn name(&self) -> &str {
"input"
@ -29,7 +24,9 @@ impl Command for Input {
fn signature(&self) -> Signature {
Signature::build("input")
.input_output_types(vec![(Type::Nothing, Type::Any)])
.input_output_types(vec![
(Type::Nothing, Type::Any),
(Type::List(Box::new(Type::String)), Type::Any)])
.allow_variants_without_examples(true)
.optional("prompt", SyntaxShape::String, "Prompt to show the user.")
.named(
@ -50,6 +47,23 @@ impl Command for Input {
"default value if no input is provided",
Some('d'),
)
.switch(
"reedline",
"use the reedline library, defaults to false",
None
)
.named(
"history-file",
SyntaxShape::Filepath,
"Path to a file to read and write command history. This is a text file and will be created if it doesn't exist. Will be used as the selection list.",
None,
)
.named(
"max-history",
SyntaxShape::Int,
"The maximum number of entries to keep in the history, defaults to $env.config.history.max_size.",
None,
)
.switch("suppress-output", "don't print keystroke values", Some('s'))
.category(Category::Platform)
}
@ -59,116 +73,127 @@ impl Command for Input {
engine_state: &EngineState,
stack: &mut Stack,
call: &Call,
_input: PipelineData,
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,
});
// Check if we should use the legacy implementation or the reedline implementation
let use_reedline = [
// reedline is not set - use legacy implementation
call.has_flag(engine_state, stack, "reedline")?,
// We have the history-file or max-history flags set to None
call.get_flag::<String>(engine_state, stack, "history-file")?
.is_some(),
call.get_flag::<i64>(engine_state, stack, "max-history")?
.is_some(),
]
.iter()
.any(|x| *x);
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,
});
if !use_reedline {
return self.legacy_input(engine_state, stack, call, input);
}
let prompt_str: Option<String> = call.opt(engine_state, stack, 0)?;
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 history_file_val: Option<String> =
call.get_flag(engine_state, stack, "history-file")?;
let max_history: usize = call
.get_flag::<i64>(engine_state, stack, "max-history")?
.map(|l| if l < 0 { 0 } else { l as usize })
.unwrap_or(HISTORY_SIZE);
let max_history_span = call.get_flag_span(stack, "max-history");
let history_file_span = call.get_flag_span(stack, "history-file");
let default_str = match (&prompt_str, &default_val) {
(Some(_prompt), Some(val)) => format!("(default: {val}) "),
_ => "".to_string(),
};
let history_entries = match input {
PipelineData::Value(Value::List { vals, .. }, ..) => Some(vals),
_ => None,
};
// If we either have history entries or history file, we create an history
let history = match (history_entries.is_some(), history_file_val.is_some()) {
(false, false) => None, // Neither are set, no need for history support
_ => {
let file_history = match history_file_val {
Some(file) => FileBackedHistory::with_file(max_history, file.into()),
None => FileBackedHistory::new(max_history),
};
let mut history = match file_history {
Ok(h) => h,
Err(e) => match e.0 {
reedline::ReedlineErrorVariants::IOError(err) => {
return Err(ShellError::IncorrectValue {
msg: err.to_string(),
val_span: history_file_span.expect("history-file should be set"),
call_span: call.head,
});
}
reedline::ReedlineErrorVariants::OtherHistoryError(msg) => {
return Err(ShellError::IncorrectValue {
msg: msg.to_string(),
val_span: max_history_span.expect("max-history should be set"),
call_span: call.head,
});
}
_ => {
return Err(ShellError::IncorrectValue {
msg: "unable to create history".to_string(),
val_span: call.head,
call_span: call.head,
});
}
},
};
if let Some(vals) = history_entries {
vals.iter().for_each(|val| {
if let Value::String { val, .. } = val {
let _ = history.save(HistoryItem::from_command_line(val.clone()));
}
});
}
Some(history)
}
let _ = std::io::stdout().flush();
}
};
let prompt = ReedlinePrompt {
indicator: default_str,
left_prompt: prompt_str.unwrap_or("".to_string()),
right_prompt: "".to_string(),
};
let mut line_editor = Reedline::create();
line_editor = line_editor.with_ansi_colors(false);
line_editor = match history {
Some(h) => line_editor.with_history(Box::new(h)),
None => line_editor,
};
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 line_editor.read_line(&prompt) {
Ok(Signal::Success(buffer)) => {
buf.push_str(&buffer);
}
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(
shell_error::io::ErrorKind::from_std(
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),
Ok(Signal::CtrlC) => {
return Err(IoError::new(
shell_error::io::ErrorKind::from_std(std::io::ErrorKind::Interrupted),
call.head,
None,
)
.map_err(|err| IoError::new(err, 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)?;
.into());
}
Ok(Signal::CtrlD) => {
// Do nothing on ctrl-d
return Ok(Value::nothing(call.head).into_pipeline_data());
}
Err(event_error) => {
let from_io_error = IoError::factory(call.head, None);
return Err(from_io_error(event_error).into());
}
}
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()),
@ -193,6 +218,16 @@ impl Command for Input {
example: "let user_input = (input --default 10)",
result: None,
},
Example {
description: "Get input from the user with history, and assign to a variable",
example: "let user_input = ([past,command,entries] | input )",
result: None,
},
Example {
description: "Get input from the user with history backed by a file, and assign to a variable",
example: "let user_input = (input --history-file ./history.txt)",
result: None,
},
]
}
}

View File

@ -0,0 +1,136 @@
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::{self, 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(
shell_error::io::ErrorKind::from_std(
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, 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,62 @@
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 right_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(&self.right_prompt)
}
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
))
}
}