Enable nushell error with backtrace (#14945)

# Description
After this pr, nushell is able to raise errors with a backtrace, which
should make users easier to debug. To enable the feature, users need to
set env variable via `$env.NU_BACKTRACE = 1`. But yeah it might not work
perfectly, there are some corner cases which might not be handled.

I think it should close #13379 in another way.

### About the change

The implementation mostly contained with 2 parts:
1. introduce a new `ChainedError` struct as well as a new
`ShellError::ChainedError` variant. If `eval_instruction` returned an
error, it converts the error to `ShellError::ChainedError`.
`ChainedError` struct is responsable to display errors properly. It
needs to handle the following 2 cases:
- if we run a function which runs `error make` internally, it needs to
display the error itself along with caller span.
- if we run a `error make` directly, or some commands directly returns
an error, we just want nushell raise an error about `error make`.

2. Attach caller spans to `ListStream` and `ByteStream`, because they
are lazy streams, and *only* contains the span that runs it
directly(like `^false`, for example), so nushell needs to add all caller
spans to the stream.
For example: in `def a [] { ^false }; def b [] { a; 33 }; b`, when we
run `b`, which runs `a`, which runs `^false`, the `ByteStream` only
contains the span of `^false`, we need to make it contains the span of
`a`, so nushell is able to get all spans if something bad happened.
This behavior is happened after running `Instruction::Call`, if it
returns a `ByteStream` and `ListStream`, it will call `push_caller_span`
method to attach call spans.

# User-Facing Changes
It's better to demostrate how it works by examples, given the following
definition:
```nushell
> $env.NU_BACKTRACE = 1
> def a [x] { if $x == 3 { error make {msg: 'a custom error'}}}
> def a_2 [x] { if $x == 3 { ^false } else { $x } }
> def a_3 [x] { if $x == 3 { [1 2 3] | each {error make {msg: 'a custom error inside list stream'} } } }
> def b [--list-stream --external] {
    if $external == true {
        # error with non-zero exit code, which is generated from external command.
        a_2 1; a_2 3; a_2 2
    } else if $list_stream == true {
        # error generated by list-stream
        a_3 1; a_3 3; a_3 2
    } else {
        # error generated by command directly
        a 1; a 2; a 3
    }
}
```

Run `b` directly shows the following error:

<details>

```nushell
Error: chained_error

  × oops
   ╭─[entry #27:1:1]
 1 │ b
   · ┬
   · ╰── error happened when running this
   ╰────

Error: chained_error

  × oops
    ╭─[entry #26:10:19]
  9 │         # error generated by command directly
 10 │         a 1; a 2; a 3
    ·                   ┬
    ·                   ╰── error happened when running this
 11 │     }
    ╰────

Error:
  × a custom error
   ╭─[entry #6:1:26]
 1 │ def a [x] { if $x == 3 { error make {msg: 'a custom error'}}}
   ·                          ─────┬────
   ·                               ╰── originates from here
   ╰────
```

</details>

Run `b --list-stream` shows the following error

<details>

```nushell
Error: chained_error

  × oops
   ╭─[entry #28:1:1]
 1 │ b --list-stream
   · ┬
   · ╰── error happened when running this
   ╰────

Error: nu:🐚:eval_block_with_input

  × Eval block failed with pipeline input
   ╭─[entry #26:7:16]
 6 │         # error generated by list-stream
 7 │         a_3 1; a_3 3; a_3 2
   ·                ─┬─
   ·                 ╰── source value
 8 │     } else {
   ╰────

Error: nu:🐚:eval_block_with_input

  × Eval block failed with pipeline input
   ╭─[entry #23:1:29]
 1 │ def a_3 [x] { if $x == 3 { [1 2 3] | each {error make {msg: 'a custom error inside list stream'} } } }
   ·                             ┬
   ·                             ╰── source value
   ╰────

Error:
  × a custom error inside list stream
   ╭─[entry #23:1:44]
 1 │ def a_3 [x] { if $x == 3 { [1 2 3] | each {error make {msg: 'a custom error inside list stream'} } } }
   ·                                            ─────┬────
   ·                                                 ╰── originates from here
   ╰────
```

</details>

Run `b --external` shows the following error:

<details>

```nushell
Error: chained_error

  × oops
   ╭─[entry #29:1:1]
 1 │ b --external
   · ┬
   · ╰── error happened when running this
   ╰────

Error: nu:🐚:eval_block_with_input

  × Eval block failed with pipeline input
   ╭─[entry #26:4:16]
 3 │         # error with non-zero exit code, which is generated from external command.
 4 │         a_2 1; a_2 3; a_2 2
   ·                ─┬─
   ·                 ╰── source value
 5 │     } else if $list_stream == true {
   ╰────

Error: nu:🐚:non_zero_exit_code

  × External command had a non-zero exit code
   ╭─[entry #7:1:29]
 1 │ def a_2 [x] { if $x == 3 { ^false } else { $x } }
   ·                             ──┬──
   ·                               ╰── exited with code 1
   ╰────
```

</details>

It also added a message to guide the usage of NU_BACKTRACE, see the last
line in the following example:
```shell
 ls asdfasd
Error: nu:🐚:io::not_found

  × I/O error
  ╰─▶   × Entity not found

   ╭─[entry #17:1:4]
 1 │ ls asdfasd
   ·    ───┬───
   ·       ╰── Entity not found
   ╰────
  help: The error occurred at '/home/windsoilder/projects/nushell/asdfasd'

set the `NU_BACKTRACE=1` environment variable to display a backtrace.
```
# Tests + Formatting
Added some tests for the behavior.

# After Submitting
This commit is contained in:
Wind 2025-02-06 22:05:58 +08:00 committed by GitHub
parent bdc767bf23
commit 2f18b9c856
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 326 additions and 8 deletions

View File

@ -187,6 +187,7 @@ fn eval_ir_block_impl<D: DebugContext>(
// Program counter, starts at zero.
let mut pc = 0;
let need_backtrace = ctx.engine_state.get_env_var("NU_BACKTRACE").is_some();
while pc < ir_block.instructions.len() {
let instruction = &ir_block.instructions[pc];
@ -195,7 +196,7 @@ fn eval_ir_block_impl<D: DebugContext>(
D::enter_instruction(ctx.engine_state, ir_block, pc, ctx.registers);
let result = eval_instruction::<D>(ctx, instruction, span, ast);
let result = eval_instruction::<D>(ctx, instruction, span, ast, need_backtrace);
D::leave_instruction(
ctx.engine_state,
@ -228,8 +229,10 @@ fn eval_ir_block_impl<D: DebugContext>(
// If an error handler is set, branch there
prepare_error_handler(ctx, error_handler, Some(err.into_spanned(*span)));
pc = error_handler.handler_index;
} else if need_backtrace {
let err = ShellError::into_chainned(err, *span);
return Err(err);
} else {
// If not, exit the block with the error
return Err(err);
}
}
@ -285,6 +288,7 @@ fn eval_instruction<D: DebugContext>(
instruction: &Instruction,
span: &Span,
ast: &Option<IrAstRef>,
need_backtrace: bool,
) -> Result<InstructionResult, ShellError> {
use self::InstructionResult::*;
@ -548,7 +552,14 @@ fn eval_instruction<D: DebugContext>(
}
Instruction::Call { decl_id, src_dst } => {
let input = ctx.take_reg(*src_dst);
let result = eval_call::<D>(ctx, *decl_id, *span, input)?;
let mut result = eval_call::<D>(ctx, *decl_id, *span, input)?;
if need_backtrace {
match &mut result {
PipelineData::ByteStream(s, ..) => s.push_caller_span(*span),
PipelineData::ListStream(s, ..) => s.push_caller_span(*span),
_ => (),
};
}
ctx.put_reg(*src_dst, result);
Ok(Continue)
}
@ -1457,14 +1468,40 @@ fn drain(ctx: &mut EvalContext<'_>, data: PipelineData) -> Result<InstructionRes
match data {
PipelineData::ByteStream(stream, ..) => {
let span = stream.span();
if let Err(err) = stream.drain() {
let callback_spans = stream.get_caller_spans().clone();
if let Err(mut err) = stream.drain() {
ctx.stack.set_last_error(&err);
return Err(err);
if callback_spans.is_empty() {
return Err(err);
} else {
for s in callback_spans {
err = ShellError::EvalBlockWithInput {
span: s,
sources: vec![err],
}
}
return Err(err);
}
} else {
ctx.stack.set_last_exit_code(0, span);
}
}
PipelineData::ListStream(stream, ..) => stream.drain()?,
PipelineData::ListStream(stream, ..) => {
let callback_spans = stream.get_caller_spans().clone();
if let Err(mut err) = stream.drain() {
if callback_spans.is_empty() {
return Err(err);
} else {
for s in callback_spans {
err = ShellError::EvalBlockWithInput {
span: s,
sources: vec![err],
}
}
return Err(err);
}
}
}
PipelineData::Value(..) | PipelineData::Empty => {}
}
Ok(Continue)

View File

@ -0,0 +1,120 @@
use super::shell_error::ShellError;
use crate::Span;
use miette::{LabeledSpan, Severity, SourceCode};
use thiserror::Error;
/// An error struct that contains source errors.
///
/// However, it's a bit special; if the error is constructed for the first time using
/// [`ChainedError::new`], it will behave the same as the single source error.
///
/// If it's constructed nestedly using [`ChainedError::new_chained`], it will treat all underlying errors as related.
///
/// For a usage example, please check [`ShellError::into_chainned`].
#[derive(Debug, Clone, PartialEq, Error)]
pub struct ChainedError {
first: bool,
pub(crate) sources: Vec<ShellError>,
span: Span,
}
impl std::fmt::Display for ChainedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.first {
write!(f, "{}", self.sources[0])
} else {
write!(f, "oops")
}
}
}
impl ChainedError {
pub fn new(source: ShellError, span: Span) -> Self {
Self {
first: true,
sources: vec![source],
span,
}
}
pub fn new_chained(sources: Self, span: Span) -> Self {
Self {
first: false,
sources: vec![ShellError::ChainedError(sources)],
span,
}
}
}
impl miette::Diagnostic for ChainedError {
fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn miette::Diagnostic> + 'a>> {
if self.first {
self.sources[0].related()
} else {
Some(Box::new(self.sources.iter().map(|s| s as _)))
}
}
fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
if self.first {
self.sources[0].code()
} else {
Some(Box::new("chained_error"))
}
}
fn severity(&self) -> Option<Severity> {
if self.first {
self.sources[0].severity()
} else {
None
}
}
fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
if self.first {
self.sources[0].help()
} else {
None
}
}
fn url<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
if self.first {
self.sources[0].url()
} else {
None
}
}
fn labels<'a>(&'a self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + 'a>> {
if self.first {
self.sources[0].labels()
} else {
Some(Box::new(
vec![LabeledSpan::new_with_span(
Some("error happened when running this".to_string()),
self.span,
)]
.into_iter(),
))
}
}
// Finally, we redirect the source_code method to our own source.
fn source_code(&self) -> Option<&dyn SourceCode> {
if self.first {
self.sources[0].source_code()
} else {
None
}
}
fn diagnostic_source(&self) -> Option<&dyn miette::Diagnostic> {
if self.first {
self.sources[0].diagnostic_source()
} else {
None
}
}
}

View File

@ -50,6 +50,10 @@ pub fn report_compile_error(working_set: &StateWorkingSet, error: &CompileError)
fn report_error(working_set: &StateWorkingSet, error: &dyn miette::Diagnostic) {
eprintln!("Error: {:?}", CliError(error, working_set));
let have_no_backtrace = working_set.get_env_var("NU_BACKTRACE").is_none();
if have_no_backtrace {
eprintln!("set the `NU_BACKTRACE=1` environment variable to display a backtrace.")
}
// reset vt processing, aka ansi because illbehaved externals can break it
#[cfg(windows)]
{

View File

@ -1,3 +1,4 @@
mod chained_error;
pub mod cli_error;
mod compile_error;
mod config_error;

View File

@ -1,3 +1,4 @@
use super::chained_error::ChainedError;
use crate::{
ast::Operator, engine::StateWorkingSet, format_shell_error, record, ConfigError, LabeledError,
ParseError, Span, Spanned, Type, Value,
@ -1325,6 +1326,10 @@ On Windows, this would be %USERPROFILE%\AppData\Roaming"#
#[label = "while running this code"]
span: Option<Span>,
},
#[error(transparent)]
#[diagnostic(transparent)]
ChainedError(ChainedError),
}
impl ShellError {
@ -1373,6 +1378,16 @@ impl ShellError {
span,
)
}
/// Convert self error to a [`ShellError::ChainedError`] variant.
pub fn into_chainned(self, span: Span) -> Self {
match self {
ShellError::ChainedError(inner) => {
ShellError::ChainedError(ChainedError::new_chained(inner, span))
}
other => ShellError::ChainedError(ChainedError::new(other, span)),
}
}
}
impl From<Box<dyn std::error::Error>> for ShellError {

View File

@ -193,6 +193,7 @@ pub struct ByteStream {
signals: Signals,
type_: ByteStreamType,
known_size: Option<u64>,
caller_spans: Vec<Span>,
}
impl ByteStream {
@ -209,9 +210,22 @@ impl ByteStream {
signals,
type_,
known_size: None,
caller_spans: vec![],
}
}
/// Push a caller [`Span`] to the bytestream, it's useful to construct a backtrace.
pub fn push_caller_span(&mut self, span: Span) {
if span != self.span {
self.caller_spans.push(span)
}
}
/// Get all caller [`Span`], it's useful to construct a backtrace.
pub fn get_caller_spans(&self) -> &Vec<Span> {
&self.caller_spans
}
/// Create a [`ByteStream`] from an arbitrary reader. The type must be provided.
pub fn read(
reader: impl Read + Send + 'static,

View File

@ -15,6 +15,7 @@ pub type ValueIterator = Box<dyn Iterator<Item = Value> + Send + 'static>;
pub struct ListStream {
stream: ValueIterator,
span: Span,
caller_spans: Vec<Span>,
}
impl ListStream {
@ -27,6 +28,7 @@ impl ListStream {
Self {
stream: Box::new(InterruptIter::new(iter, signals)),
span,
caller_spans: vec![],
}
}
@ -35,6 +37,18 @@ impl ListStream {
self.span
}
/// Push a caller [`Span`] to the bytestream, it's useful to construct a backtrace.
pub fn push_caller_span(&mut self, span: Span) {
if span != self.span {
self.caller_spans.push(span)
}
}
/// Get all caller [`Span`], it's useful to construct a backtrace.
pub fn get_caller_spans(&self) -> &Vec<Span> {
&self.caller_spans
}
/// Changes the [`Span`] associated with this [`ListStream`].
pub fn with_span(mut self, span: Span) -> Self {
self.span = span;
@ -94,6 +108,7 @@ impl ListStream {
Self {
stream: Box::new(f(self.stream)),
span: self.span,
caller_spans: self.caller_spans,
}
}

View File

@ -60,7 +60,7 @@ fn fancy_default_errors() {
assert_eq!(
actual.err,
"Error: \u{1b}[31m×\u{1b}[0m oh no!\n ╭─[\u{1b}[36;1;4mline2:1:13\u{1b}[0m]\n \u{1b}[2m1\u{1b}[0m │ force_error \"My error\"\n · \u{1b}[35;1m ─────┬────\u{1b}[0m\n · \u{1b}[35;1m╰── \u{1b}[35;1mhere's the error\u{1b}[0m\u{1b}[0m\n ╰────\n\n"
"Error: \u{1b}[31m×\u{1b}[0m oh no!\n ╭─[\u{1b}[36;1;4mline2:1:13\u{1b}[0m]\n \u{1b}[2m1\u{1b}[0m │ force_error \"My error\"\n · \u{1b}[35;1m ─────┬────\u{1b}[0m\n · \u{1b}[35;1m╰── \u{1b}[35;1mhere's the error\u{1b}[0m\u{1b}[0m\n ╰────\n\nset the `NU_BACKTRACE=1` environment variable to display a backtrace.\n"
);
}
@ -92,6 +92,7 @@ snippet line 1: force_error "my error"
label at line 1, columns 13 to 22: here's the error
set the `NU_BACKTRACE=1` environment variable to display a backtrace.
"#,
);
}

View File

@ -686,3 +686,34 @@ fn arg_dont_run_subcommand_if_surrounded_with_quote() {
let actual = nu!("nu --testbin cococo '(echo aa)'");
assert_eq!(actual.out, "(echo aa)");
}
#[test]
fn external_error_with_backtrace() {
Playground::setup("external error with backtrace", |dirs, sandbox| {
sandbox.with_files(&[FileWithContent("tmp_env.nu", "$env.NU_BACKTRACE = 1")]);
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"def a [x] { if $x == 3 { nu --testbin --fail }};def b [] {a 1; a 3; a 2}; b"#);
let chained_error_cnt: Vec<&str> = actual
.err
.matches("diagnostic code: chained_error")
.collect();
assert_eq!(chained_error_cnt.len(), 1);
assert!(actual.err.contains("non_zero_exit_code"));
let eval_with_input_cnt: Vec<&str> = actual.err.matches("eval_block_with_input").collect();
assert_eq!(eval_with_input_cnt.len(), 1);
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"nu --testbin --fail"#);
let chained_error_cnt: Vec<&str> = actual
.err
.matches("diagnostic code: chained_error")
.collect();
// run error make directly, show no backtrace is available
assert_eq!(chained_error_cnt.len(), 0);
});
}

View File

@ -1,4 +1,4 @@
use nu_test_support::fs::Stub::FileWithContentToBeTrimmed;
use nu_test_support::fs::Stub::{FileWithContent, FileWithContentToBeTrimmed};
use nu_test_support::playground::Playground;
use nu_test_support::{nu, pipeline};
use pretty_assertions::assert_eq;
@ -1135,3 +1135,83 @@ fn error_on_out_greater_pipe() {
.err
.contains("Redirecting stdout to a pipe is the same as normal piping"))
}
#[test]
fn error_with_backtrace() {
Playground::setup("error with backtrace", |dirs, sandbox| {
sandbox.with_files(&[FileWithContent("tmp_env.nu", "$env.NU_BACKTRACE = 1")]);
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"def a [x] { if $x == 3 { error make {msg: 'a custom error'}}};a 3"#);
let chained_error_cnt: Vec<&str> = actual
.err
.matches("diagnostic code: chained_error")
.collect();
// run `a 3`, and it raises error, so there should be 1.
assert_eq!(chained_error_cnt.len(), 1);
assert!(actual.err.contains("a custom error"));
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"def a [x] { if $x == 3 { error make {msg: 'a custom error'}}};def b [] { a 1; a 3; a 2 };b"#);
let chained_error_cnt: Vec<&str> = actual
.err
.matches("diagnostic code: chained_error")
.collect();
// run `b`, it runs `a 3`, and it raises error, so there should be 2.
assert_eq!(chained_error_cnt.len(), 2);
assert!(actual.err.contains("a custom error"));
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"error make {msg: 'a custom err'}"#);
let chained_error_cnt: Vec<&str> = actual
.err
.matches("diagnostic code: chained_error")
.collect();
// run error make directly, show no backtrace is available
assert_eq!(chained_error_cnt.len(), 0);
});
}
#[test]
fn liststream_error_with_backtrace() {
Playground::setup("liststream error with backtrace", |dirs, sandbox| {
sandbox.with_files(&[FileWithContent("tmp_env.nu", "$env.NU_BACKTRACE = 1")]);
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"def a [x] { if $x == 3 { [1] | each {error make {'msg': 'a custom error'}}}};a 3"#);
assert!(actual.err.contains("a custom error"));
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"def a [x] { if $x == 3 { [1] | each {error make {'msg': 'a custom error'}}}};def b [] { a 1; a 3; a 2 };b"#);
let chained_error_cnt: Vec<&str> = actual
.err
.matches("diagnostic code: chained_error")
.collect();
assert_eq!(chained_error_cnt.len(), 1);
assert!(actual.err.contains("a custom error"));
let eval_with_input_cnt: Vec<&str> = actual.err.matches("eval_block_with_input").collect();
assert_eq!(eval_with_input_cnt.len(), 2);
let actual = nu!(
env_config: "tmp_env.nu",
cwd: dirs.test(),
r#"[1] | each { error make {msg: 'a custom err'} }"#);
let chained_error_cnt: Vec<&str> = actual
.err
.matches("diagnostic code: chained_error")
.collect();
// run error make directly, show no backtrace is available
assert_eq!(chained_error_cnt.len(), 0);
});
}