Skip to main content

nu_protocol/errors/
labeled_error.rs

1use super::{ShellError, shell_error::io::IoError};
2use crate::{FromValue, IntoValue, Span, Type, Value, engine::StateWorkingSet, record};
3use miette::{Diagnostic, LabeledSpan, NamedSource, SourceSpan};
4use serde::{Deserialize, Serialize};
5use std::{fmt, fs};
6
7// # use nu_protocol::{FromValue, Value, ShellError, record, Span};
8
9/// A very generic type of error used for interfacing with external code, such as scripts and
10/// plugins.
11///
12/// This generally covers most of the interface of [`miette::Diagnostic`], but with types that are
13/// well-defined for our protocol.
14#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
15pub struct LabeledError {
16    /// The main message for the error.
17    pub msg: String,
18    /// Labeled spans attached to the error, demonstrating to the user where the problem is.
19    #[serde(default)]
20    pub labels: Box<Vec<ErrorLabel>>,
21    /// A unique machine- and search-friendly error code to associate to the error. (e.g.
22    /// `nu::shell::missing_config_value`)
23    #[serde(default)]
24    pub code: Option<String>,
25    /// A link to documentation about the error, used in conjunction with `code`
26    #[serde(default)]
27    pub url: Option<String>,
28    /// Additional help for the error, usually a hint about what the user might try
29    #[serde(default)]
30    pub help: Option<String>,
31    /// Errors that are related to or caused this error
32    #[serde(default)]
33    pub inner: Box<Vec<ShellError>>,
34}
35
36impl LabeledError {
37    /// Create a new plain [`LabeledError`] with the given message.
38    ///
39    /// This is usually used builder-style with methods like [`.with_label()`](Self::with_label) to
40    /// build an error.
41    ///
42    /// # Example
43    ///
44    /// ```rust
45    /// # use nu_protocol::LabeledError;
46    /// let error = LabeledError::new("Something bad happened");
47    /// assert_eq!("Something bad happened", error.to_string());
48    /// ```
49    pub fn new(msg: impl Into<String>) -> Self {
50        Self {
51            msg: msg.into(),
52            ..Default::default()
53        }
54    }
55
56    /// Add a labeled span to the error to demonstrate to the user where the problem is.
57    ///
58    /// # Example
59    ///
60    /// ```rust
61    /// # use nu_protocol::{LabeledError, Span};
62    /// # let span = Span::test_data();
63    /// let error = LabeledError::new("An error")
64    ///     .with_label("happened here", span);
65    /// assert_eq!("happened here", &error.labels[0].text);
66    /// assert_eq!(span, error.labels[0].span);
67    /// ```
68    pub fn with_label(mut self, text: impl Into<String>, span: Span) -> Self {
69        self.labels.push(ErrorLabel {
70            text: text.into(),
71            span,
72        });
73        self
74    }
75
76    /// Add a unique machine- and search-friendly error code to associate to the error. (e.g.
77    /// `nu::shell::missing_config_value`)
78    ///
79    /// # Example
80    ///
81    /// ```rust
82    /// # use nu_protocol::LabeledError;
83    /// let error = LabeledError::new("An error")
84    ///     .with_code("my_product::error");
85    /// assert_eq!(Some("my_product::error"), error.code.as_deref());
86    /// ```
87    pub fn with_code(mut self, code: impl Into<String>) -> Self {
88        self.code = Some(code.into());
89        self
90    }
91
92    /// Add a link to documentation about the error, used in conjunction with `code`.
93    ///
94    /// # Example
95    ///
96    /// ```rust
97    /// # use nu_protocol::LabeledError;
98    /// let error = LabeledError::new("An error")
99    ///     .with_url("https://example.org/");
100    /// assert_eq!(Some("https://example.org/"), error.url.as_deref());
101    /// ```
102    pub fn with_url(mut self, url: impl Into<String>) -> Self {
103        self.url = Some(url.into());
104        self
105    }
106
107    /// Add additional help for the error, usually a hint about what the user might try.
108    ///
109    /// # Example
110    ///
111    /// ```rust
112    /// # use nu_protocol::LabeledError;
113    /// let error = LabeledError::new("An error")
114    ///     .with_help("did you try turning it off and back on again?");
115    /// assert_eq!(Some("did you try turning it off and back on again?"), error.help.as_deref());
116    /// ```
117    pub fn with_help(mut self, help: impl Into<String>) -> Self {
118        self.help = Some(help.into());
119        self
120    }
121
122    /// Add an error that is related to or caused this error.
123    ///
124    /// # Example
125    ///
126    /// ```rust
127    /// # use nu_protocol::{LabeledError, ShellError};
128    /// let error = LabeledError::new("An error")
129    ///     .with_inner(LabeledError::new("out of coolant"));
130    /// let check: ShellError = LabeledError::new("out of coolant").into();
131    /// assert_eq!(check, error.inner[0]);
132    /// ```
133    pub fn with_inner(mut self, inner: impl Into<ShellError>) -> Self {
134        let inner_error: ShellError = inner.into();
135        self.inner.push(inner_error);
136        self
137    }
138
139    /// Create a [`LabeledError`] from a type that implements [`miette::Diagnostic`].
140    ///
141    /// # Example
142    ///
143    /// [`ShellError`] implements `miette::Diagnostic`:
144    ///
145    /// ```rust
146    /// # use nu_protocol::{ShellError, LabeledError, shell_error::{self, io::IoError}, Span};
147    /// #
148    /// let error = LabeledError::from_diagnostic(
149    ///     &ShellError::Io(IoError::new_with_additional_context(
150    ///         shell_error::io::ErrorKind::from_std(std::io::ErrorKind::Other),
151    ///         Span::test_data(),
152    ///         None,
153    ///         "some error"
154    ///     ))
155    /// );
156    /// assert!(error.to_string().contains("I/O error"));
157    /// ```
158    pub fn from_diagnostic(diag: &(impl miette::Diagnostic + ?Sized)) -> Self {
159        Self {
160            msg: diag.to_string(),
161            labels: diag
162                .labels()
163                .into_iter()
164                .flatten()
165                .map(|label| ErrorLabel {
166                    text: label.label().unwrap_or("").into(),
167                    span: Span::new(label.offset(), label.offset() + label.len()),
168                })
169                .collect::<Vec<_>>()
170                .into(),
171            code: diag.code().map(|s| s.to_string()),
172            url: diag.url().map(|s| s.to_string()),
173            help: diag.help().map(|s| s.to_string()),
174            inner: diag
175                .related()
176                .into_iter()
177                .flatten()
178                .map(|i| Self::from_diagnostic(i).into())
179                .collect::<Vec<_>>()
180                .into(),
181        }
182    }
183}
184
185/// A labeled span within a [`LabeledError`].
186#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct ErrorLabel {
188    /// Text to show together with the span
189    pub text: String,
190    /// Span pointing at where the text references in the source
191    pub span: Span,
192}
193
194impl From<ErrorLabel> for LabeledSpan {
195    fn from(val: ErrorLabel) -> Self {
196        LabeledSpan::new(
197            (!val.text.is_empty()).then_some(val.text),
198            val.span.start,
199            val.span.end - val.span.start,
200        )
201    }
202}
203
204impl From<ErrorLabel> for SourceSpan {
205    fn from(val: ErrorLabel) -> Self {
206        SourceSpan::new(val.span.start.into(), val.span.end - val.span.start)
207    }
208}
209
210impl FromValue for ErrorLabel {
211    fn from_value(v: Value) -> Result<Self, ShellError> {
212        let record = v.clone().into_record()?;
213        let text = String::from_value(match record.get("text") {
214            Some(val) => val.clone(),
215            None => Value::string("", v.span()),
216        })
217        .unwrap_or("originates from here".into());
218        let span = Span::from_value(match record.get("span") {
219            Some(val) => val.clone(),
220            // Maybe there's a better way...
221            None => Value::record(
222                record! {
223                    "start" => Value::int(v.span().start as i64, v.span()),
224                    "end" => Value::int(v.span().end as i64, v.span()),
225                },
226                v.span(),
227            ),
228        });
229
230        match span {
231            Ok(s) => Ok(Self { text, span: s }),
232            Err(e) => Err(e),
233        }
234    }
235    fn expected_type() -> crate::Type {
236        Type::Record(
237            vec![
238                ("text".into(), Type::String),
239                ("span".into(), Type::record()),
240            ]
241            .into(),
242        )
243    }
244}
245
246impl IntoValue for ErrorLabel {
247    fn into_value(self, span: Span) -> Value {
248        let ErrorLabel {
249            text,
250            span: label_span,
251        } = self;
252        record! {
253            "text" => Value::string(text, span),
254            "span" => label_span.into_value(span),
255        }
256        .into_value(span)
257    }
258}
259
260impl ErrorLabel {
261    fn into_value_with_resolved_span(self, span: Span, working_set: &StateWorkingSet) -> Value {
262        let ErrorLabel {
263            text,
264            span: label_span,
265        } = self;
266        let resolved_span = working_set.resolve_span(label_span);
267        record! {
268            "text" => Value::string(text, span),
269            "span" => label_span.into_value(span),
270            "location" => resolved_span.into_value(span),
271        }
272        .into_value(span)
273    }
274}
275
276/// Optionally named error source
277#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct ErrorSource {
279    name: Option<String>,
280    text: Option<String>,
281    path: Option<String>,
282}
283
284impl ErrorSource {
285    pub fn new(name: Option<String>, text: String) -> Self {
286        Self {
287            name,
288            text: Some(text),
289            path: None,
290        }
291    }
292}
293
294impl From<ErrorSource> for NamedSource<String> {
295    fn from(value: ErrorSource) -> Self {
296        let name = value.name.unwrap_or_default();
297        match value {
298            ErrorSource {
299                text: Some(text),
300                path: None,
301                ..
302            } => NamedSource::new(name, text),
303            ErrorSource {
304                text: None,
305                path: Some(path),
306                ..
307            } => {
308                let text = fs::read_to_string(&path).unwrap_or_default();
309                NamedSource::new(path, text)
310            }
311            _ => NamedSource::new(name, "".into()),
312        }
313    }
314}
315
316impl FromValue for ErrorSource {
317    fn from_value(v: Value) -> Result<Self, ShellError> {
318        let record = v.clone().into_record()?;
319        let name = record
320            .get("name")
321            .and_then(|s| String::from_value(s.clone()).ok());
322        // let name = String::from_value(record.get("name").unwrap().clone()).ok();
323
324        let text = if let Some(text) = record.get("text") {
325            String::from_value(text.clone()).ok()
326        } else {
327            None
328        };
329        let path = if let Some(path) = record.get("path") {
330            String::from_value(path.clone()).ok()
331        } else {
332            None
333        };
334
335        match (text, path) {
336            // Prioritize not reading from a file and using the text raw
337            (text @ Some(_), _) => Ok(ErrorSource {
338                name,
339                text,
340                path: None,
341            }),
342            (_, path @ Some(_)) => Ok(ErrorSource {
343                name: path.clone(),
344                text: None,
345                path,
346            }),
347            _ => Err(ShellError::CantConvert {
348                to_type: Self::expected_type().to_string(),
349                from_type: v.get_type().to_string(),
350                span: v.span(),
351                help: None,
352            }),
353        }
354    }
355    fn expected_type() -> crate::Type {
356        Type::Record(
357            vec![
358                ("name".into(), Type::String),
359                ("text".into(), Type::String),
360                ("path".into(), Type::String),
361            ]
362            .into(),
363        )
364    }
365}
366
367impl IntoValue for ErrorSource {
368    fn into_value(self, span: Span) -> Value {
369        match self {
370            Self {
371                name: Some(name),
372                text: Some(text),
373                ..
374            } => record! {
375                "name" => Value::string(name, span),
376                "text" => Value::string(text, span),
377            },
378            Self {
379                text: Some(text), ..
380            } => record! {
381                "text" => Value::string(text, span)
382            },
383            Self {
384                name: Some(name),
385                path: Some(path),
386                ..
387            } => record! {
388                "name" => Value::string(name, span),
389                "path" => Value::string(path, span),
390            },
391            Self {
392                path: Some(path), ..
393            } => record! {
394                "path" => Value::string(path, span),
395            },
396            _ => record! {},
397        }
398        .into_value(span)
399    }
400}
401
402impl fmt::Display for LabeledError {
403    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
404        f.write_str(&self.msg)
405    }
406}
407
408impl std::error::Error for LabeledError {
409    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
410        self.inner.first().map(|r| r as _)
411    }
412}
413
414impl Diagnostic for LabeledError {
415    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
416        self.code.as_ref().map(Box::new).map(|b| b as _)
417    }
418
419    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
420        self.help.as_ref().map(Box::new).map(|b| b as _)
421    }
422
423    fn url<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
424        self.url.as_ref().map(Box::new).map(|b| b as _)
425    }
426
427    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
428        Some(Box::new(
429            self.labels.iter().map(|label| label.clone().into()),
430        ))
431    }
432
433    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn Diagnostic> + 'a>> {
434        Some(Box::new(self.inner.iter().map(|r| r as _)))
435    }
436}
437
438impl From<ShellError> for LabeledError {
439    fn from(err: ShellError) -> Self {
440        Self::from_diagnostic(&err)
441    }
442}
443
444impl From<IoError> for LabeledError {
445    fn from(err: IoError) -> Self {
446        Self::from_diagnostic(&err)
447    }
448}
449
450impl LabeledError {
451    pub fn into_value(self, span: Span, working_set: &StateWorkingSet) -> Value {
452        let LabeledError {
453            msg,
454            labels,
455            code,
456            url,
457            help,
458            inner,
459        } = self;
460        let inner = inner
461            .into_iter()
462            .map(|err| Self::from(err).into_value(span, working_set))
463            .collect::<Vec<_>>()
464            .into_value(span);
465        let labels = labels
466            .into_iter()
467            .map(|e| e.into_value_with_resolved_span(span, working_set))
468            .collect::<Vec<_>>()
469            .into_value(span);
470        let record = record! {
471            "msg" => msg.into_value(span),
472            "labels" => labels,
473            "code" => code.into_value(span),
474            "url" => url.into_value(span),
475            "help" => help.into_value(span),
476            "inner" => inner,
477        };
478        Value::record(record, span)
479    }
480}
481
482/// Default number of context bytes on each side of an error span when truncating source
483/// for diagnostics.
484pub const DEFAULT_ERROR_CONTEXT: usize = 4096;
485
486/// Truncates a source string to a bounded window around an error span.
487///
488/// Takes `context` bytes on each side of the error location. Returns the
489/// truncated source and an adjusted span that is relative to the truncated window.
490/// This prevents unbounded memory usage when error diagnostics embed large source files.
491///
492/// Slicing is safe on multi-byte UTF-8: window boundaries are adjusted to char boundaries.
493///
494/// For multi-line inputs the window expands to the nearest line boundaries so that line
495/// numbers in the diagnostic output are consistent. For single-line (minified) inputs
496/// with a large requested context, the window is clamped to a smaller focused snippet
497/// since a wall of unbroken text is not helpful.
498///
499/// Use with `Span::try_from_row_column` when you only have (row, col) from the parser,
500/// or directly when you already have the byte offset from the parser.
501pub fn truncated_source_window(input: &str, byte_span: Span, context: usize) -> (String, Span) {
502    let mid = (byte_span.start + byte_span.end) / 2;
503
504    // Detect single-line (minified) input.  When the caller asks for a large context
505    // but the input has no newlines nearby, a wall of unbroken text is useless — clamp
506    // to a tight window focused on the error location.
507    const TIGHT_CONTEXT: usize = 128;
508    let is_single_line = if context > TIGHT_CONTEXT {
509        let probe_start = input.floor_char_boundary(mid.saturating_sub(TIGHT_CONTEXT));
510        let probe_end = input.ceil_char_boundary(input.len().min(mid + TIGHT_CONTEXT));
511        !input[probe_start..probe_end].contains('\n')
512    } else {
513        false
514    };
515    let effective = if is_single_line {
516        TIGHT_CONTEXT
517    } else {
518        context
519    };
520
521    let mut window_start = mid.saturating_sub(effective);
522    let mut window_end = input.len().min(mid + effective);
523
524    // Adjust to char boundaries to avoid panicking on multi-byte UTF-8
525    window_start = input.floor_char_boundary(window_start);
526    window_end = input.ceil_char_boundary(window_end);
527
528    if !is_single_line && context > TIGHT_CONTEXT {
529        // Multi-line with large context: round to nearest line boundaries for
530        // proper line-number display.  Guard against blowing up by 2x context.
531        window_start = if let Some(pos) = input[..window_start].rfind('\n') {
532            let line_start = pos + 1;
533            if window_start - line_start <= context * 2 {
534                line_start
535            } else {
536                window_start
537            }
538        } else {
539            window_start
540        };
541        window_end = if let Some(pos) = input[window_end..].find('\n') {
542            let line_end = window_end + pos + 1;
543            if line_end - window_end <= context * 2 {
544                line_end
545            } else {
546                window_end
547            }
548        } else {
549            window_end
550        };
551    }
552
553    let truncated = input[window_start..window_end].to_string();
554    let adjusted_span = Span::new(
555        byte_span.start.saturating_sub(window_start),
556        byte_span.end.saturating_sub(window_start),
557    );
558    (truncated, adjusted_span)
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    #[test]
566    fn truncated_source_window_middle() {
567        // 40 bytes of padding on each side, "ERROR" spanning bytes 40..45
568        // mid = (40+45)/2 = 42
569        // window_start = 42-8 = 34, window_end = min(85,42+8) = 50
570        let input = format!("{:a<40}ERROR{:b<40}", "", "");
571        assert_eq!(input.len(), 85);
572        let byte_span = Span::new(40, 45);
573        let (src, span) = truncated_source_window(&input, byte_span, 8);
574        assert!(
575            src.contains("ERROR"),
576            "truncated source should contain the error"
577        );
578        assert_eq!(span.start, 6, "40 - 34 = 6");
579        assert_eq!(span.end, 11, "45 - 34 = 11");
580    }
581
582    #[test]
583    fn truncated_source_window_near_start() {
584        // mid = (0+4)/2 = 2
585        // window_start = 2-8 = 0 (saturated), window_end = min(80, 2+8) = 10
586        let input = format!("{:x<80}", "");
587        let byte_span = Span::new(0, 4);
588        let (src, span) = truncated_source_window(&input, byte_span, 8);
589        assert_eq!(span.start, 0, "0 - 0 = 0");
590        assert_eq!(span.end, 4, "4 - 0 = 4");
591        assert_eq!(src.len(), 10, "window [0, 10) is 10 bytes");
592    }
593
594    #[test]
595    fn truncated_source_window_near_end() {
596        // mid = (76+80)/2 = 78
597        // window_start = 78-8 = 70, window_end = min(80, 78+8) = 80
598        let input = format!("{:x<80}", "");
599        let byte_span = Span::new(76, 80);
600        let (src, span) = truncated_source_window(&input, byte_span, 8);
601        assert_eq!(span.start, 6, "76 - 70 = 6");
602        assert_eq!(span.end, 10, "80 - 70 = 10");
603        assert_eq!(src.len(), 10, "window [70, 80) is 10 bytes");
604    }
605
606    #[test]
607    fn truncated_source_window_small_input() {
608        let input = "small";
609        let byte_span = Span::new(2, 4);
610        let (src, span) = truncated_source_window(input, byte_span, 100);
611        // Input is smaller than context, so window should be the entire input
612        assert_eq!(
613            src, "small",
614            "should be the full input when context > input.len()"
615        );
616        assert_eq!(span.start, 2, "adjusted span start should match original");
617        assert_eq!(span.end, 4, "adjusted span end should match original");
618    }
619
620    #[test]
621    fn truncated_source_window_span_adjustment() {
622        // Use XXXXX as the error marker to avoid typos-tool false
623        // positives on an error-word adjacent to other characters.
624        let input = "aaaaaaaaaaXXXXXbbbbbbbbbb"; // 10 a's, 5 X's, 10 b's = 25 bytes
625        // Error from byte 10 to byte 15 -> "XXXXX"
626        let byte_span = Span::new(10, 15);
627        let (src, span) = truncated_source_window(input, byte_span, 5);
628        // Window: mid=12, window_start = 12-5 = 7, window_end = 12+5 = 17
629        // src = input[7..17] = "aaaXXXXXbb" (3 a's + 5 X's + 2 b's = 10 bytes)
630        assert_eq!(src.len(), 10, "window should be 10 bytes");
631        assert!(src.starts_with("aaa"), "window should start with aaa");
632        assert!(src.ends_with("bb"), "window should end with bb");
633        assert!(
634            src.contains("XXXXX"),
635            "window should contain the error marker"
636        );
637        // Adjusted: byte_span.start - window_start = 10-7 = 3
638        assert_eq!(
639            span.start, 3,
640            "adjusted start should be original - window_start"
641        );
642        assert_eq!(
643            span.end, 8,
644            "adjusted end should be original - window_start"
645        );
646        assert_eq!(
647            &src[3..8],
648            "XXXXX",
649            "error marker should be at the right adjusted position"
650        );
651    }
652
653    #[test]
654    fn truncated_source_window_zero_width_span() {
655        let input = "abcdefghijklmnopqrstuvwxyz";
656        let byte_span = Span::new(13, 13); // middle of alphabet
657        let (src, span) = truncated_source_window(input, byte_span, 5);
658        assert_eq!(
659            span.start, span.end,
660            "zero-width span should stay zero-width"
661        );
662        assert!(src.len() <= 11, "window should be bounded");
663    }
664
665    #[test]
666    fn truncated_source_window_multibyte_utf8() {
667        // Chinese chars are 3 bytes each; slicing at arbitrary byte offsets must not panic
668        let input = "你好世界ERROR世界";
669        // "ERROR" starts at byte 12 (4 chars × 3 bytes)
670        let byte_span = Span::new(12, 17);
671        let (src, span) = truncated_source_window(input, byte_span, 3);
672        assert!(
673            src.contains("ERROR"),
674            "window must contain the error region"
675        );
676        assert_eq!(
677            &src[span.start..span.end],
678            "ERROR",
679            "adjusted span must slice correctly"
680        );
681    }
682
683    #[test]
684    fn truncated_source_window_multibyte_utf8_boundary_crossing() {
685        // Force window bounds into the middle of multi-byte chars
686        // "aaaaa" (5 bytes) + "你好世界" (12 bytes) + "ERROR" (5 bytes) + "世界你好" (12 bytes)
687        let input = "aaaaa你好世界ERROR世界你好";
688        // "ERROR" at bytes 17..22
689        let byte_span = Span::new(17, 22);
690        // context=8 should give enough room while crossing multi-byte boundaries
691        let (src, span) = truncated_source_window(input, byte_span, 8);
692        assert!(
693            src.contains("ERROR"),
694            "window must contain the error region"
695        );
696        assert_eq!(&src[span.start..span.end], "ERROR");
697    }
698
699    #[test]
700    fn truncated_source_window_single_line_minified() {
701        // Simulate a minified JSON file: one giant line, error near the end.
702        let mut input = String::new();
703        input.push_str(&"\"key\":\"value\",".repeat(500)); // 14 bytes each
704        let err_byte = input.len(); // byte right after the valid part
705        input.push_str("\"broken"); // syntax error starts here
706        let byte_span = Span::new(err_byte, err_byte + 1); // the opening quote of "broken
707        let (src, span) = truncated_source_window(&input, byte_span, DEFAULT_ERROR_CONTEXT);
708        // The window should be tight (no wall of text) for single-line input.
709        assert!(
710            src.len() < 1000,
711            "single-line window should be tight, got {} bytes",
712            src.len()
713        );
714        assert_eq!(
715            &src[span.start..span.end],
716            "\"",
717            "should point at the opening quote"
718        );
719    }
720
721    #[test]
722    fn truncated_source_window_multiline_uses_full_context() {
723        // Multi-line input should get the full context window with line boundaries.
724        let mut input = String::new();
725        for i in 0..200 {
726            use std::fmt::Write;
727            writeln!(&mut input, "line {i}").unwrap();
728        }
729        input.push_str("ERROR here\nlast line");
730        // "ERROR here" starts at some point in the file
731        let err_offset = input.find("ERROR").expect("ERROR should be in input");
732        let byte_span = Span::new(err_offset, err_offset + 5);
733        // Use a generous context to show it's not truncated to tight window
734        let (src, span) = truncated_source_window(&input, byte_span, DEFAULT_ERROR_CONTEXT);
735        // Multi-line: should contain the whole lines around the error, not tight
736        assert!(
737            src.len() > 1000,
738            "multi-line window should be large, got {} bytes",
739            src.len()
740        );
741        assert!(src.contains("ERROR"), "should contain the error region");
742        assert_eq!(&src[span.start..span.end], "ERROR");
743    }
744}