Skip to main content

nu_protocol/errors/
labeled_error.rs

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