Skip to main content

acdc_parser/
error.rs

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