Skip to main content

mos_bib/
error.rs

1//! The local BibTeX parse error type, [`BibParseError`], and its
2//! [`BibParseErrorKind`] classification.
3//!
4//! [`parse_bibtex`](crate::parse_bibtex) returns this small local error (per
5//! issue #66) so the parser stays self-contained, but it is **not** a parallel
6//! bad-document pipeline: it bridges into the standard `mos-core` diagnostics
7//! surface. [`BibParseError::to_diagnostic`] and `From<BibParseError> for
8//! CoreError` map it onto the `MOS0043` code: carrying the byte offset as a
9//! span, so a malformed `.bib` flows through the same `Diagnostic` path as
10//! every other compiler error, without callers special-casing `mos-bib`.
11
12use std::fmt;
13use std::path::PathBuf;
14
15use mos_core::{CoreError, Diagnostic, SourceSpan, codes};
16
17/// What went wrong while parsing BibTeX. Paired with a byte offset inside a
18/// [`BibParseError`].
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum BibParseErrorKind {
22    /// An entry must begin with `@`.
23    ExpectedAt,
24    /// `@` must be followed by an entry type (e.g. `article`).
25    ExpectedEntryType,
26    /// The entry type must be followed by `{`.
27    ExpectedOpenBrace,
28    /// `{` must be followed by a non-empty citation key.
29    ExpectedKey,
30    /// A field must begin with a field name.
31    ExpectedFieldName,
32    /// A field name must be followed by `=`.
33    ExpectedEquals,
34    /// `=` must be followed by a `{...}`, `"..."`, or bare value.
35    ExpectedValue,
36    /// A citation key was declared more than once in the same BibTeX input.
37    DuplicateKey,
38    /// A field value must be followed by `,` or the closing `}`.
39    ExpectedCommaOrCloseBrace,
40    /// The entry ended (a `}` was expected) before the input did.
41    UnterminatedEntry,
42    /// A `{...}` or `"..."` value had no closing delimiter.
43    UnterminatedValue,
44}
45
46impl BibParseErrorKind {
47    /// A short, human-readable description of this error kind.
48    #[must_use]
49    pub const fn message(self) -> &'static str {
50        match self {
51            Self::ExpectedAt => "expected '@' to start an entry",
52            Self::ExpectedEntryType => "expected an entry type after '@'",
53            Self::ExpectedOpenBrace => "expected '{' after the entry type",
54            Self::ExpectedKey => "expected a citation key",
55            Self::ExpectedFieldName => "expected a field name",
56            Self::ExpectedEquals => "expected '=' after the field name",
57            Self::ExpectedValue => "expected a field value",
58            Self::DuplicateKey => "duplicate citation key",
59            Self::ExpectedCommaOrCloseBrace => "expected ',' or '}'",
60            Self::UnterminatedEntry => "unterminated entry: expected '}' before end of input",
61            Self::UnterminatedValue => "unterminated value: missing closing '}' or '\"'",
62        }
63    }
64}
65
66/// A recoverable BibTeX parse error: a [`BibParseErrorKind`] plus the byte
67/// offset into the original input where the problem was detected.
68///
69/// The offset is a byte index, matching the convention `mos-core`
70/// `SourceSpan`s use, so a future citation slice can turn one of these into a
71/// compiler `Diagnostic` without re-deriving positions. Use
72/// [`line_col`](Self::line_col) for a 1-based line/column pair.
73///
74/// # Examples
75///
76/// ```
77/// use mos_bib::{BibParseErrorKind, parse_bibtex};
78///
79/// let err = parse_bibtex("article{x}").unwrap_err();
80/// assert_eq!(err.kind(), BibParseErrorKind::ExpectedAt);
81/// assert_eq!(err.offset(), 0);
82/// ```
83#[derive(Clone, Debug, PartialEq, Eq)]
84pub struct BibParseError {
85    kind: BibParseErrorKind,
86    offset: usize,
87}
88
89impl BibParseError {
90    /// Construct an error of `kind` at byte `offset`. Crate-internal: the
91    /// parser is the only place that mints these.
92    pub(crate) const fn new(kind: BibParseErrorKind, offset: usize) -> Self {
93        Self { kind, offset }
94    }
95
96    /// The kind of parse failure.
97    #[must_use]
98    pub const fn kind(&self) -> BibParseErrorKind {
99        self.kind
100    }
101
102    /// The byte offset into the parsed input where the error was detected.
103    #[must_use]
104    pub const fn offset(&self) -> usize {
105        self.offset
106    }
107
108    /// The 1-based `(line, column)` of this error within `src`.
109    ///
110    /// `src` must be the input passed to [`parse_bibtex`](crate::parse_bibtex);
111    /// columns count Unicode scalar values. Use [`to_diagnostic`](Self::to_diagnostic)
112    /// or `From<BibParseError> for CoreError` to bridge into `mos-core`
113    /// diagnostics.
114    ///
115    /// # Examples
116    ///
117    /// ```
118    /// use mos_bib::parse_bibtex;
119    ///
120    /// let src = "@article{ok}\n@bad";
121    /// let err = parse_bibtex(src).unwrap_err();
122    /// assert_eq!(err.line_col(src), (2, 5));
123    /// ```
124    #[must_use]
125    pub fn line_col(&self, src: &str) -> (usize, usize) {
126        mos_core::linecol(src, self.offset)
127    }
128
129    /// Convert this error into a `mos-core` [`Diagnostic`] anchored in `file`.
130    ///
131    /// The diagnostic carries the `MOS0043` code and a zero-width
132    /// [`SourceSpan`] at [`offset`](Self::offset), so a malformed `.bib`
133    /// reported here renders through the standard compiler pipeline. The
134    /// infallible `From<BibParseError> for CoreError` conversion is the
135    /// span-less equivalent for boundaries without a source path.
136    ///
137    /// # Examples
138    ///
139    /// ```
140    /// use mos_bib::parse_bibtex;
141    ///
142    /// let err = parse_bibtex("oops").unwrap_err();
143    /// let diagnostic = err.to_diagnostic("refs.bib");
144    /// assert_eq!(diagnostic.def().code().to_string(), "MOS0043");
145    /// ```
146    #[must_use]
147    pub fn to_diagnostic(&self, file: impl Into<PathBuf>) -> Diagnostic {
148        let span = SourceSpan::new(file.into(), self.offset, self.offset);
149        Diagnostic::simple(&codes::MOS0043, Some(span), self.kind.message())
150    }
151}
152
153impl fmt::Display for BibParseError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(
156            f,
157            "BibTeX parse error at byte {}: {}",
158            self.offset,
159            self.kind.message()
160        )
161    }
162}
163
164impl std::error::Error for BibParseError {}
165
166impl From<BibParseError> for CoreError {
167    fn from(err: BibParseError) -> Self {
168        // No source path at this boundary, so the diagnostic keeps the message
169        // (which includes the byte offset) but carries no span.
170        Self::Diagnostic(Box::new(Diagnostic::simple(
171            &codes::MOS0043,
172            None,
173            err.to_string(),
174        )))
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::parse_bibtex;
182
183    #[test]
184    fn from_bib_parse_error_yields_core_diagnostic() {
185        let err = parse_bibtex("nope").expect_err("malformed input should be rejected");
186        // Exhaustive match (no catch-all panic): the wrong variant yields an
187        // empty code so the assertion fails with a clear diff.
188        let code = match CoreError::from(err) {
189            CoreError::Diagnostic(diagnostic) => diagnostic.def().code().to_string(),
190            CoreError::Unimplemented(_) => String::new(),
191        };
192        assert_eq!(code, "MOS0043");
193    }
194}