Skip to content

Commit

Permalink
feat(prompts): enable PS0, custom right-side prompts, more (#278)
Browse files Browse the repository at this point in the history
* Add new brush-specific variable (BRUSH_PS_ALT) for right-side prompts, supported by the reedline backend.
* Implement support for PS0
* Start advertising BASH_VERSION
* Add workaround for reedline issues with prompts that start with a leading newline character
  • Loading branch information
reubeno authored Nov 29, 2024
1 parent 316e9c9 commit 7dcc411
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 36 deletions.
19 changes: 9 additions & 10 deletions brush-core/src/expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -940,10 +940,7 @@ impl<'a> WordExpander<'a> {
op,
} => {
let expanded_parameter = self.expand_parameter(&parameter, indirect).await?;

transform_expansion(expanded_parameter, |s| {
self.apply_transform_to(&op, s.as_str())
})
transform_expansion(expanded_parameter, |s| self.apply_transform_to(&op, s))
}
brush_parser::word::ParameterExpr::UppercaseFirstChar {
parameter,
Expand Down Expand Up @@ -1455,29 +1452,31 @@ impl<'a> WordExpander<'a> {
fn apply_transform_to(
&self,
op: &ParameterTransformOp,
s: &str,
s: String,
) -> Result<String, error::Error> {
match op {
brush_parser::word::ParameterTransformOp::PromptExpand => {
prompt::expand_prompt(self.shell, s)
}
brush_parser::word::ParameterTransformOp::CapitalizeInitial => {
Ok(to_initial_capitals(s))
Ok(to_initial_capitals(s.as_str()))
}
brush_parser::word::ParameterTransformOp::ExpandEscapeSequences => {
let (result, _) =
escape::expand_backslash_escapes(s, escape::EscapeExpansionMode::AnsiCQuotes)?;
let (result, _) = escape::expand_backslash_escapes(
s.as_str(),
escape::EscapeExpansionMode::AnsiCQuotes,
)?;
Ok(String::from_utf8_lossy(result.as_slice()).into_owned())
}
brush_parser::word::ParameterTransformOp::PossiblyQuoteWithArraysExpanded {
separate_words: _separate_words,
} => {
// TODO: This isn't right for arrays.
// TODO: This doesn't honor 'separate_words'
Ok(variables::quote_str_for_assignment(s))
Ok(variables::quote_str_for_assignment(s.as_str()))
}
brush_parser::word::ParameterTransformOp::Quoted => {
Ok(variables::quote_str_for_assignment(s))
Ok(variables::quote_str_for_assignment(s.as_str()))
}
brush_parser::word::ParameterTransformOp::ToLowerCase => Ok(s.to_lowercase()),
brush_parser::word::ParameterTransformOp::ToUpperCase => Ok(s.to_uppercase()),
Expand Down
19 changes: 9 additions & 10 deletions brush-core/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ const VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR");
const VERSION_MINOR: &str = env!("CARGO_PKG_VERSION_MINOR");
const VERSION_PATCH: &str = env!("CARGO_PKG_VERSION_PATCH");

pub(crate) fn expand_prompt(shell: &Shell, spec: &str) -> Result<String, error::Error> {
pub(crate) fn expand_prompt(shell: &Shell, spec: String) -> Result<String, error::Error> {
// Now parse.
let prompt_pieces = parse_prompt(spec.to_owned())?;
let prompt_pieces = parse_prompt(spec)?;

// Now render.
let formatted_prompt = prompt_pieces
.iter()
.into_iter()
.map(|p| format_prompt_piece(shell, p))
.collect::<Result<Vec<_>, error::Error>>()?
.join("");
.collect::<Result<String, error::Error>>()?;

Ok(formatted_prompt)
}
Expand All @@ -32,12 +31,12 @@ fn parse_prompt(

pub(crate) fn format_prompt_piece(
shell: &Shell,
piece: &brush_parser::prompt::PromptPiece,
piece: brush_parser::prompt::PromptPiece,
) -> Result<String, error::Error> {
let formatted = match piece {
brush_parser::prompt::PromptPiece::Literal(l) => l.to_owned(),
brush_parser::prompt::PromptPiece::Literal(l) => l,
brush_parser::prompt::PromptPiece::AsciiCharacter(c) => {
char::from_u32(*c).map_or_else(String::new, |c| c.to_string())
char::from_u32(c).map_or_else(String::new, |c| c.to_string())
}
brush_parser::prompt::PromptPiece::Backslash => "\\".to_owned(),
brush_parser::prompt::PromptPiece::BellCharacter => "\x07".to_owned(),
Expand All @@ -52,7 +51,7 @@ pub(crate) fn format_prompt_piece(
brush_parser::prompt::PromptPiece::CurrentWorkingDirectory {
tilde_replaced,
basename,
} => format_current_working_directory(shell, *tilde_replaced, *basename),
} => format_current_working_directory(shell, tilde_replaced, basename),
brush_parser::prompt::PromptPiece::Date(_) => return error::unimp("prompt: date"),
brush_parser::prompt::PromptPiece::DollarOrPound => {
if users::is_root() {
Expand All @@ -70,7 +69,7 @@ pub(crate) fn format_prompt_piece(
.unwrap_or_default()
.to_string_lossy()
.to_string();
if *only_up_to_first_dot {
if only_up_to_first_dot {
if let Some((first, _)) = hn.split_once('.') {
return Ok(first.to_owned());
}
Expand Down
51 changes: 36 additions & 15 deletions brush-core/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,6 @@ pub struct FunctionCall {
function_definition: Arc<brush_parser::ast::FunctionDefinition>,
}

lazy_static::lazy_static! {
// NOTE: We have difficulty with xterm escape sequences going through rustyline;
// so we compile a regex that can be used to strip them out.
static ref PROMPT_XTERM_ESCAPE_SEQ_REGEX: fancy_regex::Regex = fancy_regex::Regex::new("\x1b][0-2];[^\x07]*\x07").unwrap();
}

impl Shell {
/// Returns a new shell instance created with the given options.
///
Expand Down Expand Up @@ -280,15 +274,39 @@ impl Shell {
}

if !options.sh_mode {
const BASH_MAJOR: u32 = 5;
const BASH_MINOR: u32 = 2;
const BASH_PATCH: u32 = 15;
const BASH_BUILD: u32 = 1;
const BASH_RELEASE: &str = "release";
const BASH_MACHINE: &str = "unknown";

if let Some(shell_name) = &options.shell_name {
env.set_global("BASH", ShellVariable::new(shell_name.into()))?;
}
env.set_global(
"BASH_VERSINFO",
ShellVariable::new(ShellValue::indexed_array_from_slice(
["5", "2", "15", "1", "release", "unknown"].as_slice(),
[
BASH_MAJOR.to_string().as_str(),
BASH_MINOR.to_string().as_str(),
BASH_PATCH.to_string().as_str(),
BASH_BUILD.to_string().as_str(),
BASH_RELEASE,
BASH_MACHINE,
]
.as_slice(),
)),
)?;
env.set_global(
"BASH_VERSION",
ShellVariable::new(
std::format!(
"{BASH_MAJOR}.{BASH_MINOR}.{BASH_PATCH}({BASH_BUILD})-{BASH_RELEASE}"
)
.into(),
),
)?;
}

Ok(env)
Expand Down Expand Up @@ -687,6 +705,11 @@ impl Shell {
}
}

/// Composes the shell's post-input, pre-command prompt, applying all appropriate expansions.
pub async fn compose_precmd_prompt(&mut self) -> Result<String, error::Error> {
self.prompt_from_var_or_default("PS0", "").await
}

/// Composes the shell's prompt, applying all appropriate expansions.
pub async fn compose_prompt(&mut self) -> Result<String, error::Error> {
self.prompt_from_var_or_default("PS1", self.default_prompt())
Expand All @@ -696,7 +719,8 @@ impl Shell {
/// Compose's the shell's alternate-side prompt, applying all appropriate expansions.
#[allow(clippy::unused_async)]
pub async fn compose_alt_side_prompt(&mut self) -> Result<String, error::Error> {
Ok(String::new())
// This is a brush extension.
self.prompt_from_var_or_default("BRUSH_PS_ALT", "").await
}

/// Composes the shell's continuation prompt.
Expand All @@ -711,15 +735,12 @@ impl Shell {
) -> Result<String, error::Error> {
// Retrieve the spec.
let prompt_spec = self.parameter_or_default(var_name, default);
if prompt_spec.is_empty() {
return Ok(prompt_spec);
}

// Expand it.
let formatted_prompt = prompt::expand_prompt(self, prompt_spec.as_ref())?;

// NOTE: We're having difficulty with xterm escape sequences going through rustyline;
// so we strip them here.
let formatted_prompt = PROMPT_XTERM_ESCAPE_SEQ_REGEX
.replace_all(formatted_prompt.as_str(), "")
.to_string();
let formatted_prompt = prompt::expand_prompt(self, prompt_spec)?;

// Now expand.
let formatted_prompt = expansion::basic_expand_str(self, &formatted_prompt).await?;
Expand Down
6 changes: 6 additions & 0 deletions brush-interactive/src/interactive_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ pub trait InteractiveShell {
match self.read_line(prompt)? {
ReadResult::Input(read_result) => {
let mut shell_mut = self.shell_mut();

let precmd_prompt = shell_mut.as_mut().compose_precmd_prompt().await?;
if !precmd_prompt.is_empty() {
print!("{precmd_prompt}");
}

let params = shell_mut.as_mut().default_exec_params();
match shell_mut.as_mut().run_string(read_result, &params).await {
Ok(result) => Ok(InteractiveExecutionResult::Executed(result)),
Expand Down
10 changes: 9 additions & 1 deletion brush-interactive/src/reedline/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ use crate::interactive_shell::InteractivePrompt;

impl reedline::Prompt for InteractivePrompt {
fn render_prompt_left(&self) -> std::borrow::Cow<str> {
self.prompt.as_str().into()
// [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> {
Expand Down

0 comments on commit 7dcc411

Please sign in to comment.