//! Diagnostics. use std::fmt::{self, Display, Formatter, Write as _}; use std::io; use std::path::{Path, PathBuf}; use std::str::Utf8Error; use std::string::FromUtf8Error; use az::SaturatingAs; use comemo::Tracked; use ecow::{EcoVec, eco_vec}; use typst_syntax::package::{PackageSpec, PackageVersion}; use typst_syntax::{Lines, Span, Spanned, SyntaxError}; use utf8_iter::ErrorReportingUtf8Chars; use crate::engine::Engine; use crate::loading::{LoadSource, Loaded}; use crate::{World, WorldExt}; /// Early-return with a [`StrResult`] or [`SourceResult`]. /// /// If called with just a string and format args, returns with a /// `StrResult`. If called with a span, a string and format args, returns /// a `SourceResult`. /// /// You can also emit hints with the `; hint: "..."` syntax. /// /// ```ignore /// bail!("bailing with a {}", "string result"); /// bail!(span, "bailing with a {}", "source result"); /// bail!( /// span, "bailing with a {}", "source result"; /// hint: "hint 1" /// ); /// bail!( /// span, "bailing with a {}", "source result"; /// hint: "hint 1"; /// hint: "hint 2"; /// ); /// ``` #[macro_export] #[doc(hidden)] macro_rules! __bail { // For bail!("just a {}", "string") ( $fmt:literal $(, $arg:expr)* $(; hint: $hint:literal $(, $hint_arg:expr)*)* $(,)? ) => { return Err($crate::diag::error!( $fmt $(, $arg)* $(; hint: $hint $(, $hint_arg)*)* )) }; // For bail!(error!(..)) ($error:expr) => { return Err(::ecow::eco_vec![$error]) }; // For bail(span, ...) ($($tts:tt)*) => { return Err(::ecow::eco_vec![$crate::diag::error!($($tts)*)]) }; } /// Construct an [`EcoString`], [`HintedString`] or [`SourceDiagnostic`] with /// severity `Error`. #[macro_export] #[doc(hidden)] macro_rules! __error { // For bail!("just a {}", "string"). ($fmt:literal $(, $arg:expr)* $(,)?) => { $crate::diag::eco_format!($fmt, $($arg),*).into() }; // For bail!("a hinted {}", "string"; hint: "some hint"; hint: "...") ( $fmt:literal $(, $arg:expr)* $(; hint: $hint:literal $(, $hint_arg:expr)*)* $(,)? ) => { $crate::diag::HintedString::new( $crate::diag::eco_format!($fmt, $($arg),*) ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))* }; // For bail!(span, ...) ( $span:expr, $fmt:literal $(, $arg:expr)* $(; hint: $hint:literal $(, $hint_arg:expr)*)* $(,)? ) => { $crate::diag::SourceDiagnostic::error( $span, $crate::diag::eco_format!($fmt, $($arg),*), ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))* }; } /// Construct a [`SourceDiagnostic`] with severity `Warning`. /// /// You can also emit hints with the `; hint: "..."` syntax. /// /// ```ignore /// warning!(span, "warning with a {}", "source result"); /// warning!( /// span, "warning with a {}", "source result"; /// hint: "hint 1" /// ); /// warning!( /// span, "warning with a {}", "source result"; /// hint: "hint 1"; /// hint: "hint 2"; /// ); /// ``` #[macro_export] #[doc(hidden)] macro_rules! __warning { ( $span:expr, $fmt:literal $(, $arg:expr)* $(; hint: $hint:literal $(, $hint_arg:expr)*)* $(,)? ) => { $crate::diag::SourceDiagnostic::warning( $span, $crate::diag::eco_format!($fmt, $($arg),*), ) $(.with_hint($crate::diag::eco_format!($hint, $($hint_arg),*)))* }; } #[rustfmt::skip] #[doc(inline)] pub use { crate::__bail as bail, crate::__error as error, crate::__warning as warning, ecow::{eco_format, EcoString}, }; /// A result that can carry multiple source errors. pub type SourceResult = Result>; /// An output alongside warnings generated while producing it. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Warned { /// The produced output. pub output: T, /// Warnings generated while producing the output. pub warnings: EcoVec, } /// An error or warning in a source or text file. /// /// The contained spans will only be detached if any of the input source files /// were detached. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct SourceDiagnostic { /// Whether the diagnostic is an error or a warning. pub severity: Severity, /// The span of the relevant node in the source code. pub span: Span, /// A diagnostic message describing the problem. pub message: EcoString, /// The trace of function calls leading to the problem. pub trace: EcoVec>, /// Additional hints to the user, indicating how this problem could be avoided /// or worked around. pub hints: EcoVec, } /// The severity of a [`SourceDiagnostic`]. #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] pub enum Severity { /// A fatal error. Error, /// A non-fatal warning. Warning, } impl SourceDiagnostic { /// Create a new, bare error. pub fn error(span: Span, message: impl Into) -> Self { Self { severity: Severity::Error, span, trace: eco_vec![], message: message.into(), hints: eco_vec![], } } /// Create a new, bare warning. pub fn warning(span: Span, message: impl Into) -> Self { Self { severity: Severity::Warning, span, trace: eco_vec![], message: message.into(), hints: eco_vec![], } } /// Adds a single hint to the diagnostic. pub fn hint(&mut self, hint: impl Into) { self.hints.push(hint.into()); } /// Adds a single hint to the diagnostic. pub fn with_hint(mut self, hint: impl Into) -> Self { self.hint(hint); self } /// Adds user-facing hints to the diagnostic. pub fn with_hints(mut self, hints: impl IntoIterator) -> Self { self.hints.extend(hints); self } } impl From for SourceDiagnostic { fn from(error: SyntaxError) -> Self { Self { severity: Severity::Error, span: error.span, message: error.message, trace: eco_vec![], hints: error.hints, } } } /// Destination for a deprecation message when accessing a deprecated value. pub trait DeprecationSink { /// Emits the given deprecation message into this sink. fn emit(self, message: &str); } impl DeprecationSink for () { fn emit(self, _: &str) {} } impl DeprecationSink for (&mut Engine<'_>, Span) { /// Emits the deprecation message as a warning. fn emit(self, message: &str) { self.0.sink.warn(SourceDiagnostic::warning(self.1, message)); } } /// A part of a diagnostic's [trace](SourceDiagnostic::trace). #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub enum Tracepoint { /// A function call. Call(Option), /// A show rule application. Show(EcoString), /// A module import. Import, } impl Display for Tracepoint { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { Tracepoint::Call(Some(name)) => { write!(f, "error occurred in this call of function `{name}`") } Tracepoint::Call(None) => { write!(f, "error occurred in this function call") } Tracepoint::Show(name) => { write!(f, "error occurred while applying show rule to this {name}") } Tracepoint::Import => { write!(f, "error occurred while importing this module") } } } } /// Enrich a [`SourceResult`] with a tracepoint. pub trait Trace { /// Add the tracepoint to all errors that lie outside the `span`. fn trace(self, world: Tracked, make_point: F, span: Span) -> Self where F: Fn() -> Tracepoint; } impl Trace for SourceResult { fn trace(self, world: Tracked, make_point: F, span: Span) -> Self where F: Fn() -> Tracepoint, { self.map_err(|mut errors| { let Some(trace_range) = world.range(span) else { return errors }; for error in errors.make_mut().iter_mut() { // Skip traces that surround the error. if let Some(error_range) = world.range(error.span) && error.span.id() == span.id() && trace_range.start <= error_range.start && trace_range.end >= error_range.end { continue; } error.trace.push(Spanned::new(make_point(), span)); } errors }) } } /// A result type with a string error message. pub type StrResult = Result; /// A common trait to add a span to any error pub trait ErrAt { /// Attach a span to a more specialized error and turn it into /// a generic error, optionally with hints. fn err_at(self, span: Span) -> SourceDiagnostic; } /// Blanket implementation for anything that doesn't implement its own /// convertion to a SourceDiagnostic. impl ErrAt for S where S: Into, { fn err_at(self, span: Span) -> SourceDiagnostic { SourceDiagnostic::error(span, self) } } /// Convert a [`StrResult`] or [`HintedStrResult`] to a [`SourceResult`] by /// adding span information. pub trait At { /// Add the span information. fn at(self, span: Span) -> SourceResult; } impl At for Result where S: ErrAt, { fn at(self, span: Span) -> SourceResult { self.map_err(|e| eco_vec![e.err_at(span)]) } } /// A result type with a string error message and hints. pub type HintedStrResult = Result; /// A string message with hints. /// /// This is internally represented by a vector of strings. /// The first element of the vector contains the message. /// The remaining elements are the hints. /// This is done to reduce the size of a HintedString. /// The vector is guaranteed to not be empty. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct HintedString(EcoVec); impl HintedString { /// Creates a new hinted string with the given message. pub fn new(message: EcoString) -> Self { Self(eco_vec![message]) } /// A diagnostic message describing the problem. pub fn message(&self) -> &EcoString { self.0.first().unwrap() } /// Additional hints to the user, indicating how this error could be avoided /// or worked around. pub fn hints(&self) -> &[EcoString] { self.0.get(1..).unwrap_or(&[]) } /// Adds a single hint to the hinted string. pub fn hint(&mut self, hint: impl Into) { self.0.push(hint.into()); } /// Adds a single hint to the hinted string. pub fn with_hint(mut self, hint: impl Into) -> Self { self.hint(hint); self } /// Adds user-facing hints to the hinted string. pub fn with_hints(mut self, hints: impl IntoIterator) -> Self { self.0.extend(hints); self } } impl From for HintedString where S: Into, { fn from(value: S) -> Self { Self::new(value.into()) } } impl At for HintedStrResult { fn at(self, span: Span) -> SourceResult { self.map_err(|err| { let mut components = err.0.into_iter(); let message = components.next().unwrap(); let diag = SourceDiagnostic::error(span, message).with_hints(components); eco_vec![diag] }) } } /// Enrich a [`StrResult`] or [`HintedStrResult`] with a hint. pub trait Hint { /// Add the hint. fn hint(self, hint: impl Into) -> HintedStrResult; } impl Hint for Result where S: Into, { fn hint(self, hint: impl Into) -> HintedStrResult { self.map_err(|message| HintedString::new(message.into()).with_hint(hint)) } } impl Hint for HintedStrResult { fn hint(self, hint: impl Into) -> HintedStrResult { self.map_err(|mut error| { error.hint(hint.into()); error }) } } /// A result type with a file-related error. pub type FileResult = Result; /// An error that occurred while trying to load of a file. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum FileError { /// A file was not found at this path. NotFound(PathBuf), /// A file could not be accessed. AccessDenied(PathBuf), /// A directory was found, but a file was expected. IsDirectory(PathBuf), /// The file is not a Typst source file, but should have been. NotSource(PathBuf), /// The file was not valid UTF-8, but should have been. InvalidUtf8(Option), /// The package the file is part of could not be loaded. Package(PackageError), /// Another error. /// /// The optional string can give more details, if available. Other(Option), } impl FileError { /// Create a file error from an I/O error. pub fn from_io(err: io::Error, path: &Path) -> Self { match err.kind() { io::ErrorKind::NotFound => Self::NotFound(path.into()), io::ErrorKind::PermissionDenied => Self::AccessDenied(path.into()), io::ErrorKind::InvalidData if err.to_string().contains("stream did not contain valid UTF-8") => { Self::InvalidUtf8(Some(path.into())) } _ => Self::Other(Some(eco_format!("{err}"))), } } fn write_hints(&self) -> EcoVec { match self { Self::NotFound(_) => eco_vec![], Self::AccessDenied(_) => { eco_vec![ eco_format!("cannot read file outside of project root"), eco_format!( "you can adjust the project root with the --root argument" ), ] } Self::IsDirectory(_) => eco_vec![], Self::NotSource(_) => eco_vec![], Self::InvalidUtf8(Some(path)) => { eco_vec![eco_format!("tried to read {}", path.display())] } Self::InvalidUtf8(None) => eco_vec![], Self::Package(error) => error.write_hints(), Self::Other(Some(err)) => eco_vec![eco_format!("{err}")], Self::Other(None) => eco_vec![], } } } impl std::error::Error for FileError {} impl Display for FileError { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { Self::NotFound(path) => { write!(f, "file not found (searched at {})", path.display()) } Self::NotSource(path) => { write!(f, "{} is not a typst source file", path.display()) } Self::InvalidUtf8(_) => write!(f, "file is not valid utf-8"), Self::IsDirectory(path) => { write!(f, "failed to load file {} (is a directory)", path.display()) } Self::AccessDenied(path) => { write!(f, "failed to load file {} (access denied)", path.display()) } Self::Other(_) => { write!(f, "failed to load file") } Self::Package(error) => write!(f, "{error}"), } } } impl ErrAt for FileError { fn err_at(self, span: Span) -> SourceDiagnostic { let hints = self.write_hints(); SourceDiagnostic::error(span, eco_format!("{}", self)).with_hints(hints) } } impl From for FileError { fn from(_: Utf8Error) -> Self { Self::InvalidUtf8(None) } } impl From for FileError { fn from(_: FromUtf8Error) -> Self { Self::InvalidUtf8(None) } } impl From for FileError { fn from(err: PackageError) -> Self { Self::Package(err) } } /// A result type with a package-related error. pub type PackageResult = Result; /// An error that occurred while trying to load a package. /// /// Some variants have an optional string that can give more details, if available. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum PackageError { /// The specified package does not exist. /// Additionally provides information on where we tried to find the package. NotFound(PackageSpec, EcoString), /// The specified package found, but the version does not exist. /// TODO: make the registry part of the error better typed VersionNotFound(PackageSpec, Option, EcoString), /// Failed to retrieve the package through the network. NetworkFailed(Option), /// The package archive was malformed. MalformedArchive(Option), /// Another error. Other(Option), } impl PackageError { fn write_hints(&self) -> EcoVec { match self { Self::NotFound(_, detail) => { eco_vec![eco_format!("{detail}")] } Self::VersionNotFound(spec, latest, registry) => { if let Some(version) = latest { eco_vec![ eco_format!( "the package {} exists, but not with version {}", spec.name, spec.version ), eco_format!( "the registry at {registry} provides up to version {version}" ), ] } else { eco_vec![eco_format!( "the registry at {registry} contains no versions for this package" )] } } Self::NetworkFailed(Some(err)) => eco_vec![eco_format!("{err}")], Self::NetworkFailed(None) => eco_vec![], Self::MalformedArchive(Some(err)) => eco_vec![eco_format!("{err}")], Self::MalformedArchive(None) => { eco_vec![eco_format!("the archive is malformed")] } Self::Other(Some(err)) => eco_vec![eco_format!("{err}")], Self::Other(None) => eco_vec![], } } } impl std::error::Error for PackageError {} impl Display for PackageError { fn fmt(&self, f: &mut Formatter) -> fmt::Result { match self { Self::NotFound(spec, _) => write!(f, "package {spec} not found"), Self::VersionNotFound(spec, _, _) => { write!(f, "package version {spec} not found") } Self::NetworkFailed(_) => write!(f, "failed to download package"), Self::MalformedArchive(_) => write!(f, "failed to decompress package"), Self::Other(_) => f.pad("failed to load package"), } } } /// A result type with a data-loading-related error. pub type LoadResult = Result; /// A call site independent error that occurred during data loading. This avoids /// polluting the memoization with [`Span`]s and [`FileId`]s from source files. /// Can be turned into a [`SourceDiagnostic`] using the [`LoadedWithin::within`] /// method available on [`LoadResult`]. /// /// [`FileId`]: typst_syntax::FileId #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct LoadError { /// The position in the file at which the error occured. pos: ReportPos, /// Must contain a message formatted like this: `"failed to do thing (cause)"`. message: EcoString, } impl LoadError { /// Creates a new error from a position in a file, a base message /// (e.g. `failed to parse JSON`) and a concrete error (e.g. `invalid /// number`) pub fn new( pos: impl Into, message: impl std::fmt::Display, error: impl std::fmt::Display, ) -> Self { Self { pos: pos.into(), message: eco_format!("{message} ({error})"), } } } impl From for LoadError { fn from(err: Utf8Error) -> Self { let start = err.valid_up_to(); let end = start + err.error_len().unwrap_or(0); LoadError::new( start..end, "failed to convert to string", "file is not valid utf-8", ) } } /// Convert a [`LoadResult`] to a [`SourceResult`] by adding the [`Loaded`] /// context. pub trait LoadedWithin { /// Report an error, possibly in an external file. fn within(self, loaded: &Loaded) -> SourceResult; } impl LoadedWithin for Result where E: Into, { fn within(self, loaded: &Loaded) -> SourceResult { self.map_err(|err| { let LoadError { pos, message } = err.into(); load_err_in_text(loaded, pos, message) }) } } /// Report an error, possibly in an external file. This will delegate to /// [`load_err_in_invalid_text`] if the data isn't valid utf-8. fn load_err_in_text( loaded: &Loaded, pos: impl Into, mut message: EcoString, ) -> EcoVec { let pos = pos.into(); // This also does utf-8 validation. Only report an error in an external // file if it is human readable (valid utf-8), otherwise fall back to // `load_err_in_invalid_text`. let lines = Lines::try_from(&loaded.data); match (loaded.source.v, lines) { (LoadSource::Path(file_id), Ok(lines)) => { if let Some(range) = pos.range(&lines) { let span = Span::from_range(file_id, range); return eco_vec![SourceDiagnostic::error(span, message)]; } // Either `ReportPos::None` was provided, or resolving the range // from the line/column failed. If present report the possibly // wrong line/column in the error message anyway. let span = Span::from_range(file_id, 0..loaded.data.len()); if let Some(pair) = pos.line_col(&lines) { message.pop(); let (line, col) = pair.numbers(); write!(&mut message, " at {line}:{col})").ok(); } eco_vec![SourceDiagnostic::error(span, message)] } (LoadSource::Bytes, Ok(lines)) => { if let Some(pair) = pos.line_col(&lines) { message.pop(); let (line, col) = pair.numbers(); write!(&mut message, " at {line}:{col})").ok(); } eco_vec![SourceDiagnostic::error(loaded.source.span, message)] } _ => load_err_in_invalid_text(loaded, pos, message), } } /// Report an error (possibly from an external file) that isn't valid utf-8. fn load_err_in_invalid_text( loaded: &Loaded, pos: impl Into, mut message: EcoString, ) -> EcoVec { let line_col = pos.into().try_line_col(&loaded.data).map(|p| p.numbers()); match (loaded.source.v, line_col) { (LoadSource::Path(file), _) => { message.pop(); if let Some(package) = file.package() { write!( &mut message, " in {package}{}", file.vpath().as_rooted_path().display() ) .ok(); } else { write!(&mut message, " in {}", file.vpath().as_rootless_path().display()) .ok(); }; if let Some((line, col)) = line_col { write!(&mut message, ":{line}:{col}").ok(); } message.push(')'); } (LoadSource::Bytes, Some((line, col))) => { message.pop(); write!(&mut message, " at {line}:{col})").ok(); } (LoadSource::Bytes, None) => (), } eco_vec![SourceDiagnostic::error(loaded.source.span, message)] } /// A position at which an error was reported. #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] pub enum ReportPos { /// Contains a range, and a line/column pair. Full(std::ops::Range, LineCol), /// Contains a range. Range(std::ops::Range), /// Contains a line/column pair. LineCol(LineCol), #[default] None, } impl From> for ReportPos { fn from(value: std::ops::Range) -> Self { Self::Range(value.start.saturating_as()..value.end.saturating_as()) } } impl From for ReportPos { fn from(value: LineCol) -> Self { Self::LineCol(value) } } impl ReportPos { /// Creates a position from a pre-existing range and line-column pair. pub fn full(range: std::ops::Range, pair: LineCol) -> Self { let range = range.start.saturating_as()..range.end.saturating_as(); Self::Full(range, pair) } /// Tries to determine the byte range for this position. fn range(&self, lines: &Lines) -> Option> { match self { ReportPos::Full(range, _) => Some(range.start as usize..range.end as usize), ReportPos::Range(range) => Some(range.start as usize..range.end as usize), &ReportPos::LineCol(pair) => { let i = lines.line_column_to_byte(pair.line as usize, pair.col as usize)?; Some(i..i) } ReportPos::None => None, } } /// Tries to determine the line/column for this position. fn line_col(&self, lines: &Lines) -> Option { match self { &ReportPos::Full(_, pair) => Some(pair), ReportPos::Range(range) => { let (line, col) = lines.byte_to_line_column(range.start as usize)?; Some(LineCol::zero_based(line, col)) } &ReportPos::LineCol(pair) => Some(pair), ReportPos::None => None, } } /// Either gets the line/column pair, or tries to compute it from possibly /// invalid utf-8 data. fn try_line_col(&self, bytes: &[u8]) -> Option { match self { &ReportPos::Full(_, pair) => Some(pair), ReportPos::Range(range) => { LineCol::try_from_byte_pos(range.start as usize, bytes) } &ReportPos::LineCol(pair) => Some(pair), ReportPos::None => None, } } } /// A line/column pair. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct LineCol { /// The 0-based line. line: u32, /// The 0-based column. col: u32, } impl LineCol { /// Constructs the line/column pair from 0-based indices. pub fn zero_based(line: usize, col: usize) -> Self { Self { line: line.saturating_as(), col: col.saturating_as(), } } /// Constructs the line/column pair from 1-based numbers. pub fn one_based(line: usize, col: usize) -> Self { Self::zero_based(line.saturating_sub(1), col.saturating_sub(1)) } /// Try to compute a line/column pair from possibly invalid utf-8 data. pub fn try_from_byte_pos(pos: usize, bytes: &[u8]) -> Option { let bytes = &bytes[..pos]; let mut line = 0; #[allow(clippy::double_ended_iterator_last)] let line_start = memchr::memchr_iter(b'\n', bytes) .inspect(|_| line += 1) .last() .map(|i| i + 1) .unwrap_or(bytes.len()); let col = ErrorReportingUtf8Chars::new(&bytes[line_start..]).count(); Some(LineCol::zero_based(line, col)) } /// Returns the 0-based line/column indices. pub fn indices(&self) -> (usize, usize) { (self.line as usize, self.col as usize) } /// Returns the 1-based line/column numbers. pub fn numbers(&self) -> (usize, usize) { (self.line as usize + 1, self.col as usize + 1) } } /// Format a user-facing error message for an XML-like file format. pub fn format_xml_like_error(format: &str, error: roxmltree::Error) -> LoadError { let pos = LineCol::one_based(error.pos().row as usize, error.pos().col as usize); let message = match error { roxmltree::Error::UnexpectedCloseTag(expected, actual, _) => { eco_format!( "failed to parse {format} (found closing tag '{actual}' instead of '{expected}')" ) } roxmltree::Error::UnknownEntityReference(entity, _) => { eco_format!("failed to parse {format} (unknown entity '{entity}')") } roxmltree::Error::DuplicatedAttribute(attr, _) => { eco_format!("failed to parse {format} (duplicate attribute '{attr}')") } roxmltree::Error::NoRootNode => { eco_format!("failed to parse {format} (missing root node)") } err => eco_format!("failed to parse {format} ({err})"), }; LoadError { pos: pos.into(), message } }