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