acdc_parser/
error.rs

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