Merge nucleic/sleek-thistle-egret-fyej into dev
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "brush-interactive"
|
||||
description = "Interactive layer of brush-shell"
|
||||
version = "0.4.0"
|
||||
authors.workspace = true
|
||||
categories.workspace = true
|
||||
edition.workspace = true
|
||||
keywords.workspace = true
|
||||
license.workspace = true
|
||||
readme.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lib]
|
||||
bench = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
basic = ["dep:crossterm", "completion"]
|
||||
completion = []
|
||||
minimal = []
|
||||
reedline = ["dep:reedline", "dep:nu-ansi-term", "completion", "highlighting"]
|
||||
highlighting = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
bon = "3.9.1"
|
||||
brush-parser = { version = "^0.4.0", path = "../brush-parser" }
|
||||
brush-core = { version = "^0.5.0", path = "../brush-core" }
|
||||
crossterm = { version = "0.29.0", features = ["serde"], optional = true }
|
||||
indexmap = "2.13.0"
|
||||
nu-ansi-term = { version = "0.50.3", optional = true }
|
||||
radix_trie = "0.3.0"
|
||||
reedline = { version = "0.47.0", optional = true }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.1", features = ["rt", "sync"] }
|
||||
tracing = "0.1.44"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { version = "1.4.1", features = ["unstable"] }
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../LICENSE
|
||||
@@ -0,0 +1,117 @@
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use brush_core::Shell;
|
||||
|
||||
use crate::{
|
||||
InputBackend, ShellError, completion,
|
||||
input_backend::{InteractivePrompt, ReadResult},
|
||||
};
|
||||
|
||||
use super::{non_term_line_reader, term_line_reader};
|
||||
|
||||
/// Represents a basic shell input backend capable of interactive usage, with primitive support
|
||||
/// for completion and test-focused automation via pexpect and similar technologies.
|
||||
#[derive(Default)]
|
||||
pub struct BasicInputBackend;
|
||||
|
||||
impl InputBackend for BasicInputBackend {
|
||||
fn read_line(
|
||||
&mut self,
|
||||
shell: &crate::ShellRef<impl brush_core::ShellExtensions>,
|
||||
prompt: InteractivePrompt,
|
||||
) -> Result<ReadResult, ShellError> {
|
||||
if std::io::stdin().is_terminal() {
|
||||
self.read_line_via(shell, &term_line_reader::TermLineReader::new()?, &prompt)
|
||||
} else {
|
||||
self.read_line_via(shell, &non_term_line_reader::NonTermLineReader, &prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BasicInputBackend {
|
||||
fn read_line_via<R: super::LineReader, SE: brush_core::ShellExtensions>(
|
||||
&self,
|
||||
shell_ref: &crate::ShellRef<SE>,
|
||||
reader: &R,
|
||||
prompt: &InteractivePrompt,
|
||||
) -> Result<ReadResult, ShellError> {
|
||||
let mut prompt_to_use = self.should_display_prompt().then_some(&prompt);
|
||||
let mut result = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read_line(prompt_to_use.map(|p| p.prompt.as_str()), |line, cursor| {
|
||||
let mut shell = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(shell_ref.lock())
|
||||
});
|
||||
|
||||
Self::generate_completions(&mut shell, line, cursor)
|
||||
})? {
|
||||
ReadResult::Input(s) => {
|
||||
result.push_str(s.as_str());
|
||||
|
||||
let shell = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(shell_ref.lock())
|
||||
});
|
||||
|
||||
if Self::is_valid_input(&shell, result.as_str()) {
|
||||
break;
|
||||
}
|
||||
|
||||
prompt_to_use = None;
|
||||
}
|
||||
ReadResult::BoundCommand(s) => {
|
||||
result.push_str(s.as_str());
|
||||
break;
|
||||
}
|
||||
ReadResult::Eof => {
|
||||
if result.is_empty() {
|
||||
return Ok(ReadResult::Eof);
|
||||
}
|
||||
break;
|
||||
}
|
||||
ReadResult::Interrupted => return Ok(ReadResult::Interrupted),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ReadResult::Input(result))
|
||||
}
|
||||
|
||||
#[expect(clippy::unused_self)]
|
||||
fn should_display_prompt(&self) -> bool {
|
||||
std::io::stdin().is_terminal()
|
||||
}
|
||||
|
||||
fn is_valid_input(shell: &Shell<impl brush_core::ShellExtensions>, input: &str) -> bool {
|
||||
match shell.parse_string(input.to_owned()) {
|
||||
// Incomplete tokenizing (unclosed quotes, etc.) - need more input
|
||||
Err(brush_parser::ParseError::Tokenizing { inner, position: _ })
|
||||
if inner.is_incomplete() =>
|
||||
{
|
||||
false
|
||||
}
|
||||
// Parse error at end of input - could be incomplete
|
||||
Err(brush_parser::ParseError::ParsingAtEndOfInput) => false,
|
||||
// Parse error at a specific position OR successful parse - complete
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_completions(
|
||||
shell: &mut Shell<impl brush_core::ShellExtensions>,
|
||||
line: &str,
|
||||
cursor: usize,
|
||||
) -> Result<brush_core::completion::Completions, ShellError> {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(Self::generate_completions_async(shell, line, cursor))
|
||||
})
|
||||
}
|
||||
|
||||
async fn generate_completions_async(
|
||||
shell: &mut Shell<impl brush_core::ShellExtensions>,
|
||||
line: &str,
|
||||
cursor: usize,
|
||||
) -> Result<brush_core::completion::Completions, ShellError> {
|
||||
Ok(completion::complete_async(shell, line, cursor).await)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
mod input_backend;
|
||||
mod non_term_line_reader;
|
||||
mod term_line_reader;
|
||||
|
||||
pub use input_backend::BasicInputBackend;
|
||||
|
||||
use crate::{ReadResult, ShellError};
|
||||
|
||||
pub(crate) trait LineReader {
|
||||
fn read_line(
|
||||
&self,
|
||||
prompt: Option<&str>,
|
||||
completion_handler: impl FnMut(
|
||||
&str,
|
||||
usize,
|
||||
)
|
||||
-> Result<brush_core::completion::Completions, ShellError>,
|
||||
) -> Result<ReadResult, ShellError>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::{ReadResult, ShellError};
|
||||
|
||||
pub(crate) struct NonTermLineReader;
|
||||
|
||||
impl super::LineReader for NonTermLineReader {
|
||||
fn read_line(
|
||||
&self,
|
||||
_prompt: Option<&str>,
|
||||
_completion_handler: impl FnMut(
|
||||
&str,
|
||||
usize,
|
||||
) -> Result<
|
||||
brush_core::completion::Completions,
|
||||
crate::ShellError,
|
||||
>,
|
||||
) -> Result<crate::ReadResult, crate::ShellError> {
|
||||
let mut input = String::new();
|
||||
let bytes_read = std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(ShellError::InputError)?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
Ok(ReadResult::Eof)
|
||||
} else {
|
||||
Ok(ReadResult::Input(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//
|
||||
// This module is intentionally limited, and does not have all the bells and whistles. We wan
|
||||
// enough here that we can use it in the basic shell for (p)expect/pty-style testing of
|
||||
// completion, and without using VT100-style escape sequences for cursor movement and display.
|
||||
//
|
||||
|
||||
use crossterm::ExecutableCommand;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::{ReadResult, ShellError};
|
||||
|
||||
const BACKSPACE: char = 8u8 as char;
|
||||
|
||||
pub(crate) struct TermLineReader {
|
||||
term_mode: brush_core::terminal::AutoModeGuard,
|
||||
}
|
||||
|
||||
impl TermLineReader {
|
||||
pub fn new() -> Result<Self, ShellError> {
|
||||
let reader = Self {
|
||||
term_mode: brush_core::terminal::AutoModeGuard::new(std::io::stdin().into())?,
|
||||
};
|
||||
|
||||
let settings = brush_core::terminal::Settings::builder()
|
||||
.echo_input(false)
|
||||
.line_input(false)
|
||||
.interrupt_signals(false)
|
||||
.output_nl_as_nlcr(true)
|
||||
.build();
|
||||
|
||||
reader.term_mode.apply_settings(&settings)?;
|
||||
|
||||
Ok(reader)
|
||||
}
|
||||
}
|
||||
|
||||
impl super::LineReader for TermLineReader {
|
||||
fn read_line(
|
||||
&self,
|
||||
prompt: Option<&str>,
|
||||
mut completion_handler: impl FnMut(
|
||||
&str,
|
||||
usize,
|
||||
)
|
||||
-> Result<brush_core::completion::Completions, ShellError>,
|
||||
) -> Result<ReadResult, ShellError> {
|
||||
let mut state = ReadLineState::new(prompt);
|
||||
state.display_prompt()?;
|
||||
|
||||
loop {
|
||||
if let crossterm::event::Event::Key(event) = crossterm::event::read()?
|
||||
&& let Some(result) = state.on_key(event, &mut completion_handler)?
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ReadLineState<'a> {
|
||||
// Current line of input
|
||||
line: String,
|
||||
// Current position of cursor, expressed as a byte offset from the
|
||||
// start of `line`. We maintain the invariant that it will always
|
||||
// be at a clean character boundary.
|
||||
cursor: usize,
|
||||
// Current prompt to use.
|
||||
prompt: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ReadLineState<'a> {
|
||||
const fn new(prompt: Option<&'a str>) -> Self {
|
||||
Self {
|
||||
line: String::new(),
|
||||
cursor: 0,
|
||||
prompt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_prompt(&self) -> Result<(), ShellError> {
|
||||
if let Some(prompt) = self.prompt {
|
||||
eprint!("{prompt}");
|
||||
std::io::stderr().flush()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_key(
|
||||
&mut self,
|
||||
event: crossterm::event::KeyEvent,
|
||||
mut completion_handler: impl FnMut(
|
||||
&str,
|
||||
usize,
|
||||
)
|
||||
-> Result<brush_core::completion::Completions, ShellError>,
|
||||
) -> Result<Option<ReadResult>, ShellError> {
|
||||
match (event.modifiers, event.code) {
|
||||
(_, crossterm::event::KeyCode::Enter)
|
||||
| (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('j')) => {
|
||||
Self::display_newline()?;
|
||||
self.line.push('\n');
|
||||
let line = std::mem::take(&mut self.line);
|
||||
return Ok(Some(ReadResult::Input(line)));
|
||||
}
|
||||
(
|
||||
crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::NONE,
|
||||
crossterm::event::KeyCode::Char(c),
|
||||
) => {
|
||||
self.on_char(c)?;
|
||||
}
|
||||
(crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('c')) => {
|
||||
eprintln!("^C");
|
||||
return Ok(Some(ReadResult::Interrupted));
|
||||
}
|
||||
(crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('d'))
|
||||
if self.line.is_empty() =>
|
||||
{
|
||||
Self::display_newline()?;
|
||||
return Ok(Some(ReadResult::Eof));
|
||||
}
|
||||
(crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('l')) => {
|
||||
self.clear_screen()?;
|
||||
}
|
||||
(_, crossterm::event::KeyCode::Backspace) => {
|
||||
self.backspace()?;
|
||||
}
|
||||
(_, crossterm::event::KeyCode::Left) => {
|
||||
self.move_cursor_left()?;
|
||||
}
|
||||
(_, crossterm::event::KeyCode::Tab) => {
|
||||
let completions = completion_handler(self.line.as_str(), self.cursor)?;
|
||||
self.handle_completions(&completions)?;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn on_char(&mut self, c: char) -> Result<(), ShellError> {
|
||||
self.line.insert(self.cursor, c);
|
||||
self.cursor += c.len_utf8();
|
||||
eprint!("{c}");
|
||||
std::io::stderr().flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn display_newline() -> Result<(), ShellError> {
|
||||
eprintln!();
|
||||
std::io::stderr().flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_screen(&self) -> Result<(), ShellError> {
|
||||
std::io::stderr()
|
||||
.execute(crossterm::terminal::Clear(
|
||||
crossterm::terminal::ClearType::All,
|
||||
))?
|
||||
.execute(crossterm::cursor::MoveTo(0, 0))?;
|
||||
|
||||
self.display_prompt()?;
|
||||
eprint!("{}", self.line.as_str());
|
||||
std::io::stderr().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::string_slice, reason = "it's calculated based on char indices")]
|
||||
fn backspace(&mut self) -> Result<(), ShellError> {
|
||||
let char_indices = self.line.char_indices();
|
||||
|
||||
let Some((last_char_index, _)) = char_indices.last() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.cursor = last_char_index;
|
||||
self.line.truncate(last_char_index);
|
||||
|
||||
eprint!("{BACKSPACE}");
|
||||
eprint!("{} ", &self.line[self.cursor..]);
|
||||
eprint!(
|
||||
"{}",
|
||||
repeated_char_str(BACKSPACE, self.line.len() + 1 - self.cursor)
|
||||
);
|
||||
|
||||
std::io::stderr().flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_cursor_left(&mut self) -> Result<(), ShellError> {
|
||||
eprint!("{BACKSPACE}");
|
||||
std::io::stderr().flush()?;
|
||||
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
|
||||
while self.cursor > 0 && !self.line.is_char_boundary(self.cursor) {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_completions(
|
||||
&mut self,
|
||||
completions: &brush_core::completion::Completions,
|
||||
) -> Result<(), ShellError> {
|
||||
if completions.candidates.is_empty() {
|
||||
// Do nothing
|
||||
Ok(())
|
||||
} else if completions.candidates.len() == 1 {
|
||||
self.handle_single_completion(completions)
|
||||
} else {
|
||||
self.handle_multiple_completions(completions)
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::string_slice,
|
||||
reason = "all offsets are expected to be at char boundaries"
|
||||
)]
|
||||
fn handle_single_completion(
|
||||
&mut self,
|
||||
completions: &brush_core::completion::Completions,
|
||||
) -> Result<(), ShellError> {
|
||||
let Some(candidate) = completions.candidates.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if completions.insertion_index + completions.delete_count != self.cursor {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut delete_count = completions.delete_count;
|
||||
let mut redisplay_offset = completions.insertion_index;
|
||||
|
||||
// Don't bother erasing and re-writing the portion of the
|
||||
// completion's prefix that
|
||||
// is identical to what we already had in the token-being-completed.
|
||||
if delete_count > 0
|
||||
&& candidate.starts_with(&self.line[redisplay_offset..redisplay_offset + delete_count])
|
||||
{
|
||||
redisplay_offset += delete_count;
|
||||
delete_count = 0;
|
||||
}
|
||||
|
||||
let mut updated_line = self.line.clone();
|
||||
updated_line.truncate(completions.insertion_index);
|
||||
updated_line.push_str(candidate);
|
||||
updated_line.push_str(&self.line[self.cursor..]);
|
||||
self.line = updated_line;
|
||||
|
||||
self.cursor = completions.insertion_index + candidate.len();
|
||||
|
||||
let move_left = repeated_char_str(BACKSPACE, delete_count);
|
||||
eprint!("{move_left}{}", &self.line[redisplay_offset..]);
|
||||
|
||||
// TODO(completion): Remove trailing chars if completion is shorter?
|
||||
eprint!(
|
||||
"{}",
|
||||
repeated_char_str(BACKSPACE, self.line.len() - self.cursor)
|
||||
);
|
||||
|
||||
std::io::stderr().flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_multiple_completions(
|
||||
&self,
|
||||
completions: &brush_core::completion::Completions,
|
||||
) -> Result<(), ShellError> {
|
||||
// Display replacements.
|
||||
Self::display_newline()?;
|
||||
for candidate in &completions.candidates {
|
||||
let formatted = format_completion_candidate(candidate.as_str(), &completions.options);
|
||||
eprintln!("{formatted}");
|
||||
}
|
||||
std::io::stderr().flush()?;
|
||||
|
||||
// Re-display prompt.
|
||||
self.display_prompt()?;
|
||||
|
||||
// Re-display line so far.
|
||||
eprint!(
|
||||
"{}{}",
|
||||
self.line,
|
||||
repeated_char_str(BACKSPACE, self.line.len() - self.cursor)
|
||||
);
|
||||
|
||||
std::io::stderr().flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::string_slice)]
|
||||
fn format_completion_candidate(
|
||||
mut candidate: &str,
|
||||
options: &brush_core::completion::ProcessingOptions,
|
||||
) -> String {
|
||||
if options.treat_as_filenames {
|
||||
let trimmed = brush_core::sys::fs::strip_path_separator_suffix(candidate);
|
||||
if let Some(index) = brush_core::sys::fs::rfind_path_separator(trimmed) {
|
||||
candidate = &candidate[index + 1..];
|
||||
}
|
||||
}
|
||||
|
||||
candidate.to_string()
|
||||
}
|
||||
|
||||
fn repeated_char_str(c: char, count: usize) -> String {
|
||||
(0..count).map(|_| c).collect()
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use brush_core::escape;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn complete_async(
|
||||
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
|
||||
line: &str,
|
||||
pos: usize,
|
||||
) -> brush_core::completion::Completions {
|
||||
let working_dir = shell.working_dir().to_path_buf();
|
||||
|
||||
// Intentionally ignore any errors that arise.
|
||||
let completion_future = shell.complete(line, pos);
|
||||
tokio::pin!(completion_future);
|
||||
|
||||
// Wait for the completions to come back or interruption, whichever happens first.
|
||||
let result = tokio::select! {
|
||||
result = &mut completion_future => {
|
||||
result
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
Err(brush_core::ErrorKind::Interrupted.into())
|
||||
},
|
||||
};
|
||||
|
||||
let mut completions = result.unwrap_or_else(|_| brush_core::completion::Completions {
|
||||
insertion_index: pos,
|
||||
delete_count: 0,
|
||||
candidates: Vec::new(),
|
||||
options: brush_core::completion::ProcessingOptions::default(),
|
||||
});
|
||||
|
||||
// Look at the line up to 'pos' to check if we're in an unterminated
|
||||
// single or double quote string.
|
||||
let mut quote_char: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
for (i, c) in line.char_indices() {
|
||||
if i >= pos {
|
||||
break;
|
||||
}
|
||||
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(q) = quote_char {
|
||||
if c == q {
|
||||
quote_char = None;
|
||||
}
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == '\'' || c == '\"' {
|
||||
quote_char = Some(c);
|
||||
}
|
||||
}
|
||||
|
||||
let completing_end_of_line = pos == line.len();
|
||||
|
||||
// Deduplicate the candidates (retaining order), then postprocess them.
|
||||
completions.candidates = completions
|
||||
.candidates
|
||||
.into_iter()
|
||||
.collect::<indexmap::IndexSet<_>>()
|
||||
.into_iter()
|
||||
.map(|candidate| {
|
||||
postprocess_completion_candidate(
|
||||
candidate,
|
||||
&completions.options,
|
||||
working_dir.as_ref(),
|
||||
completing_end_of_line,
|
||||
quote_char,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
completions
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn postprocess_completion_candidate(
|
||||
mut candidate: String,
|
||||
options: &brush_core::completion::ProcessingOptions,
|
||||
working_dir: &Path,
|
||||
completing_end_of_line: bool,
|
||||
quote_char: Option<char>,
|
||||
) -> String {
|
||||
if options.treat_as_filenames {
|
||||
// Check if it's a directory.
|
||||
if !brush_core::sys::fs::ends_with_path_separator(&candidate) {
|
||||
let candidate_path = Path::new(&candidate);
|
||||
let abs_candidate_path = if candidate_path.is_absolute() {
|
||||
PathBuf::from(candidate_path)
|
||||
} else {
|
||||
working_dir.join(candidate_path)
|
||||
};
|
||||
|
||||
if abs_candidate_path.is_dir() {
|
||||
// Use forward slash: backslash is the shell escape character.
|
||||
candidate.push('/');
|
||||
}
|
||||
}
|
||||
|
||||
if !options.no_autoquote_filenames {
|
||||
let quote_mode = match quote_char {
|
||||
Some('\'') => escape::QuoteMode::SingleQuote,
|
||||
Some('\"') => escape::QuoteMode::DoubleQuote,
|
||||
_ => escape::QuoteMode::BackslashEscape,
|
||||
};
|
||||
|
||||
candidate = escape::quote_if_needed(&candidate, quote_mode).to_string();
|
||||
}
|
||||
}
|
||||
if completing_end_of_line && !options.no_trailing_space_at_end_of_line {
|
||||
if !options.treat_as_filenames || !brush_core::sys::fs::ends_with_path_separator(&candidate)
|
||||
{
|
||||
candidate.push(' ');
|
||||
}
|
||||
}
|
||||
|
||||
candidate
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Represents an error encountered while running or otherwise managing an interactive shell.
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ShellError {
|
||||
/// An error occurred with the embedded shell.
|
||||
#[error("{0}")]
|
||||
ShellError(#[from] brush_core::Error),
|
||||
|
||||
/// A generic I/O error occurred.
|
||||
#[error("I/O error: {0}")]
|
||||
IoError(#[from] std::io::Error),
|
||||
|
||||
/// Failed to create xtrace file.
|
||||
#[error("failed to create xtrace file '{0}': {1}")]
|
||||
FailedToCreateXtraceFile(PathBuf, std::io::Error),
|
||||
|
||||
/// An error occurred while reading input.
|
||||
#[error("input error occurred: {0}")]
|
||||
InputError(std::io::Error),
|
||||
|
||||
/// The requested input backend type is not supported.
|
||||
#[error("requested input backend type not supported")]
|
||||
InputBackendNotSupported,
|
||||
|
||||
/// An unexpected error occurred while reading input.
|
||||
#[error("unexpected error occurred reading input")]
|
||||
UnexpectedInputFailure,
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Generic syntax highlighting for shell commands.
|
||||
//!
|
||||
//! This module provides semantic tagging of shell command strings without
|
||||
//! imposing any specific styling. Consumers can map the semantic categories
|
||||
//! to their own color schemes or styles.
|
||||
|
||||
use std::str::Chars;
|
||||
|
||||
/// Semantic category for a highlighted span.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HighlightKind {
|
||||
/// Default text
|
||||
Default,
|
||||
/// Comment text
|
||||
Comment,
|
||||
/// Arithmetic expression
|
||||
Arithmetic,
|
||||
/// Parameter expansion (variables, etc.)
|
||||
Parameter,
|
||||
/// Command substitution
|
||||
CommandSubstitution,
|
||||
/// Quoted text
|
||||
Quoted,
|
||||
/// Operator (|, &&, etc.)
|
||||
Operator,
|
||||
/// Variable assignment
|
||||
Assignment,
|
||||
/// Hyphen-prefixed option
|
||||
HyphenOption,
|
||||
/// Function definition
|
||||
Function,
|
||||
/// Shell keyword
|
||||
Keyword,
|
||||
/// Builtin command
|
||||
Builtin,
|
||||
/// Alias
|
||||
Alias,
|
||||
/// External command (found in PATH)
|
||||
ExternalCommand,
|
||||
/// Command not found
|
||||
NotFoundCommand,
|
||||
/// Unknown command (cursor still in token)
|
||||
UnknownCommand,
|
||||
}
|
||||
|
||||
/// A highlighted span of text with semantic meaning.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HighlightSpan {
|
||||
/// Start byte offset in the input string
|
||||
pub start: usize,
|
||||
/// End byte offset in the input string
|
||||
pub end: usize,
|
||||
/// Semantic category of this span
|
||||
pub kind: HighlightKind,
|
||||
}
|
||||
|
||||
impl HighlightSpan {
|
||||
/// Creates a new highlight span.
|
||||
#[must_use]
|
||||
pub const fn new(start: usize, end: usize, kind: HighlightKind) -> Self {
|
||||
Self { start, end, kind }
|
||||
}
|
||||
|
||||
/// Returns the text of this span from the input string.
|
||||
#[must_use]
|
||||
#[allow(clippy::string_slice)]
|
||||
pub fn text<'a>(&self, input: &'a str) -> &'a str {
|
||||
&input[self.start..self.end]
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlights a shell command string, returning semantic spans.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `shell` - Reference to the shell for context (aliases, functions, builtins, etc.)
|
||||
/// * `line` - The command string to highlight
|
||||
/// * `cursor` - Current cursor position (byte offset)
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of highlighted spans covering the entire input string.
|
||||
#[must_use]
|
||||
pub fn highlight_command(
|
||||
shell: &brush_core::Shell<impl brush_core::ShellExtensions>,
|
||||
line: &str,
|
||||
cursor: usize,
|
||||
) -> Vec<HighlightSpan> {
|
||||
let mut highlighter = Highlighter::new(shell, line, cursor);
|
||||
highlighter.highlight_program(line, 0);
|
||||
highlighter.spans
|
||||
}
|
||||
|
||||
enum CommandType {
|
||||
Function,
|
||||
Keyword,
|
||||
Builtin,
|
||||
Alias,
|
||||
External,
|
||||
NotFound,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
struct Highlighter<'a, SE: brush_core::ShellExtensions> {
|
||||
shell: &'a brush_core::Shell<SE>,
|
||||
cursor: usize,
|
||||
spans: Vec<HighlightSpan>,
|
||||
remaining_chars: Chars<'a>,
|
||||
current_char_index: usize,
|
||||
next_missing_kind: Option<HighlightKind>,
|
||||
}
|
||||
|
||||
impl<'a, SE: brush_core::ShellExtensions> Highlighter<'a, SE> {
|
||||
fn new(shell: &'a brush_core::Shell<SE>, input_line: &'a str, cursor: usize) -> Self {
|
||||
Self {
|
||||
shell,
|
||||
cursor,
|
||||
spans: Vec::new(),
|
||||
remaining_chars: input_line.chars(),
|
||||
current_char_index: 0,
|
||||
next_missing_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_program(&mut self, line: &str, global_offset: usize) {
|
||||
if let Ok(tokens) = brush_parser::tokenize_str_with_options(
|
||||
line,
|
||||
&(self.shell.parser_options().tokenizer_options()),
|
||||
) {
|
||||
let mut saw_command_token = false;
|
||||
for token in tokens {
|
||||
match token {
|
||||
brush_parser::Token::Operator(_op, token_location) => {
|
||||
self.append_span(
|
||||
HighlightKind::Operator,
|
||||
global_offset + token_location.start.index,
|
||||
global_offset + token_location.end.index,
|
||||
);
|
||||
}
|
||||
brush_parser::Token::Word(w, token_location) => {
|
||||
if let Ok(word_pieces) =
|
||||
brush_parser::word::parse(w.as_str(), &self.shell.parser_options())
|
||||
{
|
||||
let default_text_kind = self.get_kind_for_word(
|
||||
w.as_str(),
|
||||
&token_location,
|
||||
&mut saw_command_token,
|
||||
);
|
||||
|
||||
for word_piece in word_pieces {
|
||||
self.highlight_word_piece(
|
||||
word_piece,
|
||||
default_text_kind,
|
||||
global_offset + token_location.start.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.skip_ahead(global_offset + line.len());
|
||||
} else {
|
||||
self.append_span(
|
||||
HighlightKind::Default,
|
||||
global_offset,
|
||||
global_offset + line.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn highlight_word_piece(
|
||||
&mut self,
|
||||
word_piece: brush_parser::word::WordPieceWithSource,
|
||||
default_text_kind: HighlightKind,
|
||||
global_offset: usize,
|
||||
) {
|
||||
self.skip_ahead(global_offset + word_piece.start_index);
|
||||
|
||||
match word_piece.piece {
|
||||
brush_parser::word::WordPiece::SingleQuotedText(_)
|
||||
| brush_parser::word::WordPiece::AnsiCQuotedText(_)
|
||||
| brush_parser::word::WordPiece::EscapeSequence(_) => {
|
||||
self.append_span(
|
||||
HighlightKind::Quoted,
|
||||
global_offset + word_piece.start_index,
|
||||
global_offset + word_piece.end_index,
|
||||
);
|
||||
}
|
||||
brush_parser::word::WordPiece::DoubleQuotedSequence(subpieces)
|
||||
| brush_parser::word::WordPiece::GettextDoubleQuotedSequence(subpieces) => {
|
||||
self.set_next_missing_kind(HighlightKind::Quoted);
|
||||
for subpiece in subpieces {
|
||||
self.highlight_word_piece(subpiece, HighlightKind::Quoted, global_offset);
|
||||
}
|
||||
self.set_next_missing_kind(HighlightKind::Quoted);
|
||||
}
|
||||
brush_parser::word::WordPiece::ParameterExpansion(_)
|
||||
| brush_parser::word::WordPiece::TildeExpansion(_) => {
|
||||
self.append_span(
|
||||
HighlightKind::Parameter,
|
||||
global_offset + word_piece.start_index,
|
||||
global_offset + word_piece.end_index,
|
||||
);
|
||||
}
|
||||
brush_parser::word::WordPiece::BackquotedCommandSubstitution(command) => {
|
||||
self.set_next_missing_kind(HighlightKind::CommandSubstitution);
|
||||
self.highlight_program(
|
||||
command.as_str(),
|
||||
global_offset + word_piece.start_index + 1, /* account for opening backtick */
|
||||
);
|
||||
self.set_next_missing_kind(HighlightKind::CommandSubstitution);
|
||||
}
|
||||
brush_parser::word::WordPiece::CommandSubstitution(command) => {
|
||||
self.set_next_missing_kind(HighlightKind::CommandSubstitution);
|
||||
self.highlight_program(
|
||||
command.as_str(),
|
||||
global_offset + word_piece.start_index + 2, /* account for opening $( */
|
||||
);
|
||||
self.set_next_missing_kind(HighlightKind::CommandSubstitution);
|
||||
}
|
||||
brush_parser::word::WordPiece::ArithmeticExpression(_) => {
|
||||
// TODO(highlighting): Consider individually highlighting pieces of the expression
|
||||
// itself.
|
||||
self.append_span(
|
||||
HighlightKind::Arithmetic,
|
||||
global_offset + word_piece.start_index,
|
||||
global_offset + word_piece.end_index,
|
||||
);
|
||||
}
|
||||
brush_parser::word::WordPiece::Text(_text) => {
|
||||
self.append_span(
|
||||
default_text_kind,
|
||||
global_offset + word_piece.start_index,
|
||||
global_offset + word_piece.end_index,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.skip_ahead(global_offset + word_piece.end_index);
|
||||
}
|
||||
|
||||
fn append_span(&mut self, kind: HighlightKind, start: usize, end: usize) {
|
||||
// See if we need to cover a gap between this substring and the one that preceded it.
|
||||
if start > self.current_char_index {
|
||||
let missing_kind = self.next_missing_kind.unwrap_or(HighlightKind::Comment);
|
||||
let gap_len = start - self.current_char_index;
|
||||
|
||||
// Skip characters in the gap
|
||||
for _ in 0..gap_len {
|
||||
self.remaining_chars.next();
|
||||
}
|
||||
|
||||
self.spans.push(HighlightSpan::new(
|
||||
self.current_char_index,
|
||||
start,
|
||||
missing_kind,
|
||||
));
|
||||
self.current_char_index = start;
|
||||
}
|
||||
|
||||
if end > start {
|
||||
// Skip characters in this span
|
||||
for _ in 0..(end - start) {
|
||||
self.remaining_chars.next();
|
||||
}
|
||||
|
||||
self.spans.push(HighlightSpan::new(start, end, kind));
|
||||
}
|
||||
|
||||
self.current_char_index = end;
|
||||
}
|
||||
|
||||
fn skip_ahead(&mut self, dest: usize) {
|
||||
// Append a no-op span to make sure we cover any trailing gaps in the input line not
|
||||
// otherwise styled.
|
||||
self.append_span(HighlightKind::Default, dest, dest);
|
||||
}
|
||||
|
||||
const fn set_next_missing_kind(&mut self, kind: HighlightKind) {
|
||||
self.next_missing_kind = Some(kind);
|
||||
}
|
||||
|
||||
fn get_kind_for_word(
|
||||
&self,
|
||||
w: &str,
|
||||
token_location: &brush_parser::SourceSpan,
|
||||
saw_command_token: &mut bool,
|
||||
) -> HighlightKind {
|
||||
if !*saw_command_token {
|
||||
if w.contains('=') {
|
||||
HighlightKind::Assignment
|
||||
} else {
|
||||
*saw_command_token = true;
|
||||
match self.classify_possible_command(w, token_location) {
|
||||
CommandType::Function => HighlightKind::Function,
|
||||
CommandType::Keyword => HighlightKind::Keyword,
|
||||
CommandType::Builtin => HighlightKind::Builtin,
|
||||
CommandType::Alias => HighlightKind::Alias,
|
||||
CommandType::External => HighlightKind::ExternalCommand,
|
||||
CommandType::NotFound => HighlightKind::NotFoundCommand,
|
||||
CommandType::Unknown => HighlightKind::UnknownCommand,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if self.shell.is_keyword(w) {
|
||||
HighlightKind::Keyword
|
||||
} else if w.starts_with('-') {
|
||||
HighlightKind::HyphenOption
|
||||
} else {
|
||||
HighlightKind::Default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_possible_command(
|
||||
&self,
|
||||
name: &str,
|
||||
token_location: &brush_parser::SourceSpan,
|
||||
) -> CommandType {
|
||||
if self.shell.is_keyword(name) {
|
||||
return CommandType::Keyword;
|
||||
} else if self.shell.aliases().contains_key(name) {
|
||||
return CommandType::Alias;
|
||||
} else if self.shell.funcs().get(name).is_some() {
|
||||
return CommandType::Function;
|
||||
} else if self.shell.builtins().contains_key(name) {
|
||||
return CommandType::Builtin;
|
||||
}
|
||||
|
||||
// Short-circuit if the cursor is still in this token.
|
||||
if (self.cursor >= token_location.start.index) && (self.cursor <= token_location.end.index)
|
||||
{
|
||||
return CommandType::Unknown;
|
||||
}
|
||||
|
||||
if brush_core::sys::fs::contains_path_separator(name) {
|
||||
// TODO(highlighting): Should check for executable-ness.
|
||||
let candidate_path = self.shell.absolute_path(std::path::Path::new(name));
|
||||
if candidate_path.exists() {
|
||||
CommandType::External
|
||||
} else {
|
||||
CommandType::NotFound
|
||||
}
|
||||
} else {
|
||||
if self.shell.find_first_executable_in_path(name).is_some() {
|
||||
CommandType::External
|
||||
} else {
|
||||
CommandType::NotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_highlight_simple_command() {
|
||||
let shell = brush_core::Shell::builder().build().await.unwrap();
|
||||
let line = "somecommand hello";
|
||||
// Use cursor position at the end so we get final highlighting
|
||||
let spans = highlight_command(&shell, line, line.len());
|
||||
|
||||
// Should have at least 2 spans
|
||||
assert!(!spans.is_empty());
|
||||
|
||||
// Verify highlighting produces spans that cover the input
|
||||
let total_covered: usize = spans.iter().map(|s| s.end - s.start).sum();
|
||||
assert_eq!(total_covered, line.len(), "Spans should cover entire input");
|
||||
|
||||
// The command should be classified as something (NotFound, External, etc.)
|
||||
let cmd_span = spans.iter().find(|s| s.text(line) == "somecommand");
|
||||
assert!(cmd_span.is_some(), "Should have a span for the command");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_highlight_quoted_string() {
|
||||
let shell = brush_core::Shell::builder().build().await.unwrap();
|
||||
let line = r#"echo "hello world""#;
|
||||
let spans = highlight_command(&shell, line, 0);
|
||||
|
||||
// Should have spans for: echo, space, "hello world"
|
||||
assert!(!spans.is_empty());
|
||||
|
||||
// Check that quoted parts are marked as Quoted
|
||||
assert!(spans.iter().any(|s| s.kind == HighlightKind::Quoted));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_highlight_parameter_expansion() {
|
||||
let shell = brush_core::Shell::builder().build().await.unwrap();
|
||||
let line = "echo $HOME";
|
||||
let spans = highlight_command(&shell, line, 0);
|
||||
|
||||
// Should have spans including a parameter expansion
|
||||
assert!(spans.iter().any(|s| s.kind == HighlightKind::Parameter));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_highlight_covers_entire_input() {
|
||||
let shell = brush_core::Shell::builder().build().await.unwrap();
|
||||
let line = "echo hello world";
|
||||
let spans = highlight_command(&shell, line, 0);
|
||||
|
||||
// Verify that spans cover the entire input (no gaps)
|
||||
let mut covered = vec![false; line.len()];
|
||||
for span in &spans {
|
||||
for item in covered.iter_mut().take(span.end).skip(span.start) {
|
||||
*item = true;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(covered.iter().all(|&c| c), "Not all characters are covered");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use crate::ShellError;
|
||||
|
||||
/// Represents an input backend for reading lines of input.
|
||||
pub trait InputBackend: Send {
|
||||
/// Reads a line of input, using the given prompt.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `shell` - The shell instance for which input is being read.
|
||||
/// * `prompt` - The prompt to display to the user.
|
||||
fn read_line(
|
||||
&mut self,
|
||||
shell: &crate::ShellRef<impl brush_core::ShellExtensions>,
|
||||
prompt: InteractivePrompt,
|
||||
) -> Result<ReadResult, ShellError>;
|
||||
|
||||
/// Returns the current contents of the read buffer and the current cursor
|
||||
/// position within the buffer; None is returned if the read buffer is
|
||||
/// empty or cannot be read by this implementation.
|
||||
fn get_read_buffer(&self) -> Option<(String, usize)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Updates the read buffer with the given string and cursor. Considered a
|
||||
/// no-op if the implementation does not support updating read buffers.
|
||||
fn set_read_buffer(&mut self, _buffer: String, _cursor: usize) {
|
||||
// No-op by default.
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a read operation.
|
||||
pub enum ReadResult {
|
||||
/// The user entered a line of input.
|
||||
Input(String),
|
||||
/// A bound key sequence yielded a registered command.
|
||||
BoundCommand(String),
|
||||
/// End of input was reached.
|
||||
Eof,
|
||||
/// The user interrupted the input operation.
|
||||
Interrupted,
|
||||
}
|
||||
|
||||
/// Represents an interactive prompt.
|
||||
pub struct InteractivePrompt {
|
||||
/// Prompt to display.
|
||||
pub prompt: String,
|
||||
/// Alternate-side prompt (typically right) to display.
|
||||
pub alt_side_prompt: String,
|
||||
/// Prompt to display on a continuation line of input.
|
||||
pub continuation_prompt: String,
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
use std::io::IsTerminal as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
use crate::InputBackend;
|
||||
use crate::InteractivePrompt;
|
||||
use crate::ReadResult;
|
||||
use crate::ShellError;
|
||||
|
||||
/// Result of an interactive execution.
|
||||
pub enum InteractiveExecutionResult {
|
||||
/// The command was executed and returned the given result.
|
||||
Executed(brush_core::ExecutionResult),
|
||||
/// The command failed to execute.
|
||||
Failed(brush_core::Error),
|
||||
/// End of input was reached.
|
||||
Eof,
|
||||
}
|
||||
|
||||
impl From<&InteractiveExecutionResult> for i32 {
|
||||
/// Converts an `InteractiveExecutionResult` into a signed, 32-bit exit code.
|
||||
fn from(value: &InteractiveExecutionResult) -> Self {
|
||||
match value {
|
||||
InteractiveExecutionResult::Executed(result) => u8::from(result.exit_code).into(),
|
||||
InteractiveExecutionResult::Failed(_) => 1,
|
||||
InteractiveExecutionResult::Eof => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for interactive shells.
|
||||
#[derive(Clone)]
|
||||
pub struct InteractiveOptions {
|
||||
/// Whether terminal shell integration is enabled.
|
||||
pub terminal_shell_integration: bool,
|
||||
/// Whether or not to run `PROMPT_COMMAND` before each prompt.
|
||||
pub run_prompt_command: bool,
|
||||
/// Whether or not to run zsh-style exec/cmd functions (e.g., `preexec_functions`,
|
||||
/// `precmd_functions`).
|
||||
pub run_cmd_exec_funcs: bool,
|
||||
}
|
||||
|
||||
impl Default for InteractiveOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
terminal_shell_integration: false,
|
||||
run_prompt_command: true,
|
||||
run_cmd_exec_funcs: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an interactive shell that displays prompts, interactively reads user input, etc.
|
||||
pub struct InteractiveShell<'a, IB: InputBackend, SE: brush_core::ShellExtensions> {
|
||||
/// The underlying shell instance.
|
||||
shell: crate::ShellRef<SE>,
|
||||
/// The input backend to use.
|
||||
input: &'a mut IB,
|
||||
/// Terminal integration utility, if any.
|
||||
terminal_integration: Option<crate::term_integration::TerminalIntegration>,
|
||||
/// Options.
|
||||
options: InteractiveOptions,
|
||||
}
|
||||
|
||||
impl<'a, IB: InputBackend, SE: brush_core::ShellExtensions> InteractiveShell<'a, IB, SE> {
|
||||
/// Creates a new `InteractiveShell` wrapping the given shell instance.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `shell` - The shell instance to wrap.
|
||||
/// * `input` - The input backend to use.
|
||||
/// * `options` - The user interface options to use.
|
||||
pub fn new(
|
||||
shell: &crate::ShellRef<SE>,
|
||||
input: &'a mut IB,
|
||||
options: &InteractiveOptions,
|
||||
) -> Result<Self, ShellError> {
|
||||
let stdin_is_terminal = std::io::stdin().is_terminal();
|
||||
|
||||
// Acquire terminal control if stdin is a terminal.
|
||||
if stdin_is_terminal {
|
||||
brush_core::terminal::TerminalControl::acquire()?;
|
||||
}
|
||||
|
||||
// Set up terminal integration if enabled *and* if stdin is a terminal.
|
||||
let terminal_integration = if options.terminal_shell_integration && stdin_is_terminal {
|
||||
let terminfo = crate::term_detection::get_terminal_info(&HostEnvironment);
|
||||
let terminal_integration = crate::term_integration::TerminalIntegration::new(terminfo);
|
||||
|
||||
print!("{}", terminal_integration.initialize().as_ref());
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
Some(terminal_integration)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
shell: shell.clone(),
|
||||
input,
|
||||
terminal_integration,
|
||||
options: options.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs the interactive shell loop, reading commands from standard input and writing
|
||||
/// results to standard output and standard error. Continues until the shell
|
||||
/// normally exits or until a fatal error occurs.
|
||||
pub async fn run_interactively(&mut self) -> Result<(), ShellError> {
|
||||
let mut shell = self.shell.lock().await;
|
||||
|
||||
let mut announce_exit = shell.options().interactive;
|
||||
|
||||
shell.start_interactive_session()?;
|
||||
|
||||
drop(shell);
|
||||
|
||||
loop {
|
||||
let result = self.run_interactively_once().await?;
|
||||
match result {
|
||||
InteractiveExecutionResult::Executed(brush_core::ExecutionResult {
|
||||
next_control_flow: brush_core::results::ExecutionControlFlow::ExitShell,
|
||||
..
|
||||
}) => {
|
||||
break;
|
||||
}
|
||||
InteractiveExecutionResult::Executed(brush_core::ExecutionResult {
|
||||
next_control_flow:
|
||||
brush_core::results::ExecutionControlFlow::ReturnFromFunctionOrScript,
|
||||
..
|
||||
}) => {
|
||||
tracing::error!("return from non-function/script");
|
||||
}
|
||||
InteractiveExecutionResult::Executed(_) => {}
|
||||
InteractiveExecutionResult::Failed(err) => {
|
||||
// Report the error, but continue to execute.
|
||||
let shell = self.shell.lock().await;
|
||||
let mut stderr = shell.stderr();
|
||||
let _ = shell.display_error(&mut stderr, &err);
|
||||
|
||||
drop(shell);
|
||||
}
|
||||
InteractiveExecutionResult::Eof => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if self.shell.lock().await.options().exit_after_one_command {
|
||||
announce_exit = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut shell = self.shell.lock().await;
|
||||
|
||||
shell.end_interactive_session()?;
|
||||
|
||||
if announce_exit {
|
||||
writeln!(shell.stderr(), "exit")?;
|
||||
}
|
||||
|
||||
if let Err(e) = shell.save_history() {
|
||||
// N.B. This seems like the sort of thing that's worth being noisy about,
|
||||
// but bash doesn't do that -- and probably for a reason.
|
||||
tracing::debug!("couldn't save history: {e}");
|
||||
}
|
||||
|
||||
// Give the shell an opportunity to perform any on-exit operations.
|
||||
shell.on_exit().await?;
|
||||
|
||||
drop(shell);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the interactive shell loop once, reading a single command from standard input.
|
||||
async fn run_interactively_once(&mut self) -> Result<InteractiveExecutionResult, ShellError> {
|
||||
let mut shell = self.shell.lock().await;
|
||||
|
||||
// Run any pre-prompt actions.
|
||||
Self::run_pre_prompt_actions(&mut shell, &self.options).await?;
|
||||
|
||||
// Compose the prompt.
|
||||
let prompt = Self::compose_prompt(&mut shell, self.terminal_integration.as_ref()).await?;
|
||||
|
||||
drop(shell);
|
||||
|
||||
// Read input.
|
||||
match self.input.read_line(&self.shell, prompt)? {
|
||||
ReadResult::Input(read_result) => {
|
||||
// We got a line of input -- execute it.
|
||||
self.execute_line(read_result, true /* user input */).await
|
||||
}
|
||||
ReadResult::BoundCommand(read_result) => {
|
||||
// We got a line that was bound to keybindings; execute it.
|
||||
self.execute_line(read_result, false /* user input */).await
|
||||
}
|
||||
ReadResult::Eof => {
|
||||
// We're done!
|
||||
Ok(InteractiveExecutionResult::Eof)
|
||||
}
|
||||
ReadResult::Interrupted => {
|
||||
// We were interrupted; report that appropriately.
|
||||
let result: brush_core::ExecutionResult =
|
||||
brush_core::ExecutionExitCode::Interrupted.into();
|
||||
self.shell
|
||||
.lock()
|
||||
.await
|
||||
.set_last_exit_status(result.exit_code.into());
|
||||
Ok(InteractiveExecutionResult::Executed(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn compose_prompt(
|
||||
shell: &mut brush_core::Shell<SE>,
|
||||
terminal_integration: Option<&crate::term_integration::TerminalIntegration>,
|
||||
) -> Result<InteractivePrompt, ShellError> {
|
||||
// Now that we've done that, compose the prompt.
|
||||
let mut prompt = InteractivePrompt {
|
||||
prompt: shell.compose_prompt().await?,
|
||||
alt_side_prompt: shell.compose_alt_side_prompt().await?,
|
||||
continuation_prompt: shell.compose_continuation_prompt().await?,
|
||||
};
|
||||
|
||||
if let Some(terminal_integration) = terminal_integration {
|
||||
let pre_prompt = terminal_integration.pre_prompt();
|
||||
let working_dir = terminal_integration.report_cwd(shell.working_dir());
|
||||
let post_prompt = terminal_integration.post_prompt();
|
||||
|
||||
prompt.prompt = [
|
||||
pre_prompt.as_ref(),
|
||||
working_dir.as_ref(),
|
||||
prompt.prompt.as_str(),
|
||||
post_prompt.as_ref(),
|
||||
]
|
||||
.concat();
|
||||
}
|
||||
|
||||
Ok(prompt)
|
||||
}
|
||||
|
||||
/// Executes the given line of input.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `read_result` - The line of input to execute.
|
||||
/// * `user_input` - Whether the line came from direct user input (as opposed to a key binding,
|
||||
/// say).
|
||||
async fn execute_line(
|
||||
&mut self,
|
||||
read_result: String,
|
||||
user_input: bool,
|
||||
) -> Result<InteractiveExecutionResult, ShellError> {
|
||||
let mut shell = self.shell.lock().await;
|
||||
|
||||
// See if the the user interface has a non-empty read buffer.
|
||||
let buffer_info = self.input.get_read_buffer();
|
||||
|
||||
// If the user interface did, in fact, have a non-empty read buffer,
|
||||
// then reflect it to the shell in case any shell code wants to
|
||||
// process and/or transform the buffer.
|
||||
let nonempty_buffer = if let Some((buffer, cursor)) = buffer_info {
|
||||
if !buffer.is_empty() {
|
||||
shell.set_edit_buffer(buffer, cursor)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// If the line came from direct user input (as opposed to a key binding, say), then we
|
||||
// need to do a few more things before executing it.
|
||||
if user_input {
|
||||
Self::run_pre_exec_actions(
|
||||
&mut shell,
|
||||
read_result.as_str(),
|
||||
&self.options,
|
||||
self.terminal_integration.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Count the command's lines.
|
||||
let line_count = read_result.lines().count().max(1);
|
||||
|
||||
// Execute the command.
|
||||
let params = shell.default_exec_params();
|
||||
let source_info = brush_core::SourceInfo::from("main");
|
||||
let result = match shell.run_string(read_result, &source_info, ¶ms).await {
|
||||
Ok(result) => Ok(InteractiveExecutionResult::Executed(result)),
|
||||
Err(e) => Ok(InteractiveExecutionResult::Failed(e)),
|
||||
};
|
||||
|
||||
// Update cumulative line counter based on actual lines in the command.
|
||||
shell.increment_interactive_line_offset(line_count);
|
||||
|
||||
// See if the shell has input buffer state that we need to reflect back to
|
||||
// the user interface. It may be state that originally came from the user
|
||||
// interface, or it may be state that was programmatically generated by
|
||||
// the command we just executed.
|
||||
let mut buffer_and_cursor = shell.pop_edit_buffer()?;
|
||||
|
||||
drop(shell);
|
||||
|
||||
if buffer_and_cursor.is_none() && nonempty_buffer {
|
||||
buffer_and_cursor = Some((String::new(), 0));
|
||||
}
|
||||
|
||||
if let Some((updated_buffer, updated_cursor)) = buffer_and_cursor {
|
||||
self.input.set_read_buffer(updated_buffer, updated_cursor);
|
||||
}
|
||||
|
||||
// Invoke terminal integration.
|
||||
if let Some(terminal_integration) = &self.terminal_integration {
|
||||
let exit_code = result.as_ref().map_or(1, i32::from);
|
||||
print!(
|
||||
"{}",
|
||||
terminal_integration.post_exec_command(exit_code).as_ref()
|
||||
);
|
||||
std::io::stdout().flush()?;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_pre_prompt_actions(
|
||||
shell: &mut brush_core::Shell<SE>,
|
||||
options: &InteractiveOptions,
|
||||
) -> Result<(), ShellError> {
|
||||
// Check for any completed jobs.
|
||||
shell.check_for_completed_jobs()?;
|
||||
|
||||
// If there's a variable called PROMPT_COMMAND, then run it first.
|
||||
if options.run_prompt_command {
|
||||
if let Some(prompt_cmd_var) = shell.env_var("PROMPT_COMMAND") {
|
||||
match prompt_cmd_var.value() {
|
||||
brush_core::ShellValue::String(cmd_str) => {
|
||||
Self::run_pre_prompt_command(shell, cmd_str.to_owned()).await?;
|
||||
}
|
||||
brush_core::ShellValue::IndexedArray(values) => {
|
||||
let owned_values: Vec<_> = values.values().cloned().collect();
|
||||
for cmd_str in owned_values {
|
||||
Self::run_pre_prompt_command(shell, cmd_str).await?;
|
||||
}
|
||||
}
|
||||
// Other types are ignored.
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Next, run any zsh-style `precmd_functions`.
|
||||
// TODO(precmd_functions): verify if we need to save/restore exit results.
|
||||
if options.run_cmd_exec_funcs {
|
||||
// If there's a variable called precmd_functions, then call them.
|
||||
if let Some(brush_core::ShellValue::IndexedArray(precmd_funcs)) = shell
|
||||
.env_var("precmd_functions")
|
||||
.map(|var| var.value())
|
||||
.cloned()
|
||||
{
|
||||
for func_name in precmd_funcs.values() {
|
||||
let _ = shell
|
||||
.invoke_function(
|
||||
func_name,
|
||||
std::iter::empty::<&str>(),
|
||||
&shell.default_exec_params(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_pre_exec_actions(
|
||||
shell: &mut brush_core::Shell<SE>,
|
||||
command_line: &str,
|
||||
options: &InteractiveOptions,
|
||||
terminal_integration: Option<&crate::term_integration::TerminalIntegration>,
|
||||
) -> Result<(), ShellError> {
|
||||
// Display the pre-command prompt (if there is one).
|
||||
let precmd_prompt = shell.compose_precmd_prompt().await?;
|
||||
if !precmd_prompt.is_empty() {
|
||||
print!("{precmd_prompt}");
|
||||
}
|
||||
|
||||
// Update history (if applicable).
|
||||
shell.add_to_history(command_line.trim_end_matches('\n'))?;
|
||||
|
||||
// Next, run any zsh-style `preexec_functions`.
|
||||
// TODO(preexec_functions): verify if we need to save/restore exit results.
|
||||
if options.run_cmd_exec_funcs {
|
||||
// If there's a variable called preexec_functions, then call them.
|
||||
if let Some(brush_core::ShellValue::IndexedArray(preexec_funcs)) = shell
|
||||
.env_var("preexec_functions")
|
||||
.map(|var| var.value())
|
||||
.cloned()
|
||||
{
|
||||
for func_name in preexec_funcs.values() {
|
||||
let _ = shell
|
||||
.invoke_function(func_name, &[command_line], &shell.default_exec_params())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke terminal integration.
|
||||
if let Some(terminal_integration) = terminal_integration {
|
||||
print!(
|
||||
"{}",
|
||||
terminal_integration.pre_exec_command(command_line).as_ref()
|
||||
);
|
||||
std::io::stdout().flush()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_pre_prompt_command(
|
||||
shell: &mut brush_core::Shell<SE>,
|
||||
prompt_cmd: String,
|
||||
) -> Result<(), ShellError> {
|
||||
// Save (and later restore) the last exit status.
|
||||
let prev_last_result = shell.last_exit_status();
|
||||
let prev_last_pipeline_statuses = shell.last_pipeline_statuses().to_vec();
|
||||
|
||||
// Run the command.
|
||||
let params = shell.default_exec_params();
|
||||
let source_info = brush_core::SourceInfo::from("PROMPT_COMMAND");
|
||||
shell.run_string(prompt_cmd, &source_info, ¶ms).await?;
|
||||
|
||||
// Restore the last exit status.
|
||||
*shell.last_pipeline_statuses_mut() = prev_last_pipeline_statuses;
|
||||
shell.set_last_exit_status(prev_last_result);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the host environment; used for terminal detection in conjunction
|
||||
/// with the `TerminalEnvironment` trait.
|
||||
struct HostEnvironment;
|
||||
|
||||
impl crate::term_detection::TerminalEnvironment for HostEnvironment {
|
||||
/// Gets the value of the given environment variable from the host process's
|
||||
/// OS environment variables. Returns `None` if the variable is not set.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - The name of the environment variable to get.
|
||||
fn get_env_var(&self, name: &str) -> Option<String> {
|
||||
std::env::var(name).ok()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Library implementing interactive command input and completion for the brush shell.
|
||||
|
||||
mod error;
|
||||
pub use error::ShellError;
|
||||
|
||||
mod interactive_shell;
|
||||
pub use interactive_shell::{InteractiveExecutionResult, InteractiveOptions, InteractiveShell};
|
||||
|
||||
mod input_backend;
|
||||
pub use input_backend::{InputBackend, InteractivePrompt, ReadResult};
|
||||
|
||||
mod options;
|
||||
pub use options::UIOptions;
|
||||
|
||||
mod refs;
|
||||
pub use refs::ShellRef;
|
||||
|
||||
mod term_detection;
|
||||
mod term_integration;
|
||||
mod trace_categories;
|
||||
|
||||
#[cfg(feature = "highlighting")]
|
||||
pub mod highlighting;
|
||||
|
||||
#[cfg(feature = "completion")]
|
||||
mod completion;
|
||||
|
||||
// Reedline-based shell
|
||||
#[cfg(feature = "reedline")]
|
||||
mod reedline;
|
||||
#[cfg(feature = "reedline")]
|
||||
pub use reedline::ReedlineInputBackend;
|
||||
|
||||
// Basic shell
|
||||
#[cfg(feature = "basic")]
|
||||
mod basic;
|
||||
#[cfg(feature = "basic")]
|
||||
pub use basic::BasicInputBackend;
|
||||
|
||||
// Minimal shell
|
||||
#[cfg(feature = "minimal")]
|
||||
mod minimal;
|
||||
#[cfg(feature = "minimal")]
|
||||
pub use minimal::MinimalInputBackend;
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::io::{IsTerminal, Write};
|
||||
|
||||
use crate::{
|
||||
InputBackend, ShellError,
|
||||
input_backend::{InteractivePrompt, ReadResult},
|
||||
};
|
||||
|
||||
/// Represents a minimal shell input backend, capable of taking commands from standard input.
|
||||
#[derive(Default)]
|
||||
pub struct MinimalInputBackend;
|
||||
|
||||
impl InputBackend for MinimalInputBackend {
|
||||
fn read_line(
|
||||
&mut self,
|
||||
_shell_ref: &crate::ShellRef<impl brush_core::ShellExtensions>,
|
||||
prompt: InteractivePrompt,
|
||||
) -> Result<ReadResult, ShellError> {
|
||||
self.display_prompt(&prompt)?;
|
||||
|
||||
let result = match Self::read_input_line()? {
|
||||
ReadResult::Input(s) => s,
|
||||
ReadResult::BoundCommand(s) => s,
|
||||
ReadResult::Eof => return Ok(ReadResult::Eof),
|
||||
ReadResult::Interrupted => return Ok(ReadResult::Interrupted),
|
||||
};
|
||||
|
||||
if result.is_empty() {
|
||||
Ok(ReadResult::Eof)
|
||||
} else {
|
||||
Ok(ReadResult::Input(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MinimalInputBackend {
|
||||
#[expect(clippy::unused_self)]
|
||||
fn should_display_prompt(&self) -> bool {
|
||||
std::io::stdin().is_terminal()
|
||||
}
|
||||
|
||||
fn display_prompt(&self, prompt: &InteractivePrompt) -> Result<(), ShellError> {
|
||||
if self.should_display_prompt() {
|
||||
eprint!("{}", prompt.prompt);
|
||||
std::io::stderr().flush()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_input_line() -> Result<ReadResult, ShellError> {
|
||||
let mut input = String::new();
|
||||
let bytes_read = std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(ShellError::InputError)?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
Ok(ReadResult::Eof)
|
||||
} else {
|
||||
Ok(ReadResult::Input(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod input_backend;
|
||||
|
||||
pub use input_backend::MinimalInputBackend;
|
||||
@@ -0,0 +1,29 @@
|
||||
/// Options for a shell user interface.
|
||||
#[derive(Default, bon::Builder)]
|
||||
pub struct UIOptions {
|
||||
/// Whether to disable bracketed paste mode.
|
||||
#[builder(default)]
|
||||
pub disable_bracketed_paste: bool,
|
||||
/// Whether to disable color.
|
||||
#[builder(default)]
|
||||
pub disable_color: bool,
|
||||
/// Whether to disable syntax highlighting.
|
||||
#[builder(default)]
|
||||
pub disable_highlighting: bool,
|
||||
/// Whether to enable terminal integration.
|
||||
#[builder(default)]
|
||||
pub terminal_shell_integration: bool,
|
||||
/// Whether to enable zsh-style hooks.
|
||||
#[builder(default)]
|
||||
pub zsh_style_hooks: bool,
|
||||
}
|
||||
|
||||
impl From<&UIOptions> for crate::InteractiveOptions {
|
||||
fn from(options: &UIOptions) -> Self {
|
||||
Self {
|
||||
terminal_shell_integration: options.terminal_shell_integration,
|
||||
run_cmd_exec_funcs: options.zsh_style_hooks,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use nu_ansi_term::{Color, Style};
|
||||
use std::borrow::BorrowMut;
|
||||
|
||||
use crate::{completion, refs};
|
||||
|
||||
pub(crate) struct ReedlineCompleter<SE: brush_core::ShellExtensions> {
|
||||
pub shell: refs::ShellRef<SE>,
|
||||
}
|
||||
|
||||
impl<SE: brush_core::ShellExtensions> reedline::Completer for ReedlineCompleter<SE> {
|
||||
fn complete(&mut self, line: &str, pos: usize) -> Vec<reedline::Suggestion> {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.complete_async(line, pos))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<SE: brush_core::ShellExtensions> ReedlineCompleter<SE> {
|
||||
async fn complete_async(&self, line: &str, pos: usize) -> Vec<reedline::Suggestion> {
|
||||
let mut shell_guard = self.shell.lock().await;
|
||||
let shell = shell_guard.borrow_mut().as_mut();
|
||||
let completions = completion::complete_async(shell, line, pos).await;
|
||||
|
||||
// We're done with the shell, so drop it eagerly.
|
||||
drop(shell_guard);
|
||||
|
||||
let insertion_index = completions.insertion_index;
|
||||
let delete_count = completions.delete_count;
|
||||
let options = completions.options;
|
||||
|
||||
completions
|
||||
.candidates
|
||||
.into_iter()
|
||||
.map(|candidate| {
|
||||
Self::to_suggestion(line, candidate, insertion_index, delete_count, &options)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::string_slice,
|
||||
reason = "all indices + counts are expected to be at char boundaries"
|
||||
)]
|
||||
fn to_suggestion(
|
||||
line: &str,
|
||||
mut candidate: String,
|
||||
mut insertion_index: usize,
|
||||
mut delete_count: usize,
|
||||
options: &brush_core::completion::ProcessingOptions,
|
||||
) -> reedline::Suggestion {
|
||||
let mut style = Style::new();
|
||||
|
||||
// Special handling for filename completions.
|
||||
if options.treat_as_filenames {
|
||||
if brush_core::sys::fs::ends_with_path_separator(&candidate) {
|
||||
style = style.fg(Color::Green);
|
||||
}
|
||||
|
||||
if insertion_index + delete_count <= line.len() {
|
||||
let removed = &line[insertion_index..insertion_index + delete_count];
|
||||
if let Some(last_sep_index) = brush_core::sys::fs::rfind_path_separator(removed) {
|
||||
if candidate.starts_with(removed) {
|
||||
candidate = candidate.split_off(last_sep_index + 1);
|
||||
insertion_index += last_sep_index + 1;
|
||||
delete_count -= last_sep_index + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See if there's whitespace at the end.
|
||||
let append_whitespace = candidate.ends_with(' ');
|
||||
if append_whitespace {
|
||||
candidate.pop();
|
||||
}
|
||||
|
||||
reedline::Suggestion {
|
||||
value: candidate,
|
||||
description: None,
|
||||
style: Some(style),
|
||||
extra: None,
|
||||
span: reedline::Span {
|
||||
start: insertion_index,
|
||||
end: insertion_index + delete_count,
|
||||
},
|
||||
match_indices: None,
|
||||
display_override: None,
|
||||
append_whitespace,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
use brush_core::{
|
||||
interfaces::{self, InputFunction, Key, KeyAction, KeyBindings as _, KeySequence, KeyStroke},
|
||||
trace_categories,
|
||||
};
|
||||
use radix_trie::Trie;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum KeyError {
|
||||
/// Unsupported key sequence
|
||||
#[error("unsupported key sequence: {0}")]
|
||||
UnsupportedKeySequence(KeySequence),
|
||||
|
||||
/// Unsupported key action
|
||||
#[error("unsupported key action: {0}")]
|
||||
UnsupportedKeyAction(KeyAction),
|
||||
}
|
||||
|
||||
pub(crate) struct MutableEditMode {
|
||||
inner: Arc<Mutex<UpdatableBindings>>,
|
||||
}
|
||||
|
||||
impl MutableEditMode {
|
||||
pub fn new(bindings: reedline::Keybindings) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(UpdatableBindings::new(bindings))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bindings(&self) -> Arc<Mutex<UpdatableBindings>> {
|
||||
self.inner.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl reedline::EditMode for MutableEditMode {
|
||||
fn parse_event(&mut self, event: reedline::ReedlineRawEvent) -> reedline::ReedlineEvent {
|
||||
let mut inner = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.inner.lock())
|
||||
});
|
||||
|
||||
inner.parse_event(event)
|
||||
}
|
||||
|
||||
fn edit_mode(&self) -> reedline::PromptEditMode {
|
||||
let inner = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.inner.lock())
|
||||
});
|
||||
|
||||
inner.edit_mode()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UpdatableBindings {
|
||||
bindings: reedline::Keybindings,
|
||||
edit_mode: Box<dyn reedline::EditMode>,
|
||||
/// Trie for raw byte sequences. Supports both exact lookups and prefix matching
|
||||
/// during macro resolution.
|
||||
raw_mappings: Trie<Vec<u8>, interfaces::KeyAction>,
|
||||
/// Tracks defined macros.
|
||||
macros: HashMap<interfaces::KeySequence, interfaces::KeySequence>,
|
||||
}
|
||||
|
||||
impl UpdatableBindings {
|
||||
pub fn new(bindings: reedline::Keybindings) -> Self {
|
||||
// Clone the bindings so we can keep a copy for later updates.
|
||||
let edit_mode = Self::rebuild_edit_mode(&bindings);
|
||||
|
||||
Self {
|
||||
bindings,
|
||||
edit_mode,
|
||||
raw_mappings: Trie::new(),
|
||||
macros: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, f: impl Fn(&mut reedline::Keybindings)) {
|
||||
f(&mut self.bindings);
|
||||
self.try_update_bindings_for_all_macros();
|
||||
self.edit_mode = Self::rebuild_edit_mode(&self.bindings);
|
||||
}
|
||||
|
||||
fn rebuild_edit_mode(bindings: &reedline::Keybindings) -> Box<dyn reedline::EditMode> {
|
||||
Box::new(reedline::Emacs::new(bindings.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl reedline::EditMode for UpdatableBindings {
|
||||
fn parse_event(&mut self, event: reedline::ReedlineRawEvent) -> reedline::ReedlineEvent {
|
||||
self.edit_mode.parse_event(event)
|
||||
}
|
||||
|
||||
fn edit_mode(&self) -> reedline::PromptEditMode {
|
||||
self.edit_mode.edit_mode()
|
||||
}
|
||||
}
|
||||
|
||||
impl interfaces::KeyBindings for UpdatableBindings {
|
||||
fn get_current(&self) -> HashMap<interfaces::KeySequence, interfaces::KeyAction> {
|
||||
let mut results = HashMap::new();
|
||||
|
||||
for (key_combo, event) in self.bindings.get_keybindings() {
|
||||
let action = translate_reedline_event_to_action(event);
|
||||
if let Some(action) = action {
|
||||
if let Some(key) = translate_reedline_keycode(key_combo.key_code) {
|
||||
let mut stroke = KeyStroke::from(key);
|
||||
|
||||
if key_combo.modifier.contains(reedline::KeyModifiers::CONTROL) {
|
||||
stroke.control = true;
|
||||
}
|
||||
if key_combo.modifier.contains(reedline::KeyModifiers::ALT) {
|
||||
stroke.alt = true;
|
||||
}
|
||||
if key_combo.modifier.contains(reedline::KeyModifiers::SHIFT) {
|
||||
stroke.shift = true;
|
||||
}
|
||||
if key_combo.modifier.contains(reedline::KeyModifiers::HYPER) {
|
||||
// TODO
|
||||
}
|
||||
if key_combo.modifier.contains(reedline::KeyModifiers::META) {
|
||||
// TODO
|
||||
}
|
||||
if key_combo.modifier.contains(reedline::KeyModifiers::SUPER) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
let seq = KeySequence::from(stroke);
|
||||
results.insert(seq, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn get_untranslated(&self, bytes: &[u8]) -> Option<&KeyAction> {
|
||||
self.raw_mappings.get(bytes)
|
||||
}
|
||||
|
||||
fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error> {
|
||||
self.do_bind(seq, action, true)
|
||||
}
|
||||
|
||||
fn try_unbind(&mut self, seq: KeySequence) -> bool {
|
||||
self.try_unbind_impl(&seq, true)
|
||||
}
|
||||
|
||||
fn define_macro(
|
||||
&mut self,
|
||||
seq: KeySequence,
|
||||
target: KeySequence,
|
||||
) -> Result<(), std::io::Error> {
|
||||
self.macros.insert(seq, target);
|
||||
self.update(|_| {});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_macros(&self) -> HashMap<KeySequence, KeySequence> {
|
||||
self.macros.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdatableBindings {
|
||||
/// Internal implementation that optionally removes from the macros map.
|
||||
/// When updating bindings for macros, we don't want to remove the macro definition itself.
|
||||
fn try_unbind_impl(&mut self, seq: &KeySequence, remove_from_macros: bool) -> bool {
|
||||
// Optionally remove from macros.
|
||||
let removed_macro = if remove_from_macros {
|
||||
self.macros.remove(seq).is_some()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
match seq {
|
||||
interfaces::KeySequence::Strokes(_) => {
|
||||
if let Some((modifiers, key_code)) = translate_key_sequence_to_reedline(seq) {
|
||||
let found = self.bindings.find_binding(modifiers, key_code).is_some();
|
||||
|
||||
if found {
|
||||
self.update(|bindings| {
|
||||
let _ = bindings.remove_binding(modifiers, key_code);
|
||||
});
|
||||
}
|
||||
|
||||
found || removed_macro
|
||||
} else {
|
||||
removed_macro
|
||||
}
|
||||
}
|
||||
interfaces::KeySequence::Bytes(bytes) => {
|
||||
let flat_bytes: Vec<u8> = bytes.iter().flatten().copied().collect();
|
||||
let removed_raw = self.raw_mappings.remove(&flat_bytes).is_some();
|
||||
|
||||
removed_raw || removed_macro
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn do_bind(
|
||||
&mut self,
|
||||
seq: KeySequence,
|
||||
action: KeyAction,
|
||||
rebuild_for_reedline: bool,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let Some(event) = translate_action_to_reedline_event(&action) else {
|
||||
return Err(std::io::Error::other(KeyError::UnsupportedKeyAction(
|
||||
action,
|
||||
)));
|
||||
};
|
||||
|
||||
match seq {
|
||||
interfaces::KeySequence::Strokes(_) => {
|
||||
if let Some((modifiers, key_code)) = translate_key_sequence_to_reedline(&seq) {
|
||||
if rebuild_for_reedline {
|
||||
self.update(|bindings| {
|
||||
bindings.add_binding(modifiers, key_code, event.clone());
|
||||
});
|
||||
} else {
|
||||
self.bindings
|
||||
.add_binding(modifiers, key_code, event.clone());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::other(KeyError::UnsupportedKeySequence(seq)))
|
||||
}
|
||||
}
|
||||
interfaces::KeySequence::Bytes(ref bytes) => {
|
||||
let flat_bytes: Vec<u8> = bytes.iter().flatten().copied().collect();
|
||||
self.raw_mappings.insert(flat_bytes, action);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_update_bindings_for_all_macros(&mut self) {
|
||||
let macros = self.macros.clone();
|
||||
for (seq, target) in macros {
|
||||
let _ = self.update_bindings_for_macro(seq, target);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_bindings_for_macro(
|
||||
&mut self,
|
||||
seq: KeySequence,
|
||||
target: KeySequence,
|
||||
) -> Result<(), std::io::Error> {
|
||||
match target {
|
||||
// TODO(input): We acknowledge that this implementation eagerly resolves the macro
|
||||
// and what it will do. Subsequent changes to other key binding might invalidate
|
||||
// this. We also are *extremely* limited in what we support here.
|
||||
interfaces::KeySequence::Strokes(key_strokes) => {
|
||||
if key_strokes.is_empty() {
|
||||
// Empty macro target - unbind any existing binding for this sequence.
|
||||
self.try_unbind(seq);
|
||||
} else {
|
||||
return Err(std::io::Error::other(
|
||||
"binding key sequence to readline macro with strokes",
|
||||
));
|
||||
}
|
||||
}
|
||||
interfaces::KeySequence::Bytes(items) => {
|
||||
// Flatten all byte sequences into one contiguous buffer for prefix matching.
|
||||
let flat_bytes: Vec<u8> = items.iter().flatten().copied().collect();
|
||||
let actions = self.resolve_macro_body(&flat_bytes);
|
||||
|
||||
if actions.is_empty() {
|
||||
// No actions resolved - unbind any existing key binding for this sequence,
|
||||
// but keep the macro definition so it shows up in `bind -s/-S`.
|
||||
self.try_unbind_impl(&seq, false);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create a single action: either the action itself (if just one), or a Sequence.
|
||||
let action = if let [single] = actions.as_slice() {
|
||||
single.clone()
|
||||
} else {
|
||||
KeyAction::Sequence(actions)
|
||||
};
|
||||
|
||||
self.do_bind(seq, action, false)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a macro body (byte sequence) into a sequence of actions using prefix matching.
|
||||
/// Returns a flattened vector of actions (any nested Sequences are expanded).
|
||||
fn resolve_macro_body(&self, bytes: &[u8]) -> Vec<KeyAction> {
|
||||
let mut actions = Vec::new();
|
||||
let mut remaining = bytes;
|
||||
|
||||
while !remaining.is_empty() {
|
||||
// Find the longest prefix match in the trie.
|
||||
if let Some((matched_key, action)) = self.find_longest_prefix_match(remaining) {
|
||||
// Flatten any nested Sequence actions.
|
||||
Self::flatten_action_into(&mut actions, action.clone());
|
||||
remaining = &remaining[matched_key.len()..];
|
||||
} else {
|
||||
// No match found - skip one byte and continue.
|
||||
// This handles unbound byte sequences gracefully.
|
||||
tracing::debug!(
|
||||
target: trace_categories::INPUT,
|
||||
"skipping unbound byte in macro resolution: 0x{:02x}",
|
||||
remaining[0]
|
||||
);
|
||||
remaining = &remaining[1..];
|
||||
}
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
/// Find the longest prefix match in the trie for the given bytes.
|
||||
/// Uses the trie's native `get_ancestor` method which efficiently finds
|
||||
/// the longest matching prefix.
|
||||
fn find_longest_prefix_match(&self, bytes: &[u8]) -> Option<(Vec<u8>, &KeyAction)> {
|
||||
use radix_trie::TrieCommon;
|
||||
|
||||
self.raw_mappings.get_ancestor(bytes).and_then(|subtrie| {
|
||||
let key = subtrie.key()?.clone();
|
||||
let value = subtrie.value()?;
|
||||
Some((key, value))
|
||||
})
|
||||
}
|
||||
|
||||
/// Flatten an action into the actions vector, expanding any Sequence variants.
|
||||
fn flatten_action_into(actions: &mut Vec<KeyAction>, action: KeyAction) {
|
||||
match action {
|
||||
KeyAction::Sequence(inner_actions) => {
|
||||
for inner in inner_actions {
|
||||
Self::flatten_action_into(actions, inner);
|
||||
}
|
||||
}
|
||||
other => actions.push(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_key_sequence_to_reedline(
|
||||
seq: &KeySequence,
|
||||
) -> Option<(reedline::KeyModifiers, reedline::KeyCode)> {
|
||||
let KeySequence::Strokes(strokes) = seq else {
|
||||
// TODO(input): handle other kinds of key sequences
|
||||
return None;
|
||||
};
|
||||
|
||||
let [stroke] = &strokes.as_slice() else {
|
||||
// TODO(input): handle multiple strokes
|
||||
return None;
|
||||
};
|
||||
|
||||
let mut modifiers = reedline::KeyModifiers::empty();
|
||||
modifiers.set(reedline::KeyModifiers::ALT, stroke.alt);
|
||||
modifiers.set(reedline::KeyModifiers::CONTROL, stroke.control);
|
||||
modifiers.set(reedline::KeyModifiers::SHIFT, stroke.shift);
|
||||
|
||||
let key_code = match stroke.key {
|
||||
Key::Character(c) => reedline::KeyCode::Char(c),
|
||||
Key::Backspace => reedline::KeyCode::Backspace,
|
||||
Key::Enter => reedline::KeyCode::Enter,
|
||||
Key::Left => reedline::KeyCode::Left,
|
||||
Key::Right => reedline::KeyCode::Right,
|
||||
Key::Up => reedline::KeyCode::Up,
|
||||
Key::Down => reedline::KeyCode::Down,
|
||||
Key::Home => reedline::KeyCode::Home,
|
||||
Key::End => reedline::KeyCode::End,
|
||||
Key::PageUp => reedline::KeyCode::PageUp,
|
||||
Key::PageDown => reedline::KeyCode::PageDown,
|
||||
Key::Tab => reedline::KeyCode::Tab,
|
||||
Key::BackTab => reedline::KeyCode::BackTab,
|
||||
Key::Delete => reedline::KeyCode::Delete,
|
||||
Key::Insert => reedline::KeyCode::Insert,
|
||||
Key::F(n) => reedline::KeyCode::F(n),
|
||||
Key::Escape => reedline::KeyCode::Esc,
|
||||
};
|
||||
|
||||
Some((modifiers, key_code))
|
||||
}
|
||||
|
||||
fn translate_action_to_reedline_event(action: &KeyAction) -> Option<reedline::ReedlineEvent> {
|
||||
match action {
|
||||
KeyAction::ShellCommand(cmd) => Some(reedline::ReedlineEvent::ExecuteHostCommand(
|
||||
format_reedline_host_command(cmd.as_str()),
|
||||
)),
|
||||
KeyAction::DoInputFunction(func) => translate_input_function_to_reedline_event(func),
|
||||
KeyAction::Sequence(actions) => {
|
||||
// Convert each action in the sequence to a reedline event.
|
||||
let events: Vec<_> = actions
|
||||
.iter()
|
||||
.filter_map(translate_action_to_reedline_event)
|
||||
.collect();
|
||||
|
||||
if events.is_empty() {
|
||||
None
|
||||
} else if events.len() == 1 {
|
||||
events.into_iter().next()
|
||||
} else {
|
||||
Some(reedline::ReedlineEvent::Multiple(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reedline_host_command(cmd: &str) -> String {
|
||||
// NOTE: When this command gets returned from reedline's `read_line` function,
|
||||
// we need a way to know that it didn't come from user input (e.g., so we don't
|
||||
// add it to history, etc.). Since reedline doesn't provide any facilities for
|
||||
// doing this, we apply a workaround of appending a special marker comment at
|
||||
// the end of the command.
|
||||
std::format!("{cmd} # bind-command")
|
||||
}
|
||||
|
||||
fn parse_reedline_host_command(cmd: &str) -> Option<&str> {
|
||||
// See the implementation of `format_reedline_host_command`. We look for the marker.
|
||||
cmd.strip_suffix(" # bind-command")
|
||||
}
|
||||
|
||||
fn translate_input_function_to_reedline_event(
|
||||
func: &InputFunction,
|
||||
) -> Option<reedline::ReedlineEvent> {
|
||||
use reedline::{EditCommand, ReedlineEvent};
|
||||
|
||||
match func {
|
||||
InputFunction::BackwardDeleteChar => {
|
||||
Some(ReedlineEvent::Edit(vec![EditCommand::Backspace]))
|
||||
}
|
||||
InputFunction::BackwardKillWord => {
|
||||
Some(ReedlineEvent::Edit(vec![EditCommand::CutWordLeft]))
|
||||
}
|
||||
InputFunction::KillLine => Some(ReedlineEvent::Edit(vec![EditCommand::KillLine])),
|
||||
InputFunction::KillWholeLine => Some(ReedlineEvent::Edit(vec![EditCommand::CutFromStart])),
|
||||
InputFunction::KillWord => Some(ReedlineEvent::Edit(vec![EditCommand::CutWordRight])),
|
||||
InputFunction::DeleteChar => Some(ReedlineEvent::Edit(vec![EditCommand::Delete])),
|
||||
InputFunction::DowncaseWord => Some(ReedlineEvent::Edit(vec![EditCommand::LowercaseWord])),
|
||||
InputFunction::BackwardChar => Some(ReedlineEvent::Edit(vec![EditCommand::MoveLeft {
|
||||
select: false,
|
||||
}])),
|
||||
InputFunction::ForwardChar => Some(ReedlineEvent::Edit(vec![EditCommand::MoveRight {
|
||||
select: false,
|
||||
}])),
|
||||
InputFunction::EndOfLine => Some(ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd {
|
||||
select: false,
|
||||
}])),
|
||||
InputFunction::BeginningOfLine => {
|
||||
Some(ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart {
|
||||
select: false,
|
||||
}]))
|
||||
}
|
||||
InputFunction::BackwardWord => Some(ReedlineEvent::Edit(vec![EditCommand::MoveWordLeft {
|
||||
select: false,
|
||||
}])),
|
||||
InputFunction::ForwardWord => Some(ReedlineEvent::Edit(vec![EditCommand::MoveWordRight {
|
||||
select: false,
|
||||
}])),
|
||||
InputFunction::Yank => Some(ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])),
|
||||
InputFunction::ViRedo => Some(ReedlineEvent::Edit(vec![EditCommand::Redo])),
|
||||
InputFunction::TransposeChars => {
|
||||
Some(ReedlineEvent::Edit(vec![EditCommand::SwapGraphemes]))
|
||||
}
|
||||
InputFunction::UpcaseWord => Some(ReedlineEvent::Edit(vec![EditCommand::UppercaseWord])),
|
||||
InputFunction::Undo => Some(ReedlineEvent::Edit(vec![EditCommand::Undo])),
|
||||
InputFunction::ClearScreen => Some(ReedlineEvent::ClearScreen),
|
||||
InputFunction::AcceptLine => Some(ReedlineEvent::Enter),
|
||||
InputFunction::HistorySearchBackward => Some(ReedlineEvent::SearchHistory),
|
||||
InputFunction::RedrawCurrentLine => Some(ReedlineEvent::Repaint),
|
||||
InputFunction::Complete => Some(ReedlineEvent::Edit(vec![EditCommand::Complete])),
|
||||
InputFunction::BrushAcceptHint => Some(ReedlineEvent::HistoryHintComplete),
|
||||
InputFunction::BrushAcceptHintWord => Some(ReedlineEvent::HistoryHintWordComplete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_reedline_host_command(cmd: &str) -> bool {
|
||||
// See the implementation of `format_reedline_host_command`. We look for the marker.
|
||||
cmd.ends_with("# bind-command")
|
||||
}
|
||||
|
||||
const fn translate_reedline_keycode(keycode: reedline::KeyCode) -> Option<Key> {
|
||||
match keycode {
|
||||
reedline::KeyCode::Backspace => Some(Key::Backspace),
|
||||
reedline::KeyCode::Enter => Some(Key::Enter),
|
||||
reedline::KeyCode::Left => Some(Key::Left),
|
||||
reedline::KeyCode::Right => Some(Key::Right),
|
||||
reedline::KeyCode::Up => Some(Key::Up),
|
||||
reedline::KeyCode::Down => Some(Key::Down),
|
||||
reedline::KeyCode::Home => Some(Key::Home),
|
||||
reedline::KeyCode::End => Some(Key::End),
|
||||
reedline::KeyCode::PageUp => Some(Key::PageUp),
|
||||
reedline::KeyCode::PageDown => Some(Key::PageDown),
|
||||
reedline::KeyCode::Tab => Some(Key::Tab),
|
||||
reedline::KeyCode::BackTab => Some(Key::BackTab),
|
||||
reedline::KeyCode::Delete => Some(Key::Delete),
|
||||
reedline::KeyCode::Insert => Some(Key::Insert),
|
||||
reedline::KeyCode::F(n) => Some(Key::F(n)),
|
||||
reedline::KeyCode::Char(c) => Some(Key::Character(c)),
|
||||
reedline::KeyCode::Null => None,
|
||||
reedline::KeyCode::Esc => Some(Key::Escape),
|
||||
reedline::KeyCode::CapsLock => None,
|
||||
reedline::KeyCode::ScrollLock => None,
|
||||
reedline::KeyCode::NumLock => None,
|
||||
reedline::KeyCode::PrintScreen => None,
|
||||
reedline::KeyCode::Pause => None,
|
||||
reedline::KeyCode::Menu => None,
|
||||
reedline::KeyCode::KeypadBegin => None,
|
||||
reedline::KeyCode::Media(_media_key_code) => None,
|
||||
reedline::KeyCode::Modifier(_modifier_key_code) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_lines)]
|
||||
fn translate_reedline_event_to_action(event: &reedline::ReedlineEvent) -> Option<KeyAction> {
|
||||
match event {
|
||||
reedline::ReedlineEvent::Edit(cmds) => {
|
||||
match cmds.as_slice() {
|
||||
[reedline::EditCommand::Backspace] => Some(KeyAction::DoInputFunction(
|
||||
InputFunction::BackwardDeleteChar,
|
||||
)),
|
||||
[reedline::EditCommand::BackspaceWord] => {
|
||||
// Not quite accurate, because it doesn't save the deleted text.
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BackwardKillWord))
|
||||
}
|
||||
[reedline::EditCommand::CapitalizeChar] => None,
|
||||
[reedline::EditCommand::ClearToLineEnd] => {
|
||||
// Not quite accurate, because it doesn't save the deleted text.
|
||||
Some(KeyAction::DoInputFunction(InputFunction::KillLine))
|
||||
}
|
||||
[reedline::EditCommand::Complete] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::Complete))
|
||||
}
|
||||
[reedline::EditCommand::CutFromStart] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::KillWholeLine))
|
||||
}
|
||||
[reedline::EditCommand::KillLine] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::KillLine))
|
||||
}
|
||||
[reedline::EditCommand::CutWordLeft] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BackwardKillWord))
|
||||
}
|
||||
[reedline::EditCommand::CutWordRight] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::KillWord))
|
||||
}
|
||||
[reedline::EditCommand::Delete] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::DeleteChar))
|
||||
}
|
||||
[reedline::EditCommand::DeleteWord] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::KillWord))
|
||||
}
|
||||
[reedline::EditCommand::InsertNewline] => None,
|
||||
[reedline::EditCommand::LowercaseWord] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::DowncaseWord))
|
||||
}
|
||||
[reedline::EditCommand::MoveLeft { select: false }] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BackwardChar))
|
||||
}
|
||||
[reedline::EditCommand::MoveLeft { select: true }] => None,
|
||||
[reedline::EditCommand::MoveRight { select: false }] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::ForwardChar))
|
||||
}
|
||||
[reedline::EditCommand::MoveRight { select: true }] => None,
|
||||
[reedline::EditCommand::MoveToEnd { select: false }] => {
|
||||
// TODO(input): Not quite accurate, because it doesn't just go to end of line.
|
||||
Some(KeyAction::DoInputFunction(InputFunction::EndOfLine))
|
||||
}
|
||||
[reedline::EditCommand::MoveToEnd { select: true }] => None,
|
||||
[reedline::EditCommand::MoveToLineEnd { select: false }] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::EndOfLine))
|
||||
}
|
||||
[reedline::EditCommand::MoveToLineEnd { select: true }] => None,
|
||||
[reedline::EditCommand::MoveToLineStart { select: false }] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BeginningOfLine))
|
||||
}
|
||||
[reedline::EditCommand::MoveToLineStart { select: true }] => None,
|
||||
[reedline::EditCommand::MoveToStart { select: false }] => {
|
||||
// TODO(input): Not quite accurate, because it doesn't just go to beginning of
|
||||
// line.
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BeginningOfLine))
|
||||
}
|
||||
[reedline::EditCommand::MoveToStart { select: true }] => None,
|
||||
[reedline::EditCommand::MoveWordLeft { select: false }] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BackwardWord))
|
||||
}
|
||||
[reedline::EditCommand::MoveWordLeft { select: true }] => None,
|
||||
[reedline::EditCommand::MoveWordRight { select: false }] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::ForwardWord))
|
||||
}
|
||||
[reedline::EditCommand::MoveWordRight { select: true }] => None,
|
||||
[reedline::EditCommand::PasteCutBufferAfter] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::Yank))
|
||||
}
|
||||
[reedline::EditCommand::PasteCutBufferBefore] => None,
|
||||
[reedline::EditCommand::Redo] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::ViRedo))
|
||||
}
|
||||
[reedline::EditCommand::SelectAll] => None,
|
||||
[reedline::EditCommand::SwapGraphemes] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::TransposeChars))
|
||||
}
|
||||
[reedline::EditCommand::UppercaseWord] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::UpcaseWord))
|
||||
}
|
||||
[reedline::EditCommand::Undo] => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::Undo))
|
||||
}
|
||||
_ => {
|
||||
// TODO(input): Handle more?
|
||||
tracing::debug!(target: trace_categories::INPUT, "unhandled edit commands: {cmds:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
reedline::ReedlineEvent::ClearScreen => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::ClearScreen))
|
||||
}
|
||||
reedline::ReedlineEvent::CtrlC => None,
|
||||
reedline::ReedlineEvent::CtrlD => None,
|
||||
reedline::ReedlineEvent::Enter => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::AcceptLine))
|
||||
}
|
||||
reedline::ReedlineEvent::Esc => None,
|
||||
reedline::ReedlineEvent::MenuPrevious => None,
|
||||
reedline::ReedlineEvent::OpenEditor => None,
|
||||
reedline::ReedlineEvent::Left => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BackwardChar))
|
||||
}
|
||||
reedline::ReedlineEvent::Right => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::ForwardChar))
|
||||
}
|
||||
reedline::ReedlineEvent::Up => Some(KeyAction::DoInputFunction(
|
||||
InputFunction::PreviousScreenLine,
|
||||
)),
|
||||
reedline::ReedlineEvent::Down => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::NextScreenLine))
|
||||
}
|
||||
reedline::ReedlineEvent::SearchHistory => Some(KeyAction::DoInputFunction(
|
||||
InputFunction::HistorySearchBackward,
|
||||
)),
|
||||
reedline::ReedlineEvent::Repaint => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::RedrawCurrentLine))
|
||||
}
|
||||
reedline::ReedlineEvent::HistoryHintComplete => {
|
||||
Some(KeyAction::DoInputFunction(InputFunction::BrushAcceptHint))
|
||||
}
|
||||
reedline::ReedlineEvent::HistoryHintWordComplete => Some(KeyAction::DoInputFunction(
|
||||
InputFunction::BrushAcceptHintWord,
|
||||
)),
|
||||
reedline::ReedlineEvent::Multiple(evts) => {
|
||||
if let &[
|
||||
reedline::ReedlineEvent::Edit(ref edit_cmds),
|
||||
reedline::ReedlineEvent::Enter,
|
||||
] = evts.as_slice()
|
||||
{
|
||||
if let &[
|
||||
reedline::EditCommand::MoveToStart { select: false },
|
||||
reedline::EditCommand::InsertChar('#'),
|
||||
] = edit_cmds.as_slice()
|
||||
{
|
||||
return Some(KeyAction::DoInputFunction(InputFunction::InsertComment));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(input): Try to extract something from these?
|
||||
tracing::debug!(target: trace_categories::INPUT, "unhandled composite event: {evts:?}");
|
||||
None
|
||||
}
|
||||
reedline::ReedlineEvent::UntilFound(uf_events) => {
|
||||
let mut i = 0;
|
||||
|
||||
if uf_events.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
while i < uf_events.len() {
|
||||
match &uf_events[i] {
|
||||
reedline::ReedlineEvent::HistoryHintComplete
|
||||
| reedline::ReedlineEvent::HistoryHintWordComplete
|
||||
| reedline::ReedlineEvent::Menu(_)
|
||||
| reedline::ReedlineEvent::MenuDown
|
||||
| reedline::ReedlineEvent::MenuUp
|
||||
| reedline::ReedlineEvent::MenuLeft
|
||||
| reedline::ReedlineEvent::MenuRight
|
||||
| reedline::ReedlineEvent::MenuNext
|
||||
| reedline::ReedlineEvent::MenuPrevious
|
||||
| reedline::ReedlineEvent::MenuPageNext
|
||||
| reedline::ReedlineEvent::MenuPagePrevious => {
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if i == uf_events.len() - 1 {
|
||||
translate_reedline_event_to_action(&uf_events[i])
|
||||
} else {
|
||||
// TODO(input): Try to extract something from these?
|
||||
tracing::debug!(target: trace_categories::INPUT, "unhandled until-found event: {uf_events:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
reedline::ReedlineEvent::ExecuteHostCommand(cmd) => parse_reedline_host_command(cmd)
|
||||
.map(|cmd_str| KeyAction::ShellCommand(cmd_str.to_string())),
|
||||
evt => {
|
||||
// TODO(input): Handle more?
|
||||
tracing::debug!(target: trace_categories::INPUT, "unhandled event: {evt:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::{highlighting, refs};
|
||||
use nu_ansi_term::{Color, Style};
|
||||
|
||||
mod styles {
|
||||
use super::{Color, Style};
|
||||
|
||||
pub fn default() -> Style {
|
||||
Style::new().fg(Color::White)
|
||||
}
|
||||
|
||||
pub fn comment() -> Style {
|
||||
Style::new().fg(Color::DarkGray)
|
||||
}
|
||||
|
||||
pub fn arithmetic() -> Style {
|
||||
Style::new().fg(Color::LightBlue)
|
||||
}
|
||||
|
||||
pub fn parameter() -> Style {
|
||||
Style::new().fg(Color::LightMagenta)
|
||||
}
|
||||
|
||||
pub fn command_substitution() -> Style {
|
||||
Style::new().fg(Color::LightBlue)
|
||||
}
|
||||
|
||||
pub fn quoted() -> Style {
|
||||
Style::new().fg(Color::Yellow)
|
||||
}
|
||||
|
||||
pub fn operator() -> Style {
|
||||
Style::new().fg(Color::White).italic()
|
||||
}
|
||||
|
||||
pub fn assignment() -> Style {
|
||||
Style::new().fg(Color::LightGray).dimmed()
|
||||
}
|
||||
|
||||
pub fn hyphen_option() -> Style {
|
||||
Style::new().fg(Color::White).italic()
|
||||
}
|
||||
|
||||
pub fn function() -> Style {
|
||||
Style::new().bold().fg(Color::Yellow)
|
||||
}
|
||||
|
||||
pub fn keyword() -> Style {
|
||||
Style::new().bold().fg(Color::LightYellow).italic()
|
||||
}
|
||||
|
||||
pub fn builtin() -> Style {
|
||||
Style::new().bold().fg(Color::Green)
|
||||
}
|
||||
|
||||
pub fn alias() -> Style {
|
||||
Style::new().bold().fg(Color::Cyan)
|
||||
}
|
||||
|
||||
pub fn external_command() -> Style {
|
||||
Style::new().bold().fg(Color::Green)
|
||||
}
|
||||
|
||||
pub fn not_found_command() -> Style {
|
||||
Style::new().bold().fg(Color::Red)
|
||||
}
|
||||
|
||||
pub fn unknown_command() -> Style {
|
||||
Style::new().bold().fg(Color::White)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct ReedlineHighlighter<SE: brush_core::ShellExtensions> {
|
||||
pub shell: refs::ShellRef<SE>,
|
||||
}
|
||||
|
||||
impl<SE: brush_core::ShellExtensions> reedline::Highlighter for ReedlineHighlighter<SE> {
|
||||
#[expect(clippy::significant_drop_tightening)]
|
||||
fn highlight(&self, line: &str, cursor: usize) -> reedline::StyledText {
|
||||
let shell = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.shell.lock())
|
||||
});
|
||||
|
||||
let spans = highlighting::highlight_command(shell.as_ref(), line, cursor);
|
||||
|
||||
let mut styled = reedline::StyledText::new();
|
||||
for span in spans {
|
||||
let style = kind_to_style(span.kind);
|
||||
styled.push((style, span.text(line).to_owned()));
|
||||
}
|
||||
|
||||
styled
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_to_style(kind: highlighting::HighlightKind) -> Style {
|
||||
match kind {
|
||||
highlighting::HighlightKind::Default => styles::default(),
|
||||
highlighting::HighlightKind::Comment => styles::comment(),
|
||||
highlighting::HighlightKind::Arithmetic => styles::arithmetic(),
|
||||
highlighting::HighlightKind::Parameter => styles::parameter(),
|
||||
highlighting::HighlightKind::CommandSubstitution => styles::command_substitution(),
|
||||
highlighting::HighlightKind::Quoted => styles::quoted(),
|
||||
highlighting::HighlightKind::Operator => styles::operator(),
|
||||
highlighting::HighlightKind::Assignment => styles::assignment(),
|
||||
highlighting::HighlightKind::HyphenOption => styles::hyphen_option(),
|
||||
highlighting::HighlightKind::Function => styles::function(),
|
||||
highlighting::HighlightKind::Keyword => styles::keyword(),
|
||||
highlighting::HighlightKind::Builtin => styles::builtin(),
|
||||
highlighting::HighlightKind::Alias => styles::alias(),
|
||||
highlighting::HighlightKind::ExternalCommand => styles::external_command(),
|
||||
highlighting::HighlightKind::NotFoundCommand => styles::not_found_command(),
|
||||
highlighting::HighlightKind::UnknownCommand => styles::unknown_command(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
use crate::refs;
|
||||
|
||||
pub(crate) struct ReedlineHistory<SE: brush_core::ShellExtensions> {
|
||||
pub shell: refs::ShellRef<SE>,
|
||||
}
|
||||
|
||||
impl<SE: brush_core::ShellExtensions> ReedlineHistory<SE> {
|
||||
fn lock_shell(&self) -> tokio::sync::MutexGuard<'_, brush_core::Shell<SE>> {
|
||||
tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.shell.lock())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<SE: brush_core::ShellExtensions> reedline::History for ReedlineHistory<SE> {
|
||||
/// Updates or adds a new item to the saved history.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `item` - The history item to save.
|
||||
fn save(&mut self, item: reedline::HistoryItem) -> reedline::Result<reedline::HistoryItem> {
|
||||
//
|
||||
// TODO(history): Evaluate a way to rationalize between this and the shared
|
||||
// history saving. For now, we need to do nothing here to avoid
|
||||
// duplicate history items since we are auto-updating the history
|
||||
// in a non-reedline-specific way.
|
||||
//
|
||||
|
||||
// let brush_item = reedline_history_item_to_brush(&item);
|
||||
// let mut shell = self.lock_shell();
|
||||
// let history = get_shell_history_mut(&mut shell)?;
|
||||
//
|
||||
// if let Some(id) = &item.id {
|
||||
// history
|
||||
// .update_by_id(id.0, brush_item)
|
||||
// .map_err(brush_error_to_reedline)?;
|
||||
// } else {
|
||||
// let id = history.add(brush_item).map_err(brush_error_to_reedline)?;
|
||||
// item.id = Some(reedline::HistoryItemId(id));
|
||||
// }
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
/// Loads a history item by its ID.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - The ID of the history item to load.
|
||||
fn load(&self, id: reedline::HistoryItemId) -> reedline::Result<reedline::HistoryItem> {
|
||||
let shell = self.lock_shell();
|
||||
|
||||
// Get the history, retrieve the item, and translate the item it into reedline's format.
|
||||
get_shell_history(&shell)?
|
||||
.get_by_id(id.0)
|
||||
.map_err(brush_error_to_reedline)?
|
||||
.ok_or({
|
||||
reedline::ReedlineError(reedline::ReedlineErrorVariants::OtherHistoryError(
|
||||
"history item not found",
|
||||
))
|
||||
})
|
||||
.map(brush_history_item_to_reedline)
|
||||
}
|
||||
|
||||
/// Counts all history items matching the given query.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query` - The search query to match against history items.
|
||||
fn count(&self, query: reedline::SearchQuery) -> reedline::Result<i64> {
|
||||
let query = reedline_history_query_into_brush(query)?;
|
||||
|
||||
let shell = self.lock_shell();
|
||||
let count = get_shell_history(&shell)?.search(query).iter().count();
|
||||
drop(shell);
|
||||
|
||||
#[expect(clippy::cast_possible_wrap)]
|
||||
Ok(count as i64)
|
||||
}
|
||||
|
||||
/// Searches through history, returning all items matching the given query.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `query` - The search query to match against history items.
|
||||
#[expect(clippy::significant_drop_tightening)]
|
||||
fn search(&self, query: reedline::SearchQuery) -> reedline::Result<Vec<reedline::HistoryItem>> {
|
||||
let query = reedline_history_query_into_brush(query)?;
|
||||
let shell = self.lock_shell();
|
||||
let items = get_shell_history(&shell)?
|
||||
.search(query)
|
||||
.map_err(brush_error_to_reedline)?
|
||||
.map(|item| {
|
||||
// Translate the item into reedline's format.
|
||||
brush_history_item_to_reedline(item)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Update a history item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - The ID of the history item to update.
|
||||
/// * `updater` - A function that takes a history item and returns an updated history item.
|
||||
fn update(
|
||||
&mut self,
|
||||
id: reedline::HistoryItemId,
|
||||
updater: &dyn Fn(reedline::HistoryItem) -> reedline::HistoryItem,
|
||||
) -> reedline::Result<()> {
|
||||
// TODO(history): Understand atomicity expectations of reedline.
|
||||
let item = self.load(id)?;
|
||||
let updated_item = updater(item);
|
||||
self.save(updated_item)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete all history items.
|
||||
fn clear(&mut self) -> reedline::Result<()> {
|
||||
let mut shell = self.lock_shell();
|
||||
|
||||
// Get the history, retrieve the item, and translate the item it into reedline's format.
|
||||
get_shell_history_mut(&mut shell)?
|
||||
.clear()
|
||||
.map_err(brush_error_to_reedline)
|
||||
}
|
||||
|
||||
/// Delete the history item with the given ID.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - The ID of the history item to delete.
|
||||
fn delete(&mut self, id: reedline::HistoryItemId) -> reedline::Result<()> {
|
||||
let mut shell = self.lock_shell();
|
||||
|
||||
get_shell_history_mut(&mut shell)?
|
||||
.delete_item_by_id(id.0)
|
||||
.map_err(brush_error_to_reedline)
|
||||
}
|
||||
|
||||
/// Sync all history items to backing storage.
|
||||
fn sync(&mut self) -> std::io::Result<()> {
|
||||
let mut shell = self.lock_shell();
|
||||
shell.save_history().map_err(std::io::Error::other)
|
||||
}
|
||||
|
||||
/// Retrieves a unique ID for the current session.
|
||||
fn session(&self) -> Option<reedline::HistorySessionId> {
|
||||
// Not implemented for now.
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_history_item_to_reedline(item: &brush_core::history::Item) -> reedline::HistoryItem {
|
||||
let mut rl_item = reedline::HistoryItem::from_command_line(item.command_line.as_str());
|
||||
rl_item.id = Some(reedline::HistoryItemId(item.id));
|
||||
rl_item.start_timestamp = item.timestamp;
|
||||
|
||||
rl_item
|
||||
}
|
||||
|
||||
#[expect(unused)]
|
||||
fn reedline_history_item_to_brush(item: &reedline::HistoryItem) -> brush_core::history::Item {
|
||||
// TODO(history): implement more fields when they are added to Item
|
||||
brush_core::history::Item {
|
||||
id: item.id.map_or(0, |id| id.0),
|
||||
command_line: item.command_line.clone(),
|
||||
timestamp: item.start_timestamp,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_error_to_reedline(error: brush_core::Error) -> reedline::ReedlineError {
|
||||
reedline::ReedlineError::from(std::io::Error::other(error))
|
||||
}
|
||||
|
||||
fn reedline_history_query_into_brush(
|
||||
query: reedline::SearchQuery,
|
||||
) -> reedline::Result<brush_core::history::Query> {
|
||||
let mut result = brush_core::history::Query {
|
||||
direction: match query.direction {
|
||||
reedline::SearchDirection::Forward => brush_core::history::Direction::Forward,
|
||||
reedline::SearchDirection::Backward => brush_core::history::Direction::Backward,
|
||||
},
|
||||
max_items: query.limit,
|
||||
not_at_or_before_id: if matches!(query.direction, reedline::SearchDirection::Backward) {
|
||||
query.end_id.map(|id| id.0)
|
||||
} else {
|
||||
query.start_id.map(|id| id.0)
|
||||
},
|
||||
not_at_or_after_id: if matches!(query.direction, reedline::SearchDirection::Backward) {
|
||||
query.start_id.map(|id| id.0)
|
||||
} else {
|
||||
query.end_id.map(|id| id.0)
|
||||
},
|
||||
not_at_or_before_time: if matches!(query.direction, reedline::SearchDirection::Backward) {
|
||||
query.end_time
|
||||
} else {
|
||||
query.start_time
|
||||
},
|
||||
not_at_or_after_time: if matches!(query.direction, reedline::SearchDirection::Backward) {
|
||||
query.start_time
|
||||
} else {
|
||||
query.end_time
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(cmdline_filter) = query.filter.command_line {
|
||||
result.command_line_filter = match cmdline_filter {
|
||||
reedline::CommandLineSearch::Exact(cmdline) => {
|
||||
Some(brush_core::history::CommandLineFilter::Exact(cmdline))
|
||||
}
|
||||
reedline::CommandLineSearch::Substring(cmdline) => {
|
||||
Some(brush_core::history::CommandLineFilter::Contains(cmdline))
|
||||
}
|
||||
reedline::CommandLineSearch::Prefix(cmdline) => {
|
||||
Some(brush_core::history::CommandLineFilter::Prefix(cmdline))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if query.filter.cwd_exact.is_some()
|
||||
|| query.filter.cwd_prefix.is_some()
|
||||
|| query.filter.exit_successful.is_some()
|
||||
|| query.filter.hostname.is_some()
|
||||
|| query.filter.session.is_some()
|
||||
{
|
||||
return Err(reedline::ReedlineError(
|
||||
reedline::ReedlineErrorVariants::HistoryFeatureUnsupported {
|
||||
history: "(default)",
|
||||
feature: "search filter",
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn get_shell_history<'a, SE: brush_core::ShellExtensions>(
|
||||
shell: &'a tokio::sync::MutexGuard<'_, brush_core::Shell<SE>>,
|
||||
) -> Result<&'a brush_core::history::History, reedline::ReedlineError> {
|
||||
shell.history().ok_or({
|
||||
reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryFeatureUnsupported {
|
||||
history: "(default)",
|
||||
feature: "load",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn get_shell_history_mut<'a, SE: brush_core::ShellExtensions>(
|
||||
shell: &'a mut tokio::sync::MutexGuard<'_, brush_core::Shell<SE>>,
|
||||
) -> Result<&'a mut brush_core::history::History, reedline::ReedlineError> {
|
||||
shell.history_mut().ok_or({
|
||||
reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryFeatureUnsupported {
|
||||
history: "(default)",
|
||||
feature: "load",
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use nu_ansi_term::Color;
|
||||
use reedline::MenuBuilder;
|
||||
|
||||
use super::{completer, edit_mode, highlighter, history, validator};
|
||||
use crate::{InputBackend, ReadResult, ShellError, input_backend::InteractivePrompt, refs};
|
||||
|
||||
/// Represents an interactive shell capable of taking commands from standard input
|
||||
/// and reporting results to standard output and standard error streams.
|
||||
pub struct ReedlineInputBackend {
|
||||
reedline: Option<reedline::Reedline>,
|
||||
}
|
||||
|
||||
const COMPLETION_MENU_NAME: &str = "completion_menu";
|
||||
|
||||
impl ReedlineInputBackend {
|
||||
/// Returns a new interactive shell instance, created with the provided options.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `options` - Options for creating the input backend.
|
||||
/// * `shell_ref` - Shell that the backend will be used with.
|
||||
pub fn new(
|
||||
options: &crate::UIOptions,
|
||||
shell_ref: &refs::ShellRef<impl brush_core::ShellExtensions>,
|
||||
) -> Result<Self, ShellError> {
|
||||
// Set up key bindings.
|
||||
let key_bindings = compose_key_bindings(COMPLETION_MENU_NAME);
|
||||
|
||||
// Set up mutable edit mode.
|
||||
let mutable_edit_mode = edit_mode::MutableEditMode::new(key_bindings);
|
||||
let updatable_bindings = mutable_edit_mode.bindings();
|
||||
|
||||
// Create helper objects that implement reedline traits; each will
|
||||
// hold a reference to the shell.
|
||||
let completer = completer::ReedlineCompleter {
|
||||
shell: shell_ref.clone(),
|
||||
};
|
||||
let validator = validator::ReedlineValidator {
|
||||
shell: shell_ref.clone(),
|
||||
};
|
||||
let highlighter = highlighter::ReedlineHighlighter {
|
||||
shell: shell_ref.clone(),
|
||||
};
|
||||
let history = history::ReedlineHistory {
|
||||
shell: shell_ref.clone(),
|
||||
};
|
||||
|
||||
// Set up completion menu. Set an empty marker to avoid the
|
||||
// line's text horizontally shifting around during/after completion.
|
||||
// We set a max column count of 10 to ensure it's larger than the
|
||||
// hard-coded default (4 last we checked); if there's not enough
|
||||
// horizontal space in the terminal to fit that many columns, given
|
||||
// the actual text to be displayed, it will get effectively dereased
|
||||
// anyhow.
|
||||
let completion_menu = Box::new(
|
||||
reedline::ColumnarMenu::default()
|
||||
.with_name(COMPLETION_MENU_NAME)
|
||||
.with_marker("")
|
||||
.with_columns(10)
|
||||
.with_selected_text_style(Color::Blue.bold().reverse())
|
||||
.with_selected_match_text_style(Color::Blue.bold().reverse()),
|
||||
);
|
||||
|
||||
// Set up default history-based hinter.
|
||||
let mut hinter = reedline::DefaultHinter::default();
|
||||
if !options.disable_color {
|
||||
hinter = hinter.with_style(nu_ansi_term::Style::new().italic().fg(Color::DarkGray));
|
||||
}
|
||||
|
||||
// Instantiate reedline with some defaults and hand it ownership of
|
||||
// the helpers.
|
||||
let mut reedline = reedline::Reedline::create()
|
||||
.with_ansi_colors(!options.disable_color)
|
||||
.use_bracketed_paste(!options.disable_bracketed_paste)
|
||||
.with_completer(Box::new(completer))
|
||||
.with_quick_completions(true)
|
||||
.with_validator(Box::new(validator))
|
||||
.with_hinter(Box::new(hinter))
|
||||
.with_menu(reedline::ReedlineMenu::EngineCompleter(completion_menu))
|
||||
.with_edit_mode(Box::new(mutable_edit_mode))
|
||||
.with_history(Box::new(history));
|
||||
|
||||
// If requested, apply some additional niceties.
|
||||
if !options.disable_highlighting && !options.disable_color {
|
||||
reedline = reedline.with_highlighter(Box::new(highlighter));
|
||||
}
|
||||
|
||||
let mut shell = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(shell_ref.lock())
|
||||
});
|
||||
|
||||
shell.set_key_bindings(Some(updatable_bindings));
|
||||
drop(shell);
|
||||
|
||||
Ok(Self {
|
||||
reedline: Some(reedline),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReedlineInputBackend {
|
||||
fn drop(&mut self) {
|
||||
// It's unpleasant to need to do so, but if we detect a panic in the process of being
|
||||
// unwound, then we arrange for our reedline::Reedline instance to *not* get dropped.
|
||||
// Without this, then there's a chance that our panic handler emitted important
|
||||
// diagnostics to stdout but dropping the Reedline object will end up erasing it
|
||||
// when the latter object's internal Painter gets dropped and, in turn, may flush
|
||||
// some not-yet-flushed terminal control sequences. This isn't theoretical; we've
|
||||
// actively seen this in various cases where a panic occurs with Reedline::read_line()
|
||||
// on the stack.
|
||||
if std::thread::panicking() {
|
||||
let reedline = std::mem::take(&mut self.reedline);
|
||||
std::mem::forget(reedline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InputBackend for ReedlineInputBackend {
|
||||
/// Reads a line of input, using the given prompt.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prompt` - The prompt to display to the user.
|
||||
fn read_line(
|
||||
&mut self,
|
||||
_shell: &crate::ShellRef<impl brush_core::ShellExtensions>,
|
||||
prompt: InteractivePrompt,
|
||||
) -> Result<ReadResult, ShellError> {
|
||||
if let Some(reedline) = &mut self.reedline {
|
||||
match reedline.read_line(&prompt) {
|
||||
Ok(reedline::Signal::Success(s)) => {
|
||||
if edit_mode::is_reedline_host_command(s.as_str()) {
|
||||
Ok(ReadResult::BoundCommand(s))
|
||||
} else {
|
||||
Ok(ReadResult::Input(s))
|
||||
}
|
||||
}
|
||||
Ok(reedline::Signal::CtrlC) => Ok(ReadResult::Interrupted),
|
||||
Ok(reedline::Signal::CtrlD) => Ok(ReadResult::Eof),
|
||||
Ok(reedline::Signal::ExternalBreak(_)) => Err(ShellError::UnexpectedInputFailure),
|
||||
Ok(_) => Err(ShellError::UnexpectedInputFailure),
|
||||
Err(err) => Err(ShellError::InputError(err)),
|
||||
}
|
||||
} else {
|
||||
Ok(ReadResult::Eof)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_read_buffer(&self) -> Option<(String, usize)> {
|
||||
self.reedline.as_ref().map(|r| {
|
||||
(
|
||||
r.current_buffer_contents().to_owned(),
|
||||
r.current_insertion_point(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_read_buffer(&mut self, buffer: String, cursor: usize) {
|
||||
if let Some(reedline) = &mut self.reedline {
|
||||
reedline.run_edit_commands(&[
|
||||
reedline::EditCommand::MoveToStart { select: false },
|
||||
reedline::EditCommand::ClearToLineEnd,
|
||||
reedline::EditCommand::InsertString(buffer),
|
||||
reedline::EditCommand::MoveToPosition {
|
||||
position: cursor,
|
||||
select: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_key_bindings(completion_menu_name: &str) -> reedline::Keybindings {
|
||||
let mut key_bindings = reedline::default_emacs_keybindings();
|
||||
|
||||
// Wire up tab to completion.
|
||||
key_bindings.add_binding(
|
||||
reedline::KeyModifiers::NONE,
|
||||
reedline::KeyCode::Tab,
|
||||
reedline::ReedlineEvent::UntilFound(vec![
|
||||
reedline::ReedlineEvent::Menu(completion_menu_name.to_string()),
|
||||
reedline::ReedlineEvent::MenuNext,
|
||||
reedline::ReedlineEvent::Edit(vec![reedline::EditCommand::Complete]),
|
||||
]),
|
||||
);
|
||||
// Wire up shift-tab for completion.
|
||||
key_bindings.add_binding(
|
||||
reedline::KeyModifiers::SHIFT,
|
||||
reedline::KeyCode::BackTab,
|
||||
reedline::ReedlineEvent::MenuPrevious,
|
||||
);
|
||||
|
||||
// Add undo.
|
||||
// NOTE: To match readline, we bind Ctrl+_ to undo; in practice, the only way
|
||||
// to get that to work out is to specify Ctrl+7 for the binding. It's not clear
|
||||
// that this is terribly portable across terminals/environments.
|
||||
key_bindings.add_binding(
|
||||
reedline::KeyModifiers::CONTROL,
|
||||
reedline::KeyCode::Char('7'),
|
||||
reedline::ReedlineEvent::Edit(vec![reedline::EditCommand::Undo]),
|
||||
);
|
||||
|
||||
// Capitalize.
|
||||
key_bindings.add_binding(
|
||||
reedline::KeyModifiers::ALT,
|
||||
reedline::KeyCode::Char('c'),
|
||||
reedline::ReedlineEvent::Edit(vec![
|
||||
reedline::EditCommand::CapitalizeChar,
|
||||
reedline::EditCommand::MoveWordRight { select: false },
|
||||
]),
|
||||
);
|
||||
|
||||
// Add comment.
|
||||
key_bindings.add_binding(
|
||||
reedline::KeyModifiers::ALT,
|
||||
reedline::KeyCode::Char('#'),
|
||||
reedline::ReedlineEvent::Multiple(vec![
|
||||
reedline::ReedlineEvent::Edit(vec![
|
||||
reedline::EditCommand::MoveToStart { select: false },
|
||||
reedline::EditCommand::InsertChar('#'),
|
||||
]),
|
||||
reedline::ReedlineEvent::Enter,
|
||||
]),
|
||||
);
|
||||
|
||||
key_bindings
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
mod completer;
|
||||
mod edit_mode;
|
||||
mod highlighter;
|
||||
mod history;
|
||||
mod input_backend;
|
||||
mod prompt;
|
||||
mod validator;
|
||||
|
||||
pub use input_backend::ReedlineInputBackend;
|
||||
@@ -0,0 +1,69 @@
|
||||
use crate::input_backend::InteractivePrompt;
|
||||
|
||||
impl reedline::Prompt for InteractivePrompt {
|
||||
fn render_prompt_left(&self) -> std::borrow::Cow<'_, str> {
|
||||
// [Workaround: see https://github.com/nushell/reedline/issues/707]
|
||||
// If the prompt starts with a newline character, then there's a chance
|
||||
// that it won't be rendered correctly. For this specific case, insert
|
||||
// an extra space character before the newline.
|
||||
if self.prompt.starts_with('\n') {
|
||||
std::format!(" {}", self.prompt).into()
|
||||
} else {
|
||||
self.prompt.as_str().into()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_prompt_right(&self) -> std::borrow::Cow<'_, str> {
|
||||
self.alt_side_prompt.as_str().into()
|
||||
}
|
||||
|
||||
// N.B. For now, we don't support prompt indicators.
|
||||
fn render_prompt_indicator(
|
||||
&self,
|
||||
_prompt_mode: reedline::PromptEditMode,
|
||||
) -> std::borrow::Cow<'_, str> {
|
||||
"".into()
|
||||
}
|
||||
|
||||
fn render_prompt_multiline_indicator(&self) -> std::borrow::Cow<'_, str> {
|
||||
self.continuation_prompt.as_str().into()
|
||||
}
|
||||
|
||||
fn render_prompt_history_search_indicator(
|
||||
&self,
|
||||
history_search: reedline::PromptHistorySearch,
|
||||
) -> std::borrow::Cow<'_, str> {
|
||||
match history_search.status {
|
||||
reedline::PromptHistorySearchStatus::Passing => {
|
||||
if history_search.term.is_empty() {
|
||||
"(rev search) ".into()
|
||||
} else {
|
||||
std::format!("(rev search: {}) ", history_search.term).into()
|
||||
}
|
||||
}
|
||||
reedline::PromptHistorySearchStatus::Failing => {
|
||||
std::format!("(failing rev search: {}) ", history_search.term).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_prompt_color(&self) -> reedline::Color {
|
||||
reedline::Color::Reset
|
||||
}
|
||||
|
||||
fn get_prompt_multiline_color(&self) -> nu_ansi_term::Color {
|
||||
nu_ansi_term::Color::LightBlue
|
||||
}
|
||||
|
||||
fn get_indicator_color(&self) -> reedline::Color {
|
||||
reedline::Color::Cyan
|
||||
}
|
||||
|
||||
fn get_prompt_right_color(&self) -> reedline::Color {
|
||||
reedline::Color::AnsiValue(5)
|
||||
}
|
||||
|
||||
fn right_prompt_on_last_line(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use crate::refs;
|
||||
|
||||
pub(crate) struct ReedlineValidator<SE: brush_core::ShellExtensions> {
|
||||
pub shell: refs::ShellRef<SE>,
|
||||
}
|
||||
|
||||
impl<SE: brush_core::ShellExtensions> reedline::Validator for ReedlineValidator<SE> {
|
||||
fn validate(&self, line: &str) -> reedline::ValidationResult {
|
||||
let shell = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(self.shell.lock())
|
||||
});
|
||||
|
||||
match shell.parse_string(line.to_owned()) {
|
||||
Err(brush_parser::ParseError::Tokenizing { inner, position: _ })
|
||||
if inner.is_incomplete() =>
|
||||
{
|
||||
reedline::ValidationResult::Incomplete
|
||||
}
|
||||
Err(brush_parser::ParseError::ParsingAtEndOfInput) => {
|
||||
reedline::ValidationResult::Incomplete
|
||||
}
|
||||
_ => reedline::ValidationResult::Complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// A reference-counted, thread-safe reference to a `brush_core::Shell`.
|
||||
#[allow(type_alias_bounds)]
|
||||
pub type ShellRef<
|
||||
SE: brush_core::ShellExtensions = brush_core::extensions::DefaultShellExtensions,
|
||||
> = Arc<Mutex<brush_core::Shell<SE>>>;
|
||||
@@ -0,0 +1,412 @@
|
||||
/// Holds information about the hosting terminal.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct TerminalInfo {
|
||||
/// The detected terminal, if any.
|
||||
pub terminal: Option<KnownTerminal>,
|
||||
|
||||
/// If applicable, a session nonce assigned by the terminal.
|
||||
pub session_nonce: Option<String>,
|
||||
|
||||
/// Whether the terminal's OSC support is unknown.
|
||||
pub osc_support_unknown: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 0 sequences: setting terminal title and icon.
|
||||
pub supports_osc_0: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 1 sequences: setting icon name.
|
||||
pub supports_osc_1: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 2 sequences: setting terminal title.
|
||||
pub supports_osc_2: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 3 sequences: setting X11 window properties.
|
||||
pub supports_osc_3: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 4 sequences: setting color palette.
|
||||
pub supports_osc_4: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 5 sequences: setting/querying special color number.
|
||||
pub supports_osc_5: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 6 sequences: setting title tab color (iTerm2)
|
||||
pub supports_osc_6: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 7 sequences: setting current working directory.
|
||||
pub supports_osc_7: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 8 sequences: hyperlinks.
|
||||
pub supports_osc_8: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 9 sequences: showing system notification (iTerm2).
|
||||
pub supports_osc_9: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 10 sequences: setting default foreground color.
|
||||
pub supports_osc_10: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 11 sequences: setting default background color.
|
||||
pub supports_osc_11: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 12 sequences: setting cursor color.
|
||||
pub supports_osc_12: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 13 sequences: setting pointer foreground color.
|
||||
pub supports_osc_13: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 14 sequences: setting pointer background color.
|
||||
pub supports_osc_14: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 15 sequences: setting Tektronix foreground color.
|
||||
pub supports_osc_15: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 16 sequences: setting Tektronix background color.
|
||||
pub supports_osc_16: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 17 sequences: setting highlight background color.
|
||||
pub supports_osc_17: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 18 sequences: setting Tektronix cursor color.
|
||||
pub supports_osc_18: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 19 sequences: setting highlight foreground color.
|
||||
pub supports_osc_19: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 21 sequences: color control (Kitty extension).
|
||||
pub supports_osc_21: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 22 sequences: setting mouse pointer.
|
||||
pub supports_osc_22: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 50 sequences: setting font.
|
||||
pub supports_osc_50: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 52 sequences: clipboard and primary selection.
|
||||
pub supports_osc_52: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 66 sequences: scoped text size.
|
||||
pub supports_osc_66: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 99 sequences: desktop notifications.
|
||||
pub supports_osc_99: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 104 sequences: resetting color palette.
|
||||
pub supports_osc_104: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 105 sequences: resetting special colors.
|
||||
pub supports_osc_105: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 110 sequences: resetting default foreground color.
|
||||
pub supports_osc_110: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 111 sequences: resetting default background color.
|
||||
pub supports_osc_111: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 112 sequences: resetting cursor color.
|
||||
pub supports_osc_112: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 113 sequences: resetting pointer foreground color.
|
||||
pub supports_osc_113: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 114 sequences: resetting pointer background color.
|
||||
pub supports_osc_114: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 115 sequences: resetting Tektronix foreground color.
|
||||
pub supports_osc_115: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 116 sequences: resetting Tektronix background color.
|
||||
pub supports_osc_116: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 117 sequences: resetting highlight background color
|
||||
pub supports_osc_117: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 118 sequences: resetting Tektronix cursor color.
|
||||
pub supports_osc_118: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 119 sequences: resetting highlight foreground color
|
||||
pub supports_osc_119: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 133 sequences: shell integration (input, output, and
|
||||
/// prompt zones).
|
||||
pub supports_osc_133: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 176 sequences: setting app ID.
|
||||
pub supports_osc_176: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 555 sequences: flashing screen (foot-specific)
|
||||
pub supports_osc_555: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 633 sequences: shell integration (`VSCode` extension).
|
||||
pub supports_osc_633: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 777 sequences: desktop notifications / rxvt extensions.
|
||||
pub supports_osc_777: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 1337 sequences: custom iTerm2 sequences.
|
||||
pub supports_osc_1337: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 5113 sequences: file transfer (Kitty extension).
|
||||
pub supports_osc_5113: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 5522 sequences: advanced clipboard interaction (Kitty
|
||||
/// extension).
|
||||
pub supports_osc_5522: bool,
|
||||
|
||||
/// Whether the terminal supports OSC 9001 sequences: Windows Terminal extensions.
|
||||
pub supports_osc_9001: bool,
|
||||
}
|
||||
|
||||
/// Identifies a known terminal emulator hosting this process.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum KnownTerminal {
|
||||
/// Alacritty
|
||||
Alacritty,
|
||||
/// Apple terminal
|
||||
AppleTerminal,
|
||||
/// Ghostty
|
||||
Ghostty,
|
||||
/// GNOME Terminal
|
||||
GnomeTerminal,
|
||||
/// iTerm2
|
||||
ITerm2,
|
||||
/// Kitty
|
||||
Kitty,
|
||||
/// Konsole
|
||||
Konsole,
|
||||
/// `VSCode` Terminal
|
||||
VSCode,
|
||||
/// Other VTE-based terminal
|
||||
Vte,
|
||||
/// Warp Terminal
|
||||
WarpTerminal,
|
||||
/// `WezTerm`
|
||||
WezTerm,
|
||||
/// Windows Terminal
|
||||
WindowsTerminal,
|
||||
}
|
||||
|
||||
/// Abstracts access to environment variables used for terminal detection.
|
||||
pub(crate) trait TerminalEnvironment {
|
||||
/// Gets the value of an environment variable. Returns `None` if the variable is not set.
|
||||
fn get_env_var(&self, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(crate) fn get_terminal_info(env: &impl TerminalEnvironment) -> TerminalInfo {
|
||||
let mut info = TerminalInfo {
|
||||
terminal: try_detect_terminal(env),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(terminal) = &info.terminal {
|
||||
match terminal {
|
||||
KnownTerminal::Alacritty => {
|
||||
// https://github.com/alacritty/alacritty/blob/master/docs/escape_support.md
|
||||
info.supports_osc_0 = true;
|
||||
info.supports_osc_2 = true;
|
||||
info.supports_osc_4 = true;
|
||||
info.supports_osc_8 = true;
|
||||
info.supports_osc_10 = true;
|
||||
info.supports_osc_11 = true;
|
||||
info.supports_osc_12 = true;
|
||||
info.supports_osc_50 = true; // only cursor shape supported
|
||||
info.supports_osc_52 = true; // only clipboard and primary selection supported
|
||||
info.supports_osc_104 = true;
|
||||
info.supports_osc_110 = true;
|
||||
info.supports_osc_111 = true;
|
||||
info.supports_osc_112 = true;
|
||||
}
|
||||
KnownTerminal::Ghostty => {
|
||||
// https://ghostty.org/docs/vt/osc/0
|
||||
info.supports_osc_0 = true;
|
||||
info.supports_osc_1 = true;
|
||||
info.supports_osc_2 = true;
|
||||
info.supports_osc_4 = true;
|
||||
info.supports_osc_5 = true;
|
||||
info.supports_osc_7 = true;
|
||||
info.supports_osc_8 = true;
|
||||
info.supports_osc_9 = true;
|
||||
info.supports_osc_10 = true;
|
||||
info.supports_osc_11 = true;
|
||||
info.supports_osc_12 = true;
|
||||
info.supports_osc_21 = true;
|
||||
info.supports_osc_22 = true;
|
||||
info.supports_osc_52 = true;
|
||||
info.supports_osc_104 = true;
|
||||
info.supports_osc_105 = true;
|
||||
info.supports_osc_110 = true;
|
||||
info.supports_osc_111 = true;
|
||||
info.supports_osc_112 = true;
|
||||
}
|
||||
KnownTerminal::ITerm2 => {
|
||||
// https://iterm2.com/documentation-escape-codes.html
|
||||
info.supports_osc_4 = true;
|
||||
info.supports_osc_6 = true;
|
||||
info.supports_osc_7 = true;
|
||||
info.supports_osc_8 = true;
|
||||
info.supports_osc_133 = true;
|
||||
info.supports_osc_1337 = true;
|
||||
}
|
||||
KnownTerminal::Kitty => {
|
||||
// https://sw.kovidgoyal.net/kitty/protocol-extensions/
|
||||
info.supports_osc_21 = true;
|
||||
info.supports_osc_22 = true;
|
||||
info.supports_osc_66 = true;
|
||||
info.supports_osc_5113 = true;
|
||||
info.supports_osc_5522 = true;
|
||||
}
|
||||
KnownTerminal::VSCode => {
|
||||
// https://code.visualstudio.com/docs/terminal/shell-integration
|
||||
// https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/browser/terminalEscapeSequences.ts
|
||||
info.supports_osc_7 = true;
|
||||
info.supports_osc_9 = true;
|
||||
info.supports_osc_133 = true;
|
||||
info.supports_osc_633 = true;
|
||||
info.supports_osc_1337 = true;
|
||||
info.session_nonce = env.get_env_var("VSCODE_NONCE");
|
||||
}
|
||||
KnownTerminal::WezTerm => {
|
||||
// https://wezterm.org/escape-sequences.html
|
||||
// https://wezterm.org/shell-integration.html
|
||||
info.supports_osc_0 = true;
|
||||
info.supports_osc_1 = true;
|
||||
info.supports_osc_2 = true;
|
||||
info.supports_osc_4 = true;
|
||||
info.supports_osc_7 = true;
|
||||
info.supports_osc_8 = true;
|
||||
info.supports_osc_9 = true;
|
||||
info.supports_osc_10 = true;
|
||||
info.supports_osc_11 = true;
|
||||
info.supports_osc_12 = true;
|
||||
info.supports_osc_52 = true;
|
||||
info.supports_osc_104 = true;
|
||||
info.supports_osc_133 = true;
|
||||
info.supports_osc_777 = true;
|
||||
info.supports_osc_1337 = true;
|
||||
}
|
||||
KnownTerminal::WindowsTerminal => {
|
||||
// https://learn.microsoft.com/en-us/windows/terminal/tutorials/shell-integration
|
||||
// https://github.com/microsoft/terminal/blob/main/src/terminal/parser/OutputStateMachineEngine.hpp
|
||||
info.supports_osc_0 = true;
|
||||
info.supports_osc_1 = true;
|
||||
info.supports_osc_2 = true;
|
||||
info.supports_osc_4 = true;
|
||||
info.supports_osc_8 = true;
|
||||
info.supports_osc_9 = true;
|
||||
info.supports_osc_10 = true;
|
||||
info.supports_osc_11 = true;
|
||||
info.supports_osc_12 = true;
|
||||
info.supports_osc_17 = true;
|
||||
info.supports_osc_21 = true;
|
||||
info.supports_osc_52 = true;
|
||||
info.supports_osc_104 = true;
|
||||
info.supports_osc_110 = true;
|
||||
info.supports_osc_111 = true;
|
||||
info.supports_osc_112 = true;
|
||||
info.supports_osc_117 = true;
|
||||
info.supports_osc_133 = true;
|
||||
info.supports_osc_633 = true;
|
||||
info.supports_osc_1337 = true;
|
||||
info.supports_osc_9001 = true;
|
||||
}
|
||||
_ => {
|
||||
info.osc_support_unknown = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info.osc_support_unknown = true;
|
||||
}
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
/// Tries to detect the hosting terminal.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `env` - An implementation of `TerminalEnvironment` to access environment variables.
|
||||
pub(crate) fn try_detect_terminal(env: &impl TerminalEnvironment) -> Option<KnownTerminal> {
|
||||
if let Some(detected) = try_detect_terminal_from_prog_var(env) {
|
||||
Some(detected)
|
||||
} else if env.get_env_var("WT_SESSION").is_some() {
|
||||
Some(KnownTerminal::WindowsTerminal)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn try_detect_terminal_from_prog_var(env: &impl TerminalEnvironment) -> Option<KnownTerminal> {
|
||||
let term_prog = env.get_env_var("TERM_PROGRAM")?;
|
||||
|
||||
// Remove punctuation and normalize.
|
||||
let term_prog: String = term_prog
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
|
||||
match term_prog.as_str() {
|
||||
"alacritty" => Some(KnownTerminal::Alacritty),
|
||||
"appleterminal" => Some(KnownTerminal::AppleTerminal),
|
||||
"ghostty" => Some(KnownTerminal::Ghostty),
|
||||
"gnometerminal" => Some(KnownTerminal::GnomeTerminal),
|
||||
"iterm" | "iterm2" | "itermapp" => Some(KnownTerminal::ITerm2),
|
||||
"kitty" => Some(KnownTerminal::Kitty),
|
||||
"konsole" => Some(KnownTerminal::Konsole),
|
||||
"vscode" => Some(KnownTerminal::VSCode),
|
||||
"vte" => Some(KnownTerminal::Vte),
|
||||
"warp" | "warpterminal" => Some(KnownTerminal::WarpTerminal),
|
||||
"wezterm" => Some(KnownTerminal::WezTerm),
|
||||
"windowsterminal" => Some(KnownTerminal::WindowsTerminal),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_matches;
|
||||
use std::collections::HashMap;
|
||||
|
||||
impl TerminalEnvironment for HashMap<&str, &str> {
|
||||
fn get_env_var(&self, key: &str) -> Option<String> {
|
||||
self.get(key).map(|v| (*v).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_term_program() {
|
||||
let test_env = HashMap::new();
|
||||
|
||||
let term_info = get_terminal_info(&test_env);
|
||||
assert_matches!(term_info.terminal, None);
|
||||
assert!(term_info.osc_support_unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_term_program() {
|
||||
let test_env = HashMap::from([("TERM_PROGRAM", "unknown_terminal")]);
|
||||
|
||||
let term_info = get_terminal_info(&test_env);
|
||||
assert_matches!(term_info.terminal, None);
|
||||
assert!(term_info.osc_support_unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vscode_recognition() {
|
||||
let test_env = HashMap::from([("TERM_PROGRAM", "vscode"), ("VSCODE_NONCE", "test_nonce")]);
|
||||
|
||||
let term_info = get_terminal_info(&test_env);
|
||||
assert_matches!(term_info.terminal, Some(KnownTerminal::VSCode));
|
||||
assert!(term_info.supports_osc_633);
|
||||
assert_eq!(term_info.session_nonce, Some("test_nonce".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_terminal_recognition() {
|
||||
let test_env = HashMap::from([("WT_SESSION", "some_value")]);
|
||||
|
||||
let term_info = get_terminal_info(&test_env);
|
||||
assert_matches!(term_info.terminal, Some(KnownTerminal::WindowsTerminal));
|
||||
assert!(term_info.supports_osc_9001);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::borrow::Cow;
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::term_detection;
|
||||
|
||||
/// Utility for integrating with terminal emulators.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TerminalIntegration {
|
||||
/// Info about the hosting terminal.
|
||||
term: term_detection::TerminalInfo,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TerminalIntegration {
|
||||
/// Creates a new terminal integration utility.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `term_info` - Information about the terminal capabilities.
|
||||
pub const fn new(term_info: term_detection::TerminalInfo) -> Self {
|
||||
Self { term: term_info }
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted to initialize terminal
|
||||
/// integration.
|
||||
pub fn initialize(&self) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
"\x1b]633;P;HasRichCommandDetection=True\x1b\\".into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted before the prompt.
|
||||
pub fn pre_prompt(&self) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
"\x1b]633;A\x1b\\".into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence to report the current working directory.
|
||||
pub fn report_cwd(&self, cwd: &std::path::Path) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
let escaped_cwd_str = osc_633_escape(cwd.to_string_lossy().as_ref());
|
||||
format!("\x1b]633;P;Cwd={escaped_cwd_str}\x1b\\").into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted before executing a command,
|
||||
/// but after the prompt and the user has finished entering input.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `command` - The command that is about to be executed.
|
||||
pub fn pre_exec_command(&self, command: &str) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
let mut escaped_command = osc_633_escape(command);
|
||||
escaped_command.insert_str(0, "\x1b]633;E;");
|
||||
|
||||
if let Some(session_nonce) = &self.term.session_nonce {
|
||||
escaped_command.push(';');
|
||||
escaped_command.push_str(session_nonce);
|
||||
}
|
||||
|
||||
escaped_command.push_str("\x1b\\\x1b]633;C\x1b\\");
|
||||
|
||||
escaped_command.into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted after executing a command.
|
||||
pub fn post_exec_command(&self, exit_code: i32) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
std::format!("\x1b]633;D;{exit_code}\x1b\\").into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted after the prompt.
|
||||
pub fn post_prompt(&self) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
"\x1b]633;B\x1b\\".into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted before the continuation prompt.
|
||||
pub fn pre_input_line_continuation(&self) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
"\x1b]633;F\x1b\\".into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted after the input line
|
||||
/// continuation.
|
||||
pub fn post_input_line_continuation(&self) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
"\x1b]633;G\x1b\\".into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted before the right-side prompt.
|
||||
pub fn pre_right_prompt(&self) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
"\x1b]633;H\x1b\\".into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the terminal escape sequence that should be emitted after the right-side prompt.
|
||||
pub fn post_right_prompt(&self) -> Cow<'_, str> {
|
||||
if self.term.supports_osc_633 {
|
||||
"\x1b]633;I\x1b\\".into()
|
||||
} else {
|
||||
"".into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes a string for safe inclusion in an OSC 633 escape sequence.
|
||||
/// Reference: <https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh>
|
||||
fn osc_633_escape(command: &str) -> String {
|
||||
let mut result = String::new();
|
||||
|
||||
for c in command.chars() {
|
||||
match c {
|
||||
// Escape ASCII control characters (< 0x1f, i.e., < 31)
|
||||
'\x00'..='\x1e' => {
|
||||
let _ = write!(result, r"\x{:02x}", c as u8);
|
||||
}
|
||||
// Escape backslash with an extra prefixed backslash
|
||||
'\\' => result.push_str(r"\\"),
|
||||
// Escape semicolon via \xNN syntax (like control chars)
|
||||
';' => result.push_str(r"\x3b"),
|
||||
// Keep other characters as-is
|
||||
_ => result.push(c),
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn osc_633_escape_basic() {
|
||||
// Test simple alphanumeric string
|
||||
assert_eq!(osc_633_escape("echo hello"), "echo hello");
|
||||
assert_eq!(osc_633_escape("ls -la"), "ls -la");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_633_escape_semicolon() {
|
||||
// Semicolons should be escaped
|
||||
assert_eq!(osc_633_escape("cmd1; cmd2"), r"cmd1\x3b cmd2");
|
||||
assert_eq!(osc_633_escape(";"), r"\x3b");
|
||||
assert_eq!(osc_633_escape("a;b;c"), r"a\x3bb\x3bc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_633_escape_backslash() {
|
||||
// Backslashes should be escaped
|
||||
assert_eq!(osc_633_escape(r"echo \n"), r"echo \\n");
|
||||
assert_eq!(osc_633_escape(r"\"), r"\\");
|
||||
assert_eq!(osc_633_escape(r"C:\path\to\file"), r"C:\\path\\to\\file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_633_escape_control_chars() {
|
||||
// ASCII control characters (0x00-0x1e, i.e., 0-30) should be escaped
|
||||
assert_eq!(osc_633_escape("\x00"), r"\x00");
|
||||
assert_eq!(osc_633_escape("\x01"), r"\x01");
|
||||
assert_eq!(osc_633_escape("\t"), r"\x09"); // tab
|
||||
assert_eq!(osc_633_escape("\n"), r"\x0a"); // newline
|
||||
assert_eq!(osc_633_escape("\r"), r"\x0d"); // carriage return
|
||||
assert_eq!(osc_633_escape("\x1e"), r"\x1e"); // last control char (30)
|
||||
|
||||
// 0x1f (31) should NOT be escaped as a control char (not < 31)
|
||||
assert_eq!(osc_633_escape("\x1f"), "\x1f");
|
||||
|
||||
// Space (0x20, 32) should NOT be escaped
|
||||
assert_eq!(osc_633_escape(" "), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_633_escape_mixed() {
|
||||
// Test combinations of different escape scenarios
|
||||
assert_eq!(
|
||||
osc_633_escape("echo\nhello; world\\n"),
|
||||
r"echo\x0ahello\x3b world\\n"
|
||||
);
|
||||
|
||||
assert_eq!(osc_633_escape("cmd\t\t; \\path"), r"cmd\x09\x09\x3b \\path");
|
||||
|
||||
// Test with null bytes
|
||||
assert_eq!(osc_633_escape("a\x00b\x01c"), r"a\x00b\x01c");
|
||||
|
||||
// Test all three special cases together
|
||||
assert_eq!(osc_633_escape("\\;\n"), r"\\\x3b\x0a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_633_escape_empty() {
|
||||
assert_eq!(osc_633_escape(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_633_escape_unicode() {
|
||||
// Unicode characters should pass through unchanged
|
||||
assert_eq!(osc_633_escape("echo 你好"), "echo 你好");
|
||||
assert_eq!(osc_633_escape("café"), "café");
|
||||
assert_eq!(osc_633_escape("🦀"), "🦀");
|
||||
|
||||
// But should still escape special chars
|
||||
assert_eq!(osc_633_escape("你好;世界"), r"你好\x3b世界");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
pub(crate) const COMPLETION: &str = "completion";
|
||||
Reference in New Issue
Block a user