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 <git+github@cptpiepmatz.de>
This commit is contained in:
132ikl
2025-06-25 15:26:52 -04:00
committed by GitHub
parent cb7ac9199d
commit 6fba4b409e
8 changed files with 547 additions and 243 deletions

View File

@ -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(),
}))
}

View File

@ -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<PipelineData, ShellError> {
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),

View File

@ -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<Span>,
}
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(