Move most of the peculiar argument handling for external calls into the parser (#13089)

# Description

We've had a lot of different issues and PRs related to arg handling with
externals since the rewrite of `run-external` in #12921:

- #12950
- #12955
- #13000
- #13001
- #13021
- #13027
- #13028
- #13073

Many of these are caused by the argument handling of external calls and
`run-external` being very special and involving the parser handing
quoted strings over to `run-external` so that it knows whether to expand
tildes and globs and so on. This is really unusual and also makes it
harder to use `run-external`, and also harder to understand it (and
probably is part of the reason why it was rewritten in the first place).

This PR moves a lot more of that work over to the parser, so that by the
time `run-external` gets it, it's dealing with much more normal Nushell
values. In particular:

- Unquoted strings are handled as globs with no expand
- The unescaped-but-quoted handling of strings was removed, and the
parser constructs normal looking strings instead, removing internal
quotes so that `run-external` doesn't have to do it
- Bare word interpolation is now supported and expansion is done in this
case
- Expressions typed as `Glob` containing `Expr::StringInterpolation` now
produce `Value::Glob` instead, with the quoted status from the expr
passed through so we know if it was a bare word
- Bare word interpolation for values typed as `glob` now possible, but
not implemented
- Because expansion is now triggered by `Value::Glob(_, false)` instead
of looking at the expr, externals now support glob types

# User-Facing Changes

- Bare word interpolation works for external command options, and
otherwise embedded in other strings:
  ```nushell
  ^echo --foo=(2 + 2) # prints --foo=4
  ^echo -foo=$"(2 + 2)" # prints -foo=4
  ^echo foo="(2 + 2)" # prints (no interpolation!) foo=(2 + 2)
  ^echo foo,(2 + 2),bar # prints foo,4,bar
  ```

- Bare word interpolation expands for external command head/args:
  ```nushell
  let name = "exa"
  ~/.cargo/bin/($name) # this works, and expands the tilde
  ^$"~/.cargo/bin/($name)" # this doesn't expand the tilde
  ^echo ~/($name)/* # this glob is expanded
  ^echo $"~/($name)/*" # this isn't expanded
  ```

- Ndots are now supported for the head of an external command
(`^.../foo` works)

- Glob values are now supported for head/args of an external command,
and expanded appropriately:
  ```nushell
  ^("~/.cargo/bin/exa" | into glob) # the tilde is expanded
  ^echo ("*.txt" | into glob) # this glob is expanded
  ```

- `run-external` now works more like any other command, without
expecting a special call convention
  for its args:
  ```nushell
  run-external echo "'foo'"
  # before PR: 'foo'
  # after PR:  foo
  run-external echo "*.txt"
  # before PR: (glob is expanded)
  # after PR:  *.txt
  ```

# Tests + Formatting
Lots of tests added and cleaned up. Some tests that weren't active on
Windows changed to use `nu --testbin cococo` so that they can work.
Added a test for Linux only to make sure tilde expansion of commands
works, because changing `HOME` there causes `~` to reliably change.

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

# After Submitting
- [ ] release notes: make sure to mention the new syntaxes that are
supported
This commit is contained in:
Devyn Cairns 2024-06-19 21:00:03 -07:00 committed by GitHub
parent 44aa0a2de4
commit bdc32345bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 880 additions and 476 deletions

View File

@ -138,6 +138,7 @@ impl Highlighter for NuHighlighter {
FlatShape::Filepath => add_colored_token(&shape.1, next_token), FlatShape::Filepath => add_colored_token(&shape.1, next_token),
FlatShape::Directory => 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::GlobPattern => add_colored_token(&shape.1, next_token),
FlatShape::Variable(_) | FlatShape::VarDecl(_) => { FlatShape::Variable(_) | FlatShape::VarDecl(_) => {
add_colored_token(&shape.1, next_token) add_colored_token(&shape.1, next_token)
@ -452,7 +453,8 @@ fn find_matching_block_end_in_expr(
} }
} }
Expr::StringInterpolation(exprs) => exprs.iter().find_map(|expr| { Expr::StringInterpolation(exprs) | Expr::GlobInterpolation(exprs, _) => {
exprs.iter().find_map(|expr| {
find_matching_block_end_in_expr( find_matching_block_end_in_expr(
line, line,
working_set, working_set,
@ -460,7 +462,8 @@ fn find_matching_block_end_in_expr(
global_span_offset, global_span_offset,
global_cursor_offset, global_cursor_offset,
) )
}), })
}
Expr::List(list) => { Expr::List(list) => {
if expr_last == global_cursor_offset { if expr_last == global_cursor_offset {

View File

@ -20,6 +20,7 @@ pub fn default_shape_color(shape: &str) -> Style {
"shape_flag" => Style::new().fg(Color::Blue).bold(), "shape_flag" => Style::new().fg(Color::Blue).bold(),
"shape_float" => Style::new().fg(Color::Purple).bold(), "shape_float" => Style::new().fg(Color::Purple).bold(),
"shape_garbage" => Style::new().fg(Color::White).on(Color::Red).bold(), "shape_garbage" => Style::new().fg(Color::White).on(Color::Red).bold(),
"shape_glob_interpolation" => Style::new().fg(Color::Cyan).bold(),
"shape_globpattern" => Style::new().fg(Color::Cyan).bold(), "shape_globpattern" => Style::new().fg(Color::Cyan).bold(),
"shape_int" => Style::new().fg(Color::Purple).bold(), "shape_int" => Style::new().fg(Color::Purple).bold(),
"shape_internalcall" => Style::new().fg(Color::Cyan).bold(), "shape_internalcall" => Style::new().fg(Color::Cyan).bold(),

View File

@ -1,16 +1,15 @@
use nu_cmd_base::hook::eval_hook; use nu_cmd_base::hook::eval_hook;
use nu_engine::{command_prelude::*, env_to_strings, get_eval_expression}; use nu_engine::{command_prelude::*, env_to_strings, get_eval_expression};
use nu_path::{dots::expand_ndots, expand_tilde};
use nu_protocol::{ use nu_protocol::{
ast::{Expr, Expression}, ast::Expression, did_you_mean, process::ChildProcess, ByteStream, NuGlob, OutDest,
did_you_mean,
process::ChildProcess,
ByteStream, NuGlob, OutDest,
}; };
use nu_system::ForegroundChild; use nu_system::ForegroundChild;
use nu_utils::IgnoreCaseExt; use nu_utils::IgnoreCaseExt;
use pathdiff::diff_paths; use pathdiff::diff_paths;
use std::{ use std::{
borrow::Cow, borrow::Cow,
ffi::{OsStr, OsString},
io::Write, io::Write,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Stdio, process::Stdio,
@ -33,8 +32,16 @@ impl Command for External {
fn signature(&self) -> nu_protocol::Signature { fn signature(&self) -> nu_protocol::Signature {
Signature::build(self.name()) Signature::build(self.name())
.input_output_types(vec![(Type::Any, Type::Any)]) .input_output_types(vec![(Type::Any, Type::Any)])
.required("command", SyntaxShape::String, "External command to run.") .required(
.rest("args", SyntaxShape::Any, "Arguments for external command.") "command",
SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]),
"External command to run.",
)
.rest(
"args",
SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::Any]),
"Arguments for external command.",
)
.category(Category::System) .category(Category::System)
} }
@ -47,42 +54,31 @@ impl Command for External {
) -> Result<PipelineData, ShellError> { ) -> Result<PipelineData, ShellError> {
let cwd = engine_state.cwd(Some(stack))?; let cwd = engine_state.cwd(Some(stack))?;
// Evaluate the command name in the same way the arguments are evaluated. Since this isn't let name: Value = call.req(engine_state, stack, 0)?;
// a spread, it should return a one-element vec.
let name_expr = call let name_str: Cow<str> = match &name {
.positional_nth(0) Value::Glob { val, .. } => Cow::Borrowed(val),
.ok_or_else(|| ShellError::MissingParameter { Value::String { val, .. } => Cow::Borrowed(val),
param_name: "command".into(), _ => Cow::Owned(name.clone().coerce_into_string()?),
span: call.head, };
})?;
let name = eval_argument(engine_state, stack, name_expr, false)? let expanded_name = match &name {
.pop() // Expand tilde and ndots on the name if it's a bare string / glob (#13000)
.expect("eval_argument returned zero-element vec") Value::Glob { no_expand, .. } if !*no_expand => expand_ndots(expand_tilde(&*name_str)),
.into_spanned(name_expr.span); _ => Path::new(&*name_str).to_owned(),
};
// Find the absolute path to the executable. On Windows, set the // Find the absolute path to the executable. On Windows, set the
// executable to "cmd.exe" if it's is a CMD internal command. If the // executable to "cmd.exe" if it's is a CMD internal command. If the
// command is not found, display a helpful error message. // command is not found, display a helpful error message.
let executable = if cfg!(windows) && is_cmd_internal_command(&name.item) { let executable = if cfg!(windows) && is_cmd_internal_command(&name_str) {
PathBuf::from("cmd.exe") PathBuf::from("cmd.exe")
} else { } else {
// Expand tilde on the name if it's a bare string (#13000)
let expanded_name = if is_bare_string(name_expr) {
expand_tilde(&name.item)
} else {
name.item.clone()
};
// Determine the PATH to be used and then use `which` to find it - though this has no // Determine the PATH to be used and then use `which` to find it - though this has no
// effect if it's an absolute path already // effect if it's an absolute path already
let paths = nu_engine::env::path_str(engine_state, stack, call.head)?; let paths = nu_engine::env::path_str(engine_state, stack, call.head)?;
let Some(executable) = which(&expanded_name, &paths, &cwd) else { let Some(executable) = which(expanded_name, &paths, &cwd) else {
return Err(command_not_found( return Err(command_not_found(&name_str, call.head, engine_state, stack));
&name.item,
call.head,
engine_state,
stack,
));
}; };
executable executable
}; };
@ -101,15 +97,15 @@ impl Command for External {
// Configure args. // Configure args.
let args = eval_arguments_from_call(engine_state, stack, call)?; let args = eval_arguments_from_call(engine_state, stack, call)?;
#[cfg(windows)] #[cfg(windows)]
if is_cmd_internal_command(&name.item) { if is_cmd_internal_command(&name_str) {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
// The /D flag disables execution of AutoRun commands from registry. // The /D flag disables execution of AutoRun commands from registry.
// The /C flag followed by a command name instructs CMD to execute // The /C flag followed by a command name instructs CMD to execute
// that command and quit. // that command and quit.
command.args(["/D", "/C", &name.item]); command.args(["/D", "/C", &name_str]);
for arg in &args { for arg in &args {
command.raw_arg(escape_cmd_argument(arg)?.as_ref()); command.raw_arg(escape_cmd_argument(arg)?);
} }
} else { } else {
command.args(args.into_iter().map(|s| s.item)); command.args(args.into_iter().map(|s| s.item));
@ -217,76 +213,54 @@ impl Command for External {
} }
} }
/// Removes surrounding quotes from a string. Doesn't remove quotes from raw
/// strings. Returns the original string if it doesn't have matching quotes.
fn remove_quotes(s: &str) -> Cow<'_, str> {
let quoted_by_double_quotes = s.len() >= 2 && s.starts_with('"') && s.ends_with('"');
let quoted_by_single_quotes = s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'');
let quoted_by_backticks = s.len() >= 2 && s.starts_with('`') && s.ends_with('`');
if quoted_by_double_quotes {
Cow::Owned(s[1..s.len() - 1].to_string().replace(r#"\""#, "\""))
} else if quoted_by_single_quotes || quoted_by_backticks {
Cow::Borrowed(&s[1..s.len() - 1])
} else {
Cow::Borrowed(s)
}
}
/// Evaluate all arguments from a call, performing expansions when necessary. /// Evaluate all arguments from a call, performing expansions when necessary.
pub fn eval_arguments_from_call( pub fn eval_arguments_from_call(
engine_state: &EngineState, engine_state: &EngineState,
stack: &mut Stack, stack: &mut Stack,
call: &Call, call: &Call,
) -> Result<Vec<Spanned<String>>, ShellError> { ) -> Result<Vec<Spanned<OsString>>, ShellError> {
let ctrlc = &engine_state.ctrlc; let ctrlc = &engine_state.ctrlc;
let cwd = engine_state.cwd(Some(stack))?; let cwd = engine_state.cwd(Some(stack))?;
let mut args: Vec<Spanned<String>> = vec![]; let mut args: Vec<Spanned<OsString>> = vec![];
for (expr, spread) in call.rest_iter(1) { for (expr, spread) in call.rest_iter(1) {
if is_bare_string(expr) {
// If `expr` is a bare string, perform tilde-expansion,
// glob-expansion, and inner-quotes-removal, in that order.
for arg in eval_argument(engine_state, stack, expr, spread)? { for arg in eval_argument(engine_state, stack, expr, spread)? {
let tilde_expanded = expand_tilde(&arg); match arg {
for glob_expanded in expand_glob(&tilde_expanded, &cwd, expr.span, ctrlc)? { // Expand globs passed to run-external
let inner_quotes_removed = remove_inner_quotes(&glob_expanded); Value::Glob { val, no_expand, .. } if !no_expand => args.extend(
args.push(inner_quotes_removed.into_owned().into_spanned(expr.span)); expand_glob(&val, &cwd, expr.span, ctrlc)?
.into_iter()
.map(|s| s.into_spanned(expr.span)),
),
other => {
args.push(OsString::from(coerce_into_string(other)?).into_spanned(expr.span))
} }
} }
} else {
for arg in eval_argument(engine_state, stack, expr, spread)? {
args.push(arg.into_spanned(expr.span));
}
} }
} }
Ok(args) Ok(args)
} }
/// Evaluates an expression, coercing the values to strings. /// Custom `coerce_into_string()`, including globs, since those are often args to `run-external`
/// /// as well
/// Note: The parser currently has a special hack that retains surrounding fn coerce_into_string(val: Value) -> Result<String, ShellError> {
/// quotes for string literals in `Expression`, so that we can decide whether match val {
/// the expression is considered a bare string. The hack doesn't affect string Value::Glob { val, .. } => Ok(val),
/// literals within lists or records. This function will remove the quotes _ => val.coerce_into_string(),
/// before evaluating the expression. }
}
/// Evaluate an argument, returning more than one value if it was a list to be spread.
fn eval_argument( fn eval_argument(
engine_state: &EngineState, engine_state: &EngineState,
stack: &mut Stack, stack: &mut Stack,
expr: &Expression, expr: &Expression,
spread: bool, spread: bool,
) -> Result<Vec<String>, ShellError> { ) -> Result<Vec<Value>, ShellError> {
// Remove quotes from string literals.
let mut expr = expr.clone();
if let Expr::String(s) = &expr.expr {
expr.expr = Expr::String(remove_quotes(s).into());
}
let eval = get_eval_expression(engine_state); let eval = get_eval_expression(engine_state);
match eval(engine_state, stack, &expr)? { match eval(engine_state, stack, expr)? {
Value::List { vals, .. } => { Value::List { vals, .. } => {
if spread { if spread {
vals.into_iter() Ok(vals)
.map(|val| val.coerce_into_string())
.collect()
} else { } else {
Err(ShellError::CannotPassListToExternal { Err(ShellError::CannotPassListToExternal {
arg: String::from_utf8_lossy(engine_state.get_span_contents(expr.span)).into(), arg: String::from_utf8_lossy(engine_state.get_span_contents(expr.span)).into(),
@ -298,31 +272,12 @@ fn eval_argument(
if spread { if spread {
Err(ShellError::CannotSpreadAsList { span: expr.span }) Err(ShellError::CannotSpreadAsList { span: expr.span })
} else { } else {
Ok(vec![value.coerce_into_string()?]) Ok(vec![value])
} }
} }
} }
} }
/// Returns whether an expression is considered a bare string.
///
/// Bare strings are defined as string literals that are either unquoted or
/// quoted by backticks. Raw strings or string interpolations don't count.
fn is_bare_string(expr: &Expression) -> bool {
let Expr::String(s) = &expr.expr else {
return false;
};
let quoted_by_double_quotes = s.len() >= 2 && s.starts_with('"') && s.ends_with('"');
let quoted_by_single_quotes = s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'');
!quoted_by_double_quotes && !quoted_by_single_quotes
}
/// Performs tilde expansion on `arg`. Returns the original string if `arg`
/// doesn't start with tilde.
fn expand_tilde(arg: &str) -> String {
nu_path::expand_tilde(arg).to_string_lossy().to_string()
}
/// Performs glob expansion on `arg`. If the expansion found no matches or the pattern /// Performs glob expansion on `arg`. If the expansion found no matches or the pattern
/// is not a valid glob, then this returns the original string as the expansion result. /// is not a valid glob, then this returns the original string as the expansion result.
/// ///
@ -333,19 +288,21 @@ fn expand_glob(
cwd: &Path, cwd: &Path,
span: Span, span: Span,
interrupt: &Option<Arc<AtomicBool>>, interrupt: &Option<Arc<AtomicBool>>,
) -> Result<Vec<String>, ShellError> { ) -> Result<Vec<OsString>, ShellError> {
const GLOB_CHARS: &[char] = &['*', '?', '[']; const GLOB_CHARS: &[char] = &['*', '?', '['];
// Don't expand something that doesn't include the GLOB_CHARS // For an argument that doesn't include the GLOB_CHARS, just do the `expand_tilde`
// and `expand_ndots` expansion
if !arg.contains(GLOB_CHARS) { if !arg.contains(GLOB_CHARS) {
return Ok(vec![arg.into()]); let path = expand_ndots(expand_tilde(arg));
return Ok(vec![path.into()]);
} }
// We must use `nu_engine::glob_from` here, in order to ensure we get paths from the correct // We must use `nu_engine::glob_from` here, in order to ensure we get paths from the correct
// dir // dir
let glob = NuGlob::Expand(arg.to_owned()).into_spanned(span); let glob = NuGlob::Expand(arg.to_owned()).into_spanned(span);
if let Ok((prefix, matches)) = nu_engine::glob_from(&glob, cwd, span, None) { if let Ok((prefix, matches)) = nu_engine::glob_from(&glob, cwd, span, None) {
let mut result = vec![]; let mut result: Vec<OsString> = vec![];
for m in matches { for m in matches {
if nu_utils::ctrl_c::was_pressed(interrupt) { if nu_utils::ctrl_c::was_pressed(interrupt) {
@ -353,7 +310,7 @@ fn expand_glob(
} }
if let Ok(arg) = m { if let Ok(arg) = m {
let arg = resolve_globbed_path_to_cwd_relative(arg, prefix.as_ref(), cwd); let arg = resolve_globbed_path_to_cwd_relative(arg, prefix.as_ref(), cwd);
result.push(arg.to_string_lossy().to_string()); result.push(arg.into());
} else { } else {
result.push(arg.into()); result.push(arg.into());
} }
@ -392,23 +349,6 @@ fn resolve_globbed_path_to_cwd_relative(
} }
} }
/// Transforms `--option="value"` into `--option=value`. `value` can be quoted
/// with double quotes, single quotes, or backticks. Only removes the outermost
/// pair of quotes after the equal sign.
fn remove_inner_quotes(arg: &str) -> Cow<'_, str> {
// Split `arg` on the first `=`.
let Some((option, value)) = arg.split_once('=') else {
return Cow::Borrowed(arg);
};
// Check that `option` doesn't contain quotes.
if option.contains('"') || option.contains('\'') || option.contains('`') {
return Cow::Borrowed(arg);
}
// Remove the outermost pair of quotes from `value`.
let value = remove_quotes(value);
Cow::Owned(format!("{option}={value}"))
}
/// Write `PipelineData` into `writer`. If `PipelineData` is not binary, it is /// Write `PipelineData` into `writer`. If `PipelineData` is not binary, it is
/// first rendered using the `table` command. /// first rendered using the `table` command.
/// ///
@ -577,7 +517,7 @@ pub fn command_not_found(
/// Note: the `which.rs` crate always uses PATHEXT from the environment. As /// Note: the `which.rs` crate always uses PATHEXT from the environment. As
/// such, changing PATHEXT within Nushell doesn't work without updating the /// such, changing PATHEXT within Nushell doesn't work without updating the
/// actual environment of the Nushell process. /// actual environment of the Nushell process.
pub fn which(name: &str, paths: &str, cwd: &Path) -> Option<PathBuf> { pub fn which(name: impl AsRef<OsStr>, paths: &str, cwd: &Path) -> Option<PathBuf> {
#[cfg(windows)] #[cfg(windows)]
let paths = format!("{};{}", cwd.display(), paths); let paths = format!("{};{}", cwd.display(), paths);
which::which_in(name, Some(paths), cwd).ok() which::which_in(name, Some(paths), cwd).ok()
@ -593,17 +533,18 @@ fn is_cmd_internal_command(name: &str) -> bool {
} }
/// Returns true if a string contains CMD special characters. /// Returns true if a string contains CMD special characters.
#[cfg(windows)] fn has_cmd_special_character(s: impl AsRef<[u8]>) -> bool {
fn has_cmd_special_character(s: &str) -> bool { s.as_ref()
const SPECIAL_CHARS: &[char] = &['<', '>', '&', '|', '^']; .iter()
SPECIAL_CHARS.iter().any(|c| s.contains(*c)) .any(|b| matches!(b, b'<' | b'>' | b'&' | b'|' | b'^'))
} }
/// Escape an argument for CMD internal commands. The result can be safely passed to `raw_arg()`. /// Escape an argument for CMD internal commands. The result can be safely passed to `raw_arg()`.
#[cfg(windows)] #[cfg_attr(not(windows), allow(dead_code))]
fn escape_cmd_argument(arg: &Spanned<String>) -> Result<Cow<'_, str>, ShellError> { fn escape_cmd_argument(arg: &Spanned<OsString>) -> Result<Cow<'_, OsStr>, ShellError> {
let Spanned { item: arg, span } = arg; let Spanned { item: arg, span } = arg;
if arg.contains(['\r', '\n', '%']) { let bytes = arg.as_encoded_bytes();
if bytes.iter().any(|b| matches!(b, b'\r' | b'\n' | b'%')) {
// \r and \n trunacte the rest of the arguments and % can expand environment variables // \r and \n trunacte the rest of the arguments and % can expand environment variables
Err(ShellError::ExternalCommand { Err(ShellError::ExternalCommand {
label: label:
@ -612,12 +553,12 @@ fn escape_cmd_argument(arg: &Spanned<String>) -> Result<Cow<'_, str>, ShellError
help: "some characters currently cannot be securely escaped".into(), help: "some characters currently cannot be securely escaped".into(),
span: *span, span: *span,
}) })
} else if arg.contains('"') { } else if bytes.contains(&b'"') {
// If `arg` is already quoted by double quotes, confirm there's no // If `arg` is already quoted by double quotes, confirm there's no
// embedded double quotes, then leave it as is. // embedded double quotes, then leave it as is.
if arg.chars().filter(|c| *c == '"').count() == 2 if bytes.iter().filter(|b| **b == b'"').count() == 2
&& arg.starts_with('"') && bytes.starts_with(b"\"")
&& arg.ends_with('"') && bytes.ends_with(b"\"")
{ {
Ok(Cow::Borrowed(arg)) Ok(Cow::Borrowed(arg))
} else { } else {
@ -628,9 +569,13 @@ fn escape_cmd_argument(arg: &Spanned<String>) -> Result<Cow<'_, str>, ShellError
span: *span, span: *span,
}) })
} }
} else if arg.contains(' ') || has_cmd_special_character(arg) { } else if bytes.contains(&b' ') || has_cmd_special_character(bytes) {
// If `arg` contains space or special characters, quote the entire argument by double quotes. // If `arg` contains space or special characters, quote the entire argument by double quotes.
Ok(Cow::Owned(format!("\"{arg}\""))) let mut new_str = OsString::new();
new_str.push("\"");
new_str.push(arg);
new_str.push("\"");
Ok(Cow::Owned(new_str))
} else { } else {
// FIXME?: what if `arg.is_empty()`? // FIXME?: what if `arg.is_empty()`?
Ok(Cow::Borrowed(arg)) Ok(Cow::Borrowed(arg))
@ -640,64 +585,8 @@ fn escape_cmd_argument(arg: &Spanned<String>) -> Result<Cow<'_, str>, ShellError
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use nu_protocol::ast::ListItem;
use nu_test_support::{fs::Stub, playground::Playground}; use nu_test_support::{fs::Stub, playground::Playground};
#[test]
fn test_remove_quotes() {
assert_eq!(remove_quotes(r#""#), r#""#);
assert_eq!(remove_quotes(r#"'"#), r#"'"#);
assert_eq!(remove_quotes(r#"''"#), r#""#);
assert_eq!(remove_quotes(r#""foo""#), r#"foo"#);
assert_eq!(remove_quotes(r#"`foo '"' bar`"#), r#"foo '"' bar"#);
assert_eq!(remove_quotes(r#"'foo' bar"#), r#"'foo' bar"#);
assert_eq!(remove_quotes(r#"r#'foo'#"#), r#"r#'foo'#"#);
assert_eq!(remove_quotes(r#""foo\" bar""#), r#"foo" bar"#);
}
#[test]
fn test_eval_argument() {
fn expression(expr: Expr) -> Expression {
Expression::new_unknown(expr, Span::unknown(), Type::Any)
}
fn eval(expr: Expr, spread: bool) -> Result<Vec<String>, ShellError> {
let engine_state = EngineState::new();
let mut stack = Stack::new();
eval_argument(&engine_state, &mut stack, &expression(expr), spread)
}
let actual = eval(Expr::String("".into()), false).unwrap();
let expected = &[""];
assert_eq!(actual, expected);
let actual = eval(Expr::String("'foo'".into()), false).unwrap();
let expected = &["foo"];
assert_eq!(actual, expected);
let actual = eval(Expr::RawString("'foo'".into()), false).unwrap();
let expected = &["'foo'"];
assert_eq!(actual, expected);
let actual = eval(Expr::List(vec![]), true).unwrap();
let expected: &[&str] = &[];
assert_eq!(actual, expected);
let actual = eval(
Expr::List(vec![
ListItem::Item(expression(Expr::String("'foo'".into()))),
ListItem::Item(expression(Expr::String("bar".into()))),
]),
true,
)
.unwrap();
let expected = &["'foo'", "bar"];
assert_eq!(actual, expected);
eval(Expr::String("".into()), true).unwrap_err();
eval(Expr::List(vec![]), false).unwrap_err();
}
#[test] #[test]
fn test_expand_glob() { fn test_expand_glob() {
Playground::setup("test_expand_glob", |dirs, play| { Playground::setup("test_expand_glob", |dirs, play| {
@ -721,46 +610,20 @@ mod test {
assert_eq!(actual, expected); assert_eq!(actual, expected);
let actual = expand_glob("./a.txt", cwd, Span::unknown(), &None).unwrap(); let actual = expand_glob("./a.txt", cwd, Span::unknown(), &None).unwrap();
let expected = &["./a.txt"]; let expected: Vec<OsString> = vec![Path::new(".").join("a.txt").into()];
assert_eq!(actual, expected); assert_eq!(actual, expected);
let actual = expand_glob("[*.txt", cwd, Span::unknown(), &None).unwrap(); let actual = expand_glob("[*.txt", cwd, Span::unknown(), &None).unwrap();
let expected = &["[*.txt"]; let expected = &["[*.txt"];
assert_eq!(actual, expected); assert_eq!(actual, expected);
let actual = expand_glob("~/foo.txt", cwd, Span::unknown(), &None).unwrap();
let home = dirs_next::home_dir().expect("failed to get home dir");
let expected: Vec<OsString> = vec![home.join("foo.txt").into()];
assert_eq!(actual, expected);
}) })
} }
#[test]
fn test_remove_inner_quotes() {
let actual = remove_inner_quotes(r#"--option=value"#);
let expected = r#"--option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"--option="value""#);
let expected = r#"--option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"--option='value'"#);
let expected = r#"--option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"--option "value""#);
let expected = r#"--option "value""#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"-option="value""#);
let expected = r#"-option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"option="value""#);
let expected = r#"option=value"#;
assert_eq!(actual, expected);
let actual = remove_inner_quotes(r#"option="v\"value""#);
let expected = r#"option=v"value"#;
assert_eq!(actual, expected);
}
#[test] #[test]
fn test_write_pipeline_data() { fn test_write_pipeline_data() {
let engine_state = EngineState::new(); let engine_state = EngineState::new();

View File

@ -1,4 +1,3 @@
#[cfg(not(windows))]
use nu_test_support::fs::Stub::EmptyFile; use nu_test_support::fs::Stub::EmptyFile;
use nu_test_support::playground::Playground; use nu_test_support::playground::Playground;
use nu_test_support::{nu, pipeline}; use nu_test_support::{nu, pipeline};
@ -17,7 +16,6 @@ fn better_empty_redirection() {
assert!(!actual.out.contains('2')); assert!(!actual.out.contains('2'));
} }
#[cfg(not(windows))]
#[test] #[test]
fn explicit_glob() { fn explicit_glob() {
Playground::setup("external with explicit glob", |dirs, sandbox| { Playground::setup("external with explicit glob", |dirs, sandbox| {
@ -30,15 +28,15 @@ fn explicit_glob() {
let actual = nu!( let actual = nu!(
cwd: dirs.test(), pipeline( cwd: dirs.test(), pipeline(
r#" r#"
^ls | glob '*.txt' | length ^nu --testbin cococo ('*.txt' | into glob)
"# "#
)); ));
assert_eq!(actual.out, "2"); assert!(actual.out.contains("D&D_volume_1.txt"));
assert!(actual.out.contains("D&D_volume_2.txt"));
}) })
} }
#[cfg(not(windows))]
#[test] #[test]
fn bare_word_expand_path_glob() { fn bare_word_expand_path_glob() {
Playground::setup("bare word should do the expansion", |dirs, sandbox| { Playground::setup("bare word should do the expansion", |dirs, sandbox| {
@ -51,7 +49,7 @@ fn bare_word_expand_path_glob() {
let actual = nu!( let actual = nu!(
cwd: dirs.test(), pipeline( cwd: dirs.test(), pipeline(
" "
^ls *.txt ^nu --testbin cococo *.txt
" "
)); ));
@ -60,7 +58,6 @@ fn bare_word_expand_path_glob() {
}) })
} }
#[cfg(not(windows))]
#[test] #[test]
fn backtick_expand_path_glob() { fn backtick_expand_path_glob() {
Playground::setup("backtick should do the expansion", |dirs, sandbox| { Playground::setup("backtick should do the expansion", |dirs, sandbox| {
@ -73,7 +70,7 @@ fn backtick_expand_path_glob() {
let actual = nu!( let actual = nu!(
cwd: dirs.test(), pipeline( cwd: dirs.test(), pipeline(
r#" r#"
^ls `*.txt` ^nu --testbin cococo `*.txt`
"# "#
)); ));
@ -82,7 +79,6 @@ fn backtick_expand_path_glob() {
}) })
} }
#[cfg(not(windows))]
#[test] #[test]
fn single_quote_does_not_expand_path_glob() { fn single_quote_does_not_expand_path_glob() {
Playground::setup("single quote do not run the expansion", |dirs, sandbox| { Playground::setup("single quote do not run the expansion", |dirs, sandbox| {
@ -95,15 +91,14 @@ fn single_quote_does_not_expand_path_glob() {
let actual = nu!( let actual = nu!(
cwd: dirs.test(), pipeline( cwd: dirs.test(), pipeline(
r#" r#"
^ls '*.txt' ^nu --testbin cococo '*.txt'
"# "#
)); ));
assert!(actual.err.contains("No such file or directory")); assert_eq!(actual.out, "*.txt");
}) })
} }
#[cfg(not(windows))]
#[test] #[test]
fn double_quote_does_not_expand_path_glob() { fn double_quote_does_not_expand_path_glob() {
Playground::setup("double quote do not run the expansion", |dirs, sandbox| { Playground::setup("double quote do not run the expansion", |dirs, sandbox| {
@ -116,22 +111,21 @@ fn double_quote_does_not_expand_path_glob() {
let actual = nu!( let actual = nu!(
cwd: dirs.test(), pipeline( cwd: dirs.test(), pipeline(
r#" r#"
^ls "*.txt" ^nu --testbin cococo "*.txt"
"# "#
)); ));
assert!(actual.err.contains("No such file or directory")); assert_eq!(actual.out, "*.txt");
}) })
} }
#[cfg(not(windows))]
#[test] #[test]
fn failed_command_with_semicolon_will_not_execute_following_cmds() { fn failed_command_with_semicolon_will_not_execute_following_cmds() {
Playground::setup("external failed command with semicolon", |dirs, _| { Playground::setup("external failed command with semicolon", |dirs, _| {
let actual = nu!( let actual = nu!(
cwd: dirs.test(), pipeline( cwd: dirs.test(), pipeline(
" "
^ls *.abc; echo done nu --testbin fail; echo done
" "
)); ));
@ -155,16 +149,51 @@ fn external_args_with_quoted() {
#[cfg(not(windows))] #[cfg(not(windows))]
#[test] #[test]
fn external_arg_with_long_flag_value_quoted() { fn external_arg_with_option_like_embedded_quotes() {
Playground::setup("external failed command with semicolon", |dirs, _| { // TODO: would be nice to make this work with cococo, but arg parsing interferes
Playground::setup(
"external arg with option like embedded quotes",
|dirs, _| {
let actual = nu!( let actual = nu!(
cwd: dirs.test(), pipeline( cwd: dirs.test(), pipeline(
r#" r#"
^echo --foo='bar' ^echo --foo='bar' -foo='bar'
"# "#
)); ));
assert_eq!(actual.out, "--foo=bar"); assert_eq!(actual.out, "--foo=bar -foo=bar");
},
)
}
#[test]
fn external_arg_with_non_option_like_embedded_quotes() {
Playground::setup(
"external arg with non option like embedded quotes",
|dirs, _| {
let actual = nu!(
cwd: dirs.test(), pipeline(
r#"
^nu --testbin cococo foo='bar' 'foo'=bar
"#
));
assert_eq!(actual.out, "foo=bar foo=bar");
},
)
}
#[test]
fn external_arg_with_string_interpolation() {
Playground::setup("external arg with string interpolation", |dirs, _| {
let actual = nu!(
cwd: dirs.test(), pipeline(
r#"
^nu --testbin cococo foo=(2 + 2) $"foo=(2 + 2)" foo=$"(2 + 2)"
"#
));
assert_eq!(actual.out, "foo=4 foo=4 foo=4");
}) })
} }
@ -200,6 +229,67 @@ fn external_command_escape_args() {
}) })
} }
#[test]
#[cfg_attr(
not(target_os = "linux"),
ignore = "only runs on Linux, where controlling the HOME var is reliable"
)]
fn external_command_expand_tilde() {
Playground::setup("external command expand tilde", |dirs, _| {
// Make a copy of the nu executable that we can use
let mut src = std::fs::File::open(nu_test_support::fs::binaries().join("nu"))
.expect("failed to open nu");
let mut dst = std::fs::File::create_new(dirs.test().join("test_nu"))
.expect("failed to create test_nu file");
std::io::copy(&mut src, &mut dst).expect("failed to copy data for nu binary");
// Make test_nu have the same permissions so that it's executable
dst.set_permissions(
src.metadata()
.expect("failed to get nu metadata")
.permissions(),
)
.expect("failed to set permissions on test_nu");
// Close the files
drop(dst);
drop(src);
let actual = nu!(
envs: vec![
("HOME".to_string(), dirs.test().to_string_lossy().into_owned()),
],
r#"
^~/test_nu --testbin cococo hello
"#
);
assert_eq!(actual.out, "hello");
})
}
#[test]
fn external_arg_expand_tilde() {
Playground::setup("external arg expand tilde", |dirs, _| {
let actual = nu!(
cwd: dirs.test(), pipeline(
r#"
^nu --testbin cococo ~/foo ~/(2 + 2)
"#
));
let home = dirs_next::home_dir().expect("failed to find home dir");
assert_eq!(
actual.out,
format!(
"{} {}",
home.join("foo").display(),
home.join("4").display()
)
);
})
}
#[test] #[test]
fn external_command_not_expand_tilde_with_quotes() { fn external_command_not_expand_tilde_with_quotes() {
Playground::setup( Playground::setup(
@ -231,21 +321,6 @@ fn external_command_receives_raw_binary_data() {
}) })
} }
#[cfg(windows)]
#[test]
fn failed_command_with_semicolon_will_not_execute_following_cmds_windows() {
Playground::setup("external failed command with semicolon", |dirs, _| {
let actual = nu!(
cwd: dirs.test(), pipeline(
"
^cargo asdf; echo done
"
));
assert!(!actual.out.contains("done"));
})
}
#[cfg(windows)] #[cfg(windows)]
#[test] #[test]
fn can_run_batch_files() { fn can_run_batch_files() {

View File

@ -26,6 +26,7 @@ pub enum FlatShape {
Flag, Flag,
Float, Float,
Garbage, Garbage,
GlobInterpolation,
GlobPattern, GlobPattern,
Int, Int,
InternalCall(DeclId), InternalCall(DeclId),
@ -67,6 +68,7 @@ impl FlatShape {
FlatShape::Flag => "shape_flag", FlatShape::Flag => "shape_flag",
FlatShape::Float => "shape_float", FlatShape::Float => "shape_float",
FlatShape::Garbage => "shape_garbage", FlatShape::Garbage => "shape_garbage",
FlatShape::GlobInterpolation => "shape_glob_interpolation",
FlatShape::GlobPattern => "shape_globpattern", FlatShape::GlobPattern => "shape_globpattern",
FlatShape::Int => "shape_int", FlatShape::Int => "shape_int",
FlatShape::InternalCall(_) => "shape_internalcall", FlatShape::InternalCall(_) => "shape_internalcall",
@ -277,7 +279,7 @@ fn flatten_expression_into(
output[arg_start..].sort(); output[arg_start..].sort();
} }
Expr::ExternalCall(head, args) => { Expr::ExternalCall(head, args) => {
if let Expr::String(..) = &head.expr { if let Expr::String(..) | Expr::GlobPattern(..) = &head.expr {
output.push((head.span, FlatShape::External)); output.push((head.span, FlatShape::External));
} else { } else {
flatten_expression_into(working_set, head, output); flatten_expression_into(working_set, head, output);
@ -286,7 +288,7 @@ fn flatten_expression_into(
for arg in args.as_ref() { for arg in args.as_ref() {
match arg { match arg {
ExternalArgument::Regular(expr) => { ExternalArgument::Regular(expr) => {
if let Expr::String(..) = &expr.expr { if let Expr::String(..) | Expr::GlobPattern(..) = &expr.expr {
output.push((expr.span, FlatShape::ExternalArg)); output.push((expr.span, FlatShape::ExternalArg));
} else { } else {
flatten_expression_into(working_set, expr, output); flatten_expression_into(working_set, expr, output);
@ -431,6 +433,25 @@ fn flatten_expression_into(
} }
output.extend(flattened); output.extend(flattened);
} }
Expr::GlobInterpolation(exprs, quoted) => {
let mut flattened = vec![];
for expr in exprs {
flatten_expression_into(working_set, expr, &mut flattened);
}
if *quoted {
// If we aren't a bare word interpolation, also highlight the outer quotes
output.push((
Span::new(expr.span.start, expr.span.start + 2),
FlatShape::GlobInterpolation,
));
flattened.push((
Span::new(expr.span.end - 1, expr.span.end),
FlatShape::GlobInterpolation,
));
}
output.extend(flattened);
}
Expr::Record(list) => { Expr::Record(list) => {
let outer_span = expr.span; let outer_span = expr.span;
let mut last_end = outer_span.start; let mut last_end = outer_span.start;

View File

@ -16,7 +16,6 @@ use nu_protocol::{
IN_VARIABLE_ID, IN_VARIABLE_ID,
}; };
use std::{ use std::{
borrow::Cow,
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
num::ParseIntError, num::ParseIntError,
str, str,
@ -222,6 +221,209 @@ pub(crate) fn check_call(
} }
} }
/// Parses a string in the arg or head position of an external call.
///
/// If the string begins with `r#`, it is parsed as a raw string. If it doesn't contain any quotes
/// or parentheses, it is parsed as a glob pattern so that tilde and glob expansion can be handled
/// by `run-external`. Otherwise, we use a custom state machine to put together an interpolated
/// string, where each balanced pair of quotes is parsed as a separate part of the string, and then
/// concatenated together.
///
/// For example, `-foo="bar\nbaz"` becomes `$"-foo=bar\nbaz"`
fn parse_external_string(working_set: &mut StateWorkingSet, span: Span) -> Expression {
let contents = &working_set.get_span_contents(span);
if contents.starts_with(b"r#") {
parse_raw_string(working_set, span)
} else if contents
.iter()
.any(|b| matches!(b, b'"' | b'\'' | b'(' | b')'))
{
enum State {
Bare {
from: usize,
},
Quote {
from: usize,
quote_char: u8,
escaped: bool,
depth: i32,
},
}
// Find the spans of parts of the string that can be parsed as their own strings for
// concatenation.
//
// By passing each of these parts to `parse_string()`, we can eliminate the quotes and also
// handle string interpolation.
let make_span = |from: usize, index: usize| Span {
start: span.start + from,
end: span.start + index,
};
let mut spans = vec![];
let mut state = State::Bare { from: 0 };
let mut index = 0;
while index < contents.len() {
let ch = contents[index];
match &mut state {
State::Bare { from } => match ch {
b'"' | b'\'' => {
// Push bare string
if index != *from {
spans.push(make_span(*from, index));
}
// then transition to other state
state = State::Quote {
from: index,
quote_char: ch,
escaped: false,
depth: 1,
};
}
b'$' => {
if let Some(&quote_char @ (b'"' | b'\'')) = contents.get(index + 1) {
// Start a dollar quote (interpolated string)
if index != *from {
spans.push(make_span(*from, index));
}
state = State::Quote {
from: index,
quote_char,
escaped: false,
depth: 1,
};
// Skip over two chars (the dollar sign and the quote)
index += 2;
continue;
}
}
// Continue to consume
_ => (),
},
State::Quote {
from,
quote_char,
escaped,
depth,
} => match ch {
ch if ch == *quote_char && !*escaped => {
// Count if there are more than `depth` quotes remaining
if contents[index..]
.iter()
.filter(|b| *b == quote_char)
.count() as i32
> *depth
{
// Increment depth to be greedy
*depth += 1;
} else {
// Decrement depth
*depth -= 1;
}
if *depth == 0 {
// End of string
spans.push(make_span(*from, index + 1));
// go back to Bare state
state = State::Bare { from: index + 1 };
}
}
b'\\' if !*escaped && *quote_char == b'"' => {
// The next token is escaped so it doesn't count (only for double quote)
*escaped = true;
}
_ => {
*escaped = false;
}
},
}
index += 1;
}
// Add the final span
match state {
State::Bare { from } | State::Quote { from, .. } => {
if from < contents.len() {
spans.push(make_span(from, contents.len()));
}
}
}
// Log the spans that will be parsed
if log::log_enabled!(log::Level::Trace) {
let contents = spans
.iter()
.map(|span| String::from_utf8_lossy(working_set.get_span_contents(*span)))
.collect::<Vec<_>>();
trace!("parsing: external string, parts: {contents:?}")
}
// Check if the whole thing is quoted. If not, it should be a glob
let quoted =
(contents.len() >= 3 && contents.starts_with(b"$\"") && contents.ends_with(b"\""))
|| is_quoted(contents);
// Parse each as its own string
let exprs: Vec<Expression> = spans
.into_iter()
.map(|span| parse_string(working_set, span))
.collect();
if exprs
.iter()
.all(|expr| matches!(expr.expr, Expr::String(..)))
{
// If the exprs are all strings anyway, just collapse into a single string.
let string = exprs
.into_iter()
.map(|expr| {
let Expr::String(contents) = expr.expr else {
unreachable!("already checked that this was a String")
};
contents
})
.collect::<String>();
if quoted {
Expression::new(working_set, Expr::String(string), span, Type::String)
} else {
Expression::new(
working_set,
Expr::GlobPattern(string, false),
span,
Type::Glob,
)
}
} else {
// Flatten any string interpolations contained with the exprs.
let exprs = exprs
.into_iter()
.flat_map(|expr| match expr.expr {
Expr::StringInterpolation(subexprs) => subexprs,
_ => vec![expr],
})
.collect();
// Make an interpolation out of the expressions. Use `GlobInterpolation` if it's a bare
// word, so that the unquoted state can get passed through to `run-external`.
if quoted {
Expression::new(
working_set,
Expr::StringInterpolation(exprs),
span,
Type::String,
)
} else {
Expression::new(
working_set,
Expr::GlobInterpolation(exprs, false),
span,
Type::Glob,
)
}
}
} else {
parse_glob_pattern(working_set, span)
}
}
fn parse_external_arg(working_set: &mut StateWorkingSet, span: Span) -> ExternalArgument { fn parse_external_arg(working_set: &mut StateWorkingSet, span: Span) -> ExternalArgument {
let contents = working_set.get_span_contents(span); let contents = working_set.get_span_contents(span);
@ -229,8 +431,6 @@ fn parse_external_arg(working_set: &mut StateWorkingSet, span: Span) -> External
ExternalArgument::Regular(parse_dollar_expr(working_set, span)) ExternalArgument::Regular(parse_dollar_expr(working_set, span))
} else if contents.starts_with(b"[") { } else if contents.starts_with(b"[") {
ExternalArgument::Regular(parse_list_expression(working_set, span, &SyntaxShape::Any)) ExternalArgument::Regular(parse_list_expression(working_set, span, &SyntaxShape::Any))
} else if contents.starts_with(b"r#") {
ExternalArgument::Regular(parse_raw_string(working_set, span))
} else if contents.len() > 3 } else if contents.len() > 3
&& contents.starts_with(b"...") && contents.starts_with(b"...")
&& (contents[3] == b'$' || contents[3] == b'[' || contents[3] == b'(') && (contents[3] == b'$' || contents[3] == b'[' || contents[3] == b'(')
@ -241,18 +441,7 @@ fn parse_external_arg(working_set: &mut StateWorkingSet, span: Span) -> External
&SyntaxShape::List(Box::new(SyntaxShape::Any)), &SyntaxShape::List(Box::new(SyntaxShape::Any)),
)) ))
} else { } else {
// Eval stage trims the quotes, so we don't have to do the same thing when parsing. ExternalArgument::Regular(parse_external_string(working_set, span))
let (contents, err) = unescape_string_preserving_quotes(contents, span);
if let Some(err) = err {
working_set.error(err);
}
ExternalArgument::Regular(Expression::new(
working_set,
Expr::String(contents),
span,
Type::String,
))
} }
} }
@ -274,18 +463,7 @@ pub fn parse_external_call(working_set: &mut StateWorkingSet, spans: &[Span]) ->
let arg = parse_expression(working_set, &[head_span]); let arg = parse_expression(working_set, &[head_span]);
Box::new(arg) Box::new(arg)
} else { } else {
// Eval stage will unquote the string, so we don't bother with that here Box::new(parse_external_string(working_set, head_span))
let (contents, err) = unescape_string_preserving_quotes(&head_contents, head_span);
if let Some(err) = err {
working_set.error(err)
}
Box::new(Expression::new(
working_set,
Expr::String(contents),
head_span,
Type::String,
))
}; };
let args = spans[1..] let args = spans[1..]
@ -2639,23 +2817,6 @@ pub fn unescape_unquote_string(bytes: &[u8], span: Span) -> (String, Option<Pars
} }
} }
/// XXX: This is here temporarily as a patch, but we should replace this with properly representing
/// the quoted state of a string in the AST
fn unescape_string_preserving_quotes(bytes: &[u8], span: Span) -> (String, Option<ParseError>) {
let (bytes, err) = if bytes.starts_with(b"\"") {
let (bytes, err) = unescape_string(bytes, span);
(Cow::Owned(bytes), err)
} else {
(Cow::Borrowed(bytes), None)
};
// The original code for args used lossy conversion here, even though that's not what we
// typically use for strings. Revisit whether that's actually desirable later, but don't
// want to introduce a breaking change for this patch.
let token = String::from_utf8_lossy(&bytes).into_owned();
(token, err)
}
pub fn parse_string(working_set: &mut StateWorkingSet, span: Span) -> Expression { pub fn parse_string(working_set: &mut StateWorkingSet, span: Span) -> Expression {
trace!("parsing: string"); trace!("parsing: string");
@ -6012,7 +6173,7 @@ pub fn discover_captures_in_expr(
} }
Expr::String(_) => {} Expr::String(_) => {}
Expr::RawString(_) => {} Expr::RawString(_) => {}
Expr::StringInterpolation(exprs) => { Expr::StringInterpolation(exprs) | Expr::GlobInterpolation(exprs, _) => {
for expr in exprs { for expr in exprs {
discover_captures_in_expr(working_set, expr, seen, seen_blocks, output)?; discover_captures_in_expr(working_set, expr, seen, seen_blocks, output)?;
} }

View File

@ -1,8 +1,8 @@
use nu_parser::*; use nu_parser::*;
use nu_protocol::{ use nu_protocol::{
ast::{Argument, Call, Expr, ExternalArgument, PathMember, Range}, ast::{Argument, Call, Expr, Expression, ExternalArgument, PathMember, Range},
engine::{Command, EngineState, Stack, StateWorkingSet}, engine::{Command, EngineState, Stack, StateWorkingSet},
ParseError, PipelineData, ShellError, Signature, Span, SyntaxShape, ParseError, PipelineData, ShellError, Signature, Span, SyntaxShape, Type,
}; };
use rstest::rstest; use rstest::rstest;
@ -182,7 +182,7 @@ pub fn multi_test_parse_int() {
Test( Test(
"ranges or relative paths not confused for int", "ranges or relative paths not confused for int",
b"./a/b", b"./a/b",
Expr::String("./a/b".into()), Expr::GlobPattern("./a/b".into(), false),
None, None,
), ),
Test( Test(
@ -694,6 +694,50 @@ pub fn parse_call_missing_req_flag() {
)); ));
} }
fn test_external_call(input: &str, tag: &str, f: impl FnOnce(&Expression, &[ExternalArgument])) {
let engine_state = EngineState::new();
let mut working_set = StateWorkingSet::new(&engine_state);
let block = parse(&mut working_set, None, input.as_bytes(), true);
assert!(
working_set.parse_errors.is_empty(),
"{tag}: errors: {:?}",
working_set.parse_errors
);
let pipeline = &block.pipelines[0];
assert_eq!(1, pipeline.len());
let element = &pipeline.elements[0];
match &element.expr.expr {
Expr::ExternalCall(name, args) => f(name, args),
other => {
panic!("{tag}: Unexpected expression in pipeline: {other:?}");
}
}
}
fn check_external_call_interpolation(
tag: &str,
subexpr_count: usize,
quoted: bool,
expr: &Expression,
) -> bool {
match &expr.expr {
Expr::StringInterpolation(exprs) => {
assert!(quoted, "{tag}: quoted");
assert_eq!(expr.ty, Type::String, "{tag}: expr.ty");
assert_eq!(subexpr_count, exprs.len(), "{tag}: subexpr_count");
true
}
Expr::GlobInterpolation(exprs, is_quoted) => {
assert_eq!(quoted, *is_quoted, "{tag}: quoted");
assert_eq!(expr.ty, Type::Glob, "{tag}: expr.ty");
assert_eq!(subexpr_count, exprs.len(), "{tag}: subexpr_count");
true
}
_ => false,
}
}
#[rstest] #[rstest]
#[case("foo-external-call", "foo-external-call", "bare word")] #[case("foo-external-call", "foo-external-call", "bare word")]
#[case("^foo-external-call", "foo-external-call", "bare word with caret")] #[case("^foo-external-call", "foo-external-call", "bare word with caret")]
@ -713,77 +757,118 @@ pub fn parse_call_missing_req_flag() {
r"foo\external-call", r"foo\external-call",
"bare word with backslash and caret" "bare word with backslash and caret"
)] )]
#[case( #[case("`foo external call`", "foo external call", "backtick quote")]
"^'foo external call'",
"'foo external call'",
"single quote with caret"
)]
#[case(
"^'foo/external call'",
"'foo/external call'",
"single quote with forward slash and caret"
)]
#[case(
r"^'foo\external call'",
r"'foo\external call'",
"single quote with backslash and caret"
)]
#[case(
r#"^"foo external call""#,
r#""foo external call""#,
"double quote with caret"
)]
#[case(
r#"^"foo/external call""#,
r#""foo/external call""#,
"double quote with forward slash and caret"
)]
#[case(
r#"^"foo\\external call""#,
r#""foo\external call""#,
"double quote with backslash and caret"
)]
#[case("`foo external call`", "`foo external call`", "backtick quote")]
#[case( #[case(
"^`foo external call`", "^`foo external call`",
"`foo external call`", "foo external call",
"backtick quote with caret" "backtick quote with caret"
)] )]
#[case( #[case(
"`foo/external call`", "`foo/external call`",
"`foo/external call`", "foo/external call",
"backtick quote with forward slash" "backtick quote with forward slash"
)] )]
#[case( #[case(
"^`foo/external call`", "^`foo/external call`",
"`foo/external call`", "foo/external call",
"backtick quote with forward slash and caret" "backtick quote with forward slash and caret"
)] )]
#[case( #[case(
r"^`foo\external call`",
r"`foo\external call`", r"`foo\external call`",
r"foo\external call",
"backtick quote with backslash" "backtick quote with backslash"
)] )]
#[case( #[case(
r"^`foo\external call`", r"^`foo\external call`",
r"`foo\external call`", r"foo\external call",
"backtick quote with backslash and caret" "backtick quote with backslash and caret"
)] )]
fn test_external_call_name(#[case] input: &str, #[case] expected: &str, #[case] tag: &str) { pub fn test_external_call_head_glob(
let engine_state = EngineState::new(); #[case] input: &str,
let mut working_set = StateWorkingSet::new(&engine_state); #[case] expected: &str,
let block = parse(&mut working_set, None, input.as_bytes(), true); #[case] tag: &str,
assert!( ) {
working_set.parse_errors.is_empty(), test_external_call(input, tag, |name, args| {
"{tag}: errors: {:?}", match &name.expr {
working_set.parse_errors Expr::GlobPattern(string, is_quoted) => {
); assert_eq!(expected, string, "{tag}: incorrect name");
assert!(!*is_quoted);
}
other => {
panic!("{tag}: Unexpected expression in command name position: {other:?}");
}
}
assert_eq!(0, args.len());
})
}
let pipeline = &block.pipelines[0]; #[rstest]
assert_eq!(1, pipeline.len()); #[case(
let element = &pipeline.elements[0]; r##"^r#'foo-external-call'#"##,
match &element.expr.expr { "foo-external-call",
Expr::ExternalCall(name, args) => { "raw string with caret"
)]
#[case(
r##"^r#'foo/external-call'#"##,
"foo/external-call",
"raw string with forward slash and caret"
)]
#[case(
r##"^r#'foo\external-call'#"##,
r"foo\external-call",
"raw string with backslash and caret"
)]
pub fn test_external_call_head_raw_string(
#[case] input: &str,
#[case] expected: &str,
#[case] tag: &str,
) {
test_external_call(input, tag, |name, args| {
match &name.expr {
Expr::RawString(string) => {
assert_eq!(expected, string, "{tag}: incorrect name");
}
other => {
panic!("{tag}: Unexpected expression in command name position: {other:?}");
}
}
assert_eq!(0, args.len());
})
}
#[rstest]
#[case("^'foo external call'", "foo external call", "single quote with caret")]
#[case(
"^'foo/external call'",
"foo/external call",
"single quote with forward slash and caret"
)]
#[case(
r"^'foo\external call'",
r"foo\external call",
"single quote with backslash and caret"
)]
#[case(
r#"^"foo external call""#,
r#"foo external call"#,
"double quote with caret"
)]
#[case(
r#"^"foo/external call""#,
r#"foo/external call"#,
"double quote with forward slash and caret"
)]
#[case(
r#"^"foo\\external call""#,
r#"foo\external call"#,
"double quote with backslash and caret"
)]
pub fn test_external_call_head_string(
#[case] input: &str,
#[case] expected: &str,
#[case] tag: &str,
) {
test_external_call(input, tag, |name, args| {
match &name.expr { match &name.expr {
Expr::String(string) => { Expr::String(string) => {
assert_eq!(expected, string); assert_eq!(expected, string);
@ -793,47 +878,162 @@ fn test_external_call_name(#[case] input: &str, #[case] expected: &str, #[case]
} }
} }
assert_eq!(0, args.len()); assert_eq!(0, args.len());
} })
other => {
panic!("{tag}: Unexpected expression in pipeline: {other:?}");
}
}
} }
#[rstest] #[rstest]
#[case("^foo bar-baz", "bar-baz", "bare word")] #[case(r"~/.foo/(1)", 2, false, "unquoted interpolated string")]
#[case("^foo bar/baz", "bar/baz", "bare word with forward slash")] #[case(
#[case(r"^foo bar\baz", r"bar\baz", "bare word with backslash")] r"~\.foo(2)\(1)",
#[case("^foo 'bar baz'", "'bar baz'", "single quote")] 4,
#[case("foo 'bar/baz'", "'bar/baz'", "single quote with forward slash")] false,
#[case(r"foo 'bar\baz'", r"'bar\baz'", "single quote with backslash")] "unquoted interpolated string with backslash"
#[case(r#"^foo "bar baz""#, r#""bar baz""#, "double quote")] )]
#[case(r#"^foo "bar/baz""#, r#""bar/baz""#, "double quote with forward slash")] #[case(r"^~/.foo/(1)", 2, false, "unquoted interpolated string with caret")]
#[case(r#"^foo "bar\\baz""#, r#""bar\baz""#, "double quote with backslash")] #[case(r#"^$"~/.foo/(1)""#, 2, true, "quoted interpolated string with caret")]
#[case("^foo `bar baz`", "`bar baz`", "backtick quote")] pub fn test_external_call_head_interpolated_string(
#[case("^foo `bar/baz`", "`bar/baz`", "backtick quote with forward slash")] #[case] input: &str,
#[case(r"^foo `bar\baz`", r"`bar\baz`", "backtick quote with backslash")] #[case] subexpr_count: usize,
fn test_external_call_argument_regular( #[case] quoted: bool,
#[case] tag: &str,
) {
test_external_call(input, tag, |name, args| {
if !check_external_call_interpolation(tag, subexpr_count, quoted, name) {
panic!("{tag}: Unexpected expression in command name position: {name:?}");
}
assert_eq!(0, args.len());
})
}
#[rstest]
#[case("^foo foo-external-call", "foo-external-call", "bare word")]
#[case(
"^foo foo/external-call",
"foo/external-call",
"bare word with forward slash"
)]
#[case(
r"^foo foo\external-call",
r"foo\external-call",
"bare word with backslash"
)]
#[case(
"^foo `foo external call`",
"foo external call",
"backtick quote with caret"
)]
#[case(
"^foo `foo/external call`",
"foo/external call",
"backtick quote with forward slash"
)]
#[case(
r"^foo `foo\external call`",
r"foo\external call",
"backtick quote with backslash"
)]
pub fn test_external_call_arg_glob(#[case] input: &str, #[case] expected: &str, #[case] tag: &str) {
test_external_call(input, tag, |name, args| {
match &name.expr {
Expr::GlobPattern(string, _) => {
assert_eq!("foo", string, "{tag}: incorrect name");
}
other => {
panic!("{tag}: Unexpected expression in command name position: {other:?}");
}
}
assert_eq!(1, args.len());
match &args[0] {
ExternalArgument::Regular(expr) => match &expr.expr {
Expr::GlobPattern(string, is_quoted) => {
assert_eq!(expected, string, "{tag}: incorrect arg");
assert!(!*is_quoted);
}
other => {
panic!("Unexpected expression in command arg position: {other:?}")
}
},
other @ ExternalArgument::Spread(..) => {
panic!("Unexpected external spread argument in command arg position: {other:?}")
}
}
})
}
#[rstest]
#[case(r##"^foo r#'foo-external-call'#"##, "foo-external-call", "raw string")]
#[case(
r##"^foo r#'foo/external-call'#"##,
"foo/external-call",
"raw string with forward slash"
)]
#[case(
r##"^foo r#'foo\external-call'#"##,
r"foo\external-call",
"raw string with backslash"
)]
pub fn test_external_call_arg_raw_string(
#[case] input: &str, #[case] input: &str,
#[case] expected: &str, #[case] expected: &str,
#[case] tag: &str, #[case] tag: &str,
) { ) {
let engine_state = EngineState::new(); test_external_call(input, tag, |name, args| {
let mut working_set = StateWorkingSet::new(&engine_state);
let block = parse(&mut working_set, None, input.as_bytes(), true);
assert!(
working_set.parse_errors.is_empty(),
"{tag}: errors: {:?}",
working_set.parse_errors
);
let pipeline = &block.pipelines[0];
assert_eq!(1, pipeline.len());
let element = &pipeline.elements[0];
match &element.expr.expr {
Expr::ExternalCall(name, args) => {
match &name.expr { match &name.expr {
Expr::String(string) => { Expr::GlobPattern(string, _) => {
assert_eq!("foo", string, "{tag}: incorrect name");
}
other => {
panic!("{tag}: Unexpected expression in command name position: {other:?}");
}
}
assert_eq!(1, args.len());
match &args[0] {
ExternalArgument::Regular(expr) => match &expr.expr {
Expr::RawString(string) => {
assert_eq!(expected, string, "{tag}: incorrect arg");
}
other => {
panic!("Unexpected expression in command arg position: {other:?}")
}
},
other @ ExternalArgument::Spread(..) => {
panic!("Unexpected external spread argument in command arg position: {other:?}")
}
}
})
}
#[rstest]
#[case("^foo 'foo external call'", "foo external call", "single quote")]
#[case(
"^foo 'foo/external call'",
"foo/external call",
"single quote with forward slash"
)]
#[case(
r"^foo 'foo\external call'",
r"foo\external call",
"single quote with backslash"
)]
#[case(r#"^foo "foo external call""#, r#"foo external call"#, "double quote")]
#[case(
r#"^foo "foo/external call""#,
r#"foo/external call"#,
"double quote with forward slash"
)]
#[case(
r#"^foo "foo\\external call""#,
r#"foo\external call"#,
"double quote with backslash"
)]
pub fn test_external_call_arg_string(
#[case] input: &str,
#[case] expected: &str,
#[case] tag: &str,
) {
test_external_call(input, tag, |name, args| {
match &name.expr {
Expr::GlobPattern(string, _) => {
assert_eq!("foo", string, "{tag}: incorrect name"); assert_eq!("foo", string, "{tag}: incorrect name");
} }
other => { other => {
@ -847,38 +1047,58 @@ fn test_external_call_argument_regular(
assert_eq!(expected, string, "{tag}: incorrect arg"); assert_eq!(expected, string, "{tag}: incorrect arg");
} }
other => { other => {
panic!("Unexpected expression in command arg position: {other:?}") panic!("{tag}: Unexpected expression in command arg position: {other:?}")
} }
}, },
other @ ExternalArgument::Spread(..) => {
panic!(
"{tag}: Unexpected external spread argument in command arg position: {other:?}"
)
}
}
})
}
#[rstest]
#[case(r"^foo ~/.foo/(1)", 2, false, "unquoted interpolated string")]
#[case(r#"^foo $"~/.foo/(1)""#, 2, true, "quoted interpolated string")]
pub fn test_external_call_arg_interpolated_string(
#[case] input: &str,
#[case] subexpr_count: usize,
#[case] quoted: bool,
#[case] tag: &str,
) {
test_external_call(input, tag, |name, args| {
match &name.expr {
Expr::GlobPattern(string, _) => {
assert_eq!("foo", string, "{tag}: incorrect name");
}
other => {
panic!("{tag}: Unexpected expression in command name position: {other:?}");
}
}
assert_eq!(1, args.len());
match &args[0] {
ExternalArgument::Regular(expr) => {
if !check_external_call_interpolation(tag, subexpr_count, quoted, expr) {
panic!("Unexpected expression in command arg position: {expr:?}")
}
}
other @ ExternalArgument::Spread(..) => { other @ ExternalArgument::Spread(..) => {
panic!("Unexpected external spread argument in command arg position: {other:?}") panic!("Unexpected external spread argument in command arg position: {other:?}")
} }
} }
} })
other => {
panic!("{tag}: Unexpected expression in pipeline: {other:?}");
}
}
} }
#[test] #[test]
fn test_external_call_argument_spread() { fn test_external_call_argument_spread() {
let engine_state = EngineState::new(); let input = r"^foo ...[a b c]";
let mut working_set = StateWorkingSet::new(&engine_state); let tag = "spread";
let block = parse(&mut working_set, None, b"^foo ...[a b c]", true);
assert!(
working_set.parse_errors.is_empty(),
"errors: {:?}",
working_set.parse_errors
);
let pipeline = &block.pipelines[0]; test_external_call(input, tag, |name, args| {
assert_eq!(1, pipeline.len());
let element = &pipeline.elements[0];
match &element.expr.expr {
Expr::ExternalCall(name, args) => {
match &name.expr { match &name.expr {
Expr::String(string) => { Expr::GlobPattern(string, _) => {
assert_eq!("foo", string, "incorrect name"); assert_eq!("foo", string, "incorrect name");
} }
other => { other => {
@ -897,16 +1117,10 @@ fn test_external_call_argument_spread() {
} }
}, },
other @ ExternalArgument::Regular(..) => { other @ ExternalArgument::Regular(..) => {
panic!( panic!("Unexpected external regular argument in command arg position: {other:?}")
"Unexpected external regular argument in command arg position: {other:?}"
)
}
}
}
other => {
panic!("Unexpected expression in pipeline: {other:?}");
} }
} }
})
} }
#[test] #[test]
@ -1132,6 +1346,44 @@ mod string {
assert_eq!(subexprs[0], &Expr::String("(1 + 3)(7 - 5)".to_string())); assert_eq!(subexprs[0], &Expr::String("(1 + 3)(7 - 5)".to_string()));
} }
#[test]
pub fn parse_string_interpolation_bare() {
let engine_state = EngineState::new();
let mut working_set = StateWorkingSet::new(&engine_state);
let block = parse(
&mut working_set,
None,
b"\"\" ++ foo(1 + 3)bar(7 - 5)",
true,
);
assert!(working_set.parse_errors.is_empty());
assert_eq!(block.len(), 1);
let pipeline = &block.pipelines[0];
assert_eq!(pipeline.len(), 1);
let element = &pipeline.elements[0];
assert!(element.redirection.is_none());
let subexprs: Vec<&Expr> = match &element.expr.expr {
Expr::BinaryOp(_, _, rhs) => match &rhs.expr {
Expr::StringInterpolation(expressions) => {
expressions.iter().map(|e| &e.expr).collect()
}
_ => panic!("Expected an `Expr::StringInterpolation`"),
},
_ => panic!("Expected an `Expr::BinaryOp`"),
};
assert_eq!(subexprs.len(), 4);
assert_eq!(subexprs[0], &Expr::String("foo".to_string()));
assert!(matches!(subexprs[1], &Expr::FullCellPath(..)));
assert_eq!(subexprs[2], &Expr::String("bar".to_string()));
assert!(matches!(subexprs[3], &Expr::FullCellPath(..)));
}
#[test] #[test]
pub fn parse_nested_expressions() { pub fn parse_nested_expressions() {
let engine_state = EngineState::new(); let engine_state = EngineState::new();

View File

@ -32,8 +32,11 @@ pub enum Expr {
Keyword(Box<Keyword>), Keyword(Box<Keyword>),
ValueWithUnit(Box<ValueWithUnit>), ValueWithUnit(Box<ValueWithUnit>),
DateTime(chrono::DateTime<FixedOffset>), DateTime(chrono::DateTime<FixedOffset>),
/// The boolean is `true` if the string is quoted.
Filepath(String, bool), Filepath(String, bool),
/// The boolean is `true` if the string is quoted.
Directory(String, bool), Directory(String, bool),
/// The boolean is `true` if the string is quoted.
GlobPattern(String, bool), GlobPattern(String, bool),
String(String), String(String),
RawString(String), RawString(String),
@ -43,6 +46,8 @@ pub enum Expr {
Overlay(Option<BlockId>), // block ID of the overlay's origin module Overlay(Option<BlockId>), // block ID of the overlay's origin module
Signature(Box<Signature>), Signature(Box<Signature>),
StringInterpolation(Vec<Expression>), StringInterpolation(Vec<Expression>),
/// The boolean is `true` if the string is quoted.
GlobInterpolation(Vec<Expression>, bool),
Nothing, Nothing,
Garbage, Garbage,
} }
@ -84,6 +89,7 @@ impl Expr {
| Expr::RawString(_) | Expr::RawString(_)
| Expr::CellPath(_) | Expr::CellPath(_)
| Expr::StringInterpolation(_) | Expr::StringInterpolation(_)
| Expr::GlobInterpolation(_, _)
| Expr::Nothing => { | Expr::Nothing => {
// These expressions do not use the output of the pipeline in any meaningful way, // These expressions do not use the output of the pipeline in any meaningful way,
// so we can discard the previous output by redirecting it to `Null`. // so we can discard the previous output by redirecting it to `Null`.

View File

@ -232,7 +232,7 @@ impl Expression {
} }
false false
} }
Expr::StringInterpolation(items) => { Expr::StringInterpolation(items) | Expr::GlobInterpolation(items, _) => {
for i in items { for i in items {
if i.has_in_variable(working_set) { if i.has_in_variable(working_set) {
return true; return true;
@ -441,7 +441,7 @@ impl Expression {
Expr::Signature(_) => {} Expr::Signature(_) => {}
Expr::String(_) => {} Expr::String(_) => {}
Expr::RawString(_) => {} Expr::RawString(_) => {}
Expr::StringInterpolation(items) => { Expr::StringInterpolation(items) | Expr::GlobInterpolation(items, _) => {
for i in items { for i in items {
i.replace_span(working_set, replaced, new_span) i.replace_span(working_set, replaced, new_span)
} }

View File

@ -258,6 +258,7 @@ fn expr_to_string(engine_state: &EngineState, expr: &Expr) -> String {
Expr::Signature(_) => "signature".to_string(), Expr::Signature(_) => "signature".to_string(),
Expr::String(_) | Expr::RawString(_) => "string".to_string(), Expr::String(_) | Expr::RawString(_) => "string".to_string(),
Expr::StringInterpolation(_) => "string interpolation".to_string(), Expr::StringInterpolation(_) => "string interpolation".to_string(),
Expr::GlobInterpolation(_, _) => "glob interpolation".to_string(),
Expr::Subexpression(_) => "subexpression".to_string(), Expr::Subexpression(_) => "subexpression".to_string(),
Expr::Table(_) => "table".to_string(), Expr::Table(_) => "table".to_string(),
Expr::UnaryNot(_) => "unary not".to_string(), Expr::UnaryNot(_) => "unary not".to_string(),

View File

@ -290,6 +290,15 @@ pub trait Eval {
Ok(Value::string(str, expr_span)) Ok(Value::string(str, expr_span))
} }
Expr::GlobInterpolation(exprs, quoted) => {
let config = Self::get_config(state, mut_state);
let str = exprs
.iter()
.map(|expr| Self::eval::<D>(state, mut_state, expr).map(|v| v.to_expanded_string(", ", &config)))
.collect::<Result<String, _>>()?;
Ok(Value::glob(str, *quoted, expr_span))
}
Expr::Overlay(_) => Self::eval_overlay(state, expr_span), Expr::Overlay(_) => Self::eval_overlay(state, expr_span),
Expr::GlobPattern(pattern, quoted) => { Expr::GlobPattern(pattern, quoted) => {
// GlobPattern is similar to Filepath // GlobPattern is similar to Filepath

View File

@ -245,6 +245,7 @@ use tempfile::tempdir;
pub struct NuOpts { pub struct NuOpts {
pub cwd: Option<String>, pub cwd: Option<String>,
pub locale: Option<String>, pub locale: Option<String>,
pub envs: Option<Vec<(String, String)>>,
pub collapse_output: Option<bool>, pub collapse_output: Option<bool>,
} }
@ -278,6 +279,11 @@ pub fn nu_run_test(opts: NuOpts, commands: impl AsRef<str>, with_std: bool) -> O
command command
.env(nu_utils::locale::LOCALE_OVERRIDE_ENV_VAR, locale) .env(nu_utils::locale::LOCALE_OVERRIDE_ENV_VAR, locale)
.env(NATIVE_PATH_ENV_VAR, paths_joined); .env(NATIVE_PATH_ENV_VAR, paths_joined);
if let Some(envs) = opts.envs {
command.envs(envs);
}
// Ensure that the user's config doesn't interfere with the tests // Ensure that the user's config doesn't interfere with the tests
command.arg("--no-config-file"); command.arg("--no-config-file");
if !with_std { if !with_std {

View File

@ -323,6 +323,12 @@ fn convert_to_value(
msg: "string interpolation not supported in nuon".into(), msg: "string interpolation not supported in nuon".into(),
span: expr.span, span: expr.span,
}), }),
Expr::GlobInterpolation(..) => Err(ShellError::OutsideSpannedLabeledError {
src: original_text.to_string(),
error: "Error when loading".into(),
msg: "glob interpolation not supported in nuon".into(),
span: expr.span,
}),
Expr::Subexpression(..) => Err(ShellError::OutsideSpannedLabeledError { Expr::Subexpression(..) => Err(ShellError::OutsideSpannedLabeledError {
src: original_text.to_string(), src: original_text.to_string(),
error: "Error when loading".into(), error: "Error when loading".into(),