typst_library/
diag.rs

1//! Diagnostics.
2
3use std::fmt::{self, Display, Formatter, Write as _};
4use std::io;
5use std::path::{Path, PathBuf};
6use std::str::Utf8Error;
7use std::string::FromUtf8Error;
8
9use az::SaturatingAs;
10use comemo::Tracked;
11use ecow::{EcoVec, eco_vec};
12use typst_syntax::package::{PackageSpec, PackageVersion};
13use typst_syntax::{Lines, Span, Spanned, SyntaxError};
14use utf8_iter::ErrorReportingUtf8Chars;
15
16use crate::engine::Engine;
17use crate::loading::{LoadSource, Loaded};
18use crate::{World, WorldExt};
19
20/// Early-return with a [`StrResult`] or [`SourceResult`].
21///
22/// If called with just a string and format args, returns with a
23/// `StrResult`. If called with a span, a string and format args, returns
24/// a `SourceResult`.
25///
26/// You can also emit hints with the `; hint: "..."` syntax.
27///
28/// ```ignore
29/// bail!("bailing with a {}", "string result");
30/// bail!(span, "bailing with a {}", "source result");
31/// bail!(
32///     span, "bailing with a {}", "source result";
33///     hint: "hint 1"
34/// );
35/// bail!(
36///     span, "bailing with a {}", "source result";
37///     hint: "hint 1";
38///     hint: "hint 2";
39/// );
40/// ```
41#[macro_export]
42#[doc(hidden)]
43macro_rules! __bail {
44    // For bail!("just a {}", "string")
45    (
46        $fmt:literal $(, $arg:expr)*
47        $(; hint: $hint:literal $(, $hint_arg:expr)*)*
48        $(,)?
49    ) => {
50        return Err($crate::diag::error!(
51            $fmt $(, $arg)*
52            $(; hint: $hint $(, $hint_arg)*)*
53        ))
54    };
55
56    // For bail!(error!(..))
57    ($error:expr) => {
58        return Err(::ecow::eco_vec![$error])
59    };
60
61    // For bail(span, ...)
62    ($($tts:tt)*) => {
63        return Err(::ecow::eco_vec![$crate::diag::error!($($tts)*)])
64    };
65}
66
67/// Construct an [`EcoString`], [`HintedString`] or [`SourceDiagnostic`] with
68/// severity `Error`.
69#[macro_export]
70#[doc(hidden)]
71macro_rules! __error {
72    // For bail!("just a {}", "string").
73    ($fmt:literal $(, $arg:expr)* $(,)?) => {
74        $crate::diag::eco_format!($fmt, $($arg),*).into()
75    };
76
77    // For bail!("a hinted {}", "string"; hint: "some hint"; hint: "...")
78    (
79        $fmt:literal $(, $arg:expr)*
80        $(; hint: $hint:literal $(, $hint_arg:expr)*)*
81        $(,)?
82    ) => {
83        $crate::diag::HintedString::new(
84            $crate::diag::eco_format!($fmt, $($arg),*)
85        ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))*
86    };
87
88    // For bail!(span, ...)
89    (
90        $span:expr, $fmt:literal $(, $arg:expr)*
91        $(; hint: $hint:literal $(, $hint_arg:expr)*)*
92        $(,)?
93    ) => {
94        $crate::diag::SourceDiagnostic::error(
95            $span,
96            $crate::diag::eco_format!($fmt, $($arg),*),
97        )  $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))*
98    };
99}
100
101/// Construct a [`SourceDiagnostic`] with severity `Warning`.
102///
103/// You can also emit hints with the `; hint: "..."` syntax.
104///
105/// ```ignore
106/// warning!(span, "warning with a {}", "source result");
107/// warning!(
108///     span, "warning with a {}", "source result";
109///     hint: "hint 1"
110/// );
111/// warning!(
112///     span, "warning with a {}", "source result";
113///     hint: "hint 1";
114///     hint: "hint 2";
115/// );
116/// ```
117#[macro_export]
118#[doc(hidden)]
119macro_rules! __warning {
120    (
121        $span:expr,
122        $fmt:literal $(, $arg:expr)*
123        $(; hint: $hint:literal $(, $hint_arg:expr)*)*
124        $(,)?
125    ) => {
126        $crate::diag::SourceDiagnostic::warning(
127            $span,
128            $crate::diag::eco_format!($fmt, $($arg),*),
129        ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))*
130    };
131}
132
133#[rustfmt::skip]
134#[doc(inline)]
135pub use {
136    crate::__bail as bail,
137    crate::__error as error,
138    crate::__warning as warning,
139    ecow::{eco_format, EcoString},
140};
141
142/// A result that can carry multiple source errors.
143pub type SourceResult<T> = Result<T, EcoVec<SourceDiagnostic>>;
144
145/// An output alongside warnings generated while producing it.
146#[derive(Debug, Clone, Eq, PartialEq, Hash)]
147pub struct Warned<T> {
148    /// The produced output.
149    pub output: T,
150    /// Warnings generated while producing the output.
151    pub warnings: EcoVec<SourceDiagnostic>,
152}
153
154impl<T> Warned<T> {
155    /// Maps the output, keeping the same warnings.
156    pub fn map<R, F: FnOnce(T) -> R>(self, f: F) -> Warned<R> {
157        Warned { output: f(self.output), warnings: self.warnings }
158    }
159}
160
161/// An error or warning in a source or text file.
162///
163/// The contained spans will only be detached if any of the input source files
164/// were detached.
165#[derive(Debug, Clone, Eq, PartialEq, Hash)]
166pub struct SourceDiagnostic {
167    /// Whether the diagnostic is an error or a warning.
168    pub severity: Severity,
169    /// The span of the relevant node in the source code.
170    pub span: Span,
171    /// A diagnostic message describing the problem.
172    pub message: EcoString,
173    /// The trace of function calls leading to the problem.
174    pub trace: EcoVec<Spanned<Tracepoint>>,
175    /// Additional hints to the user, indicating how this problem could be avoided
176    /// or worked around.
177    pub hints: EcoVec<EcoString>,
178}
179
180/// The severity of a [`SourceDiagnostic`].
181#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
182pub enum Severity {
183    /// A fatal error.
184    Error,
185    /// A non-fatal warning.
186    Warning,
187}
188
189impl SourceDiagnostic {
190    /// Create a new, bare error.
191    pub fn error(span: Span, message: impl Into<EcoString>) -> Self {
192        Self {
193            severity: Severity::Error,
194            span,
195            trace: eco_vec![],
196            message: message.into(),
197            hints: eco_vec![],
198        }
199    }
200
201    /// Create a new, bare warning.
202    pub fn warning(span: Span, message: impl Into<EcoString>) -> Self {
203        Self {
204            severity: Severity::Warning,
205            span,
206            trace: eco_vec![],
207            message: message.into(),
208            hints: eco_vec![],
209        }
210    }
211
212    /// Adds a single hint to the diagnostic.
213    pub fn hint(&mut self, hint: impl Into<EcoString>) {
214        self.hints.push(hint.into());
215    }
216
217    /// Adds a single hint to the diagnostic.
218    pub fn with_hint(mut self, hint: impl Into<EcoString>) -> Self {
219        self.hint(hint);
220        self
221    }
222
223    /// Adds user-facing hints to the diagnostic.
224    pub fn with_hints(mut self, hints: impl IntoIterator<Item = EcoString>) -> Self {
225        self.hints.extend(hints);
226        self
227    }
228}
229
230impl From<SyntaxError> for SourceDiagnostic {
231    fn from(error: SyntaxError) -> Self {
232        Self {
233            severity: Severity::Error,
234            span: error.span,
235            message: error.message,
236            trace: eco_vec![],
237            hints: error.hints,
238        }
239    }
240}
241
242/// Destination for a deprecation message when accessing a deprecated value.
243pub trait DeprecationSink {
244    /// Emits the given deprecation message into this sink alongside a version
245    /// in which the deprecated item is planned to be removed.
246    fn emit(self, message: &str, until: Option<&str>);
247}
248
249impl DeprecationSink for () {
250    fn emit(self, _: &str, _: Option<&str>) {}
251}
252
253impl DeprecationSink for (&mut Engine<'_>, Span) {
254    /// Emits the deprecation message as a warning.
255    fn emit(self, message: &str, version: Option<&str>) {
256        self.0
257            .sink
258            .warn(SourceDiagnostic::warning(self.1, message).with_hints(
259                version.map(|v| eco_format!("it will be removed in Typst {}", v)),
260            ));
261    }
262}
263
264/// A part of a diagnostic's [trace](SourceDiagnostic::trace).
265#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
266pub enum Tracepoint {
267    /// A function call.
268    Call(Option<EcoString>),
269    /// A show rule application.
270    Show(EcoString),
271    /// A module import.
272    Import,
273}
274
275impl Display for Tracepoint {
276    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
277        match self {
278            Tracepoint::Call(Some(name)) => {
279                write!(f, "error occurred in this call of function `{name}`")
280            }
281            Tracepoint::Call(None) => {
282                write!(f, "error occurred in this function call")
283            }
284            Tracepoint::Show(name) => {
285                write!(f, "error occurred while applying show rule to this {name}")
286            }
287            Tracepoint::Import => {
288                write!(f, "error occurred while importing this module")
289            }
290        }
291    }
292}
293
294/// Enrich a [`SourceResult`] with a tracepoint.
295pub trait Trace<T> {
296    /// Add the tracepoint to all errors that lie outside the `span`.
297    fn trace<F>(self, world: Tracked<dyn World + '_>, make_point: F, span: Span) -> Self
298    where
299        F: Fn() -> Tracepoint;
300}
301
302impl<T> Trace<T> for SourceResult<T> {
303    fn trace<F>(self, world: Tracked<dyn World + '_>, make_point: F, span: Span) -> Self
304    where
305        F: Fn() -> Tracepoint,
306    {
307        self.map_err(|mut errors| {
308            let Some(trace_range) = world.range(span) else { return errors };
309            for error in errors.make_mut().iter_mut() {
310                // Skip traces that surround the error.
311                if let Some(error_range) = world.range(error.span)
312                    && error.span.id() == span.id()
313                    && trace_range.start <= error_range.start
314                    && trace_range.end >= error_range.end
315                {
316                    continue;
317                }
318
319                error.trace.push(Spanned::new(make_point(), span));
320            }
321            errors
322        })
323    }
324}
325
326/// A result type with a string error message.
327pub type StrResult<T> = Result<T, EcoString>;
328
329/// Convert a [`StrResult`] or [`HintedStrResult`] to a [`SourceResult`] by
330/// adding span information.
331pub trait At<T> {
332    /// Add the span information.
333    fn at(self, span: Span) -> SourceResult<T>;
334}
335
336impl<T, S> At<T> for Result<T, S>
337where
338    S: Into<EcoString>,
339{
340    fn at(self, span: Span) -> SourceResult<T> {
341        self.map_err(|message| {
342            let mut diagnostic = SourceDiagnostic::error(span, message);
343            if diagnostic.message.contains("(access denied)") {
344                diagnostic.hint("cannot read file outside of project root");
345                diagnostic
346                    .hint("you can adjust the project root with the --root argument");
347            }
348            eco_vec![diagnostic]
349        })
350    }
351}
352
353/// A result type with a string error message and hints.
354pub type HintedStrResult<T> = Result<T, HintedString>;
355
356/// A string message with hints.
357///
358/// This is internally represented by a vector of strings.
359/// The first element of the vector contains the message.
360/// The remaining elements are the hints.
361/// This is done to reduce the size of a HintedString.
362/// The vector is guaranteed to not be empty.
363#[derive(Debug, Clone, Eq, PartialEq, Hash)]
364pub struct HintedString(EcoVec<EcoString>);
365
366impl HintedString {
367    /// Creates a new hinted string with the given message.
368    pub fn new(message: EcoString) -> Self {
369        Self(eco_vec![message])
370    }
371
372    /// A diagnostic message describing the problem.
373    pub fn message(&self) -> &EcoString {
374        self.0.first().unwrap()
375    }
376
377    /// Additional hints to the user, indicating how this error could be avoided
378    /// or worked around.
379    pub fn hints(&self) -> &[EcoString] {
380        self.0.get(1..).unwrap_or(&[])
381    }
382
383    /// Adds a single hint to the hinted string.
384    pub fn hint(&mut self, hint: impl Into<EcoString>) {
385        self.0.push(hint.into());
386    }
387
388    /// Adds a single hint to the hinted string.
389    pub fn with_hint(mut self, hint: impl Into<EcoString>) -> Self {
390        self.hint(hint);
391        self
392    }
393
394    /// Adds user-facing hints to the hinted string.
395    pub fn with_hints(mut self, hints: impl IntoIterator<Item = EcoString>) -> Self {
396        self.0.extend(hints);
397        self
398    }
399}
400
401impl<S> From<S> for HintedString
402where
403    S: Into<EcoString>,
404{
405    fn from(value: S) -> Self {
406        Self::new(value.into())
407    }
408}
409
410impl<T> At<T> for HintedStrResult<T> {
411    fn at(self, span: Span) -> SourceResult<T> {
412        self.map_err(|err| {
413            let mut components = err.0.into_iter();
414            let message = components.next().unwrap();
415            let diag = SourceDiagnostic::error(span, message).with_hints(components);
416            eco_vec![diag]
417        })
418    }
419}
420
421/// Enrich a [`StrResult`] or [`HintedStrResult`] with a hint.
422pub trait Hint<T> {
423    /// Add the hint.
424    fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T>;
425}
426
427impl<T, S> Hint<T> for Result<T, S>
428where
429    S: Into<EcoString>,
430{
431    fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T> {
432        self.map_err(|message| HintedString::new(message.into()).with_hint(hint))
433    }
434}
435
436impl<T> Hint<T> for HintedStrResult<T> {
437    fn hint(self, hint: impl Into<EcoString>) -> HintedStrResult<T> {
438        self.map_err(|mut error| {
439            error.hint(hint.into());
440            error
441        })
442    }
443}
444
445/// A result type with a file-related error.
446pub type FileResult<T> = Result<T, FileError>;
447
448/// An error that occurred while trying to load of a file.
449#[derive(Debug, Clone, Eq, PartialEq, Hash)]
450pub enum FileError {
451    /// A file was not found at this path.
452    NotFound(PathBuf),
453    /// A file could not be accessed.
454    AccessDenied,
455    /// A directory was found, but a file was expected.
456    IsDirectory,
457    /// The file is not a Typst source file, but should have been.
458    NotSource,
459    /// The file was not valid UTF-8, but should have been.
460    InvalidUtf8,
461    /// The package the file is part of could not be loaded.
462    Package(PackageError),
463    /// Another error.
464    ///
465    /// The optional string can give more details, if available.
466    Other(Option<EcoString>),
467}
468
469impl FileError {
470    /// Create a file error from an I/O error.
471    pub fn from_io(err: io::Error, path: &Path) -> Self {
472        match err.kind() {
473            io::ErrorKind::NotFound => Self::NotFound(path.into()),
474            io::ErrorKind::PermissionDenied => Self::AccessDenied,
475            io::ErrorKind::InvalidData
476                if err.to_string().contains("stream did not contain valid UTF-8") =>
477            {
478                Self::InvalidUtf8
479            }
480            _ => Self::Other(Some(eco_format!("{err}"))),
481        }
482    }
483}
484
485impl std::error::Error for FileError {}
486
487impl Display for FileError {
488    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
489        match self {
490            Self::NotFound(path) => {
491                write!(f, "file not found (searched at {})", path.display())
492            }
493            Self::AccessDenied => f.pad("failed to load file (access denied)"),
494            Self::IsDirectory => f.pad("failed to load file (is a directory)"),
495            Self::NotSource => f.pad("not a Typst source file"),
496            Self::InvalidUtf8 => f.pad("file is not valid utf-8"),
497            Self::Package(error) => error.fmt(f),
498            Self::Other(Some(err)) => write!(f, "failed to load file ({err})"),
499            Self::Other(None) => f.pad("failed to load file"),
500        }
501    }
502}
503
504impl From<Utf8Error> for FileError {
505    fn from(_: Utf8Error) -> Self {
506        Self::InvalidUtf8
507    }
508}
509
510impl From<FromUtf8Error> for FileError {
511    fn from(_: FromUtf8Error) -> Self {
512        Self::InvalidUtf8
513    }
514}
515
516impl From<PackageError> for FileError {
517    fn from(err: PackageError) -> Self {
518        Self::Package(err)
519    }
520}
521
522impl From<FileError> for EcoString {
523    fn from(err: FileError) -> Self {
524        eco_format!("{err}")
525    }
526}
527
528/// A result type with a package-related error.
529pub type PackageResult<T> = Result<T, PackageError>;
530
531/// An error that occurred while trying to load a package.
532///
533/// Some variants have an optional string can give more details, if available.
534#[derive(Debug, Clone, Eq, PartialEq, Hash)]
535pub enum PackageError {
536    /// The specified package does not exist.
537    NotFound(PackageSpec),
538    /// The specified package found, but the version does not exist.
539    VersionNotFound(PackageSpec, PackageVersion),
540    /// Failed to retrieve the package through the network.
541    NetworkFailed(Option<EcoString>),
542    /// The package archive was malformed.
543    MalformedArchive(Option<EcoString>),
544    /// Another error.
545    Other(Option<EcoString>),
546}
547
548impl std::error::Error for PackageError {}
549
550impl Display for PackageError {
551    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
552        match self {
553            Self::NotFound(spec) => {
554                write!(f, "package not found (searched for {spec})",)
555            }
556            Self::VersionNotFound(spec, latest) => {
557                write!(
558                    f,
559                    "package found, but version {} does not exist (latest is {})",
560                    spec.version, latest,
561                )
562            }
563            Self::NetworkFailed(Some(err)) => {
564                write!(f, "failed to download package ({err})")
565            }
566            Self::NetworkFailed(None) => f.pad("failed to download package"),
567            Self::MalformedArchive(Some(err)) => {
568                write!(f, "failed to decompress package ({err})")
569            }
570            Self::MalformedArchive(None) => {
571                f.pad("failed to decompress package (archive malformed)")
572            }
573            Self::Other(Some(err)) => write!(f, "failed to load package ({err})"),
574            Self::Other(None) => f.pad("failed to load package"),
575        }
576    }
577}
578
579impl From<PackageError> for EcoString {
580    fn from(err: PackageError) -> Self {
581        eco_format!("{err}")
582    }
583}
584
585/// A result type with a data-loading-related error.
586pub type LoadResult<T> = Result<T, LoadError>;
587
588/// A call site independent error that occurred during data loading. This avoids
589/// polluting the memoization with [`Span`]s and [`FileId`]s from source files.
590/// Can be turned into a [`SourceDiagnostic`] using the [`LoadedWithin::within`]
591/// method available on [`LoadResult`].
592///
593/// [`FileId`]: typst_syntax::FileId
594#[derive(Debug, Clone, Eq, PartialEq, Hash)]
595pub struct LoadError {
596    /// The position in the file at which the error occurred.
597    pos: ReportPos,
598    /// Must contain a message formatted like this: `"failed to do thing (cause)"`.
599    message: EcoString,
600}
601
602impl LoadError {
603    /// Creates a new error from a position in a file, a base message
604    /// (e.g. `failed to parse JSON`) and a concrete error (e.g. `invalid
605    /// number`)
606    pub fn new(
607        pos: impl Into<ReportPos>,
608        message: impl std::fmt::Display,
609        error: impl std::fmt::Display,
610    ) -> Self {
611        Self {
612            pos: pos.into(),
613            message: eco_format!("{message} ({error})"),
614        }
615    }
616}
617
618impl From<Utf8Error> for LoadError {
619    fn from(err: Utf8Error) -> Self {
620        let start = err.valid_up_to();
621        let end = start + err.error_len().unwrap_or(0);
622        LoadError::new(
623            start..end,
624            "failed to convert to string",
625            "file is not valid utf-8",
626        )
627    }
628}
629
630/// Convert a [`LoadResult`] to a [`SourceResult`] by adding the [`Loaded`]
631/// context.
632pub trait LoadedWithin<T> {
633    /// Report an error, possibly in an external file.
634    fn within(self, loaded: &Loaded) -> SourceResult<T>;
635}
636
637impl<T, E> LoadedWithin<T> for Result<T, E>
638where
639    E: Into<LoadError>,
640{
641    fn within(self, loaded: &Loaded) -> SourceResult<T> {
642        self.map_err(|err| {
643            let LoadError { pos, message } = err.into();
644            load_err_in_text(loaded, pos, message)
645        })
646    }
647}
648
649/// Report an error, possibly in an external file. This will delegate to
650/// [`load_err_in_invalid_text`] if the data isn't valid utf-8.
651fn load_err_in_text(
652    loaded: &Loaded,
653    pos: impl Into<ReportPos>,
654    mut message: EcoString,
655) -> EcoVec<SourceDiagnostic> {
656    let pos = pos.into();
657    // This also does utf-8 validation. Only report an error in an external
658    // file if it is human readable (valid utf-8), otherwise fall back to
659    // `load_err_in_invalid_text`.
660    let lines = Lines::try_from(&loaded.data);
661    match (loaded.source.v, lines) {
662        (LoadSource::Path(file_id), Ok(lines)) => {
663            if let Some(range) = pos.range(&lines) {
664                let span = Span::from_range(file_id, range);
665                return eco_vec![SourceDiagnostic::error(span, message)];
666            }
667
668            // Either `ReportPos::None` was provided, or resolving the range
669            // from the line/column failed. If present report the possibly
670            // wrong line/column in the error message anyway.
671            let span = Span::from_range(file_id, 0..loaded.data.len());
672            if let Some(pair) = pos.line_col(&lines) {
673                message.pop();
674                let (line, col) = pair.numbers();
675                write!(&mut message, " at {line}:{col})").ok();
676            }
677            eco_vec![SourceDiagnostic::error(span, message)]
678        }
679        (LoadSource::Bytes, Ok(lines)) => {
680            if let Some(pair) = pos.line_col(&lines) {
681                message.pop();
682                let (line, col) = pair.numbers();
683                write!(&mut message, " at {line}:{col})").ok();
684            }
685            eco_vec![SourceDiagnostic::error(loaded.source.span, message)]
686        }
687        _ => load_err_in_invalid_text(loaded, pos, message),
688    }
689}
690
691/// Report an error (possibly from an external file) that isn't valid utf-8.
692fn load_err_in_invalid_text(
693    loaded: &Loaded,
694    pos: impl Into<ReportPos>,
695    mut message: EcoString,
696) -> EcoVec<SourceDiagnostic> {
697    let line_col = pos.into().try_line_col(&loaded.data).map(|p| p.numbers());
698    match (loaded.source.v, line_col) {
699        (LoadSource::Path(file), _) => {
700            message.pop();
701            if let Some(package) = file.package() {
702                write!(
703                    &mut message,
704                    " in {package}{}",
705                    file.vpath().as_rooted_path().display()
706                )
707                .ok();
708            } else {
709                write!(&mut message, " in {}", file.vpath().as_rootless_path().display())
710                    .ok();
711            };
712            if let Some((line, col)) = line_col {
713                write!(&mut message, ":{line}:{col}").ok();
714            }
715            message.push(')');
716        }
717        (LoadSource::Bytes, Some((line, col))) => {
718            message.pop();
719            write!(&mut message, " at {line}:{col})").ok();
720        }
721        (LoadSource::Bytes, None) => (),
722    }
723    eco_vec![SourceDiagnostic::error(loaded.source.span, message)]
724}
725
726/// A position at which an error was reported.
727#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
728pub enum ReportPos {
729    /// Contains a range, and a line/column pair.
730    Full(std::ops::Range<u32>, LineCol),
731    /// Contains a range.
732    Range(std::ops::Range<u32>),
733    /// Contains a line/column pair.
734    LineCol(LineCol),
735    #[default]
736    None,
737}
738
739impl From<std::ops::Range<usize>> for ReportPos {
740    fn from(value: std::ops::Range<usize>) -> Self {
741        Self::Range(value.start.saturating_as()..value.end.saturating_as())
742    }
743}
744
745impl From<LineCol> for ReportPos {
746    fn from(value: LineCol) -> Self {
747        Self::LineCol(value)
748    }
749}
750
751impl ReportPos {
752    /// Creates a position from a pre-existing range and line-column pair.
753    pub fn full(range: std::ops::Range<usize>, pair: LineCol) -> Self {
754        let range = range.start.saturating_as()..range.end.saturating_as();
755        Self::Full(range, pair)
756    }
757
758    /// Tries to determine the byte range for this position.
759    fn range(&self, lines: &Lines<String>) -> Option<std::ops::Range<usize>> {
760        match self {
761            ReportPos::Full(range, _) => Some(range.start as usize..range.end as usize),
762            ReportPos::Range(range) => Some(range.start as usize..range.end as usize),
763            &ReportPos::LineCol(pair) => {
764                let i =
765                    lines.line_column_to_byte(pair.line as usize, pair.col as usize)?;
766                Some(i..i)
767            }
768            ReportPos::None => None,
769        }
770    }
771
772    /// Tries to determine the line/column for this position.
773    fn line_col(&self, lines: &Lines<String>) -> Option<LineCol> {
774        match self {
775            &ReportPos::Full(_, pair) => Some(pair),
776            ReportPos::Range(range) => {
777                let (line, col) = lines.byte_to_line_column(range.start as usize)?;
778                Some(LineCol::zero_based(line, col))
779            }
780            &ReportPos::LineCol(pair) => Some(pair),
781            ReportPos::None => None,
782        }
783    }
784
785    /// Either gets the line/column pair, or tries to compute it from possibly
786    /// invalid utf-8 data.
787    fn try_line_col(&self, bytes: &[u8]) -> Option<LineCol> {
788        match self {
789            &ReportPos::Full(_, pair) => Some(pair),
790            ReportPos::Range(range) => {
791                LineCol::try_from_byte_pos(range.start as usize, bytes)
792            }
793            &ReportPos::LineCol(pair) => Some(pair),
794            ReportPos::None => None,
795        }
796    }
797}
798
799/// A line/column pair.
800#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
801pub struct LineCol {
802    /// The 0-based line.
803    line: u32,
804    /// The 0-based column.
805    col: u32,
806}
807
808impl LineCol {
809    /// Constructs the line/column pair from 0-based indices.
810    pub fn zero_based(line: usize, col: usize) -> Self {
811        Self {
812            line: line.saturating_as(),
813            col: col.saturating_as(),
814        }
815    }
816
817    /// Constructs the line/column pair from 1-based numbers.
818    pub fn one_based(line: usize, col: usize) -> Self {
819        Self::zero_based(line.saturating_sub(1), col.saturating_sub(1))
820    }
821
822    /// Try to compute a line/column pair from possibly invalid utf-8 data.
823    pub fn try_from_byte_pos(pos: usize, bytes: &[u8]) -> Option<Self> {
824        let bytes = &bytes[..pos];
825        let mut line = 0;
826        #[allow(clippy::double_ended_iterator_last)]
827        let line_start = memchr::memchr_iter(b'\n', bytes)
828            .inspect(|_| line += 1)
829            .last()
830            .map(|i| i + 1)
831            .unwrap_or(bytes.len());
832
833        let col = ErrorReportingUtf8Chars::new(&bytes[line_start..]).count();
834        Some(LineCol::zero_based(line, col))
835    }
836
837    /// Returns the 0-based line/column indices.
838    pub fn indices(&self) -> (usize, usize) {
839        (self.line as usize, self.col as usize)
840    }
841
842    /// Returns the 1-based line/column numbers.
843    pub fn numbers(&self) -> (usize, usize) {
844        (self.line as usize + 1, self.col as usize + 1)
845    }
846}
847
848/// Format a user-facing error message for an XML-like file format.
849pub fn format_xml_like_error(format: &str, error: roxmltree::Error) -> LoadError {
850    let pos = LineCol::one_based(error.pos().row as usize, error.pos().col as usize);
851    let message = match error {
852        roxmltree::Error::UnexpectedCloseTag(expected, actual, _) => {
853            eco_format!(
854                "failed to parse {format} (found closing tag '{actual}' instead of '{expected}')"
855            )
856        }
857        roxmltree::Error::UnknownEntityReference(entity, _) => {
858            eco_format!("failed to parse {format} (unknown entity '{entity}')")
859        }
860        roxmltree::Error::DuplicatedAttribute(attr, _) => {
861            eco_format!("failed to parse {format} (duplicate attribute '{attr}')")
862        }
863        roxmltree::Error::NoRootNode => {
864            eco_format!("failed to parse {format} (missing root node)")
865        }
866        err => eco_format!("failed to parse {format} ({err})"),
867    };
868
869    LoadError { pos: pos.into(), message }
870}