Skip to main content

nu_test_support/
macros.rs

1/// Run a command in nu and get its output
2///
3/// The `nu!` macro accepts a number of options like the `cwd` in which the
4/// command should be run. It is also possible to specify a different `locale`
5/// to test locale dependent commands.
6///
7/// Pass options as the first arguments in the form of `key_1: value_1, key_1:
8/// value_2, ...`. The options are defined in the `NuOpts` struct inside the
9/// `nu!` macro.
10///
11/// The command can be formatted using `{}` just like `println!` or `format!`.
12/// Pass the format arguments comma separated after the command itself.
13///
14/// # Examples
15///
16/// ```no_run
17/// # // NOTE: The `nu!` macro needs the `nu` binary to exist. The test are
18/// # //       therefore only compiled but not run (that's what the `no_run` at
19/// # //       the beginning of this code block is for).
20/// #
21/// use nu_test_support::nu;
22///
23/// let outcome = nu!(
24///     "date now | date to-record | get year"
25/// );
26///
27/// let dir = "/";
28/// let outcome = nu!(
29///     "ls {} | get name",
30///     dir,
31/// );
32///
33/// let outcome = nu!(
34///     cwd: "/",
35///     "ls | get name",
36/// );
37///
38/// let cell = "size";
39/// let outcome = nu!(
40///     locale: "de_DE.UTF-8",
41///     "ls | into int {}",
42///     cell,
43/// );
44///
45/// let decimals = 2;
46/// let outcome = nu!(
47///     locale: "de_DE.UTF-8",
48///     "10 | into string --decimals {}",
49///     decimals,
50/// );
51/// ```
52#[macro_export]
53macro_rules! nu {
54    // In the `@options` phase, we restructure all the
55    // `$field_1: $value_1, $field_2: $value_2, ...`
56    // pairs to a structure like
57    // `@options[ $field_1 => $value_1 ; $field_2 => $value_2 ; ... ]`.
58    // We do this to later distinguish the options from the `$path` and `$part`s.
59    // (See
60    //   https://users.rust-lang.org/t/i-dont-think-this-local-ambiguity-when-calling-macro-is-ambiguous/79401?u=x3ro
61    // )
62    //
63    // If there is any special treatment needed for the `$value`, we can just
64    // match for the specific `field` name.
65    (
66        @options [ $($options:tt)* ]
67        cwd: $value:expr,
68        $($rest:tt)*
69    ) => {
70        nu!(@options [ $($options)* cwd => $crate::fs::in_directory($value) ; ] $($rest)*)
71    };
72    // For all other options, we call `.into()` on the `$value` and hope for the best. ;)
73    (
74        @options [ $($options:tt)* ]
75        $field:ident : $value:expr,
76        $($rest:tt)*
77    ) => {
78        nu!(@options [ $($options)* $field => $value.into() ; ] $($rest)*)
79    };
80
81    // When the `$field: $value,` pairs are all parsed, the next tokens are the `$path` and any
82    // number of `$part`s, potentially followed by a trailing comma.
83    (
84        @options [ $($options:tt)* ]
85        $path:expr
86        $(, $part:expr)*
87        $(,)*
88    ) => {{
89        // Here we parse the options into a `NuOpts` struct
90        let opts = nu!(@nu_opts $($options)*);
91        // and format the `$path` using the `$part`s
92        let path = $path;
93        // Then finally we go to the `@main` phase, where the actual work is done.
94        nu!(@main opts, path)
95    }};
96
97    // Create the NuOpts struct from the `field => value ;` pairs
98    (@nu_opts $( $field:ident => $value:expr ; )*) => {
99        $crate::macros::NuOpts{
100            $(
101                $field: Some($value),
102            )*
103            ..Default::default()
104        }
105    };
106
107    // Do the actual work.
108    (@main $opts:expr, $path:expr) => {{
109        $crate::macros::nu_run_test($opts, $path, false)
110    }};
111
112    // This is the entrypoint for this macro.
113    ($($token:tt)*) => {{
114
115        nu!(@options [ ] $($token)*)
116    }};
117}
118
119#[macro_export]
120macro_rules! nu_with_std {
121    // In the `@options` phase, we restructure all the
122    // `$field_1: $value_1, $field_2: $value_2, ...`
123    // pairs to a structure like
124    // `@options[ $field_1 => $value_1 ; $field_2 => $value_2 ; ... ]`.
125    // We do this to later distinguish the options from the `$path` and `$part`s.
126    // (See
127    //   https://users.rust-lang.org/t/i-dont-think-this-local-ambiguity-when-calling-macro-is-ambiguous/79401?u=x3ro
128    // )
129    //
130    // If there is any special treatment needed for the `$value`, we can just
131    // match for the specific `field` name.
132    (
133        @options [ $($options:tt)* ]
134        cwd: $value:expr,
135        $($rest:tt)*
136    ) => {
137        nu_with_std!(@options [ $($options)* cwd => $crate::fs::in_directory($value) ; ] $($rest)*)
138    };
139    // For all other options, we call `.into()` on the `$value` and hope for the best. ;)
140    (
141        @options [ $($options:tt)* ]
142        $field:ident : $value:expr,
143        $($rest:tt)*
144    ) => {
145        nu_with_std!(@options [ $($options)* $field => $value.into() ; ] $($rest)*)
146    };
147
148    // When the `$field: $value,` pairs are all parsed, the next tokens are the `$path` and any
149    // number of `$part`s, potentially followed by a trailing comma.
150    (
151        @options [ $($options:tt)* ]
152        $path:expr
153        $(, $part:expr)*
154        $(,)*
155    ) => {{
156        // Here we parse the options into a `NuOpts` struct
157        let opts = nu_with_std!(@nu_opts $($options)*);
158        // and format the `$path` using the `$part`s
159        let path = nu_with_std!(@format_path $path, $($part),*);
160        // Then finally we go to the `@main` phase, where the actual work is done.
161        nu_with_std!(@main opts, path)
162    }};
163
164    // Create the NuOpts struct from the `field => value ;` pairs
165    (@nu_opts $( $field:ident => $value:expr ; )*) => {
166        $crate::macros::NuOpts{
167            $(
168                $field: Some($value),
169            )*
170            ..Default::default()
171        }
172    };
173
174    // Helper to format `$path`.
175    (@format_path $path:expr $(,)?) => {
176        // When there are no `$part`s, do not format anything
177        $path
178    };
179    (@format_path $path:expr, $($part:expr),* $(,)?) => {{
180        format!($path, $( $part ),*)
181    }};
182
183    // Do the actual work.
184    (@main $opts:expr, $path:expr) => {{
185        $crate::macros::nu_run_test($opts, $path, true)
186    }};
187
188    // This is the entrypoint for this macro.
189    ($($token:tt)*) => {{
190        nu_with_std!(@options [ ] $($token)*)
191    }};
192}
193
194#[macro_export]
195macro_rules! nu_with_plugins {
196    (cwd: $cwd:expr, plugins: [$(($plugin_name:expr)),*$(,)?], $command:expr) => {{
197        nu_with_plugins!(
198            cwd: $cwd,
199            envs: Vec::<(&str, &str)>::new(),
200            plugins: [$(($plugin_name)),*],
201            $command
202        )
203    }};
204    (cwd: $cwd:expr, plugin: ($plugin_name:expr), $command:expr) => {{
205        nu_with_plugins!(
206            cwd: $cwd,
207            envs: Vec::<(&str, &str)>::new(),
208            plugin: ($plugin_name),
209            $command
210        )
211    }};
212
213    (
214        cwd: $cwd:expr,
215        envs: $envs:expr,
216        plugins: [$(($plugin_name:expr)),*$(,)?],
217        $command:expr
218    ) => {{
219        $crate::macros::nu_with_plugin_run_test($cwd, $envs, &[$($plugin_name),*], $command)
220    }};
221    (cwd: $cwd:expr, envs: $envs:expr, plugin: ($plugin_name:expr), $command:expr) => {{
222        $crate::macros::nu_with_plugin_run_test($cwd, $envs, &[$plugin_name], $command)
223    }};
224
225}
226
227use crate::{NATIVE_PATH_ENV_VAR, Outcome};
228use nu_path::{AbsolutePath, AbsolutePathBuf, Path, PathBuf};
229use std::{
230    ffi::OsStr,
231    process::{Command, Stdio},
232};
233use tempfile::tempdir;
234
235#[derive(Default)]
236pub struct NuOpts {
237    pub cwd: Option<AbsolutePathBuf>,
238    pub locale: Option<String>,
239    pub envs: Option<Vec<(String, String)>>,
240    pub experimental: Option<Vec<String>>,
241    pub collapse_output: Option<bool>,
242    // Note: At the time this was added, passing in a file path was more convenient. However,
243    // passing in file contents seems like a better API - consider this when adding new uses of
244    // this field.
245    pub env_config: Option<PathBuf>,
246}
247
248pub fn nu_run_test(opts: NuOpts, commands: impl AsRef<str>, with_std: bool) -> Outcome {
249    let test_bins = crate::fs::binaries()
250        .canonicalize()
251        .expect("Could not canonicalize dummy binaries path");
252
253    let mut paths = crate::shell_os_paths();
254    paths.insert(0, test_bins.into());
255
256    let commands = commands.as_ref();
257
258    let paths_joined = match std::env::join_paths(paths) {
259        Ok(all) => all,
260        Err(_) => panic!("Couldn't join paths for PATH var."),
261    };
262
263    let target_cwd = opts.cwd.unwrap_or_else(crate::fs::root);
264    let locale = opts.locale.unwrap_or("en_US.UTF-8".to_string());
265    let executable_path = crate::fs::executable_path();
266
267    let mut command = setup_command(&executable_path, &target_cwd);
268    command
269        .env(nu_utils::locale::LOCALE_OVERRIDE_ENV_VAR, locale)
270        .env(NATIVE_PATH_ENV_VAR, paths_joined);
271
272    if let Some(envs) = opts.envs {
273        command.envs(envs);
274    }
275
276    match opts.env_config {
277        Some(path) => command.arg("--env-config").arg(path),
278        // TODO: This seems unnecessary: the code that runs for integration tests
279        // (run_commands) loads startup configs only if they are specified via flags explicitly or
280        // the shell is started as logging shell (which it is not in this case).
281        None => command.arg("--no-config-file"),
282    };
283
284    if let Some(experimental_opts) = opts.experimental {
285        let opts = format!("[{}]", experimental_opts.join(","));
286        command.arg(format!("--experimental-options={opts}"));
287    }
288
289    if !with_std {
290        command.arg("--no-std-lib");
291    }
292    // Use plain errors to help make error text matching more consistent
293    command.args(["--error-style", "plain", "--commands", commands]);
294    command.stdout(Stdio::piped()).stderr(Stdio::piped());
295
296    // Uncomment to debug the command being run:
297    // println!("=== command\n{command:#?}\n");
298
299    let process = match command.spawn() {
300        Ok(child) => child,
301        Err(why) => panic!("Can't run test {:?} {}", crate::fs::executable_path(), why),
302    };
303
304    let output = process
305        .wait_with_output()
306        .expect("couldn't read from stdout/stderr");
307
308    let out = String::from_utf8_lossy(&output.stdout);
309    let err = String::from_utf8_lossy(&output.stderr);
310
311    let out = if opts.collapse_output.unwrap_or(true) {
312        collapse_output(&out)
313    } else {
314        out.into_owned()
315    };
316
317    println!("=== stderr\n{err}");
318
319    Outcome::new(out, err.into_owned(), output.status)
320}
321
322pub fn nu_with_plugin_run_test<E, K, V>(
323    cwd: impl AsRef<Path>,
324    envs: E,
325    plugins: &[&str],
326    command: &str,
327) -> Outcome
328where
329    E: IntoIterator<Item = (K, V)>,
330    K: AsRef<OsStr>,
331    V: AsRef<OsStr>,
332{
333    let test_bins = crate::fs::binaries();
334    let test_bins = nu_path::canonicalize_with(&test_bins, ".").unwrap_or_else(|e| {
335        panic!(
336            "Couldn't canonicalize dummy binaries path {}: {:?}",
337            test_bins.display(),
338            e
339        )
340    });
341
342    let temp = tempdir().expect("couldn't create a temporary directory");
343    let [temp_config_file, temp_env_config_file] = ["config.nu", "env.nu"].map(|name| {
344        let temp_file = temp.path().join(name);
345        std::fs::File::create(&temp_file).expect("couldn't create temporary config file");
346        temp_file
347    });
348
349    // We don't have to write the plugin registry file, it's ok for it to not exist
350    let temp_plugin_file = temp.path().join("plugin.msgpackz");
351
352    crate::commands::ensure_plugins_built();
353
354    let plugin_paths: Vec<std::path::PathBuf> = plugins
355        .iter()
356        .map(|plugin_name| {
357            let plugin = with_exe(plugin_name);
358            nu_path::canonicalize_with(&plugin, &test_bins)
359                .unwrap_or_else(|_| panic!("failed to canonicalize plugin {} path", &plugin))
360        })
361        .collect();
362
363    let target_cwd = crate::fs::in_directory(&cwd);
364    // In plugin testing, we need to use installed nushell to drive
365    // plugin commands.
366    let mut executable_path = crate::fs::executable_path();
367    if !executable_path.exists() {
368        executable_path = crate::fs::installed_nu_path();
369    }
370
371    let mut cmd = setup_command(&executable_path, &target_cwd);
372    cmd.envs(envs)
373        .arg("--commands")
374        .arg(command)
375        // Use plain errors to help make error text matching more consistent
376        .args(["--error-style", "plain"])
377        .arg("--config")
378        .arg(temp_config_file)
379        .arg("--env-config")
380        .arg(temp_env_config_file)
381        .arg("--plugin-config")
382        .arg(temp_plugin_file);
383
384    // Add each plugin path as a separate argument after --plugins
385    if !plugin_paths.is_empty() {
386        cmd.arg("--plugins");
387        for path in &plugin_paths {
388            cmd.arg(path);
389        }
390    }
391
392    let process = match cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).spawn() {
393        Ok(child) => child,
394        Err(why) => panic!("Can't run test {why}"),
395    };
396
397    let output = process
398        .wait_with_output()
399        .expect("couldn't read from stdout/stderr");
400
401    let out = collapse_output(&String::from_utf8_lossy(&output.stdout));
402    let err = String::from_utf8_lossy(&output.stderr);
403
404    println!("=== stderr\n{err}");
405
406    Outcome::new(out, err.into_owned(), output.status)
407}
408
409fn with_exe(name: &str) -> String {
410    #[cfg(windows)]
411    {
412        name.to_string() + ".exe"
413    }
414    #[cfg(not(windows))]
415    {
416        name.to_string()
417    }
418}
419
420fn collapse_output(out: &str) -> String {
421    let out = out.lines().collect::<Vec<_>>().join("\n");
422    let out = out.replace("\r\n", "");
423    out.replace('\n', "")
424}
425
426fn setup_command(executable_path: &AbsolutePath, target_cwd: &AbsolutePath) -> Command {
427    let mut command = Command::new(executable_path);
428
429    command
430        .env_clear()
431        .current_dir(target_cwd)
432        .env_remove("FILE_PWD")
433        .env("PWD", target_cwd); // setting PWD is enough to set cwd;
434
435    // Need these extra environments from before the environment is cleared.
436    let envs: std::collections::HashMap<String, String> = std::env::vars()
437        .filter(|(n, _)| {
438            n.starts_with("System") // System variables for disks, paths, etc.
439                || n == "NUSHELL_CARGO_PROFILE" // Variable for crate::fs::binaries()
440                || n == "PATHEXT" // Needed for Windows translate `nu` to `.../nu.exe`
441                || n == "TMP"
442                || n == "TEMP"
443                || n == "USERPROFILE"
444                || n == "TMPDIR"
445                || n.starts_with("CARGO_")
446                || n.starts_with("RUSTUP_")
447        })
448        .collect();
449
450    #[cfg(windows)]
451    let mut envs = envs;
452
453    #[cfg(windows)]
454    if let Some(pathext) = envs.get_mut("PATHEXT")
455        && !pathext.to_uppercase().contains(".PS1")
456    {
457        pathext.push_str(";.PS1");
458    }
459
460    command.envs(envs);
461
462    command
463}