mirror of
https://github.com/nushell/nushell.git
synced 2024-12-28 09:59:36 +01:00
6c649809d3
This PR is a complete rewrite of `run_external.rs`. The main goal of the rewrite is improving readability, but it also fixes some bugs related to argument handling and the PATH variable (fixes https://github.com/nushell/nushell/issues/6011). I'll discuss some technical details to make reviewing easier. ## Argument handling Quoting arguments for external commands is hard. Like, *really* hard. We've had more than a dozen issues and PRs dedicated to quoting arguments (see Appendix) but the current implementation is still buggy. Here's a demonstration of the buggy behavior: ```nu let foo = "'bar'" ^touch $foo # This creates a file named `bar`, but it should be `'bar'` ^touch ...[ "'bar'" ] # Same ``` I'll describe how this PR deals with argument handling. First, we'll introduce the concept of **bare strings**. Bare strings are **string literals** that are either **unquoted** or **quoted by backticks** [^1]. Strings within a list literal are NOT considered bare strings, even if they are unquoted or quoted by backticks. When a bare string is used as an argument to external process, we need to perform tilde-expansion, glob-expansion, and inner-quotes-removal, in that order. "Inner-quotes-removal" means transforming from `--option="value"` into `--option=value`. ## `.bat` files and CMD built-ins On Windows, `.bat` files and `.cmd` files are considered executable, but they need `CMD.exe` as the interpreter. The Rust standard library supports running `.bat` files directly and will spawn `CMD.exe` under the hood (see [documentation](https://doc.rust-lang.org/std/process/index.html#windows-argument-splitting)). However, other extensions are not supported [^2]. Nushell also supports a selected number of CMD built-ins. The problem with CMD is that it uses a different set of quoting rules. Correctly quoting for CMD requires using [Command::raw_arg()](https://doc.rust-lang.org/std/os/windows/process/trait.CommandExt.html#tymethod.raw_arg) and manually quoting CMD special characters, on top of quoting from the Nushell side. ~~I decided that this is too complex and chose to reject special characters in CMD built-ins instead [^3]. Hopefully this will not affact real-world use cases.~~ I've implemented escaping that works reasonably well. ## `which-support` feature The `which` crate is now a hard dependency of `nu-command`, making the `which-support` feature essentially useless. The `which` crate is already a hard dependency of `nu-cli`, and we should consider removing the `which-support` feature entirely. ## Appendix Here's a list of quoting-related issues and PRs in rough chronological order. * https://github.com/nushell/nushell/issues/4609 * https://github.com/nushell/nushell/issues/4631 * https://github.com/nushell/nushell/issues/4601 * https://github.com/nushell/nushell/pull/5846 * https://github.com/nushell/nushell/issues/5978 * https://github.com/nushell/nushell/pull/6014 * https://github.com/nushell/nushell/issues/6154 * https://github.com/nushell/nushell/pull/6161 * https://github.com/nushell/nushell/issues/6399 * https://github.com/nushell/nushell/pull/6420 * https://github.com/nushell/nushell/pull/6426 * https://github.com/nushell/nushell/issues/6465 * https://github.com/nushell/nushell/issues/6559 * https://github.com/nushell/nushell/pull/6560 [^1]: The idea that backtick-quoted strings act like bare strings was introduced by Kubouch and briefly mentioned in [the language reference](https://www.nushell.sh/lang-guide/chapters/strings_and_text.html#backtick-quotes). [^2]: The documentation also said "running .bat scripts in this way may be removed in the future and so should not be relied upon", which is another reason to move away from this. But again, quoting for CMD is hard. [^3]: If anyone wants to try, the best resource I found on the topic is [this](https://daviddeley.com/autohotkey/parameters/parameters.htm).
312 lines
7.6 KiB
Rust
312 lines
7.6 KiB
Rust
use nu_test_support::fs::AbsolutePath;
|
|
use nu_test_support::fs::Stub::{FileWithContent, FileWithContentToBeTrimmed};
|
|
use nu_test_support::nu;
|
|
use nu_test_support::pipeline;
|
|
use nu_test_support::playground::Playground;
|
|
|
|
#[test]
|
|
fn use_module_file_within_block() {
|
|
Playground::setup("use_test_1", |dirs, nu| {
|
|
let file = AbsolutePath::new(dirs.test().join("spam.nu"));
|
|
|
|
nu.with_files(&[FileWithContent(
|
|
&file.to_string(),
|
|
r#"
|
|
export def foo [] {
|
|
echo "hello world"
|
|
}
|
|
"#,
|
|
)]);
|
|
|
|
let actual = nu!(
|
|
cwd: dirs.test(), pipeline(
|
|
"
|
|
def bar [] {
|
|
use spam.nu foo;
|
|
foo
|
|
};
|
|
bar
|
|
"
|
|
)
|
|
);
|
|
|
|
assert_eq!(actual.out, "hello world");
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_keeps_doc_comments() {
|
|
Playground::setup("use_doc_comments", |dirs, nu| {
|
|
let file = AbsolutePath::new(dirs.test().join("spam.nu"));
|
|
|
|
nu.with_files(&[FileWithContent(
|
|
&file.to_string(),
|
|
r#"
|
|
# this is my foo command
|
|
export def foo [
|
|
x:string # this is an x parameter
|
|
] {
|
|
echo "hello world"
|
|
}
|
|
"#,
|
|
)]);
|
|
|
|
let actual = nu!(
|
|
cwd: dirs.test(), pipeline(
|
|
"
|
|
use spam.nu foo;
|
|
help foo
|
|
"
|
|
)
|
|
);
|
|
|
|
assert!(actual.out.contains("this is my foo command"));
|
|
assert!(actual.out.contains("this is an x parameter"));
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_eval_export_env() {
|
|
Playground::setup("use_eval_export_env", |dirs, sandbox| {
|
|
sandbox.with_files(&[FileWithContentToBeTrimmed(
|
|
"spam.nu",
|
|
r#"
|
|
export-env { $env.FOO = 'foo' }
|
|
"#,
|
|
)]);
|
|
|
|
let inp = &[r#"use spam.nu"#, r#"$env.FOO"#];
|
|
|
|
let actual = nu!(cwd: dirs.test(), pipeline(&inp.join("; ")));
|
|
|
|
assert_eq!(actual.out, "foo");
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_eval_export_env_hide() {
|
|
Playground::setup("use_eval_export_env", |dirs, sandbox| {
|
|
sandbox.with_files(&[FileWithContentToBeTrimmed(
|
|
"spam.nu",
|
|
r#"
|
|
export-env { hide-env FOO }
|
|
"#,
|
|
)]);
|
|
|
|
let inp = &[r#"$env.FOO = 'foo'"#, r#"use spam.nu"#, r#"$env.FOO"#];
|
|
|
|
let actual = nu!(cwd: dirs.test(), pipeline(&inp.join("; ")));
|
|
|
|
assert!(actual.err.contains("not_found"));
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_do_cd() {
|
|
Playground::setup("use_do_cd", |dirs, sandbox| {
|
|
sandbox
|
|
.mkdir("test1/test2")
|
|
.with_files(&[FileWithContentToBeTrimmed(
|
|
"test1/test2/spam.nu",
|
|
r#"
|
|
export-env { cd test1/test2 }
|
|
"#,
|
|
)]);
|
|
|
|
let inp = &[r#"use test1/test2/spam.nu"#, r#"$env.PWD | path basename"#];
|
|
|
|
let actual = nu!(cwd: dirs.test(), pipeline(&inp.join("; ")));
|
|
|
|
assert_eq!(actual.out, "test2");
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_do_cd_file_relative() {
|
|
Playground::setup("use_do_cd_file_relative", |dirs, sandbox| {
|
|
sandbox
|
|
.mkdir("test1/test2")
|
|
.with_files(&[FileWithContentToBeTrimmed(
|
|
"test1/test2/spam.nu",
|
|
r#"
|
|
export-env { cd ($env.FILE_PWD | path join '..') }
|
|
"#,
|
|
)]);
|
|
|
|
let inp = &[r#"use test1/test2/spam.nu"#, r#"$env.PWD | path basename"#];
|
|
|
|
let actual = nu!(cwd: dirs.test(), pipeline(&inp.join("; ")));
|
|
|
|
assert_eq!(actual.out, "test1");
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_dont_cd_overlay() {
|
|
Playground::setup("use_dont_cd_overlay", |dirs, sandbox| {
|
|
sandbox
|
|
.mkdir("test1/test2")
|
|
.with_files(&[FileWithContentToBeTrimmed(
|
|
"test1/test2/spam.nu",
|
|
r#"
|
|
export-env {
|
|
overlay new spam
|
|
cd test1/test2
|
|
overlay hide spam
|
|
}
|
|
"#,
|
|
)]);
|
|
|
|
let inp = &[r#"use test1/test2/spam.nu"#, r#"$env.PWD | path basename"#];
|
|
|
|
let actual = nu!(cwd: dirs.test(), pipeline(&inp.join("; ")));
|
|
|
|
assert_eq!(actual.out, "use_dont_cd_overlay");
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_export_env_combined() {
|
|
Playground::setup("use_is_scoped", |dirs, sandbox| {
|
|
sandbox.with_files(&[FileWithContentToBeTrimmed(
|
|
"spam.nu",
|
|
r#"
|
|
def foo [] { 'foo' }
|
|
alias bar = foo
|
|
export-env { $env.FOO = (bar) }
|
|
"#,
|
|
)]);
|
|
|
|
let inp = &[r#"use spam.nu"#, r#"$env.FOO"#];
|
|
|
|
let actual = nu!(cwd: dirs.test(), pipeline(&inp.join("; ")));
|
|
assert_eq!(actual.out, "foo");
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn use_module_creates_accurate_did_you_mean_1() {
|
|
let actual = nu!(r#"
|
|
module spam { export def foo [] { "foo" } }; use spam; foo
|
|
"#);
|
|
assert!(actual.err.contains("Did you mean `spam foo`"));
|
|
}
|
|
|
|
#[test]
|
|
fn use_module_creates_accurate_did_you_mean_2() {
|
|
let actual = nu!(r#"
|
|
module spam { export def foo [] { "foo" } }; foo
|
|
"#);
|
|
assert!(actual
|
|
.err
|
|
.contains("A command with that name exists in module `spam`"));
|
|
}
|
|
|
|
#[test]
|
|
fn use_main_1() {
|
|
let inp = &[
|
|
r#"module spam { export def main [] { "spam" } }"#,
|
|
r#"use spam"#,
|
|
r#"spam"#,
|
|
];
|
|
|
|
let actual = nu!(&inp.join("; "));
|
|
|
|
assert_eq!(actual.out, "spam");
|
|
}
|
|
|
|
#[test]
|
|
fn use_main_2() {
|
|
let inp = &[
|
|
r#"module spam { export def main [] { "spam" } }"#,
|
|
r#"use spam main"#,
|
|
r#"spam"#,
|
|
];
|
|
|
|
let actual = nu!(&inp.join("; "));
|
|
|
|
assert_eq!(actual.out, "spam");
|
|
}
|
|
|
|
#[test]
|
|
fn use_main_3() {
|
|
let inp = &[
|
|
r#"module spam { export def main [] { "spam" } }"#,
|
|
r#"use spam [ main ]"#,
|
|
r#"spam"#,
|
|
];
|
|
|
|
let actual = nu!(&inp.join("; "));
|
|
|
|
assert_eq!(actual.out, "spam");
|
|
}
|
|
|
|
#[test]
|
|
fn use_main_4() {
|
|
let inp = &[
|
|
r#"module spam { export def main [] { "spam" } }"#,
|
|
r#"use spam *"#,
|
|
r#"spam"#,
|
|
];
|
|
|
|
let actual = nu!(&inp.join("; "));
|
|
|
|
assert_eq!(actual.out, "spam");
|
|
}
|
|
|
|
#[test]
|
|
fn use_main_def_env() {
|
|
let inp = &[
|
|
r#"module spam { export def --env main [] { $env.SPAM = "spam" } }"#,
|
|
r#"use spam"#,
|
|
r#"spam"#,
|
|
r#"$env.SPAM"#,
|
|
];
|
|
|
|
let actual = nu!(&inp.join("; "));
|
|
|
|
assert_eq!(actual.out, "spam");
|
|
}
|
|
|
|
#[test]
|
|
fn use_main_def_known_external() {
|
|
// note: requires installed cargo
|
|
let inp = &[
|
|
r#"module cargo { export extern main [] }"#,
|
|
r#"use cargo"#,
|
|
r#"cargo --version"#,
|
|
];
|
|
|
|
let actual = nu!(&inp.join("; "));
|
|
|
|
assert!(actual.out.contains("cargo"));
|
|
}
|
|
|
|
#[test]
|
|
fn use_main_not_exported() {
|
|
let inp = &[
|
|
r#"module spam { def main [] { "spam" } }"#,
|
|
r#"use spam"#,
|
|
r#"spam"#,
|
|
];
|
|
|
|
let actual = nu!(&inp.join("; "));
|
|
|
|
assert!(actual.err.contains("external_command"));
|
|
}
|
|
|
|
#[test]
|
|
fn use_sub_subname_error_if_not_from_submodule() {
|
|
let inp = r#"module spam { export def foo [] {}; export def bar [] {} }; use spam foo bar"#;
|
|
let actual = nu!(inp);
|
|
assert!(actual.err.contains("try `use <module> [<name1>, <name2>]`"))
|
|
}
|
|
|
|
#[test]
|
|
fn can_use_sub_subname_from_submodule() {
|
|
let inp =
|
|
r#"module spam { export module foo { export def bar [] {"bar"} } }; use spam foo bar; bar"#;
|
|
let actual = nu!(inp);
|
|
assert_eq!(actual.out, "bar")
|
|
}
|