Skip to main content

smiles_rs/
errors.rs

1//! Defines errors used in the SMILES parser.
2
3use alloc::{format, string::String};
4use core::{fmt, num::TryFromIntError, ops::Range};
5
6use elements_rs::Element;
7use thiserror::Error;
8
9use crate::{
10    atom::atom_symbol::AtomSymbol,
11    bond::{Bond, BondDescriptor},
12};
13
14/// The errors that could occur during SMILES parsing.
15#[derive(Debug, Copy, Clone, PartialEq, Eq, Error)]
16pub enum SmilesError {
17    /// Bond Inside Bracket
18    #[error("Bond in bracket: {0}")]
19    BondInBracket(Bond),
20    /// A charge is over the allowed maximum (15)
21    #[error("Charge overflow: {0}")]
22    ChargeOverflow(i8),
23    /// A charge is below allowed minimum (-15)
24    #[error("Charge underflow: {0}")]
25    ChargeUnderflow(i8),
26    /// A duplicate edge between two nodes has been found
27    #[error("Node A: {0} has multiple edges with Node B: {1}")]
28    DuplicateEdge(usize, usize),
29    /// A non bare element found outside of brackets
30    #[error("Element requires brackets")]
31    ElementRequiresBrackets,
32    /// Wrapper for `element_rs` errors
33    #[error("Error Parsing Element: {0}")]
34    ElementsRs(#[from] elements_rs::errors::Error),
35    /// A branch without any nodes has been parsed
36    #[error("A branch without any nodes has been found")]
37    EmptyBranch,
38    /// A bond was not able to bind two atoms
39    #[error("Bond: {0} missing atom index(es)")]
40    IncompleteBond(BondDescriptor),
41    /// Element forbidden to be written as aromatic here
42    #[error("Invalid aromatic element: {0}")]
43    InvalidAromaticElement(Element),
44    /// A bond is not in a valid position, such as outside of a branch start or
45    /// next to another bond
46    #[error("A bond has been found in a non valid location")]
47    InvalidBond,
48    /// A branch is invalid, missing an atom
49    #[error("An invalid branch has been found")]
50    InvalidBranch,
51    /// Specified Chirality is not a valid form
52    #[error("Invalid chirality")]
53    InvalidChirality,
54    /// The class is not valid
55    #[error("Invalid class")]
56    InvalidClass,
57    /// Error indicating invalid Element name
58    #[error("Invalid element name: {0}")]
59    InvalidElementName(char),
60    /// A bracket atom's explicit hydrogen count exceeds the maximum supported
61    /// value (15).
62    ///
63    /// The cap mirrors the magnitude cap on bracket-atom charges and prevents
64    /// downstream `u8` valence math (`explicit_valence + hydrogen_count +
65    /// implicit_hydrogen_count`) from overflowing for adversarial inputs.
66    /// Real chemistry SMILES never need an explicit `H` count above this bound.
67    #[error("Hydrogen count overflow: {0}")]
68    HydrogenCountOverflow(u8),
69    /// A hydrogen bracket atom has an unsupported explicit hydrogen count
70    #[error("Hydrogen found as bracketed atom with an unsupported explicit hydrogen count")]
71    InvalidHydrogenWithExplicitHydrogensFound,
72    /// Invalid Isotope value passed
73    #[error("Invalid isotope")]
74    InvalidIsotope,
75    /// Invalid `Token::NonBond`
76    #[error("Invalid Non-bond '.' found")]
77    InvalidNonBondToken,
78    /// Error indicating that an invalid number was encountered.
79    #[error("Invalid number")]
80    InvalidNumber,
81    /// Integer Overflow
82    #[error("Integer overflow")]
83    IntegerOverflow,
84    /// Non organic element found out of bracket
85    #[error("Invalid unbracketed atom: {0}")]
86    InvalidUnbracketedAtom(AtomSymbol),
87    /// An invalid ring number has been found
88    #[error("Invalid ring number")]
89    InvalidRingNumber,
90    /// found `[..]` that did not contain an element
91    #[error("Missing element inside brackets")]
92    MissingBracketElement,
93    /// Missing Element
94    #[error("Missing element")]
95    MissingElement,
96    /// Node id does not point to a valid atom in the graph
97    #[error("Invalid atom index: {0}")]
98    NodeIdInvalid(usize),
99    /// Non Bond in Bracket
100    #[error("Non-bond '.' in bracket")]
101    NonBondInBracket,
102    /// Ring number above [`RingNum::MAX`](crate::bond::ring_num::RingNum::MAX)
103    #[error("Ring number overflow: {0}")]
104    RingNumberOverflow(u32),
105    /// An edge connects a node to itself
106    #[error("Node: {0} has an edge that goes from itself and to itself")]
107    SelfLoopEdge(usize),
108    /// Unexpectedly inside of brackets
109    #[error("Unexpected bracketed state")]
110    UnexpectedBracketedState,
111    /// Unexpected end of string
112    #[error("Unexpected end of string")]
113    UnexpectedEndOfString,
114    /// Error indicating that an unexpected character was encountered.
115    #[error("Unexpected character: {0}")]
116    UnexpectedCharacter(char),
117    /// Error indicating that unexpected unicode input was encountered.
118    #[error("Unexpected unicode character")]
119    UnexpectedUnicodeCharacter,
120    /// An unexpected `:` has been found
121    #[error("Unexpected ':'")]
122    UnexpectedColon,
123    /// An unexpected `-` has been found
124    #[error("Unexpected '-'")]
125    UnexpectedDash,
126    /// An unexpected `%` has been found
127    #[error("Unexpected '%'")]
128    UnexpectedPercent,
129    /// An unexpected left bracket `[` was found
130    #[error("Unexpected '['")]
131    UnexpectedLeftBracket,
132    /// A `(` that wasn't expected has been found
133    #[error("Unexpected '('")]
134    UnexpectedLeftParentheses,
135    /// An unexpected right bracket `]` was found
136    #[error("Unexpected ']'")]
137    UnexpectedRightBracket,
138    /// An unexpected right parentheses `)` was found
139    #[error("Unexpected `)`")]
140    UnexpectedRightParentheses,
141    /// A wildcard atom was parsed where only concrete atoms are allowed.
142    #[error("Wildcard atom not allowed")]
143    WildcardAtomNotAllowed,
144    /// A closing `]` bracket was not found
145    #[error("Unclosed '['")]
146    UnclosedBracket,
147    /// A branch has not been closed with a `)`
148    #[error("Branch not closed")]
149    UnclosedBranch,
150    /// A ring number has been found that was not completed
151    #[error("Ring not closed")]
152    UnclosedRing,
153}
154
155impl From<TryFromIntError> for SmilesError {
156    fn from(_: TryFromIntError) -> Self {
157        SmilesError::InvalidNumber
158    }
159}
160
161/// Wraps the `Smiles` error adding the location of where the error was found
162#[derive(Debug)]
163pub struct SmilesErrorWithSpan {
164    /// The [`SmilesError`]
165    smiles_error: SmilesError,
166    /// The span as `usize`
167    span: Range<usize>,
168}
169
170impl SmilesErrorWithSpan {
171    /// Creates a new error from the [`SmilesError`] and the `span`
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use smiles_rs::{SmilesError, SmilesErrorWithSpan};
177    ///
178    /// let err = SmilesErrorWithSpan::new(SmilesError::UnexpectedEndOfString, 2, 3);
179    /// assert_eq!(err.start(), 2);
180    /// assert_eq!(err.end(), 3);
181    /// ```
182    #[must_use]
183    pub fn new(smiles_error: SmilesError, start: usize, end: usize) -> Self {
184        Self { smiles_error, span: Range { start, end } }
185    }
186
187    /// Returns the [`SmilesError`]
188    ///
189    /// # Examples
190    ///
191    /// ```
192    /// use smiles_rs::{SmilesError, SmilesErrorWithSpan};
193    ///
194    /// let err = SmilesErrorWithSpan::new(SmilesError::InvalidNumber, 0, 1);
195    /// assert_eq!(err.smiles_error(), SmilesError::InvalidNumber);
196    /// ```
197    #[must_use]
198    pub fn smiles_error(&self) -> SmilesError {
199        self.smiles_error
200    }
201
202    /// Returns the start of the span
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use smiles_rs::{SmilesError, SmilesErrorWithSpan};
208    ///
209    /// let err = SmilesErrorWithSpan::new(SmilesError::InvalidClass, 4, 6);
210    /// assert_eq!(err.start(), 4);
211    /// ```
212    #[must_use]
213    pub fn start(&self) -> usize {
214        self.span.start
215    }
216
217    /// Returns the end of the span
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use smiles_rs::{SmilesError, SmilesErrorWithSpan};
223    ///
224    /// let err = SmilesErrorWithSpan::new(SmilesError::InvalidClass, 4, 6);
225    /// assert_eq!(err.end(), 6);
226    /// ```
227    #[must_use]
228    pub fn end(&self) -> usize {
229        self.span.end
230    }
231
232    /// Returns the full span for the error
233    ///
234    /// # Examples
235    ///
236    /// ```
237    /// use smiles_rs::{SmilesError, SmilesErrorWithSpan};
238    ///
239    /// let err = SmilesErrorWithSpan::new(SmilesError::UnexpectedLeftBracket, 1, 2);
240    /// assert_eq!(err.span(), 1..2);
241    /// ```
242    #[must_use]
243    pub fn span(&self) -> Range<usize> {
244        self.span.start..self.span.end
245    }
246
247    /// Render the error pointing back to location in the original string
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use smiles_rs::{SmilesError, SmilesErrorWithSpan};
253    ///
254    /// let err = SmilesErrorWithSpan::new(SmilesError::UnexpectedRightBracket, 1, 2);
255    /// let rendered = err.render("C]");
256    ///
257    /// assert!(rendered.contains("C]"));
258    /// assert!(rendered.contains("^"));
259    /// ```
260    #[must_use]
261    pub fn render(&self, input: &str) -> String {
262        let start = self.start().min(input.len());
263        let end = self.end().min(input.len()).max(start + 1).min(input.len());
264
265        let mut underline = String::new();
266        underline.push_str(&" ".repeat(start));
267        underline.push_str(&"^".repeat(end - start));
268
269        format!("{input}\n{underline}\n{}", self.smiles_error)
270    }
271}
272
273impl fmt::Display for SmilesErrorWithSpan {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        write!(f, "{} at {}..{}", self.smiles_error, self.start(), self.end())
276    }
277}
278
279/// Error returned when carving a [`Fragment`](crate::smiles::Fragment) out of a
280/// parent graph fails.
281#[derive(Debug, Copy, Clone, PartialEq, Eq, Error)]
282#[non_exhaustive]
283pub enum SubgraphError {
284    /// An atom id in the input is not a valid atom of the parent graph.
285    #[error("Atom id {0} is out of range for the parent graph")]
286    AtomOutOfRange(usize),
287    /// A bond in the input references an atom that is not part of the fragment.
288    #[error("Bond references atom {0}, which is not in the fragment")]
289    BondReferencesUnknownAtom(usize),
290}
291
292/// Error returned when rendering a fragment anchored at a chosen parent atom.
293#[derive(Debug, Copy, Clone, PartialEq, Eq, Error)]
294#[non_exhaustive]
295pub enum RootError {
296    /// The requested root atom is not part of the fragment.
297    #[error("Atom {0} is not in the fragment")]
298    AtomNotInFragment(usize),
299}
300
301#[cfg(test)]
302mod tests {
303    use alloc::string::ToString;
304    use std::num::TryFromIntError;
305
306    use elements_rs::Element;
307
308    use crate::{
309        atom::atom_symbol::AtomSymbol,
310        bond::{Bond, BondDescriptor},
311        errors::{SmilesError, SmilesErrorWithSpan},
312    };
313
314    #[test]
315    fn test_smiles_error_display_monolithic() {
316        let elements_rs_error = elements_rs::errors::Error::AtomicNumber(4);
317
318        let cases = [
319            (
320                SmilesError::BondInBracket(Bond::Double),
321                format!("Bond in bracket: {}", Bond::Double),
322            ),
323            (SmilesError::ChargeOverflow(50), "Charge overflow: 50".to_string()),
324            (SmilesError::ChargeUnderflow(-50), "Charge underflow: -50".to_string()),
325            (SmilesError::ElementRequiresBrackets, "Element requires brackets".to_string()),
326            (
327                SmilesError::ElementsRs(elements_rs_error),
328                format!("Error Parsing Element: {elements_rs_error}"),
329            ),
330            (
331                SmilesError::IncompleteBond(BondDescriptor::aromatic(Bond::Single)),
332                "Bond: : missing atom index(es)".to_string(),
333            ),
334            (SmilesError::HydrogenCountOverflow(16), "Hydrogen count overflow: 16".to_string()),
335            (
336                SmilesError::InvalidAromaticElement(Element::Ac),
337                format!("Invalid aromatic element: {}", Element::Ac),
338            ),
339            (SmilesError::InvalidChirality, "Invalid chirality".to_string()),
340            (SmilesError::InvalidClass, "Invalid class".to_string()),
341            (SmilesError::InvalidElementName('w'), "Invalid element name: w".to_string()),
342            (SmilesError::InvalidIsotope, "Invalid isotope".to_string()),
343            (SmilesError::InvalidNonBondToken, "Invalid Non-bond '.' found".to_string()),
344            (SmilesError::InvalidNumber, "Invalid number".to_string()),
345            (SmilesError::IntegerOverflow, "Integer overflow".to_string()),
346            (
347                SmilesError::InvalidUnbracketedAtom(AtomSymbol::WildCard),
348                format!("Invalid unbracketed atom: {}", AtomSymbol::WildCard),
349            ),
350            (SmilesError::InvalidRingNumber, "Invalid ring number".to_string()),
351            (SmilesError::MissingBracketElement, "Missing element inside brackets".to_string()),
352            (SmilesError::MissingElement, "Missing element".to_string()),
353            (SmilesError::NodeIdInvalid(2), "Invalid atom index: 2".to_string()),
354            (SmilesError::NonBondInBracket, "Non-bond '.' in bracket".to_string()),
355            (SmilesError::RingNumberOverflow(100), "Ring number overflow: 100".to_string()),
356            (SmilesError::UnexpectedBracketedState, "Unexpected bracketed state".to_string()),
357            (SmilesError::UnexpectedEndOfString, "Unexpected end of string".to_string()),
358            (SmilesError::UnexpectedCharacter('$'), "Unexpected character: $".to_string()),
359            (SmilesError::UnexpectedUnicodeCharacter, "Unexpected unicode character".to_string()),
360            (SmilesError::UnexpectedColon, "Unexpected ':'".to_string()),
361            (SmilesError::UnexpectedDash, "Unexpected '-'".to_string()),
362            (SmilesError::UnexpectedPercent, "Unexpected '%'".to_string()),
363            (SmilesError::UnexpectedLeftBracket, "Unexpected '['".to_string()),
364            (SmilesError::UnexpectedLeftParentheses, "Unexpected '('".to_string()),
365            (SmilesError::UnexpectedRightBracket, "Unexpected ']'".to_string()),
366            (SmilesError::UnexpectedRightParentheses, "Unexpected `)`".to_string()),
367            (SmilesError::WildcardAtomNotAllowed, "Wildcard atom not allowed".to_string()),
368            (SmilesError::UnclosedBracket, "Unclosed '['".to_string()),
369            (SmilesError::UnclosedBranch, "Branch not closed".to_string()),
370            (SmilesError::UnclosedRing, "Ring not closed".to_string()),
371            (
372                SmilesError::SelfLoopEdge(1),
373                "Node: 1 has an edge that goes from itself and to itself".to_string(),
374            ),
375            (
376                SmilesError::DuplicateEdge(0, 1),
377                "Node A: 0 has multiple edges with Node B: 1".to_string(),
378            ),
379        ];
380
381        for (error, expected) in cases {
382            assert_eq!(error.to_string(), expected, "wrong Display for {error:?}");
383        }
384    }
385
386    #[test]
387    fn test_smiles_error_from_conversions() {
388        let int_error: Result<u8, TryFromIntError> = u8::try_from(300_i16);
389        let int_error = int_error.unwrap_err();
390
391        let smiles_error: SmilesError = int_error.into();
392        assert_eq!(smiles_error, SmilesError::InvalidNumber);
393
394        let elements_error = elements_rs::errors::Error::AtomicNumber(4);
395        let smiles_error: SmilesError = elements_error.into();
396        assert_eq!(
397            smiles_error,
398            SmilesError::ElementsRs(elements_rs::errors::Error::AtomicNumber(4))
399        );
400    }
401
402    #[test]
403    fn test_smiles_error_with_span() {
404        let error = SmilesErrorWithSpan::new(SmilesError::UnexpectedCharacter('$'), 2, 3);
405
406        assert_eq!(error.smiles_error(), SmilesError::UnexpectedCharacter('$'));
407        assert_eq!(error.start(), 2);
408        assert_eq!(error.end(), 3);
409        assert_eq!(error.span(), (2..3));
410
411        assert_eq!(error.to_string(), "Unexpected character: $ at 2..3");
412
413        assert_eq!(error.render("CC$O"), "CC$O\n  ^\nUnexpected character: $");
414    }
415
416    #[test]
417    fn render_marks_zero_width_and_multi_character_spans() {
418        // A zero-width span (start == end) must still produce exactly one
419        // caret, which only holds because the end is raised to at least
420        // start + 1.
421        let zero_width = SmilesErrorWithSpan::new(SmilesError::UnexpectedEndOfString, 2, 2);
422        assert_eq!(zero_width.render("CCO"), "CCO\n  ^\nUnexpected end of string");
423
424        // A multi-character span draws one caret per covered position, so the
425        // caret run width is end minus start rather than any other combination.
426        let two_wide = SmilesErrorWithSpan::new(SmilesError::UnexpectedCharacter('x'), 1, 3);
427        assert_eq!(two_wide.render("CCCC"), "CCCC\n ^^\nUnexpected character: x");
428    }
429
430    #[test]
431    fn test_smiles_error_with_unicode_span() {
432        let error = SmilesErrorWithSpan::new(SmilesError::UnexpectedUnicodeCharacter, 2, 4);
433
434        assert_eq!(error.smiles_error(), SmilesError::UnexpectedUnicodeCharacter);
435        assert_eq!(error.start(), 2);
436        assert_eq!(error.end(), 4);
437        assert_eq!(error.span(), (2..4));
438
439        assert_eq!(error.to_string(), "Unexpected unicode character at 2..4");
440    }
441}