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            [Err(_), Ok(span_val)] => ["".into_value(span), span_val],
233            results => {
234                let err = LabeledError::new("Value is missing required columns.");
235                let err = results
236                    .into_iter()
237                    .filter_map(|x| x.err())
238                    .fold(err, |err, (col, col_ty)| {
239                        err.with_label(format!("missing `{col}: {col_ty}` column"), span)
240                    })
241                    .with_code("nu::shell::missing_required_columns");
242                return Err(err.into());
243            }
244        };
245
246        match (String::from_value(text_val), Span::from_value(span_val)) {
247            (Ok(text), Ok(span)) => Ok(Self { text, span }),
248            (r_0, r_1) => {
249                let errs = [r_0.err(), r_1.err()];
250                Err(
251                    GenericError::new("Unable to parse ErrorLabel.", "here", span)
252                        .with_inner(errs.into_iter().filter_map(|x| x))
253                        .into(),
254                )
255            }
256        }
257    }
258
259    fn expected_type() -> crate::Type {
260        Type::Record([("text", Type::String), ("span", Span::expected_type())].into())
261    }
262}
263
264impl IntoValue for ErrorLabel {
265    fn into_value(self, span: Span) -> Value {
266        let ErrorLabel {
267            text,
268            span: label_span,
269        } = self;
270        record! {
271            "text" => Value::string(text, span),
272            "span" => label_span.into_value(span),
273        }
274        .into_value(span)
275    }
276}
277
278impl ErrorLabel {
279    fn into_value_with_resolved_span(self, span: Span, working_set: &StateWorkingSet) -> Value {
280        let ErrorLabel {
281            text,
282            span: label_span,
283        } = self;
284        let resolved_span = working_set.resolve_span(label_span);
285        record! {
286            "text" => Value::string(text, span),
287            "span" => label_span.into_value(span),
288            "location" => resolved_span.into_value(span),
289        }
290        .into_value(span)
291    }
292}
293
294/// Optionally named error source
295#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
296pub struct ErrorSource {
297    name: Option<String>,
298    text: Option<String>,
299    path: Option<String>,
300}
301
302impl ErrorSource {
303    pub fn new(name: Option<String>, text: String) -> Self {
304        Self {
305            name,
306            text: Some(text),
307            path: None,
308        }
309    }
310}
311
312impl From<ErrorSource> for NamedSource<String> {
313    fn from(value: ErrorSource) -> Self {
314        let name = value.name.unwrap_or_default();
315        match value {
316            ErrorSource {
317                text: Some(text),
318                path: None,
319                ..
320            } => NamedSource::new(name, text),
321            ErrorSource {
322                text: None,
323                path: Some(path),
324                ..
325            } => {
326                let text = fs::read_to_string(&path).unwrap_or_default();
327                NamedSource::new(path, text)
328            }
329            _ => NamedSource::new(name, "".into()),
330        }
331    }
332}
333
334impl FromValue for ErrorSource {
335    fn from_value(v: Value) -> Result<Self, ShellError> {
336        let record = v.clone().into_record()?;
337        let name = record
338            .get("name")
339            .and_then(|s| String::from_value(s.clone()).ok());
340        // let name = String::from_value(record.get("name").unwrap().clone()).ok();
341
342        let text = if let Some(text) = record.get("text") {
343            String::from_value(text.clone()).ok()
344        } else {
345            None
346        };
347        let path = if let Some(path) = record.get("path") {
348            String::from_value(path.clone()).ok()
349        } else {
350            None
351        };
352
353        match (text, path) {
354            // Prioritize not reading from a file and using the text raw
355            (text @ Some(_), _) => Ok(ErrorSource {
356                name,
357                text,
358                path: None,
359            }),
360            (_, path @ Some(_)) => Ok(ErrorSource {
361                name: path.clone(),
362                text: None,
363                path,
364            }),
365            _ => Err(ShellError::CantConvert {
366                to_type: Self::expected_type().to_string(),
367                from_type: v.get_type().to_string(),
368                span: v.span(),
369                help: None,
370            }),
371        }
372    }
373    fn expected_type() -> crate::Type {
374        Type::Record(
375            vec![
376                ("name".into(), Type::String),
377                ("text".into(), Type::String),
378                ("path".into(), Type::String),
379            ]
380            .into(),
381        )
382    }
383}
384
385impl IntoValue for ErrorSource {
386    fn into_value(self, span: Span) -> Value {
387        match self {
388            Self {
389                name: Some(name),
390                text: Some(text),
391                ..
392            } => record! {
393                "name" => Value::string(name, span),
394                "text" => Value::string(text, span),
395            },
396            Self {
397                text: Some(text), ..
398            } => record! {
399                "text" => Value::string(text, span)
400            },
401            Self {
402                name: Some(name),
403                path: Some(path),
404                ..
405            } => record! {
406                "name" => Value::string(name, span),
407                "path" => Value::string(path, span),
408            },
409            Self {
410                path: Some(path), ..
411            } => record! {
412                "path" => Value::string(path, span),
413            },
414            _ => record! {},
415        }
416        .into_value(span)
417    }
418}
419
420impl fmt::Display for LabeledError {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        f.write_str(&self.msg)
423    }
424}
425
426impl std::error::Error for LabeledError {
427    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
428        self.inner.first().map(|r| r as _)
429    }
430}
431
432impl Diagnostic for LabeledError {
433    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
434        self.code.as_ref().map(Box::new).map(|b| b as _)
435    }
436
437    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
438        self.help.as_ref().map(Box::new).map(|b| b as _)
439    }
440
441    fn url<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
442        self.url.as_ref().map(Box::new).map(|b| b as _)
443    }
444
445    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
446        Some(Box::new(
447            self.labels.iter().map(|label| label.clone().into()),
448        ))
449    }
450
451    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn Diagnostic> + 'a>> {
452        Some(Box::new(self.inner.iter().map(|r| r as _)))
453    }
454}
455
456impl From<ShellError> for LabeledError {
457    fn from(err: ShellError) -> Self {
458        Self::from_diagnostic(&err)
459    }
460}
461
462impl From<IoError> for LabeledError {
463    fn from(err: IoError) -> Self {
464        Self::from_diagnostic(&err)
465    }
466}
467
468impl LabeledError {
469    pub fn into_value(self, span: Span, working_set: &StateWorkingSet) -> Value {
470        let LabeledError {
471            msg,
472            labels,
473            code,
474            url,
475            help,
476            inner,
477        } = self;
478        let inner = inner
479            .into_iter()
480            .map(|err| Self::from(err).into_value(span, working_set))
481            .collect::<Vec<_>>()
482            .into_value(span);
483        let labels = labels
484            .into_iter()
485            .map(|e| e.into_value_with_resolved_span(span, working_set))
486            .collect::<Vec<_>>()
487            .into_value(span);
488        let record = record! {
489            "msg" => msg.into_value(span),
490            "labels" => labels,
491            "code" => code.into_value(span),
492            "url" => url.into_value(span),
493            "help" => help.into_value(span),
494            "inner" => inner,
495        };
496        Value::record(record, span)
497    }
498}
499
500/// Default number of context bytes on each side of an error span when truncating source
501/// for diagnostics.
502pub const DEFAULT_ERROR_CONTEXT: usize = 4096;
503
504/// Truncates a source string to a bounded window around an error span.
505///
506/// Takes `context` bytes on each side of the error location. Returns the
507/// truncated source and an adjusted span that is relative to the truncated window.
508/// This prevents unbounded memory usage when error diagnostics embed large source files.
509///
510/// Slicing is safe on multi-byte UTF-8: window boundaries are adjusted to char boundaries.
511///
512/// For multi-line inputs the window expands to the nearest line boundaries so that line
513/// numbers in the diagnostic output are consistent. For single-line (minified) inputs
514/// with a large requested context, the window is clamped to a smaller focused snippet
515/// since a wall of unbroken text is not helpful.
516///
517/// Use with `Span::try_from_row_column` when you only have (row, col) from the parser,
518/// or directly when you already have the byte offset from the parser.
519pub fn truncated_source_window(input: &str, byte_span: Span, context: usize) -> (String, Span) {
520    let mid = (byte_span.start + byte_span.end) / 2;
521
522    // Detect single-line (minified) input.  When the caller asks for a large context
523    // but the input has no newlines nearby, a wall of unbroken text is useless — clamp
524    // to a tight window focused on the error location.
525    const TIGHT_CONTEXT: usize = 128;
526    let is_single_line = if context > TIGHT_CONTEXT {
527        let probe_start = input.floor_char_boundary(mid.saturating_sub(TIGHT_CONTEXT));
528        let probe_end = input.ceil_char_boundary(input.len().min(mid + TIGHT_CONTEXT));
529        !input[probe_start..probe_end].contains('\n')
530    } else {
531        false
532    };
533    let effective = if is_single_line {
534        TIGHT_CONTEXT
535    } else {
536        context
537    };
538
539    let mut window_start = mid.saturating_sub(effective);
540    let mut window_end = input.len().min(mid + effective);
541
542    // Adjust to char boundaries to avoid panicking on multi-byte UTF-8
543    window_start = input.floor_char_boundary(window_start);
544    window_end = input.ceil_char_boundary(window_end);
545
546    if !is_single_line && context > TIGHT_CONTEXT {
547        // Multi-line with large context: round to nearest line boundaries for
548        // proper line-number display.  Guard against blowing up by 2x context.
549        window_start = if let Some(pos) = input[..window_start].rfind('\n') {
550            let line_start = pos + 1;
551            if window_start - line_start <= context * 2 {
552                line_start
553            } else {
554                window_start
555            }
556        } else {
557            window_start
558        };
559        window_end = if let Some(pos) = input[window_end..].find('\n') {
560            let line_end = window_end + pos + 1;
561            if line_end - window_end <= context * 2 {
562                line_end
563            } else {
564                window_end
565            }
566        } else {
567            window_end
568        };
569    }
570
571    let truncated = input[window_start..window_end].to_string();
572    let adjusted_span = Span::new(
573        byte_span.start.saturating_sub(window_start),
574        byte_span.end.saturating_sub(window_start),
575    );
576    (truncated, adjusted_span)
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn truncated_source_window_middle() {
585        // 40 bytes of padding on each side, "ERROR" spanning bytes 40..45
586        // mid = (40+45)/2 = 42
587        // window_start = 42-8 = 34, window_end = min(85,42+8) = 50
588        let input = format!("{:a<40}ERROR{:b<40}", "", "");
589        assert_eq!(input.len(), 85);
590        let byte_span = Span::new(40, 45);
591        let (src, span) = truncated_source_window(&input, byte_span, 8);
592        assert!(
593            src.contains("ERROR"),
594            "truncated source should contain the error"
595        );
596        assert_eq!(span.start, 6, "40 - 34 = 6");
597        assert_eq!(span.end, 11, "45 - 34 = 11");
598    }
599
600    #[test]
601    fn truncated_source_window_near_start() {
602        // mid = (0+4)/2 = 2
603        // window_start = 2-8 = 0 (saturated), window_end = min(80, 2+8) = 10
604        let input = format!("{:x<80}", "");
605        let byte_span = Span::new(0, 4);
606        let (src, span) = truncated_source_window(&input, byte_span, 8);
607        assert_eq!(span.start, 0, "0 - 0 = 0");
608        assert_eq!(span.end, 4, "4 - 0 = 4");
609        assert_eq!(src.len(), 10, "window [0, 10) is 10 bytes");
610    }
611
612    #[test]
613    fn truncated_source_window_near_end() {
614        // mid = (76+80)/2 = 78
615        // window_start = 78-8 = 70, window_end = min(80, 78+8) = 80
616        let input = format!("{:x<80}", "");
617        let byte_span = Span::new(76, 80);
618        let (src, span) = truncated_source_window(&input, byte_span, 8);
619        assert_eq!(span.start, 6, "76 - 70 = 6");
620        assert_eq!(span.end, 10, "80 - 70 = 10");
621        assert_eq!(src.len(), 10, "window [70, 80) is 10 bytes");
622    }
623
624    #[test]
625    fn truncated_source_window_small_input() {
626        let input = "small";
627        let byte_span = Span::new(2, 4);
628        let (src, span) = truncated_source_window(input, byte_span, 100);
629        // Input is smaller than context, so window should be the entire input
630        assert_eq!(
631            src, "small",
632            "should be the full input when context > input.len()"
633        );
634        assert_eq!(span.start, 2, "adjusted span start should match original");
635        assert_eq!(span.end, 4, "adjusted span end should match original");
636    }
637
638    #[test]
639    fn truncated_source_window_span_adjustment() {
640        // Use XXXXX as the error marker to avoid typos-tool false
641        // positives on an error-word adjacent to other characters.
642        let input = "aaaaaaaaaaXXXXXbbbbbbbbbb"; // 10 a's, 5 X's, 10 b's = 25 bytes
643        // Error from byte 10 to byte 15 -> "XXXXX"
644        let byte_span = Span::new(10, 15);
645        let (src, span) = truncated_source_window(input, byte_span, 5);
646        // Window: mid=12, window_start = 12-5 = 7, window_end = 12+5 = 17
647        // src = input[7..17] = "aaaXXXXXbb" (3 a's + 5 X's + 2 b's = 10 bytes)
648        assert_eq!(src.len(), 10, "window should be 10 bytes");
649        assert!(src.starts_with("aaa"), "window should start with aaa");
650        assert!(src.ends_with("bb"), "window should end with bb");
651        assert!(
652            src.contains("XXXXX"),
653            "window should contain the error marker"
654        );
655        // Adjusted: byte_span.start - window_start = 10-7 = 3
656        assert_eq!(
657            span.start, 3,
658            "adjusted start should be original - window_start"
659        );
660        assert_eq!(
661            span.end, 8,
662            "adjusted end should be original - window_start"
663        );
664        assert_eq!(
665            &src[3..8],
666            "XXXXX",
667            "error marker should be at the right adjusted position"
668        );
669    }
670
671    #[test]
672    fn truncated_source_window_zero_width_span() {
673        let input = "abcdefghijklmnopqrstuvwxyz";
674        let byte_span = Span::new(13, 13); // middle of alphabet
675        let (src, span) = truncated_source_window(input, byte_span, 5);
676        assert_eq!(
677            span.start, span.end,
678            "zero-width span should stay zero-width"
679        );
680        assert!(src.len() <= 11, "window should be bounded");
681    }
682
683    #[test]
684    fn truncated_source_window_multibyte_utf8() {
685        // Chinese chars are 3 bytes each; slicing at arbitrary byte offsets must not panic
686        let input = "你好世界ERROR世界";
687        // "ERROR" starts at byte 12 (4 chars × 3 bytes)
688        let byte_span = Span::new(12, 17);
689        let (src, span) = truncated_source_window(input, byte_span, 3);
690        assert!(
691            src.contains("ERROR"),
692            "window must contain the error region"
693        );
694        assert_eq!(
695            &src[span.start..span.end],
696            "ERROR",
697            "adjusted span must slice correctly"
698        );
699    }
700
701    #[test]
702    fn truncated_source_window_multibyte_utf8_boundary_crossing() {
703        // Force window bounds into the middle of multi-byte chars
704        // "aaaaa" (5 bytes) + "你好世界" (12 bytes) + "ERROR" (5 bytes) + "世界你好" (12 bytes)
705        let input = "aaaaa你好世界ERROR世界你好";
706        // "ERROR" at bytes 17..22
707        let byte_span = Span::new(17, 22);
708        // context=8 should give enough room while crossing multi-byte boundaries
709        let (src, span) = truncated_source_window(input, byte_span, 8);
710        assert!(
711            src.contains("ERROR"),
712            "window must contain the error region"
713        );
714        assert_eq!(&src[span.start..span.end], "ERROR");
715    }
716
717    #[test]
718    fn truncated_source_window_single_line_minified() {
719        // Simulate a minified JSON file: one giant line, error near the end.
720        let mut input = String::new();
721        input.push_str(&"\"key\":\"value\",".repeat(500)); // 14 bytes each
722        let err_byte = input.len(); // byte right after the valid part
723        input.push_str("\"broken"); // syntax error starts here
724        let byte_span = Span::new(err_byte, err_byte + 1); // the opening quote of "broken
725        let (src, span) = truncated_source_window(&input, byte_span, DEFAULT_ERROR_CONTEXT);
726        // The window should be tight (no wall of text) for single-line input.
727        assert!(
728            src.len() < 1000,
729            "single-line window should be tight, got {} bytes",
730            src.len()
731        );
732        assert_eq!(
733            &src[span.start..span.end],
734            "\"",
735            "should point at the opening quote"
736        );
737    }
738
739    #[test]
740    fn truncated_source_window_multiline_uses_full_context() {
741        // Multi-line input should get the full context window with line boundaries.
742        let mut input = String::new();
743        for i in 0..200 {
744            use std::fmt::Write;
745            writeln!(&mut input, "line {i}").unwrap();
746        }
747        input.push_str("ERROR here\nlast line");
748        // "ERROR here" starts at some point in the file
749        let err_offset = input.find("ERROR").expect("ERROR should be in input");
750        let byte_span = Span::new(err_offset, err_offset + 5);
751        // Use a generous context to show it's not truncated to tight window
752        let (src, span) = truncated_source_window(&input, byte_span, DEFAULT_ERROR_CONTEXT);
753        // Multi-line: should contain the whole lines around the error, not tight
754        assert!(
755            src.len() > 1000,
756            "multi-line window should be large, got {} bytes",
757            src.len()
758        );
759        assert!(src.contains("ERROR"), "should contain the error region");
760        assert_eq!(&src[span.start..span.end], "ERROR");
761    }
762}