Merge nucleic/sleek-thistle-egret-fyej into dev

This commit is contained in:
2026-07-18 05:19:31 -07:00
commit b6be87b72d
677 changed files with 102939 additions and 0 deletions
+42
View File
@@ -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"] }
+1
View File
@@ -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)
}
}
+19
View File
@@ -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()
}
+123
View File
@@ -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
}
+29
View File
@@ -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,
}
+415
View File
@@ -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");
}
}
+51
View File
@@ -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,
}
+457
View File
@@ -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, &params).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, &params).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()
}
}
+44
View File
@@ -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))
}
}
}
+3
View File
@@ -0,0 +1,3 @@
mod input_backend;
pub use input_backend::MinimalInputBackend;
+29
View File
@@ -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,
}
}
}
+713
View File
@@ -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(),
}
}
+263
View File
@@ -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
}
+9
View File
@@ -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;
+69
View File
@@ -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,
}
}
}
+9
View File
@@ -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>>>;
+412
View File
@@ -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);
}
}
+232
View File
@@ -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";