Skip to main content

acdc_parser/
error.rs

1use std::{fmt, path::PathBuf};
2
3use crate::model::{Location, Position, SectionLevel};
4
5#[non_exhaustive]
6#[derive(thiserror::Error, Debug)]
7pub enum Error {
8    #[error("Invalid include path: {1}, position: {0}")]
9    InvalidIncludePath(Box<SourceLocation>, PathBuf),
10
11    #[error("Invalid line range: {1}, position: {0}")]
12    InvalidLineRange(Box<SourceLocation>, String),
13
14    #[error("Parsing error: {1}, position: {0}")]
15    Parse(Box<SourceLocation>, String),
16
17    #[error("PEG parsing error: {1}, position {0}")]
18    PegParse(Box<SourceLocation>, String),
19
20    #[error("Parsing error: {0}")]
21    ParseGrammar(#[from] peg::error::ParseError<peg::str::LineCol>),
22
23    #[error("section level mismatch: {1} (expected '{2}'), position: {0}")]
24    NestedSectionLevelMismatch(Box<SourceLocation>, SectionLevel, SectionLevel),
25
26    #[error("mismatched delimiters: {1}, position: {0}")]
27    MismatchedDelimiters(Box<SourceLocation>, String),
28
29    #[error("Invalid admonition variant: {1}, position: {0}")]
30    InvalidAdmonitionVariant(Box<SourceLocation>, String),
31
32    #[error("Invalid conditional directive, position: {0}")]
33    InvalidConditionalDirective(Box<SourceLocation>),
34
35    #[error("Invalid include directive: {1}, position: {0}")]
36    InvalidIncludeDirective(Box<SourceLocation>, String),
37
38    #[error("Invalid indent: {1}, position: {0}")]
39    InvalidIndent(Box<SourceLocation>, String),
40
41    #[error("Invalid level offset: {1}, position: {0}")]
42    InvalidLevelOffset(Box<SourceLocation>, String),
43
44    #[error("I/O error: {0}")]
45    Io(#[from] std::io::Error),
46
47    #[error("URL error: {0}")]
48    Url(#[from] url::ParseError),
49
50    #[error("ParseInt error: {0}")]
51    ParseInt(#[from] std::num::ParseIntError),
52
53    #[error("Invalid ifeval directive, position: {0}")]
54    InvalidIfEvalDirectiveMismatchedTypes(Box<SourceLocation>),
55
56    #[error("Unknown encoding: {0}")]
57    UnknownEncoding(String),
58
59    #[error("Unrecognized encoding in file: {0}")]
60    UnrecognizedEncodingInFile(String),
61
62    #[cfg(feature = "network")]
63    #[error("Unable to retrieve HTTP response: {0}")]
64    HttpRequest(String),
65
66    #[cfg(not(feature = "network"))]
67    #[error(
68        "Network support is disabled (compile with 'network' feature to enable remote includes)"
69    )]
70    NetworkDisabled,
71
72    #[error("Could not convert from int: {0}")]
73    TryFromIntError(#[from] std::num::TryFromIntError),
74
75    #[error("Non-conforming manpage title: {1}, position: {0}")]
76    NonConformingManpageTitle(Box<SourceLocation>, String),
77}
78
79impl Error {
80    /// Helper for creating mismatched delimiter errors
81    #[must_use]
82    pub(crate) fn mismatched_delimiters(detail: SourceLocation, block_type: &str) -> Self {
83        Self::MismatchedDelimiters(Box::new(detail), block_type.to_string())
84    }
85
86    /// Extract source location information from this error if available.
87    /// Returns the `SourceLocation` (either Location or Position) for errors that have positional information.
88    #[must_use]
89    pub fn source_location(&self) -> Option<&SourceLocation> {
90        match self {
91            Self::NestedSectionLevelMismatch(detail, ..)
92            | Self::MismatchedDelimiters(detail, ..)
93            | Self::InvalidAdmonitionVariant(detail, ..)
94            | Self::Parse(detail, ..)
95            | Self::PegParse(detail, ..)
96            | Self::InvalidIncludePath(detail, ..)
97            | Self::InvalidLineRange(detail, ..)
98            | Self::InvalidConditionalDirective(detail)
99            | Self::InvalidIncludeDirective(detail, ..)
100            | Self::InvalidIndent(detail, ..)
101            | Self::InvalidLevelOffset(detail, ..)
102            | Self::InvalidIfEvalDirectiveMismatchedTypes(detail)
103            | Self::NonConformingManpageTitle(detail, ..) => Some(detail),
104            Self::ParseGrammar(_)
105            | Self::Io(_)
106            | Self::Url(_)
107            | Self::ParseInt(_)
108            | Self::UnknownEncoding(_)
109            | Self::UnrecognizedEncodingInFile(_)
110            | Self::TryFromIntError(_) => None,
111            #[cfg(feature = "network")]
112            Self::HttpRequest(_) => None,
113            #[cfg(not(feature = "network"))]
114            Self::NetworkDisabled => None,
115        }
116    }
117
118    /// Get advice for this error if available.
119    /// Returns helpful information for resolving the error.
120    #[must_use]
121    pub fn advice(&self) -> Option<&'static str> {
122        match self {
123            Self::NestedSectionLevelMismatch(..) => Some(
124                "Section levels must increment by at most 1. For example, level 2 (==) cannot be followed directly by level 4 (====)",
125            ),
126            Self::MismatchedDelimiters(..) => Some(
127                "Delimited blocks must use the same delimiter to open and close (e.g., '====' to open, '====' to close)",
128            ),
129            Self::InvalidAdmonitionVariant(..) => {
130                Some("Valid admonition types are: NOTE, TIP, IMPORTANT, WARNING, CAUTION")
131            }
132            Self::InvalidIfEvalDirectiveMismatchedTypes(..) => Some(
133                "ifeval expressions must compare values of the same type (both numbers or both strings)",
134            ),
135            Self::InvalidConditionalDirective(..) => Some(
136                "Valid conditional directives are: ifdef, ifndef, ifeval, endif. Check the syntax of your conditional block.",
137            ),
138            Self::InvalidLineRange(..) => Some(
139                "Line ranges must be in the format 'start..end' where start and end are positive integers",
140            ),
141            Self::InvalidIncludeDirective(..) => Some(
142                "Valid include directive attributes are: leveloffset, lines, tag, tags, indent, encoding, opts",
143            ),
144            Self::InvalidIndent(..) => Some(
145                "The indent attribute must be a non-negative integer specifying the number of spaces to indent included content",
146            ),
147            Self::InvalidLevelOffset(..) => Some(
148                "The leveloffset attribute must be a signed integer (e.g., +1, -1, 0) to adjust section levels in included content",
149            ),
150            Self::InvalidIncludePath(..) => Some(
151                "Include paths must have a valid parent directory. Check that the path is not empty or relative to a non-existent location",
152            ),
153            Self::Parse(..) => Some(
154                "Check the AsciiDoc syntax at the indicated location. Common issues: incorrect block delimiters, malformed section headings, or invalid attribute syntax",
155            ),
156            Self::PegParse(..) => Some(
157                "The parser encountered unexpected syntax. Verify that block delimiters match, section levels increment correctly, and all syntax follows AsciiDoc specification",
158            ),
159            Self::Url(..) => Some(
160                "Verify the URL syntax is correct (e.g., https://example.com/file.adoc). Check for typos in the protocol, domain, or path",
161            ),
162            #[cfg(feature = "network")]
163            Self::HttpRequest(..) => Some(
164                "Check that the URL is accessible, the server is reachable, and you have network connectivity. For includes, consider using safe mode restrictions",
165            ),
166            #[cfg(not(feature = "network"))]
167            Self::NetworkDisabled => Some(
168                "Remote includes require the 'network' feature. Rebuild with `cargo build --features network` or use local file includes instead",
169            ),
170            Self::UnknownEncoding(..) | Self::UnrecognizedEncodingInFile(..) => Some(
171                "We only support UTF-8 or UTF-16 encoded files. Ensure the specified encoding is correct and the file is saved with that encoding",
172            ),
173            Self::NonConformingManpageTitle(..) => Some(
174                "Manpage document titles must be in the format 'name(volume)', e.g., 'git-commit(1)'. Remove --strict flag to use fallback values.",
175            ),
176            Self::ParseGrammar(_) | Self::Io(_) | Self::ParseInt(_) | Self::TryFromIntError(_) => {
177                None
178            }
179        }
180    }
181}
182
183/// Positioning information - either a full Location with start/end or a single Position
184#[derive(Debug, PartialEq)]
185pub enum Positioning {
186    Location(Location),
187    Position(Position),
188}
189
190impl fmt::Display for Positioning {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        match self {
193            Positioning::Location(location) => write!(
194                f,
195                "start(line: {}, column: {}), end(line: {}, column: {})",
196                location.start.line, location.start.column, location.end.line, location.end.column
197            ),
198            Positioning::Position(position) => {
199                write!(f, "line: {}, column: {}", position.line, position.column)
200            }
201        }
202    }
203}
204
205/// Source location information combining file path and positioning
206#[derive(Debug, PartialEq)]
207#[non_exhaustive]
208pub struct SourceLocation {
209    pub file: Option<PathBuf>,
210    pub positioning: Positioning,
211}
212
213impl fmt::Display for SourceLocation {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(f, "{}", self.positioning)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn test_error_detail_display() {
225        let detail = SourceLocation {
226            file: None,
227            positioning: Positioning::Location(Location {
228                absolute_start: 2,
229                absolute_end: 20,
230                start: Position { line: 1, column: 2 },
231                end: Position { line: 3, column: 4 },
232            }),
233        };
234        assert_eq!(
235            format!("{detail}"),
236            "start(line: 1, column: 2), end(line: 3, column: 4)"
237        );
238    }
239
240    #[test]
241    fn test_error_nested_section_level_mismatch_display() {
242        let error = Error::NestedSectionLevelMismatch(
243            Box::new(SourceLocation {
244                file: None,
245                positioning: Positioning::Location(Location {
246                    absolute_start: 2,
247                    absolute_end: 20,
248                    start: Position { line: 1, column: 2 },
249                    end: Position { line: 3, column: 4 },
250                }),
251            }),
252            1,
253            2,
254        );
255        assert_eq!(
256            format!("{error}"),
257            "section level mismatch: 1 (expected '2'), position: start(line: 1, column: 2), end(line: 3, column: 4)"
258        );
259    }
260
261    #[test]
262    fn test_error_invalid_admonition_variant_display() {
263        let error = Error::InvalidAdmonitionVariant(
264            Box::new(SourceLocation {
265                file: None,
266                positioning: Positioning::Location(Location {
267                    absolute_start: 10,
268                    absolute_end: 25,
269                    start: Position { line: 2, column: 1 },
270                    end: Position {
271                        line: 2,
272                        column: 15,
273                    },
274                }),
275            }),
276            "INVALID".to_string(),
277        );
278        assert_eq!(
279            format!("{error}"),
280            "Invalid admonition variant: INVALID, position: start(line: 2, column: 1), end(line: 2, column: 15)"
281        );
282    }
283
284    #[test]
285    fn test_error_mismatched_delimiters_display() {
286        let error = Error::MismatchedDelimiters(
287            Box::new(SourceLocation {
288                file: None,
289                positioning: Positioning::Location(Location {
290                    absolute_start: 0,
291                    absolute_end: 50,
292                    start: Position { line: 1, column: 1 },
293                    end: Position { line: 5, column: 5 },
294                }),
295            }),
296            "example".to_string(),
297        );
298        assert_eq!(
299            format!("{error}"),
300            "mismatched delimiters: example, position: start(line: 1, column: 1), end(line: 5, column: 5)"
301        );
302    }
303
304    #[test]
305    fn test_error_parse_display() {
306        let error = Error::Parse(
307            Box::new(SourceLocation {
308                file: None,
309                positioning: Positioning::Position(Position { line: 1, column: 6 }),
310            }),
311            "unexpected token".to_string(),
312        );
313        assert_eq!(
314            format!("{error}"),
315            "Parsing error: unexpected token, position: line: 1, column: 6"
316        );
317    }
318}