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 nu_utils::escape_quote_string;
230use std::{
231    ffi::OsStr,
232    process::{Command, Stdio},
233};
234use tempfile::tempdir;
235
236#[derive(Default)]
237pub struct NuOpts {
238    pub cwd: Option<AbsolutePathBuf>,
239    pub locale: Option<String>,
240    pub envs: Option<Vec<(String, String)>>,
241    pub experimental: Option<Vec<String>>,
242    pub collapse_output: Option<bool>,
243    // Note: At the time this was added, passing in a file path was more convenient. However,
244    // passing in file contents seems like a better API - consider this when adding new uses of
245    // this field.
246    pub env_config: Option<PathBuf>,
247}
248
249pub fn nu_run_test(opts: NuOpts, commands: impl AsRef<str>, with_std: bool) -> Outcome {
250    let test_bins = crate::fs::binaries()
251        .canonicalize()
252        .expect("Could not canonicalize dummy binaries path");
253
254    let mut paths = crate::shell_os_paths();
255    paths.insert(0, test_bins.into());
256
257    let commands = commands.as_ref();
258
259    let paths_joined = match std::env::join_paths(paths) {
260        Ok(all) => all,
261        Err(_) => panic!("Couldn't join paths for PATH var."),
262    };
263
264    let target_cwd = opts.cwd.unwrap_or_else(crate::fs::root);
265    let locale = opts.locale.unwrap_or("en_US.UTF-8".to_string());
266    let executable_path = crate::fs::executable_path();
267
268    let mut command = setup_command(&executable_path, &target_cwd);
269    command
270        .env(nu_utils::locale::LOCALE_OVERRIDE_ENV_VAR, locale)
271        .env(NATIVE_PATH_ENV_VAR, paths_joined);
272
273    if let Some(envs) = opts.envs {
274        command.envs(envs);
275    }
276
277    match opts.env_config {
278        Some(path) => command.arg("--env-config").arg(path),
279        // TODO: This seems unnecessary: the code that runs for integration tests
280        // (run_commands) loads startup configs only if they are specified via flags explicitly or
281        // the shell is started as logging shell (which it is not in this case).
282        None => command.arg("--no-config-file"),
283    };
284
285    if let Some(experimental_opts) = opts.experimental {
286        let opts = format!("[{}]", experimental_opts.join(","));
287        command.arg(format!("--experimental-options={opts}"));
288    }
289
290    if !with_std {
291        command.arg("--no-std-lib");
292    }
293    // Use plain errors to help make error text matching more consistent
294    command.args(["--error-style", "plain", "--commands", commands]);
295    command.stdout(Stdio::piped()).stderr(Stdio::piped());
296
297    // Uncomment to debug the command being run:
298    // println!("=== command\n{command:#?}\n");
299
300    let process = match command.spawn() {
301        Ok(child) => child,
302        Err(why) => panic!("Can't run test {:?} {}", crate::fs::executable_path(), why),
303    };
304
305    let output = process
306        .wait_with_output()
307        .expect("couldn't read from stdout/stderr");
308
309    let out = String::from_utf8_lossy(&output.stdout);
310    let err = String::from_utf8_lossy(&output.stderr);
311
312    let out = if opts.collapse_output.unwrap_or(true) {
313        collapse_output(&out)
314    } else {
315        out.into_owned()
316    };
317
318    println!("=== stderr\n{err}");
319
320    Outcome::new(out, err.into_owned(), output.status)
321}
322
323pub fn nu_with_plugin_run_test<E, K, V>(
324    cwd: impl AsRef<Path>,
325    envs: E,
326    plugins: &[&str],
327    command: &str,
328) -> Outcome
329where
330    E: IntoIterator<Item = (K, V)>,
331    K: AsRef<OsStr>,
332    V: AsRef<OsStr>,
333{
334    let test_bins = crate::fs::binaries();
335    let test_bins = nu_path::canonicalize_with(&test_bins, ".").unwrap_or_else(|e| {
336        panic!(
337            "Couldn't canonicalize dummy binaries path {}: {:?}",
338            test_bins.display(),
339            e
340        )
341    });
342
343    let temp = tempdir().expect("couldn't create a temporary directory");
344    let [temp_config_file, temp_env_config_file] = ["config.nu", "env.nu"].map(|name| {
345        let temp_file = temp.path().join(name);
346        std::fs::File::create(&temp_file).expect("couldn't create temporary config file");
347        temp_file
348    });
349
350    // We don't have to write the plugin registry file, it's ok for it to not exist
351    let temp_plugin_file = temp.path().join("plugin.msgpackz");
352
353    crate::commands::ensure_plugins_built();
354
355    let plugin_paths_quoted: Vec<String> = plugins
356        .iter()
357        .map(|plugin_name| {
358            let plugin = with_exe(plugin_name);
359            let plugin_path = nu_path::canonicalize_with(&plugin, &test_bins)
360                .unwrap_or_else(|_| panic!("failed to canonicalize plugin {} path", &plugin));
361            let plugin_path = plugin_path.to_string_lossy();
362            escape_quote_string(&plugin_path)
363        })
364        .collect();
365    let plugins_arg = format!("[{}]", plugin_paths_quoted.join(","));
366
367    let target_cwd = crate::fs::in_directory(&cwd);
368    // In plugin testing, we need to use installed nushell to drive
369    // plugin commands.
370    let mut executable_path = crate::fs::executable_path();
371    if !executable_path.exists() {
372        executable_path = crate::fs::installed_nu_path();
373    }
374
375    let process = match setup_command(&executable_path, &target_cwd)
376        .envs(envs)
377        .arg("--commands")
378        .arg(command)
379        // Use plain errors to help make error text matching more consistent
380        .args(["--error-style", "plain"])
381        .arg("--config")
382        .arg(temp_config_file)
383        .arg("--env-config")
384        .arg(temp_env_config_file)
385        .arg("--plugin-config")
386        .arg(temp_plugin_file)
387        .arg("--plugins")
388        .arg(plugins_arg)
389        .stdout(Stdio::piped())
390        .stderr(Stdio::piped())
391        .spawn()
392    {
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        .current_dir(target_cwd)
431        .env_remove("FILE_PWD")
432        .env("PWD", target_cwd); // setting PWD is enough to set cwd;
433
434    command
435}