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}