Skip to main content

mos_csl/
error.rs

1//! The local CSL style parse error type, [`CslParseError`].
2//!
3//! Like `mos-bib`'s `BibParseError`, [`parse_style`](crate::parse_style)
4//! returns this small local error, but it bridges into the standard `mos-core`
5//! diagnostics surface rather than forming a parallel pipeline:
6//! [`CslParseError::to_diagnostic`] and `From<CslParseError> for CoreError` map
7//! it onto the `MOS0044` code, carrying the byte offset as a span.
8
9use std::fmt;
10use std::path::PathBuf;
11
12use mos_core::{CoreError, Diagnostic, SourceSpan, codes};
13
14/// What went wrong while parsing a CSL style. Paired with a byte offset inside
15/// a [`CslParseError`].
16#[derive(Clone, Debug, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum CslParseErrorKind {
19    /// The input is not well-formed XML.
20    MalformedXml(String),
21    /// The root element is not `<style>`.
22    UnexpectedRoot(String),
23    /// The `<style>` root is in a namespace other than the CSL namespace.
24    ForeignNamespace(String),
25    /// `<style>` lacks the required `version` attribute.
26    MissingVersion,
27    /// `<style>` lacks the required `class` attribute.
28    MissingClass,
29    /// `class` is neither `in-text` nor `note`.
30    UnknownClass(String),
31    /// `version` is not a supported CSL style version.
32    UnsupportedVersion(String),
33    /// A `<macro>` lacks the required `name` attribute.
34    MissingMacroName,
35    /// A `<citation>` or `<bibliography>` lacks its required `<layout>`.
36    MissingLayout,
37    /// A `<text>` element selects none of `variable`/`macro`/`term`/`value`.
38    TextWithoutSource,
39    /// A `<text>` element selects more than one source attribute.
40    TextWithMultipleSources,
41    /// A `<choose>` has no leading `<if>` or branches in the wrong order.
42    InvalidChooseOrder,
43    /// A rendering element name is not part of the supported CSL subset.
44    UnsupportedElement(String),
45}
46
47impl CslParseErrorKind {
48    fn describe(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::MalformedXml(message) => write!(f, "malformed XML: {message}"),
51            Self::UnexpectedRoot(name) => {
52                write!(f, "expected a <style> root element, found <{name}>")
53            }
54            Self::ForeignNamespace(namespace) => {
55                write!(
56                    f,
57                    "<style> is in an unsupported namespace `{namespace}` (expected the CSL namespace or none)"
58                )
59            }
60            Self::MissingVersion => {
61                f.write_str("<style> is missing the required `version` attribute")
62            }
63            Self::MissingClass => f.write_str("<style> is missing the required `class` attribute"),
64            Self::UnknownClass(class) => {
65                write!(
66                    f,
67                    "unknown style class `{class}` (expected `in-text` or `note`)"
68                )
69            }
70            Self::UnsupportedVersion(version) => {
71                write!(
72                    f,
73                    "unsupported CSL version `{version}` (expected `1.0` or a `1.0.x` release)"
74                )
75            }
76            Self::MissingMacroName => {
77                f.write_str("<macro> is missing the required `name` attribute")
78            }
79            Self::MissingLayout => {
80                f.write_str("<citation>/<bibliography> is missing the required <layout>")
81            }
82            Self::TextWithoutSource => {
83                f.write_str("<text> must select a variable, macro, term, or value")
84            }
85            Self::TextWithMultipleSources => {
86                f.write_str("<text> must select exactly one variable, macro, term, or value")
87            }
88            Self::InvalidChooseOrder => {
89                f.write_str("<choose> must contain <if>, then <else-if>, then optional <else>")
90            }
91            Self::UnsupportedElement(name) => write!(f, "unsupported CSL element <{name}>"),
92        }
93    }
94}
95
96/// A recoverable CSL parse error: a [`CslParseErrorKind`] plus the byte offset
97/// into the original input where the problem was detected.
98///
99/// The offset is a byte index (the same convention `mos-core` `SourceSpan`s
100/// use). Use [`line_col`](Self::line_col) for a 1-based line/column pair, or
101/// [`to_diagnostic`](Self::to_diagnostic) to bridge into the compiler
102/// diagnostics pipeline.
103///
104/// # Examples
105///
106/// ```
107/// use mos_csl::{CslParseErrorKind, parse_style};
108///
109/// let err = parse_style("<not-a-style/>").unwrap_err();
110/// assert!(matches!(err.kind(), CslParseErrorKind::UnexpectedRoot(_)));
111/// ```
112#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct CslParseError {
114    kind: CslParseErrorKind,
115    offset: usize,
116}
117
118impl CslParseError {
119    pub(crate) const fn new(kind: CslParseErrorKind, offset: usize) -> Self {
120        Self { kind, offset }
121    }
122
123    /// The kind of parse failure.
124    #[must_use]
125    pub const fn kind(&self) -> &CslParseErrorKind {
126        &self.kind
127    }
128
129    /// The byte offset into the parsed input where the error was detected.
130    #[must_use]
131    pub const fn offset(&self) -> usize {
132        self.offset
133    }
134
135    /// The 1-based `(line, column)` of this error within `src`.
136    ///
137    /// `src` must be the input passed to [`parse_style`](crate::parse_style);
138    /// columns count Unicode scalar values.
139    #[must_use]
140    pub fn line_col(&self, src: &str) -> (usize, usize) {
141        mos_core::linecol(src, self.offset)
142    }
143
144    /// Convert this error into a `mos-core` [`Diagnostic`] anchored in `file`,
145    /// carrying the `MOS0044` code and a zero-width span at
146    /// [`offset`](Self::offset).
147    #[must_use]
148    pub fn to_diagnostic(&self, file: impl Into<PathBuf>) -> Diagnostic {
149        let span = SourceSpan::new(file.into(), self.offset, self.offset);
150        Diagnostic::simple(&codes::MOS0044, Some(span), self.to_string())
151    }
152}
153
154impl fmt::Display for CslParseError {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        write!(f, "CSL parse error at byte {}: ", self.offset)?;
157        self.kind.describe(f)
158    }
159}
160
161impl std::error::Error for CslParseError {}
162
163impl From<CslParseError> for CoreError {
164    fn from(err: CslParseError) -> Self {
165        // No source path at this boundary, so the diagnostic keeps the message
166        // (which includes the byte offset) but carries no span.
167        Self::Diagnostic(Box::new(Diagnostic::simple(
168            &codes::MOS0044,
169            None,
170            err.to_string(),
171        )))
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn display_covers_error_kinds() {
181        let cases = [
182            (
183                CslParseErrorKind::MalformedXml("bad token".to_owned()),
184                "CSL parse error at byte 7: malformed XML: bad token",
185            ),
186            (
187                CslParseErrorKind::UnexpectedRoot("not-style".to_owned()),
188                "CSL parse error at byte 7: expected a <style> root element, found <not-style>",
189            ),
190            (
191                CslParseErrorKind::ForeignNamespace("urn:not-csl".to_owned()),
192                "CSL parse error at byte 7: <style> is in an unsupported namespace `urn:not-csl` (expected the CSL namespace or none)",
193            ),
194            (
195                CslParseErrorKind::MissingVersion,
196                "CSL parse error at byte 7: <style> is missing the required `version` attribute",
197            ),
198            (
199                CslParseErrorKind::MissingClass,
200                "CSL parse error at byte 7: <style> is missing the required `class` attribute",
201            ),
202            (
203                CslParseErrorKind::UnknownClass("weird".to_owned()),
204                "CSL parse error at byte 7: unknown style class `weird` (expected `in-text` or `note`)",
205            ),
206            (
207                CslParseErrorKind::UnsupportedVersion("1.1".to_owned()),
208                "CSL parse error at byte 7: unsupported CSL version `1.1` (expected `1.0` or a `1.0.x` release)",
209            ),
210            (
211                CslParseErrorKind::MissingMacroName,
212                "CSL parse error at byte 7: <macro> is missing the required `name` attribute",
213            ),
214            (
215                CslParseErrorKind::MissingLayout,
216                "CSL parse error at byte 7: <citation>/<bibliography> is missing the required <layout>",
217            ),
218            (
219                CslParseErrorKind::TextWithoutSource,
220                "CSL parse error at byte 7: <text> must select a variable, macro, term, or value",
221            ),
222            (
223                CslParseErrorKind::TextWithMultipleSources,
224                "CSL parse error at byte 7: <text> must select exactly one variable, macro, term, or value",
225            ),
226            (
227                CslParseErrorKind::InvalidChooseOrder,
228                "CSL parse error at byte 7: <choose> must contain <if>, then <else-if>, then optional <else>",
229            ),
230            (
231                CslParseErrorKind::UnsupportedElement("magic".to_owned()),
232                "CSL parse error at byte 7: unsupported CSL element <magic>",
233            ),
234        ];
235
236        for (kind, expected) in cases {
237            assert_eq!(CslParseError::new(kind, 7).to_string(), expected);
238        }
239    }
240
241    #[test]
242    fn error_carries_offset_line_col_and_diagnostic() {
243        let src = "<style version=\"1.0\" class=\"in-text\">\n  <citation/>\n</style>";
244        let err = crate::parse_style(src).expect_err("citation without layout");
245        assert_eq!(err.kind(), &CslParseErrorKind::MissingLayout);
246        assert_eq!(err.line_col(src), (2, 3));
247
248        let diagnostic = err.to_diagnostic("style.csl");
249        assert_eq!(diagnostic.def().code().to_string(), "MOS0044");
250        let span = diagnostic.span().expect("diagnostic should carry a span");
251        assert_eq!(span.start(), err.offset());
252    }
253
254    #[test]
255    fn from_error_yields_core_diagnostic() {
256        let err = CslParseError::new(CslParseErrorKind::MissingClass, 3);
257        let code = match CoreError::from(err) {
258            CoreError::Diagnostic(diagnostic) => diagnostic.def().code().to_string(),
259            CoreError::Unimplemented(_) => String::new(),
260        };
261        assert_eq!(code, "MOS0044");
262    }
263}