Skip to main content

nu_test_support/tester/
mod.rs

1use std::{
2    env,
3    error::Error,
4    fmt::{Debug, Display},
5    panic::Location,
6    path::PathBuf,
7    sync::{Arc, LazyLock},
8};
9
10use nu_protocol::{
11    CompileError, Config, FromValue, IntoValue, LabeledError, ParseError, PipelineData,
12    PipelineExecutionData, ShellError, Span, Value,
13    ast::Block,
14    debugger::WithoutDebug,
15    engine::{Command, EngineState, Stack, StateDelta, StateWorkingSet},
16    shell_error::{io::IoError, network::NetworkError},
17};
18use nu_utils::{consts::ENV_PATH_SEPARATOR_CHAR, sync::KeyedLazyLock};
19
20use crate::harness::group::GroupKey;
21
22static ROOT: LazyLock<PathBuf> = LazyLock::new(|| {
23    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
24        .join("../..")
25        .canonicalize()
26        .expect("could not canonicalize root")
27});
28
29// By using different engine states depending on the group key, we can ensure that behavior from
30// experimental options or environment variables take proper effect in the setup of an engine state.
31static INITIAL_ENGINE_STATES: KeyedLazyLock<GroupKey, EngineState> = KeyedLazyLock::new(|_| {
32    // Some modules below are commented out because they don't depend on nu-test-support
33    // Copied from `nu::command_context::add_command_context`
34    let engine_state = nu_cmd_lang::create_default_context();
35    // #[cfg(feature = "plugin")]
36    // let engine_state = nu_cmd_plugin::add_plugin_command_context(engine_state);
37    let engine_state = nu_command::add_shell_command_context(engine_state);
38    let engine_state = nu_cmd_extra::add_extra_command_context(engine_state);
39    #[cfg(feature = "os")]
40    let engine_state = nu_cli::add_cli_context(engine_state);
41    // let engine_state = nu_explore::add_explore_context(engine_state);
42
43    // Make `engine_state` mutable without fiddling with features
44    let mut engine_state = engine_state;
45
46    engine_state.generate_nu_constant();
47    [
48        ("PWD", Value::test_string(ROOT.to_string_lossy())),
49        ("config", Config::default().into_value(Span::unknown())),
50        ("NO_COLOR", Value::test_bool(true)),
51    ]
52    .into_iter()
53    .for_each(|(key, val)| engine_state.add_env_var(key.into(), val));
54
55    nu_std::load_standard_library(&mut engine_state).expect("could not load standard library");
56
57    engine_state
58});
59
60/// Create a [`NuTester`] for running Nushell snippets in tests.
61///
62/// Prefer this helper over the `nu!` macro for most tests.
63/// It runs snippets in-process instead of shelling out to a subprocess, which makes tests faster
64/// and lets you pass and read values directly without inferring from stdout or stderr.
65/// The `nu!` macro executes the `nu` binary, and changes in a single crate might not trigger a
66/// rebuild of that binary, so tests can run against stale behavior unless you run `cargo build`
67/// first.
68/// Using this helper avoids that by executing against the in-process engine components.
69///
70/// The tester starts from a default [`EngineState`] with the standard library loaded, and a fresh
71/// [`Stack`].
72/// Use the returned value to configure environment variables or the working directory before
73/// running code.
74///
75/// # Environment behavior
76///
77/// - This tester does not inherit process environment variables.
78/// - Any variables you want available to the engine must be added explicitly via
79///   [`NuTester::env`] (or convenience helpers like [`NuTester::locale`]).
80/// - Experimental options and other external environment settings are respected
81///   when constructing the underlying engine state for the current test group.
82///
83/// # Examples
84///
85/// ```rust
86/// use nu_test_support::prelude::*;
87///
88/// let code = "use std/util ellie; ellie | ansi strip";
89/// let value: String = test().run(code)?;
90/// assert_eq!(value, r#"
91///      __  ,
92///  .--()°'.'
93/// '|, . ,'
94///  !_-(_\
95/// "#.trim_matches('\n'));
96/// # Ok::<(), nu_test_support::tester::TestError>(())
97/// ```
98///
99/// ```rust
100/// use nu_test_support::prelude::*;
101///
102/// let mut tester = test()
103///     .env("FOO", "bar")
104///     .cwd("crates/nu-test-support");
105///
106/// let value: String = tester.run("$env.FOO")?;
107/// assert_eq!(value, "bar");
108/// # Ok::<(), nu_test_support::tester::TestError>(())
109/// ```
110pub fn test() -> NuTester {
111    NuTester::default()
112}
113
114/// Helper for running Nushell code in tests.
115///
116/// `NuTester` owns an [`EngineState`] and [`Stack`] that are reused across invocations.
117/// Configuration methods update the engine state before execution.
118#[derive(Clone)]
119#[non_exhaustive] // Ensure this type is only generated using `test()`, `new()` or `default()`.
120pub struct NuTester {
121    pub engine_state: EngineState,
122    pub stack: Stack,
123}
124
125impl Default for NuTester {
126    /// Create a default tester.
127    ///
128    /// Prefer [`test()`] for a shorter entry point that avoids naming [`NuTester`].
129    fn default() -> Self {
130        Self {
131            engine_state: INITIAL_ENGINE_STATES.get(&GroupKey::current()).clone(),
132            stack: Stack::new().collect_value(),
133        }
134    }
135}
136
137impl NuTester {
138    /// Create a default tester with the standard engine state.
139    ///
140    /// Prefer [`test()`] for a shorter entry point that avoids naming [`NuTester`].
141    pub fn new() -> Self {
142        Self::default()
143    }
144
145    /// Set the working directory used for evaluation.
146    ///
147    /// Relative paths are resolved from the repository root and canonicalized.
148    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
149        let cwd = cwd.into();
150
151        let cwd = match cwd.is_absolute() {
152            true => cwd,
153            false => ROOT
154                .join(cwd)
155                .canonicalize()
156                .expect("could not canonicalize path"),
157        };
158
159        self.engine_state
160            .add_env_var("PWD".into(), Value::test_string(cwd.to_string_lossy()));
161        self
162    }
163
164    /// Set the locale used by tests via `NU_TEST_LOCALE_OVERRIDE`.
165    pub fn locale(mut self, locale: impl Into<String>) -> Self {
166        self.engine_state.add_env_var(
167            "NU_TEST_LOCALE_OVERRIDE".into(),
168            Value::test_string(locale.into()),
169        );
170        self
171    }
172
173    /// Set the locale to `en_US.utf8`.
174    pub fn locale_en(self) -> Self {
175        self.locale("en_US.utf8")
176    }
177
178    /// Inherit the `PATH` environment variable from the running process.
179    ///
180    /// This is useful for tests that spawn external commands and should resolve
181    /// binaries the same way as the parent test process.
182    ///
183    /// Panics if `PATH` is not set in the current process environment.
184    pub fn inherit_path(self) -> Self {
185        let path = env::var("PATH").expect("PATH not available in env");
186        self.env("PATH", path)
187    }
188
189    /// Inherit an environment variable from the running process, but only if it is set.
190    ///
191    /// This is useful for optional variables whose absence should not cause a panic.
192    pub fn inherit_env_if_set(self, key: impl AsRef<str>) -> Self {
193        let key = key.as_ref();
194        match env::var(key) {
195            Ok(val) => self.env(key, val),
196            Err(_) => self,
197        }
198    }
199
200    /// Inherit Rust toolchain related environment variables from the running process,
201    /// but only when they are set.
202    ///
203    /// This helps tests that spawn `cargo`, `rustc`, or `rustup` behave more like
204    /// the parent process, especially when the active toolchain or install location
205    /// is configured through environment variables.
206    ///
207    /// The following variables are inherited when present:
208    /// - `PATH`
209    /// - `CARGO_HOME`
210    /// - `RUSTUP_HOME`
211    /// - `RUSTUP_TOOLCHAIN`
212    /// - `RUSTUP_DIST_SERVER`
213    /// - `RUSTUP_UPDATE_ROOT`
214    ///
215    /// Proxy variables are also inherited when present since `rustup` may need them
216    /// to download or resolve toolchain metadata:
217    /// - `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`
218    /// - `http_proxy`, `https_proxy`, `no_proxy`
219    ///
220    /// This does not guarantee identical behavior to an interactive shell since the
221    /// current working directory can still affect rustup toolchain resolution.
222    pub fn inherit_rust_toolchain_env(self) -> Self {
223        self.inherit_env_if_set("PATH")
224            .inherit_env_if_set("CARGO_HOME")
225            .inherit_env_if_set("RUSTUP_HOME")
226            .inherit_env_if_set("RUSTUP_TOOLCHAIN")
227            .inherit_env_if_set("RUSTUP_DIST_SERVER")
228            .inherit_env_if_set("RUSTUP_UPDATE_ROOT")
229            .inherit_env_if_set("HTTP_PROXY")
230            .inherit_env_if_set("HTTPS_PROXY")
231            .inherit_env_if_set("NO_PROXY")
232            .inherit_env_if_set("http_proxy")
233            .inherit_env_if_set("https_proxy")
234            .inherit_env_if_set("no_proxy")
235    }
236
237    /// Adds the "nu" binary for testing to the path.
238    ///
239    /// Calling [`inherit_path`](Self::inherit_path) after this methods removes the path entry.
240    pub fn add_nu_to_path(self) -> Self {
241        let nu_home = crate::fs::binaries();
242        let path = self.engine_state.get_env_var("PATH");
243        let path = match path {
244            None => nu_home.display().to_string(),
245            Some(path) => format!(
246                "{nu}{sep}{prev}",
247                nu = nu_home.display(),
248                sep = ENV_PATH_SEPARATOR_CHAR,
249                prev = path.as_str().expect("PATH should always be a string")
250            ),
251        };
252        self.env("PATH", path)
253    }
254
255    /// Add a custom environment variable to the engine state.
256    pub fn env(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
257        self.engine_state
258            .add_env_var(key.into(), Value::test_string(val.into()));
259        self
260    }
261
262    /// Run Nushell code and extract the value into `T`.
263    ///
264    /// Parsing, compilation, or evaluation failures are returned as [`TestError`].
265    #[track_caller]
266    pub fn run<T: FromValue>(&mut self, code: impl AsRef<str>) -> Result<T> {
267        Self::extract_value(self.run_raw(code)?)
268    }
269
270    /// Run Nushell code with input data and extract the value into `T`.
271    ///
272    /// The input value is converted into `PipelineData` using [`IntoValue`].
273    #[track_caller]
274    pub fn run_with_data<T: FromValue>(
275        &mut self,
276        code: impl AsRef<str>,
277        data: impl IntoValue,
278    ) -> Result<T> {
279        let input = PipelineData::value(data.into_value(Span::test_data()), None);
280        Self::extract_value(self.run_raw_with_data(code, input)?)
281    }
282
283    /// Run Nushell code and return the raw [`PipelineExecutionData`].
284    #[track_caller]
285    pub fn run_raw(&mut self, code: impl AsRef<str>) -> Result<PipelineExecutionData> {
286        self.run_raw_with_data(code, PipelineData::empty())
287    }
288
289    /// Run Nushell code with input data and return the raw execution results.
290    ///
291    /// This parses, compiles, and evaluates the code against the current engine state.
292    #[track_caller]
293    pub fn run_raw_with_data(
294        &mut self,
295        code: impl AsRef<str>,
296        data: PipelineData,
297    ) -> Result<PipelineExecutionData> {
298        let location = TestLocation(Location::caller());
299        let (delta, block) = self.parse_and_compile(code)?;
300        self.engine_state.merge_delta(delta)?;
301        nu_engine::eval_block::<WithoutDebug>(&self.engine_state, &mut self.stack, &block, data)
302            .map_err(|err| TestError {
303                location,
304                kind: TestErrorKind::Shell(err),
305            })
306    }
307
308    #[track_caller]
309    pub fn parse_and_compile(&self, code: impl AsRef<str>) -> Result<(StateDelta, Arc<Block>)> {
310        let location = TestLocation(Location::caller());
311        let code = code.as_ref().as_bytes();
312
313        let mut working_set = StateWorkingSet::new(&self.engine_state);
314        let block = nu_parser::parse(&mut working_set, None, code, false);
315
316        if let Some(err) = working_set.parse_errors.into_iter().next() {
317            return Err(TestError {
318                location,
319                kind: TestErrorKind::Parse(err),
320            });
321        }
322
323        if let Some(err) = working_set.compile_errors.into_iter().next() {
324            return Err(TestError {
325                location,
326                kind: TestErrorKind::Compile(err),
327            });
328        }
329
330        Ok((working_set.delta, block))
331    }
332
333    #[track_caller]
334    fn extract_value<T: FromValue>(
335        pipeline_execution_data: PipelineExecutionData,
336    ) -> Result<T, TestError> {
337        let pipeline_data = pipeline_execution_data.body;
338        let value = pipeline_data.into_value(Span::test_data())?;
339        let value = T::from_value(value)?;
340        Ok(value)
341    }
342
343    /// Test examples of a command.
344    #[track_caller]
345    pub fn examples(&self, command: impl Command + 'static) -> Result {
346        let location = TestLocation(Location::caller());
347        for example in command.examples() {
348            match example.result {
349                None => self
350                    .parse_and_compile(example.example)
351                    .map(|_| ())
352                    .map_err(|err| TestError {
353                        location,
354                        kind: TestErrorKind::ExampleFailed {
355                            command: command.name().to_string(),
356                            description: example.description.to_string(),
357                            code: example.example.to_string(),
358                            err: Box::new(err.kind),
359                        },
360                    })?,
361                Some(expected) => {
362                    let got = self.clone().run(example.example)?;
363                    if got != expected {
364                        return Err(TestError {
365                            location,
366                            kind: TestErrorKind::ExampleFailed {
367                                command: command.name().to_string(),
368                                description: example.description.to_string(),
369                                code: example.example.to_string(),
370                                err: Box::new(TestErrorKind::UnexpectedValue { expected, got }),
371                            },
372                        });
373                    }
374                }
375            }
376        }
377
378        Ok(())
379    }
380}
381
382#[derive(Debug, Clone, PartialEq)]
383pub struct TestError {
384    location: TestLocation,
385    kind: TestErrorKind,
386}
387
388#[derive(Clone, Copy, PartialEq, derive_more::Debug)]
389#[debug("{_0}")]
390pub struct TestLocation(&'static Location<'static>);
391
392/// Errors emitted by `NuTester` when parsing, compiling, or evaluating code.
393///
394/// This enum is marked as non-exhaustive to allow adding new variants.
395#[non_exhaustive]
396#[derive(Debug, Clone, PartialEq)]
397pub enum TestErrorKind {
398    Parse(ParseError),
399    Compile(CompileError),
400    Shell(ShellError),
401    GotValue {
402        got: Value,
403    },
404    NoInner,
405    UnexpectedErrorKind {
406        expected: &'static str,
407        got: ShellError,
408    },
409    UnexpectedValue {
410        expected: Value,
411        got: Value,
412    },
413    ExampleFailed {
414        command: String,
415        description: String,
416        code: String,
417        err: Box<TestErrorKind>,
418    },
419}
420
421impl Display for TestError {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        write!(f, "{self:#?}")
424    }
425}
426
427impl Error for TestError {}
428
429impl From<ShellError> for TestError {
430    #[track_caller]
431    fn from(err: ShellError) -> Self {
432        Self {
433            location: TestLocation(Location::caller()),
434            kind: TestErrorKind::Shell(err),
435        }
436    }
437}
438
439impl From<ParseError> for TestError {
440    #[track_caller]
441    fn from(err: ParseError) -> Self {
442        Self {
443            location: TestLocation(Location::caller()),
444            kind: TestErrorKind::Parse(err),
445        }
446    }
447}
448
449impl TestError {
450    /// Convert this error into a [`ParseError`], if it is one.
451    pub fn parse(self) -> Result<ParseError, TestError> {
452        match self.kind {
453            TestErrorKind::Parse(err) => Ok(err),
454            _ => Err(self),
455        }
456    }
457
458    /// Convert this error into a [`CompileError`], if it is one.
459    pub fn compile(self) -> Result<CompileError, TestError> {
460        match self.kind {
461            TestErrorKind::Compile(err) => Ok(err),
462            _ => Err(self),
463        }
464    }
465
466    /// Convert this error into a [`ShellError`], if it is one.
467    pub fn shell(self) -> Result<ShellError, TestError> {
468        match self.kind {
469            TestErrorKind::Shell(err) => Ok(err),
470            _ => Err(self),
471        }
472    }
473
474    /// Update it's inner location with the call site of this function.
475    #[track_caller]
476    pub fn update_location(self) -> Self {
477        Self {
478            location: TestLocation(Location::caller()),
479            ..self
480        }
481    }
482}
483
484/// Convenience result type for test helpers.
485pub type Result<T = (), E = TestError> = std::result::Result<T, E>;
486
487/// Extensions for asserting error kinds from test helpers.
488pub trait TestResultExt: Sized {
489    /// Expect the result to be a `Value` equal to the provided input.
490    fn expect_value_eq<T: IntoValue>(self, value: T) -> Result;
491
492    /// Expect the result to be a [`ShellError`].
493    fn expect_shell_error(self) -> Result<ShellError>;
494    /// Expect the result to be a [`ParseError`].
495    fn expect_parse_error(self) -> Result<ParseError>;
496    /// Expect the result to be a [`CompileError`].
497    fn expect_compile_error(self) -> Result<CompileError>;
498
499    /// Expect the result to be a [`ShellError::Io`].
500    fn expect_io_error(self) -> Result<IoError>;
501    /// Expect the result to be a [`ShellError::Network`].
502    fn expect_network_error(self) -> Result<NetworkError>;
503    /// Expect the result to be a [`ShellError::LabeledError`].
504    fn expect_labeled_error(self) -> Result<LabeledError>;
505
506    /// Expect the result to be a [`ShellError`].
507    #[track_caller]
508    fn expect_error(self) -> Result<ShellError> {
509        self.expect_shell_error()
510    }
511}
512
513impl TestResultExt for Result<Value> {
514    #[track_caller]
515    fn expect_value_eq<T: IntoValue>(self, expected: T) -> Result {
516        let expected = expected.into_value(Span::test_data());
517        match self {
518            Err(err) => Err(err.update_location()),
519            Ok(actual) if actual == expected => Ok(()),
520            Ok(actual) => Err(TestError {
521                location: TestLocation(Location::caller()),
522                kind: TestErrorKind::UnexpectedValue {
523                    expected,
524                    got: actual,
525                },
526            }),
527        }
528    }
529
530    #[track_caller]
531    fn expect_shell_error(self) -> Result<ShellError> {
532        match self {
533            Ok(got) => Err(TestError {
534                location: TestLocation(Location::caller()),
535                kind: TestErrorKind::GotValue { got },
536            }),
537            Err(TestError {
538                kind: TestErrorKind::Shell(err),
539                ..
540            }) => Ok(err),
541            Err(err) => Err(err.update_location()),
542        }
543    }
544
545    #[track_caller]
546    fn expect_parse_error(self) -> Result<ParseError> {
547        match self {
548            Ok(got) => Err(TestError {
549                location: TestLocation(Location::caller()),
550                kind: TestErrorKind::GotValue { got },
551            }),
552            Err(TestError {
553                kind: TestErrorKind::Parse(err),
554                ..
555            }) => Ok(err),
556            Err(err) => Err(err.update_location()),
557        }
558    }
559
560    #[track_caller]
561    fn expect_compile_error(self) -> Result<CompileError> {
562        match self {
563            Ok(got) => Err(TestError {
564                location: TestLocation(Location::caller()),
565                kind: TestErrorKind::GotValue { got },
566            }),
567            Err(TestError {
568                kind: TestErrorKind::Compile(err),
569                ..
570            }) => Ok(err),
571            Err(err) => Err(err.update_location()),
572        }
573    }
574
575    #[track_caller]
576    fn expect_io_error(self) -> Result<IoError> {
577        match self {
578            Ok(got) => Err(TestError {
579                location: TestLocation(Location::caller()),
580                kind: TestErrorKind::GotValue { got },
581            }),
582            Err(TestError {
583                kind: TestErrorKind::Shell(ShellError::Io(err)),
584                ..
585            }) => Ok(err),
586            Err(err) => Err(err.update_location()),
587        }
588    }
589
590    #[track_caller]
591    fn expect_network_error(self) -> Result<NetworkError> {
592        match self {
593            Ok(got) => Err(TestError {
594                location: TestLocation(Location::caller()),
595                kind: TestErrorKind::GotValue { got },
596            }),
597            Err(TestError {
598                kind: TestErrorKind::Shell(ShellError::Network(err)),
599                ..
600            }) => Ok(err),
601            Err(err) => Err(err.update_location()),
602        }
603    }
604
605    #[track_caller]
606    fn expect_labeled_error(self) -> Result<LabeledError> {
607        match self {
608            Ok(got) => Err(TestError {
609                location: TestLocation(Location::caller()),
610                kind: TestErrorKind::GotValue { got },
611            }),
612            Err(TestError {
613                kind: TestErrorKind::Shell(ShellError::LabeledError(err)),
614                ..
615            }) => Ok(*err),
616            Err(err) => Err(err.update_location()),
617        }
618    }
619}
620
621/// Extensions for interrogating [`ShellError`] values in tests.
622pub trait ShellErrorExt {
623    /// Tries to convert into an inner value from a [`ShellError`].
624    ///
625    /// Useful if the error is expected to be a generic error that contains an inner error or a
626    /// chained error that chained another error.
627    ///
628    /// However, this function returns [`None`]
629    /// - if `inner` of [`ShellError::Generic`] is empty
630    /// - if `sources` of [`ShellError::ChainedError`] is empty
631    /// - the error is none of the above types
632    ///
633    /// So make sure that a [`None`] value is not surprise.
634    fn into_inner(self) -> Result<ShellError>;
635
636    /// Extract the [`LabeledError`] from [`ShellError::LabeledError`], if it is one.
637    fn into_labeled(self) -> Result<LabeledError>;
638
639    /// Extract the error field from [`ShellError::Generic`], if it is one.
640    fn generic_error(self) -> Result<String>;
641
642    /// Extract the message field from [`ShellError::Generic`], if it is one.
643    fn generic_msg(self) -> Result<String>;
644}
645
646impl ShellErrorExt for ShellError {
647    #[track_caller]
648    fn into_inner(self) -> Result<ShellError> {
649        let no_inner = TestError {
650            location: TestLocation(Location::caller()),
651            kind: TestErrorKind::NoInner,
652        };
653        match self {
654            ShellError::Generic(err) => err.inner.into_iter().next().ok_or(no_inner),
655            ShellError::ChainedError(err) => err.sources_iter().next().ok_or(no_inner),
656            _ => Err(no_inner),
657        }
658    }
659
660    #[track_caller]
661    fn into_labeled(self) -> Result<LabeledError> {
662        match self {
663            ShellError::LabeledError(err) => Ok(*err),
664            got => Err(TestError {
665                location: TestLocation(Location::caller()),
666                kind: TestErrorKind::UnexpectedErrorKind {
667                    expected: "Labeled",
668                    got,
669                },
670            }),
671        }
672    }
673
674    #[track_caller]
675    fn generic_error(self) -> Result<String> {
676        match self {
677            ShellError::Generic(err) => Ok(err.error.into_owned()),
678            got => Err(TestError {
679                location: TestLocation(Location::caller()),
680                kind: TestErrorKind::UnexpectedErrorKind {
681                    expected: "Generic",
682                    got,
683                },
684            }),
685        }
686    }
687
688    #[track_caller]
689    fn generic_msg(self) -> Result<String> {
690        match self {
691            ShellError::Generic(err) => Ok(err.msg.into_owned()),
692            got => Err(TestError {
693                location: TestLocation(Location::caller()),
694                kind: TestErrorKind::UnexpectedErrorKind {
695                    expected: "Generic",
696                    got,
697                },
698            }),
699        }
700    }
701}