Enable conditional source and use patterns by allowing null as a no-op module (#14773)

Related:
- #14329
- #13872
- #8214

# Description & User-Facing Changes

This PR allows enables the following uses, which are all no-op.
```nushell
source null
source-env null
use null
overlay use null
```

The motivation for this change is conditional sourcing of files. For
example, with this change `login.nu` may be deprecated and replaced with
the following code in `config.nu`
```nushell
const login_module = if $nu.is-login { "login.nu" } else { null }
source $login_module
```

# Tests + Formatting
I'm hoping for CI to pass 😄

# After Submitting
Add a part about the conditional sourcing pattern to the website.
This commit is contained in:
Bahex 2025-01-09 15:37:27 +03:00 committed by GitHub
parent 5cf6dea997
commit 79f19f2fc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 160 additions and 25 deletions

View file

@ -24,8 +24,8 @@ impl Command for OverlayUse {
.allow_variants_without_examples(true) .allow_variants_without_examples(true)
.required( .required(
"name", "name",
SyntaxShape::String, SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::Nothing]),
"Module name to use overlay for.", "Module name to use overlay for (`null` for no-op).",
) )
.optional( .optional(
"as", "as",
@ -61,6 +61,11 @@ impl Command for OverlayUse {
call: &Call, call: &Call,
input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
let noop = call.get_parser_info(caller_stack, "noop");
if noop.is_some() {
return Ok(PipelineData::empty());
}
let mut name_arg: Spanned<String> = call.req(engine_state, caller_stack, 0)?; let mut name_arg: Spanned<String> = call.req(engine_state, caller_stack, 0)?;
name_arg.item = trim_quotes_str(&name_arg.item).to_string(); name_arg.item = trim_quotes_str(&name_arg.item).to_string();

View file

@ -22,7 +22,11 @@ impl Command for Use {
Signature::build("use") Signature::build("use")
.input_output_types(vec![(Type::Nothing, Type::Nothing)]) .input_output_types(vec![(Type::Nothing, Type::Nothing)])
.allow_variants_without_examples(true) .allow_variants_without_examples(true)
.required("module", SyntaxShape::String, "Module or module file.") .required(
"module",
SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::Nothing]),
"Module or module file (`null` for no-op).",
)
.rest( .rest(
"members", "members",
SyntaxShape::Any, SyntaxShape::Any,
@ -54,6 +58,9 @@ This command is a parser keyword. For details, check:
call: &Call, call: &Call,
input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
if call.get_parser_info(caller_stack, "noop").is_some() {
return Ok(PipelineData::empty());
}
let Some(Expression { let Some(Expression {
expr: Expr::ImportPattern(import_pattern), expr: Expr::ImportPattern(import_pattern),
.. ..

View file

@ -19,8 +19,8 @@ impl Command for SourceEnv {
.input_output_types(vec![(Type::Any, Type::Any)]) .input_output_types(vec![(Type::Any, Type::Any)])
.required( .required(
"filename", "filename",
SyntaxShape::String, // type is string to avoid automatically canonicalizing the path SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::Nothing]), // type is string to avoid automatically canonicalizing the path
"The filepath to the script file to source the environment from.", "The filepath to the script file to source the environment from (`null` for no-op).",
) )
.category(Category::Core) .category(Category::Core)
} }
@ -45,6 +45,10 @@ impl Command for SourceEnv {
call: &Call, call: &Call,
input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
if call.get_parser_info(caller_stack, "noop").is_some() {
return Ok(PipelineData::empty());
}
let source_filename: Spanned<String> = call.req(engine_state, caller_stack, 0)?; let source_filename: Spanned<String> = call.req(engine_state, caller_stack, 0)?;
// Note: this hidden positional is the block_id that corresponded to the 0th position // Note: this hidden positional is the block_id that corresponded to the 0th position
@ -99,10 +103,17 @@ impl Command for SourceEnv {
} }
fn examples(&self) -> Vec<Example> { fn examples(&self) -> Vec<Example> {
vec![Example { vec![
description: "Sources the environment from foo.nu in the current context", Example {
example: r#"source-env foo.nu"#, description: "Sources the environment from foo.nu in the current context",
result: None, example: r#"source-env foo.nu"#,
}] result: None,
},
Example {
description: "Sourcing `null` is a no-op.",
example: r#"source-env null"#,
result: None,
},
]
} }
} }

View file

@ -16,8 +16,8 @@ impl Command for Source {
.input_output_types(vec![(Type::Any, Type::Any)]) .input_output_types(vec![(Type::Any, Type::Any)])
.required( .required(
"filename", "filename",
SyntaxShape::Filepath, SyntaxShape::OneOf(vec![SyntaxShape::Filepath, SyntaxShape::Nothing]),
"The filepath to the script file to source.", "The filepath to the script file to source (`null` for no-op).",
) )
.category(Category::Core) .category(Category::Core)
} }
@ -42,6 +42,9 @@ impl Command for Source {
call: &Call, call: &Call,
input: PipelineData, input: PipelineData,
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
if call.get_parser_info(stack, "noop").is_some() {
return Ok(PipelineData::empty());
}
// Note: two hidden positionals are used here that are injected by the parser: // Note: two hidden positionals are used here that are injected by the parser:
// 1. The block_id that corresponded to the 0th position // 1. The block_id that corresponded to the 0th position
// 2. The block_id_name that corresponded to the file name at the 0th position // 2. The block_id_name that corresponded to the file name at the 0th position
@ -107,6 +110,16 @@ impl Command for Source {
example: r#"source ./foo.nu; say-hi"#, example: r#"source ./foo.nu; say-hi"#,
result: None, result: None,
}, },
Example {
description: "Sourcing `null` is a no-op.",
example: r#"source null"#,
result: None,
},
Example {
description: "Source can be used with const variables.",
example: r#"const file = if $nu.is-interactive { "interactive.nu" } else { null }; source $file"#,
result: None,
}
] ]
} }
} }

View file

@ -2367,18 +2367,37 @@ pub fn parse_use(
let import_pattern_expr = parse_import_pattern(working_set, args_spans); let import_pattern_expr = parse_import_pattern(working_set, args_spans);
let import_pattern = if let Expression { let import_pattern = match &import_pattern_expr {
expr: Expr::ImportPattern(import_pattern), Expression {
.. expr: Expr::Nothing,
} = &import_pattern_expr ..
{ } => {
import_pattern.clone() let mut call = call;
} else { call.set_parser_info(
working_set.error(ParseError::UnknownState( "noop".to_string(),
"internal error: Import pattern positional is not import pattern".into(), Expression::new_unknown(Expr::Nothing, Span::unknown(), Type::Nothing),
import_pattern_expr.span, );
)); return (
return (garbage_pipeline(working_set, spans), vec![]); Pipeline::from_vec(vec![Expression::new(
working_set,
Expr::Call(call),
Span::concat(spans),
Type::Any,
)]),
vec![],
);
}
Expression {
expr: Expr::ImportPattern(import_pattern),
..
} => import_pattern.clone(),
_ => {
working_set.error(ParseError::UnknownState(
"internal error: Import pattern positional is not import pattern".into(),
import_pattern_expr.span,
));
return (garbage_pipeline(working_set, spans), vec![]);
}
}; };
let (mut import_pattern, module, module_id) = if let Some(module_id) = import_pattern.head.id { let (mut import_pattern, module, module_id) = if let Some(module_id) = import_pattern.head.id {
@ -2755,6 +2774,19 @@ pub fn parse_overlay_use(working_set: &mut StateWorkingSet, call: Box<Call>) ->
let (overlay_name, overlay_name_span) = if let Some(expr) = call.positional_nth(0) { let (overlay_name, overlay_name_span) = if let Some(expr) = call.positional_nth(0) {
match eval_constant(working_set, expr) { match eval_constant(working_set, expr) {
Ok(Value::Nothing { .. }) => {
let mut call = call;
call.set_parser_info(
"noop".to_string(),
Expression::new_unknown(Expr::Bool(true), Span::unknown(), Type::Bool),
);
return Pipeline::from_vec(vec![Expression::new(
working_set,
Expr::Call(call),
call_span,
Type::Any,
)]);
}
Ok(val) => match val.coerce_into_string() { Ok(val) => match val.coerce_into_string() {
Ok(s) => (s, expr.span), Ok(s) => (s, expr.span),
Err(err) => { Err(err) => {
@ -3494,6 +3526,20 @@ pub fn parse_source(working_set: &mut StateWorkingSet, lite_command: &LiteComman
} }
}; };
if val.is_nothing() {
let mut call = call;
call.set_parser_info(
"noop".to_string(),
Expression::new_unknown(Expr::Nothing, Span::unknown(), Type::Nothing),
);
return Pipeline::from_vec(vec![Expression::new(
working_set,
Expr::Call(call),
Span::concat(spans),
Type::Any,
)]);
}
let filename = match val.coerce_into_string() { let filename = match val.coerce_into_string() {
Ok(s) => s, Ok(s) => s,
Err(err) => { Err(err) => {

View file

@ -15,7 +15,7 @@ use nu_engine::DIR_VAR_PARSER_INFO;
use nu_protocol::{ use nu_protocol::{
ast::*, engine::StateWorkingSet, eval_const::eval_constant, BlockId, DeclId, DidYouMean, ast::*, engine::StateWorkingSet, eval_const::eval_constant, BlockId, DeclId, DidYouMean,
FilesizeUnit, Flag, ParseError, PositionalArg, Signature, Span, Spanned, SyntaxShape, Type, FilesizeUnit, Flag, ParseError, PositionalArg, Signature, Span, Spanned, SyntaxShape, Type,
VarId, ENV_VARIABLE_ID, IN_VARIABLE_ID, Value, VarId, ENV_VARIABLE_ID, IN_VARIABLE_ID,
}; };
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
@ -3054,6 +3054,14 @@ pub fn parse_import_pattern(working_set: &mut StateWorkingSet, spans: &[Span]) -
let head_expr = parse_value(working_set, *head_span, &SyntaxShape::Any); let head_expr = parse_value(working_set, *head_span, &SyntaxShape::Any);
let (maybe_module_id, head_name) = match eval_constant(working_set, &head_expr) { let (maybe_module_id, head_name) = match eval_constant(working_set, &head_expr) {
Ok(Value::Nothing { .. }) => {
return Expression::new(
working_set,
Expr::Nothing,
Span::concat(spans),
Type::Nothing,
);
}
Ok(val) => match val.coerce_into_string() { Ok(val) => match val.coerce_into_string() {
Ok(s) => (working_set.find_module(s.as_bytes()), s.into_bytes()), Ok(s) => (working_set.find_module(s.as_bytes()), s.into_bytes()),
Err(err) => { Err(err) => {

View file

@ -335,6 +335,51 @@ fn source_empty_file() {
}) })
} }
#[test]
fn source_use_null() {
let actual = nu!(r#"source null"#);
assert!(actual.out.is_empty());
assert!(actual.err.is_empty());
let actual = nu!(r#"source-env null"#);
assert!(actual.out.is_empty());
assert!(actual.err.is_empty());
let actual = nu!(r#"use null"#);
assert!(actual.out.is_empty());
assert!(actual.err.is_empty());
let actual = nu!(r#"overlay use null"#);
assert!(actual.out.is_empty());
assert!(actual.err.is_empty());
}
#[test]
fn source_use_file_named_null() {
Playground::setup("source_file_named_null", |dirs, sandbox| {
sandbox.with_files(&[FileWithContent(
"null",
r#"export-env { print "hello world" }"#,
)]);
let actual = nu!(cwd: dirs.test(), r#"source "null""#);
assert!(actual.out.contains("hello world"));
assert!(actual.err.is_empty());
let actual = nu!(cwd: dirs.test(), r#"source-env "null""#);
assert!(actual.out.contains("hello world"));
assert!(actual.err.is_empty());
let actual = nu!(cwd: dirs.test(), r#"use "null""#);
assert!(actual.out.contains("hello world"));
assert!(actual.err.is_empty());
let actual = nu!(cwd: dirs.test(), r#"overlay use "null""#);
assert!(actual.out.contains("hello world"));
assert!(actual.err.is_empty());
})
}
#[test] #[test]
fn main_script_help_uses_script_name1() { fn main_script_help_uses_script_name1() {
// Note: this test is somewhat fragile and might need to be adapted if the usage help message changes // Note: this test is somewhat fragile and might need to be adapted if the usage help message changes