From 6fba4b409e426dc8d63c32d0b3104786fea5a66a Mon Sep 17 00:00:00 2001 From: 132ikl <132@ikl.sh> Date: Wed, 25 Jun 2025 15:26:52 -0400 Subject: [PATCH] Add backtick code formatting to `help` (#15892) # Description Adds formatting for code in backticks in `help` output. If it's possible to highlight syntax (`nu-highlight` is available and there's no invalid syntax) then it's highlighted. If the syntax is invalid or not an internal command, then it's dimmed and italicized. like some of the output from `std/help`. If `use_ansi_coloring` is `false`, then we leave the backticks alone. Here's a couple examples: ![image](https://github.com/user-attachments/assets/57eed1dd-b38c-48ef-92c6-3f805392487c) ![image](https://github.com/user-attachments/assets/a0efa0d7-fc11-4702-973b-a0b448c383e0) (note on this one: usually we can highlight partial commands, like `get` in the `select` help page which is invalid according to `nu-check` but is still properly highlighted, however `where` is special cased and just typing `where` with no row condition is highlighted with the garbage style so `where` alone isn't highlighted here) ![image](https://github.com/user-attachments/assets/28c110c9-16c4-4890-bc74-6de0f2e6d1b8) here's the `where` page with `$env.config.use_ansi_coloring = false`: ![image](https://github.com/user-attachments/assets/57871cc8-d509-4719-9dd4-e6f24f9d891c) Technically, some syntax is valid but isn't really "Nushell code". For example, the `select` help page has a line that says "Select just the \`name\` column". If you just type `name` in the REPL, Nushell treats it as an external command, but for the purposes of highlighted we actually want this to fall back to the generic dimmed/italic style. This is accomplished by temporarily setting the `shape_external` and `shape_externalarg` color config to the generic/fallback style, and then restoring the color config after highlighting. This is a bit hack-ish but it seems to work pretty well. # User-Facing Changes - `help` command now supports code backtick formatting. Code will be highlighted using `nu-highlight` if possible, otherwise it will fall back to a generic format. - Adds `--reject-garbage` flag to `nu-highlight` which will return an error on invalid syntax (which would otherwise be highlighted with `$env.config.color_config.shape_garbage`) # Tests + Formatting Added tests for the regex. I don't think tests for the actual highlighting are very necessary since the failure mode is graceful and it would be difficult to meaningfully test. # After Submitting N/A --------- Co-authored-by: Piepmatz --- Cargo.lock | 1 + crates/nu-cli/src/menus/help_completions.rs | 9 +- crates/nu-cli/src/nu_highlight.rs | 31 +- crates/nu-cli/src/syntax_highlight.rs | 278 +++++++------ crates/nu-command/src/help/help_aliases.rs | 37 +- crates/nu-engine/Cargo.toml | 1 + crates/nu-engine/src/documentation.rs | 426 ++++++++++++++++---- crates/nu-plugin/src/plugin/mod.rs | 7 +- 8 files changed, 547 insertions(+), 243 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 72366f85da..5acd80f952 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3821,6 +3821,7 @@ dependencies = [ name = "nu-engine" version = "0.105.2" dependencies = [ + "fancy-regex", "log", "nu-glob", "nu-path", diff --git a/crates/nu-cli/src/menus/help_completions.rs b/crates/nu-cli/src/menus/help_completions.rs index a002dd85ee..16262637a7 100644 --- a/crates/nu-cli/src/menus/help_completions.rs +++ b/crates/nu-cli/src/menus/help_completions.rs @@ -1,4 +1,4 @@ -use nu_engine::documentation::{HelpStyle, get_flags_section}; +use nu_engine::documentation::{FormatterValue, HelpStyle, get_flags_section}; use nu_protocol::{Config, engine::EngineState, levenshtein_distance}; use nu_utils::IgnoreCaseExt; use reedline::{Completer, Suggestion}; @@ -66,8 +66,11 @@ impl NuHelpCompleter { let _ = write!(long_desc, "Usage:\r\n > {}\r\n", sig.call_signature()); if !sig.named.is_empty() { - long_desc.push_str(&get_flags_section(&sig, &help_style, |v| { - v.to_parsable_string(", ", &self.config) + long_desc.push_str(&get_flags_section(&sig, &help_style, |v| match v { + FormatterValue::DefaultValue(value) => { + value.to_parsable_string(", ", &self.config) + } + FormatterValue::CodeString(text) => text.to_string(), })) } diff --git a/crates/nu-cli/src/nu_highlight.rs b/crates/nu-cli/src/nu_highlight.rs index dd202cc061..0feaed8e6e 100644 --- a/crates/nu-cli/src/nu_highlight.rs +++ b/crates/nu-cli/src/nu_highlight.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use nu_engine::command_prelude::*; use reedline::{Highlighter, StyledText}; +use crate::syntax_highlight::highlight_syntax; + #[derive(Clone)] pub struct NuHighlight; @@ -14,6 +16,11 @@ impl Command for NuHighlight { fn signature(&self) -> Signature { Signature::build("nu-highlight") .category(Category::Strings) + .switch( + "reject-garbage", + "Return an error if invalid syntax (garbage) was encountered", + Some('r'), + ) .input_output_types(vec![(Type::String, Type::String)]) } @@ -32,19 +39,33 @@ impl Command for NuHighlight { call: &Call, input: PipelineData, ) -> Result { + let reject_garbage = call.has_flag(engine_state, stack, "reject-garbage")?; let head = call.head; let signals = engine_state.signals(); - let highlighter = crate::NuHighlighter { - engine_state: Arc::new(engine_state.clone()), - stack: Arc::new(stack.clone()), - }; + let engine_state = Arc::new(engine_state.clone()); + let stack = Arc::new(stack.clone()); input.map( move |x| match x.coerce_into_string() { Ok(line) => { - let highlights = highlighter.highlight(&line, line.len()); + let result = highlight_syntax(&engine_state, &stack, &line, line.len()); + + let highlights = match (reject_garbage, result.found_garbage) { + (false, _) => result.text, + (true, None) => result.text, + (true, Some(span)) => { + let error = ShellError::OutsideSpannedLabeledError { + src: line, + error: "encountered invalid syntax while highlighting".into(), + msg: "invalid syntax".into(), + span, + }; + return Value::error(error, head); + } + }; + Value::string(highlights.render_simple(), head) } Err(err) => Value::error(err, head), diff --git a/crates/nu-cli/src/syntax_highlight.rs b/crates/nu-cli/src/syntax_highlight.rs index caf59a5c30..552e4c5630 100644 --- a/crates/nu-cli/src/syntax_highlight.rs +++ b/crates/nu-cli/src/syntax_highlight.rs @@ -18,146 +18,172 @@ pub struct NuHighlighter { impl Highlighter for NuHighlighter { fn highlight(&self, line: &str, cursor: usize) -> StyledText { - trace!("highlighting: {}", line); + let result = highlight_syntax(&self.engine_state, &self.stack, line, cursor); + result.text + } +} - let config = self.stack.get_config(&self.engine_state); - let highlight_resolved_externals = config.highlight_resolved_externals; - let mut working_set = StateWorkingSet::new(&self.engine_state); - let block = parse(&mut working_set, None, line.as_bytes(), false); - let (shapes, global_span_offset) = { - let mut shapes = flatten_block(&working_set, &block); - // Highlighting externals has a config point because of concerns that using which to resolve - // externals may slow down things too much. - if highlight_resolved_externals { - for (span, shape) in shapes.iter_mut() { - if *shape == FlatShape::External { - let str_contents = - working_set.get_span_contents(Span::new(span.start, span.end)); +/// Result of a syntax highlight operation +#[derive(Default)] +pub(crate) struct HighlightResult { + /// The highlighted text + pub(crate) text: StyledText, + /// The span of any garbage that was highlighted + pub(crate) found_garbage: Option, +} - let str_word = String::from_utf8_lossy(str_contents).to_string(); - let paths = env::path_str(&self.engine_state, &self.stack, *span).ok(); - #[allow(deprecated)] - let res = if let Ok(cwd) = - env::current_dir_str(&self.engine_state, &self.stack) - { - which::which_in(str_word, paths.as_ref(), cwd).ok() - } else { - which::which_in_global(str_word, paths.as_ref()) - .ok() - .and_then(|mut i| i.next()) - }; - if res.is_some() { - *shape = FlatShape::ExternalResolved; - } +pub(crate) fn highlight_syntax( + engine_state: &EngineState, + stack: &Stack, + line: &str, + cursor: usize, +) -> HighlightResult { + trace!("highlighting: {}", line); + + let config = stack.get_config(engine_state); + let highlight_resolved_externals = config.highlight_resolved_externals; + let mut working_set = StateWorkingSet::new(engine_state); + let block = parse(&mut working_set, None, line.as_bytes(), false); + let (shapes, global_span_offset) = { + let mut shapes = flatten_block(&working_set, &block); + // Highlighting externals has a config point because of concerns that using which to resolve + // externals may slow down things too much. + if highlight_resolved_externals { + for (span, shape) in shapes.iter_mut() { + if *shape == FlatShape::External { + let str_contents = + working_set.get_span_contents(Span::new(span.start, span.end)); + + let str_word = String::from_utf8_lossy(str_contents).to_string(); + let paths = env::path_str(engine_state, stack, *span).ok(); + let res = if let Ok(cwd) = engine_state.cwd(Some(stack)) { + which::which_in(str_word, paths.as_ref(), cwd).ok() + } else { + which::which_in_global(str_word, paths.as_ref()) + .ok() + .and_then(|mut i| i.next()) + }; + if res.is_some() { + *shape = FlatShape::ExternalResolved; } } } - (shapes, self.engine_state.next_span_start()) + } + (shapes, engine_state.next_span_start()) + }; + + let mut result = HighlightResult::default(); + let mut last_seen_span = global_span_offset; + + let global_cursor_offset = cursor + global_span_offset; + let matching_brackets_pos = find_matching_brackets( + line, + &working_set, + &block, + global_span_offset, + global_cursor_offset, + ); + + for shape in &shapes { + if shape.0.end <= last_seen_span + || last_seen_span < global_span_offset + || shape.0.start < global_span_offset + { + // We've already output something for this span + // so just skip this one + continue; + } + if shape.0.start > last_seen_span { + let gap = line + [(last_seen_span - global_span_offset)..(shape.0.start - global_span_offset)] + .to_string(); + result.text.push((Style::new(), gap)); + } + let next_token = line + [(shape.0.start - global_span_offset)..(shape.0.end - global_span_offset)] + .to_string(); + + let mut add_colored_token = |shape: &FlatShape, text: String| { + result + .text + .push((get_shape_color(shape.as_str(), &config), text)); }; - let mut output = StyledText::default(); - let mut last_seen_span = global_span_offset; - - let global_cursor_offset = cursor + global_span_offset; - let matching_brackets_pos = find_matching_brackets( - line, - &working_set, - &block, - global_span_offset, - global_cursor_offset, - ); - - for shape in &shapes { - if shape.0.end <= last_seen_span - || last_seen_span < global_span_offset - || shape.0.start < global_span_offset - { - // We've already output something for this span - // so just skip this one - continue; + match shape.1 { + FlatShape::Garbage => { + result.found_garbage.get_or_insert_with(|| { + Span::new( + shape.0.start - global_span_offset, + shape.0.end - global_span_offset, + ) + }); + add_colored_token(&shape.1, next_token) } - if shape.0.start > last_seen_span { - let gap = line - [(last_seen_span - global_span_offset)..(shape.0.start - global_span_offset)] - .to_string(); - output.push((Style::new(), gap)); - } - let next_token = line - [(shape.0.start - global_span_offset)..(shape.0.end - global_span_offset)] - .to_string(); - - let mut add_colored_token = |shape: &FlatShape, text: String| { - output.push((get_shape_color(shape.as_str(), &config), text)); - }; - - match shape.1 { - FlatShape::Garbage => add_colored_token(&shape.1, next_token), - FlatShape::Nothing => add_colored_token(&shape.1, next_token), - FlatShape::Binary => add_colored_token(&shape.1, next_token), - FlatShape::Bool => add_colored_token(&shape.1, next_token), - FlatShape::Int => add_colored_token(&shape.1, next_token), - FlatShape::Float => add_colored_token(&shape.1, next_token), - FlatShape::Range => add_colored_token(&shape.1, next_token), - FlatShape::InternalCall(_) => add_colored_token(&shape.1, next_token), - FlatShape::External => add_colored_token(&shape.1, next_token), - FlatShape::ExternalArg => add_colored_token(&shape.1, next_token), - FlatShape::ExternalResolved => add_colored_token(&shape.1, next_token), - FlatShape::Keyword => add_colored_token(&shape.1, next_token), - FlatShape::Literal => add_colored_token(&shape.1, next_token), - FlatShape::Operator => add_colored_token(&shape.1, next_token), - FlatShape::Signature => add_colored_token(&shape.1, next_token), - FlatShape::String => add_colored_token(&shape.1, next_token), - FlatShape::RawString => add_colored_token(&shape.1, next_token), - FlatShape::StringInterpolation => add_colored_token(&shape.1, next_token), - FlatShape::DateTime => add_colored_token(&shape.1, next_token), - FlatShape::List - | FlatShape::Table - | FlatShape::Record - | FlatShape::Block - | FlatShape::Closure => { - let span = shape.0; - let shape = &shape.1; - let spans = split_span_by_highlight_positions( - line, - span, - &matching_brackets_pos, - global_span_offset, - ); - for (part, highlight) in spans { - let start = part.start - span.start; - let end = part.end - span.start; - let text = next_token[start..end].to_string(); - let mut style = get_shape_color(shape.as_str(), &config); - if highlight { - style = get_matching_brackets_style(style, &config); - } - output.push((style, text)); + FlatShape::Nothing => add_colored_token(&shape.1, next_token), + FlatShape::Binary => add_colored_token(&shape.1, next_token), + FlatShape::Bool => add_colored_token(&shape.1, next_token), + FlatShape::Int => add_colored_token(&shape.1, next_token), + FlatShape::Float => add_colored_token(&shape.1, next_token), + FlatShape::Range => add_colored_token(&shape.1, next_token), + FlatShape::InternalCall(_) => add_colored_token(&shape.1, next_token), + FlatShape::External => add_colored_token(&shape.1, next_token), + FlatShape::ExternalArg => add_colored_token(&shape.1, next_token), + FlatShape::ExternalResolved => add_colored_token(&shape.1, next_token), + FlatShape::Keyword => add_colored_token(&shape.1, next_token), + FlatShape::Literal => add_colored_token(&shape.1, next_token), + FlatShape::Operator => add_colored_token(&shape.1, next_token), + FlatShape::Signature => add_colored_token(&shape.1, next_token), + FlatShape::String => add_colored_token(&shape.1, next_token), + FlatShape::RawString => add_colored_token(&shape.1, next_token), + FlatShape::StringInterpolation => add_colored_token(&shape.1, next_token), + FlatShape::DateTime => add_colored_token(&shape.1, next_token), + FlatShape::List + | FlatShape::Table + | FlatShape::Record + | FlatShape::Block + | FlatShape::Closure => { + let span = shape.0; + let shape = &shape.1; + let spans = split_span_by_highlight_positions( + line, + span, + &matching_brackets_pos, + global_span_offset, + ); + for (part, highlight) in spans { + let start = part.start - span.start; + let end = part.end - span.start; + let text = next_token[start..end].to_string(); + let mut style = get_shape_color(shape.as_str(), &config); + if highlight { + style = get_matching_brackets_style(style, &config); } + result.text.push((style, text)); } - - FlatShape::Filepath => add_colored_token(&shape.1, next_token), - FlatShape::Directory => add_colored_token(&shape.1, next_token), - FlatShape::GlobInterpolation => add_colored_token(&shape.1, next_token), - FlatShape::GlobPattern => add_colored_token(&shape.1, next_token), - FlatShape::Variable(_) | FlatShape::VarDecl(_) => { - add_colored_token(&shape.1, next_token) - } - FlatShape::Flag => add_colored_token(&shape.1, next_token), - FlatShape::Pipe => add_colored_token(&shape.1, next_token), - FlatShape::Redirection => add_colored_token(&shape.1, next_token), - FlatShape::Custom(..) => add_colored_token(&shape.1, next_token), - FlatShape::MatchPattern => add_colored_token(&shape.1, next_token), } - last_seen_span = shape.0.end; - } - let remainder = line[(last_seen_span - global_span_offset)..].to_string(); - if !remainder.is_empty() { - output.push((Style::new(), remainder)); + FlatShape::Filepath => add_colored_token(&shape.1, next_token), + FlatShape::Directory => add_colored_token(&shape.1, next_token), + FlatShape::GlobInterpolation => add_colored_token(&shape.1, next_token), + FlatShape::GlobPattern => add_colored_token(&shape.1, next_token), + FlatShape::Variable(_) | FlatShape::VarDecl(_) => { + add_colored_token(&shape.1, next_token) + } + FlatShape::Flag => add_colored_token(&shape.1, next_token), + FlatShape::Pipe => add_colored_token(&shape.1, next_token), + FlatShape::Redirection => add_colored_token(&shape.1, next_token), + FlatShape::Custom(..) => add_colored_token(&shape.1, next_token), + FlatShape::MatchPattern => add_colored_token(&shape.1, next_token), } - - output + last_seen_span = shape.0.end; } + + let remainder = line[(last_seen_span - global_span_offset)..].to_string(); + if !remainder.is_empty() { + result.text.push((Style::new(), remainder)); + } + + result } fn split_span_by_highlight_positions( diff --git a/crates/nu-command/src/help/help_aliases.rs b/crates/nu-command/src/help/help_aliases.rs index 6bbb82e0f0..39db18fed7 100644 --- a/crates/nu-command/src/help/help_aliases.rs +++ b/crates/nu-command/src/help/help_aliases.rs @@ -1,5 +1,5 @@ use crate::filters::find_internal; -use nu_engine::{command_prelude::*, scope::ScopeData}; +use nu_engine::{command_prelude::*, get_full_help, scope::ScopeData}; #[derive(Clone)] pub struct HelpAliases; @@ -101,42 +101,17 @@ pub fn help_aliases( }); }; - let Some(alias) = engine_state.get_decl(alias).as_alias() else { + let alias = engine_state.get_decl(alias); + + if alias.as_alias().is_none() { return Err(ShellError::AliasNotFound { span: Span::merge_many(rest.iter().map(|s| s.span)), }); }; - let alias_expansion = - String::from_utf8_lossy(engine_state.get_span_contents(alias.wrapped_call.span)); - let description = alias.description(); - let extra_desc = alias.extra_description(); + let help = get_full_help(alias, engine_state, stack); - // TODO: merge this into documentation.rs at some point - const G: &str = "\x1b[32m"; // green - const C: &str = "\x1b[36m"; // cyan - const RESET: &str = "\x1b[0m"; // reset - - let mut long_desc = String::new(); - - long_desc.push_str(description); - long_desc.push_str("\n\n"); - - if !extra_desc.is_empty() { - long_desc.push_str(extra_desc); - long_desc.push_str("\n\n"); - } - - long_desc.push_str(&format!("{G}Alias{RESET}: {C}{name}{RESET}")); - long_desc.push_str("\n\n"); - long_desc.push_str(&format!("{G}Expansion{RESET}:\n {alias_expansion}")); - - let config = stack.get_config(engine_state); - if !config.use_ansi_coloring.get(engine_state) { - long_desc = nu_utils::strip_ansi_string_likely(long_desc); - } - - Ok(Value::string(long_desc, call.head).into_pipeline_data()) + Ok(Value::string(help, call.head).into_pipeline_data()) } } diff --git a/crates/nu-engine/Cargo.toml b/crates/nu-engine/Cargo.toml index 8f6aba6d3e..7c80bdd5c7 100644 --- a/crates/nu-engine/Cargo.toml +++ b/crates/nu-engine/Cargo.toml @@ -18,6 +18,7 @@ nu-protocol = { path = "../nu-protocol", version = "0.105.2", default-features = nu-path = { path = "../nu-path", version = "0.105.2" } nu-glob = { path = "../nu-glob", version = "0.105.2" } nu-utils = { path = "../nu-utils", version = "0.105.2", default-features = false } +fancy-regex = { workspace = true } log = { workspace = true } [features] diff --git a/crates/nu-engine/src/documentation.rs b/crates/nu-engine/src/documentation.rs index 36f4a301d3..0198df210e 100644 --- a/crates/nu-engine/src/documentation.rs +++ b/crates/nu-engine/src/documentation.rs @@ -1,7 +1,8 @@ use crate::eval_call; +use fancy_regex::{Captures, Regex}; use nu_protocol::{ - Category, Config, Example, IntoPipelineData, PipelineData, PositionalArg, Signature, Span, - SpanId, Spanned, SyntaxShape, Type, Value, + Category, Config, IntoPipelineData, PipelineData, PositionalArg, Signature, Span, SpanId, + Spanned, SyntaxShape, Type, Value, ast::{Argument, Call, Expr, Expression, RecordItem}, debugger::WithoutDebug, engine::CommandType, @@ -9,12 +10,21 @@ use nu_protocol::{ record, }; use nu_utils::terminal_size; -use std::{collections::HashMap, fmt::Write}; +use std::{ + borrow::Cow, + collections::HashMap, + fmt::Write, + sync::{Arc, LazyLock}, +}; /// ANSI style reset const RESET: &str = "\x1b[0m"; /// ANSI set default color (as set in the terminal) const DEFAULT_COLOR: &str = "\x1b[39m"; +/// ANSI set default dimmed +const DEFAULT_DIMMED: &str = "\x1b[2;39m"; +/// ANSI set default italic +const DEFAULT_ITALIC: &str = "\x1b[3;39m"; pub fn get_full_help( command: &dyn Command, @@ -27,71 +37,225 @@ pub fn get_full_help( // execution. let stack = &mut stack.start_collect_value(); - let signature = engine_state + let nu_config = stack.get_config(engine_state); + + let sig = engine_state .get_signature(command) .update_from_command(command); - get_documentation( - &signature, - &command.examples(), - engine_state, - stack, - command.is_keyword(), - ) -} - -/// Syntax highlight code using the `nu-highlight` command if available -fn nu_highlight_string(code_string: &str, engine_state: &EngineState, stack: &mut Stack) -> String { - if let Some(highlighter) = engine_state.find_decl(b"nu-highlight", &[]) { - let decl = engine_state.get_decl(highlighter); - - let call = Call::new(Span::unknown()); - - if let Ok(output) = decl.run( - engine_state, - stack, - &(&call).into(), - Value::string(code_string, Span::unknown()).into_pipeline_data(), - ) { - let result = output.into_value(Span::unknown()); - if let Ok(s) = result.and_then(Value::coerce_into_string) { - return s; // successfully highlighted string - } - } - } - code_string.to_string() -} - -fn get_documentation( - sig: &Signature, - examples: &[Example], - engine_state: &EngineState, - stack: &mut Stack, - is_parser_keyword: bool, -) -> String { - let nu_config = stack.get_config(engine_state); - // Create ansi colors let mut help_style = HelpStyle::default(); help_style.update_from_config(engine_state, &nu_config); - let help_section_name = &help_style.section_name; - let help_subcolor_one = &help_style.subcolor_one; - let cmd_name = &sig.name; let mut long_desc = String::new(); let desc = &sig.description; if !desc.is_empty() { - long_desc.push_str(desc); + long_desc.push_str(&highlight_code(desc, engine_state, stack)); long_desc.push_str("\n\n"); } let extra_desc = &sig.extra_description; if !extra_desc.is_empty() { - long_desc.push_str(extra_desc); + long_desc.push_str(&highlight_code(extra_desc, engine_state, stack)); long_desc.push_str("\n\n"); } + match command.command_type() { + CommandType::Alias => get_alias_documentation( + &mut long_desc, + command, + &sig, + &help_style, + engine_state, + stack, + ), + _ => get_command_documentation( + &mut long_desc, + command, + &sig, + &nu_config, + &help_style, + engine_state, + stack, + ), + }; + + if !nu_config.use_ansi_coloring.get(engine_state) { + nu_utils::strip_ansi_string_likely(long_desc) + } else { + long_desc + } +} + +/// Syntax highlight code using the `nu-highlight` command if available +fn try_nu_highlight( + code_string: &str, + reject_garbage: bool, + engine_state: &EngineState, + stack: &mut Stack, +) -> Option { + let highlighter = engine_state.find_decl(b"nu-highlight", &[])?; + + let decl = engine_state.get_decl(highlighter); + let mut call = Call::new(Span::unknown()); + if reject_garbage { + call.add_named(( + Spanned { + item: "reject-garbage".into(), + span: Span::unknown(), + }, + None, + None, + )); + } + + decl.run( + engine_state, + stack, + &(&call).into(), + Value::string(code_string, Span::unknown()).into_pipeline_data(), + ) + .and_then(|pipe| pipe.into_value(Span::unknown())) + .and_then(|val| val.coerce_into_string()) + .ok() +} + +/// Syntax highlight code using the `nu-highlight` command if available, falling back to the given string +fn nu_highlight_string(code_string: &str, engine_state: &EngineState, stack: &mut Stack) -> String { + try_nu_highlight(code_string, false, engine_state, stack) + .unwrap_or_else(|| code_string.to_string()) +} + +/// Apply code highlighting to code in a capture group +fn highlight_capture_group( + captures: &Captures, + engine_state: &EngineState, + stack: &mut Stack, +) -> String { + let Some(content) = captures.get(1) else { + // this shouldn't happen + return String::new(); + }; + + // Save current color config + let config_old = stack.get_config(engine_state); + let mut config = (*config_old).clone(); + + // Style externals and external arguments with fallback style, + // so nu-highlight styles code which is technically valid syntax, + // but not an internal command is highlighted with the fallback style + let code_style = Value::record( + record! { + "attr" => Value::string("di", Span::unknown()), + }, + Span::unknown(), + ); + let color_config = &mut config.color_config; + color_config.insert("shape_external".into(), code_style.clone()); + color_config.insert("shape_external_resolved".into(), code_style.clone()); + color_config.insert("shape_externalarg".into(), code_style); + + // Apply config with external argument style + stack.config = Some(Arc::new(config)); + + // Highlight and reject invalid syntax + let highlighted = try_nu_highlight(content.into(), true, engine_state, stack) + // // Make highlighted string italic + .map(|text| { + let resets = text.match_indices(RESET).count(); + // replace resets with reset + italic, so the whole string is italicized, excluding the final reset + let text = text.replacen(RESET, &format!("{RESET}{DEFAULT_ITALIC}"), resets - 1); + // start italicized + format!("{DEFAULT_ITALIC}{text}") + }); + + // Restore original config + stack.config = Some(config_old); + + // Use fallback style if highlight failed/syntax was invalid + highlighted.unwrap_or_else(|| highlight_fallback(content.into())) +} + +/// Apply fallback code style +fn highlight_fallback(text: &str) -> String { + format!("{DEFAULT_DIMMED}{DEFAULT_ITALIC}{text}{RESET}") +} + +/// Highlight code within backticks +/// +/// Will attempt to use nu-highlight, falling back to dimmed and italic on invalid syntax +fn highlight_code<'a>( + text: &'a str, + engine_state: &EngineState, + stack: &mut Stack, +) -> Cow<'a, str> { + let config = stack.get_config(engine_state); + if !config.use_ansi_coloring.get(engine_state) { + return Cow::Borrowed(text); + } + + // See [`tests::test_code_formatting`] for examples + static PATTERN: &str = r"(?x) # verbose mode + (? = LazyLock::new(|| Regex::new(PATTERN).expect("valid regex")); + + let do_try_highlight = + |captures: &Captures| highlight_capture_group(captures, engine_state, stack); + RE.replace_all(text, do_try_highlight) +} + +fn get_alias_documentation( + long_desc: &mut String, + command: &dyn Command, + sig: &Signature, + help_style: &HelpStyle, + engine_state: &EngineState, + stack: &mut Stack, +) { + let help_section_name = &help_style.section_name; + let help_subcolor_one = &help_style.subcolor_one; + + let alias_name = &sig.name; + + long_desc.push_str(&format!( + "{help_section_name}Alias{RESET}: {help_subcolor_one}{alias_name}{RESET}" + )); + long_desc.push_str("\n\n"); + + let Some(alias) = command.as_alias() else { + // this is already checked in `help alias`, but just omit the expansion if this is somehow not actually an alias + return; + }; + + let alias_expansion = + String::from_utf8_lossy(engine_state.get_span_contents(alias.wrapped_call.span)); + + long_desc.push_str(&format!( + "{help_section_name}Expansion{RESET}:\n {}", + nu_highlight_string(&alias_expansion, engine_state, stack) + )); +} + +fn get_command_documentation( + long_desc: &mut String, + command: &dyn Command, + sig: &Signature, + nu_config: &Config, + help_style: &HelpStyle, + engine_state: &EngineState, + stack: &mut Stack, +) { + let help_section_name = &help_style.section_name; + let help_subcolor_one = &help_style.subcolor_one; + + let cmd_name = &sig.name; + if !sig.search_terms.is_empty() { let _ = write!( long_desc, @@ -129,12 +293,15 @@ fn get_documentation( { subcommands.push(format!( " {help_subcolor_one}{} {help_section_name}({}){RESET} - {}", - sig.name, command_type, sig.description + sig.name, + command_type, + highlight_code(&sig.description, engine_state, stack) )); } else { subcommands.push(format!( " {help_subcolor_one}{}{RESET} - {}", - sig.name, sig.description + sig.name, + highlight_code(&sig.description, engine_state, stack) )); } } @@ -148,8 +315,15 @@ fn get_documentation( } if !sig.named.is_empty() { - long_desc.push_str(&get_flags_section(sig, &help_style, |v| { - nu_highlight_string(&v.to_parsable_string(", ", &nu_config), engine_state, stack) + long_desc.push_str(&get_flags_section(sig, help_style, |v| match v { + FormatterValue::DefaultValue(value) => nu_highlight_string( + &value.to_parsable_string(", ", nu_config), + engine_state, + stack, + ), + FormatterValue::CodeString(text) => { + highlight_code(text, engine_state, stack).to_string() + } })) } @@ -160,22 +334,22 @@ fn get_documentation( let _ = write!(long_desc, "\n{help_section_name}Parameters{RESET}:\n"); for positional in &sig.required_positional { write_positional( - &mut long_desc, + long_desc, positional, PositionalKind::Required, - &help_style, - &nu_config, + help_style, + nu_config, engine_state, stack, ); } for positional in &sig.optional_positional { write_positional( - &mut long_desc, + long_desc, positional, PositionalKind::Optional, - &help_style, - &nu_config, + help_style, + nu_config, engine_state, stack, ); @@ -183,11 +357,11 @@ fn get_documentation( if let Some(rest_positional) = &sig.rest_positional { write_positional( - &mut long_desc, + long_desc, rest_positional, PositionalKind::Rest, - &help_style, - &nu_config, + help_style, + nu_config, engine_state, stack, ); @@ -202,7 +376,7 @@ fn get_documentation( } } - if !is_parser_keyword && !sig.input_output_types.is_empty() { + if !command.is_keyword() && !sig.input_output_types.is_empty() { if let Some(decl_id) = engine_state.find_decl(b"table", &[]) { // FIXME: we may want to make this the span of the help command in the future let span = Span::unknown(); @@ -250,6 +424,8 @@ fn get_documentation( } } + let examples = command.examples(); + if !examples.is_empty() { let _ = write!(long_desc, "\n{help_section_name}Examples{RESET}:"); } @@ -257,7 +433,7 @@ fn get_documentation( for example in examples { long_desc.push('\n'); long_desc.push_str(" "); - long_desc.push_str(example.description); + long_desc.push_str(&highlight_code(example.description, engine_state, stack)); if !nu_config.use_ansi_coloring.get(engine_state) { let _ = write!(long_desc, "\n > {}\n", example.example); @@ -320,7 +496,7 @@ fn get_documentation( let _ = writeln!( long_desc, " {}", - item.to_expanded_string("", &nu_config) + item.to_expanded_string("", nu_config) .replace('\n', "\n ") .trim() ); @@ -329,12 +505,6 @@ fn get_documentation( } long_desc.push('\n'); - - if !nu_config.use_ansi_coloring.get(engine_state) { - nu_utils::strip_ansi_string_likely(long_desc) - } else { - long_desc - } } fn update_ansi_from_config( @@ -529,7 +699,11 @@ fn write_positional( } }; if !positional.desc.is_empty() || arg_kind == PositionalKind::Optional { - let _ = write!(long_desc, ": {}", positional.desc); + let _ = write!( + long_desc, + ": {}", + highlight_code(&positional.desc, engine_state, stack) + ); } if arg_kind == PositionalKind::Optional { if let Some(value) = &positional.default_value { @@ -549,13 +723,25 @@ fn write_positional( long_desc.push('\n'); } +/// Helper for `get_flags_section` +/// +/// The formatter with access to nu-highlight must be passed to `get_flags_section`, but it's not possible +/// to pass separate closures since they both need `&mut Stack`, so this enum lets us differentiate between +/// default values to be formatted and strings which might contain code in backticks to be highlighted. +pub enum FormatterValue<'a> { + /// Default value to be styled + DefaultValue(&'a Value), + /// String which might have code in backticks to be highlighted + CodeString(&'a str), +} + pub fn get_flags_section( signature: &Signature, help_style: &HelpStyle, - mut value_formatter: F, // format default Value (because some calls cant access config or nu-highlight) + mut formatter: F, // format default Value or text with code (because some calls cant access config or nu-highlight) ) -> String where - F: FnMut(&nu_protocol::Value) -> String, + F: FnMut(FormatterValue) -> String, { let help_section_name = &help_style.section_name; let help_subcolor_one = &help_style.subcolor_one; @@ -588,12 +774,100 @@ where ); } if !flag.desc.is_empty() { - let _ = write!(long_desc, ": {}", flag.desc); + let _ = write!( + long_desc, + ": {}", + &formatter(FormatterValue::CodeString(&flag.desc)) + ); } if let Some(value) = &flag.default_value { - let _ = write!(long_desc, " (default: {})", &value_formatter(value)); + let _ = write!( + long_desc, + " (default: {})", + &formatter(FormatterValue::DefaultValue(value)) + ); } long_desc.push('\n'); } long_desc } + +#[cfg(test)] +mod tests { + use nu_protocol::UseAnsiColoring; + + use super::*; + + #[test] + fn test_code_formatting() { + let mut engine_state = EngineState::new(); + let mut stack = Stack::new(); + + // force coloring on for test + let mut config = (*engine_state.config).clone(); + config.use_ansi_coloring = UseAnsiColoring::True; + engine_state.config = Arc::new(config); + + // using Cow::Owned here to mean a match, since the content changed, + // and borrowed to mean not a match, since the content didn't change + + // match: typical example + let haystack = "Run the `foo` command"; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Owned(_) + )); + + // no match: backticks preceded by alphanum + let haystack = "foo`bar`"; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Borrowed(_) + )); + + // match: command at beginning of string is ok + let haystack = "`my-command` is cool"; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Owned(_) + )); + + // match: preceded and followed by newline is ok + let haystack = r" + `command` + "; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Owned(_) + )); + + // no match: newline between backticks + let haystack = "// hello `beautiful \n world`"; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Borrowed(_) + )); + + // match: backticks followed by period, not letter/number + let haystack = "try running `my cool command`."; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Owned(_) + )); + + // match: backticks enclosed by parenthesis, not letter/number + let haystack = "a command (`my cool command`)."; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Owned(_) + )); + + // no match: only characters inside backticks are backticks + // (the regex sees two backtick pairs with a single backtick inside, which doesn't qualify) + let haystack = "```\ncode block\n```"; + assert!(matches!( + highlight_code(haystack, &engine_state, &mut stack), + Cow::Borrowed(_) + )); + } +} diff --git a/crates/nu-plugin/src/plugin/mod.rs b/crates/nu-plugin/src/plugin/mod.rs index 323ff84a7c..90b0add3fd 100644 --- a/crates/nu-plugin/src/plugin/mod.rs +++ b/crates/nu-plugin/src/plugin/mod.rs @@ -9,7 +9,7 @@ use std::{ thread, }; -use nu_engine::documentation::{HelpStyle, get_flags_section}; +use nu_engine::documentation::{FormatterValue, HelpStyle, get_flags_section}; use nu_plugin_core::{ ClientCommunicationIo, CommunicationMode, InterfaceManager, PluginEncoder, PluginRead, PluginWrite, @@ -684,7 +684,10 @@ fn print_help(plugin: &impl Plugin, encoder: impl PluginEncoder) { } }) .and_then(|_| { - let flags = get_flags_section(&signature, &help_style, |v| format!("{:#?}", v)); + let flags = get_flags_section(&signature, &help_style, |v| match v { + FormatterValue::DefaultValue(value) => format!("{:#?}", value), + FormatterValue::CodeString(text) => text.to_string(), + }); write!(help, "{flags}") }) .and_then(|_| writeln!(help, "\nParameters:"))