Custom command attributes (#14906)

# Description
Add custom command attributes.

- Attributes are placed before a command definition and start with a `@`
character.
- Attribute invocations consist of const command call. The command's
name must start with "attr ", but this prefix is not used in the
invocation.
- A command named `attr example` is invoked as an attribute as
`@example`
-   Several built-in attribute commands are provided as part of this PR
    -   `attr example`: Attaches an example to the commands help text
        ```nushell
        # Double numbers
        @example "double an int"  { 5 | double }   --result 10
        @example "double a float" { 0.5 | double } --result 1.0
        def double []: [number -> number] {
            $in * 2
        }
        ```
    -   `attr search-terms`: Adds search terms to a command
    -   ~`attr env`: Equivalent to using `def --env`~
- ~`attr wrapped`: Equivalent to using `def --wrapped`~ shelved for
later discussion
    -   several testing related attributes in `std/testing`
- If an attribute has no internal/special purpose, it's stored as
command metadata that can be obtained with `scope commands`.
- This allows having attributes like `@test` which can be used by test
runners.
-   Used the `@example` attribute for `std` examples.
-   Updated the std tests and test runner to use `@test` attributes
-   Added completions for attributes

# User-Facing Changes
Users can add examples to their own command definitions, and add other
arbitrary attributes.

# Tests + Formatting

- 🟢 toolkit fmt
- 🟢 toolkit clippy
- 🟢 toolkit test
- 🟢 toolkit test stdlib

# After Submitting
- Add documentation about the attribute syntax and built-in attributes
- `help attributes`

---------

Co-authored-by: 132ikl <132@ikl.sh>
This commit is contained in:
Bahex
2025-02-11 15:34:51 +03:00
committed by GitHub
parent a58d9b0b3a
commit 442df9e39c
57 changed files with 2028 additions and 987 deletions

View File

@ -14,8 +14,8 @@ use log::trace;
use nu_engine::DIR_VAR_PARSER_INFO;
use nu_protocol::{
ast::*, engine::StateWorkingSet, eval_const::eval_constant, BlockId, DeclId, DidYouMean,
FilesizeUnit, Flag, ParseError, PositionalArg, Signature, Span, Spanned, SyntaxShape, Type,
Value, VarId, ENV_VARIABLE_ID, IN_VARIABLE_ID,
FilesizeUnit, Flag, ParseError, PositionalArg, ShellError, Signature, Span, Spanned,
SyntaxShape, Type, Value, VarId, ENV_VARIABLE_ID, IN_VARIABLE_ID,
};
use std::{
collections::{HashMap, HashSet},
@ -1283,51 +1283,7 @@ pub fn parse_call(working_set: &mut StateWorkingSet, spans: &[Span], head: Span)
return garbage(working_set, head);
}
let mut pos = 0;
let cmd_start = pos;
let mut name_spans = vec![];
let mut name = vec![];
for word_span in spans[cmd_start..].iter() {
// Find the longest group of words that could form a command
name_spans.push(*word_span);
let name_part = working_set.get_span_contents(*word_span);
if name.is_empty() {
name.extend(name_part);
} else {
name.push(b' ');
name.extend(name_part);
}
pos += 1;
}
let mut maybe_decl_id = working_set.find_decl(&name);
while maybe_decl_id.is_none() {
// Find the longest command match
if name_spans.len() <= 1 {
// Keep the first word even if it does not match -- could be external command
break;
}
name_spans.pop();
pos -= 1;
let mut name = vec![];
for name_span in &name_spans {
let name_part = working_set.get_span_contents(*name_span);
if name.is_empty() {
name.extend(name_part);
} else {
name.push(b' ');
name.extend(name_part);
}
}
maybe_decl_id = working_set.find_decl(&name);
}
let (cmd_start, pos, _name, maybe_decl_id) = find_longest_decl(working_set, spans);
if let Some(decl_id) = maybe_decl_id {
// Before the internal parsing we check if there is no let or alias declarations
@ -1424,6 +1380,172 @@ pub fn parse_call(working_set: &mut StateWorkingSet, spans: &[Span], head: Span)
}
}
pub fn find_longest_decl(
working_set: &mut StateWorkingSet<'_>,
spans: &[Span],
) -> (
usize,
usize,
Vec<u8>,
Option<nu_protocol::Id<nu_protocol::marker::Decl>>,
) {
find_longest_decl_with_prefix(working_set, spans, b"")
}
pub fn find_longest_decl_with_prefix(
working_set: &mut StateWorkingSet<'_>,
spans: &[Span],
prefix: &[u8],
) -> (
usize,
usize,
Vec<u8>,
Option<nu_protocol::Id<nu_protocol::marker::Decl>>,
) {
let mut pos = 0;
let cmd_start = pos;
let mut name_spans = vec![];
let mut name = vec![];
name.extend(prefix);
for word_span in spans[cmd_start..].iter() {
// Find the longest group of words that could form a command
name_spans.push(*word_span);
let name_part = working_set.get_span_contents(*word_span);
if name.is_empty() {
name.extend(name_part);
} else {
name.push(b' ');
name.extend(name_part);
}
pos += 1;
}
let mut maybe_decl_id = working_set.find_decl(&name);
while maybe_decl_id.is_none() {
// Find the longest command match
if name_spans.len() <= 1 {
// Keep the first word even if it does not match -- could be external command
break;
}
name_spans.pop();
pos -= 1;
// TODO: Refactor to avoid recreating name with an inner loop.
name.clear();
name.extend(prefix);
for name_span in &name_spans {
let name_part = working_set.get_span_contents(*name_span);
if name.is_empty() {
name.extend(name_part);
} else {
name.push(b' ');
name.extend(name_part);
}
}
maybe_decl_id = working_set.find_decl(&name);
}
(cmd_start, pos, name, maybe_decl_id)
}
pub fn parse_attribute(
working_set: &mut StateWorkingSet,
lite_command: &LiteCommand,
) -> (Attribute, Option<String>) {
let _ = lite_command
.parts
.first()
.filter(|s| working_set.get_span_contents(**s).starts_with(b"@"))
.expect("Attributes always start with an `@`");
assert!(
lite_command.attribute_idx.is_empty(),
"attributes can't have attributes"
);
let mut spans = lite_command.parts.clone();
if let Some(first) = spans.first_mut() {
first.start += 1;
}
let spans = spans.as_slice();
let attr_span = Span::concat(spans);
let (cmd_start, cmd_end, mut name, decl_id) =
find_longest_decl_with_prefix(working_set, spans, b"attr");
debug_assert!(name.starts_with(b"attr "));
let _ = name.drain(..(b"attr ".len()));
let name_span = Span::concat(&spans[cmd_start..cmd_end]);
let Ok(name) = String::from_utf8(name) else {
working_set.error(ParseError::NonUtf8(name_span));
return (
Attribute {
expr: garbage(working_set, attr_span),
},
None,
);
};
let Some(decl_id) = decl_id else {
working_set.error(ParseError::UnknownCommand(name_span));
return (
Attribute {
expr: garbage(working_set, attr_span),
},
None,
);
};
let decl = working_set.get_decl(decl_id);
let parsed_call = match decl.as_alias() {
// TODO: Once `const def` is available, we should either disallow aliases as attributes OR
// allow them but rather than using the aliases' name, use the name of the aliased command
Some(alias) => match &alias.clone().wrapped_call {
Expression {
expr: Expr::ExternalCall(..),
..
} => {
let shell_error = ShellError::NotAConstCommand { span: name_span };
working_set.error(shell_error.wrap(working_set, attr_span));
return (
Attribute {
expr: garbage(working_set, Span::concat(spans)),
},
None,
);
}
_ => {
trace!("parsing: alias of internal call");
parse_internal_call(working_set, name_span, &spans[cmd_end..], decl_id)
}
},
None => {
trace!("parsing: internal call");
parse_internal_call(working_set, name_span, &spans[cmd_end..], decl_id)
}
};
(
Attribute {
expr: Expression::new(
working_set,
Expr::Call(parsed_call.call),
Span::concat(spans),
parsed_call.output,
),
},
Some(name),
)
}
pub fn parse_binary(working_set: &mut StateWorkingSet, span: Span) -> Expression {
trace!("parsing: binary");
let contents = working_set.get_span_contents(span);
@ -4166,7 +4288,7 @@ pub fn parse_list_expression(
working_set.error(err)
}
let (mut output, err) = lite_parse(&output);
let (mut output, err) = lite_parse(&output, working_set);
if let Some(err) = err {
working_set.error(err)
}
@ -5728,11 +5850,20 @@ pub fn parse_builtin_commands(
}
trace!("parsing: checking for keywords");
let name = working_set.get_span_contents(lite_command.parts[0]);
let name = lite_command
.command_parts()
.first()
.map(|s| working_set.get_span_contents(*s))
.unwrap_or(b"");
match name {
// `parse_def` and `parse_extern` work both with and without attributes
b"def" => parse_def(working_set, lite_command, None).0,
b"extern" => parse_extern(working_set, lite_command, None),
// `parse_export_in_block` also handles attributes by itself
b"export" => parse_export_in_block(working_set, lite_command),
// Other definitions can't have attributes, so we handle attributes here with parse_attribute_block
_ if lite_command.has_attributes() => parse_attribute_block(working_set, lite_command),
b"let" => parse_let(
working_set,
&lite_command
@ -5761,7 +5892,6 @@ pub fn parse_builtin_commands(
parse_keyword(working_set, lite_command)
}
b"source" | b"source-env" => parse_source(working_set, lite_command),
b"export" => parse_export_in_block(working_set, lite_command),
b"hide" => parse_hide(working_set, lite_command),
b"where" => parse_where(working_set, lite_command),
// Only "plugin use" is a keyword
@ -6154,7 +6284,7 @@ pub fn parse_block(
scoped: bool,
is_subexpression: bool,
) -> Block {
let (lite_block, err) = lite_parse(tokens);
let (lite_block, err) = lite_parse(tokens, working_set);
if let Some(err) = err {
working_set.error(err);
}
@ -6169,7 +6299,7 @@ pub fn parse_block(
// that share the same block can see each other
for pipeline in &lite_block.block {
if pipeline.commands.len() == 1 {
parse_def_predecl(working_set, &pipeline.commands[0].parts)
parse_def_predecl(working_set, pipeline.commands[0].command_parts())
}
}
@ -6354,6 +6484,9 @@ pub fn discover_captures_in_expr(
output: &mut Vec<(VarId, Span)>,
) -> Result<(), ParseError> {
match &expr.expr {
Expr::AttributeBlock(ab) => {
discover_captures_in_expr(working_set, &ab.item, seen, seen_blocks, output)?;
}
Expr::BinaryOp(lhs, _, rhs) => {
discover_captures_in_expr(working_set, lhs, seen, seen_blocks, output)?;
discover_captures_in_expr(working_set, rhs, seen, seen_blocks, output)?;