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 #[error("Invalid document attribute `{name}` value `{value}`: expected {expected}")]
52 InvalidDocumentAttribute {
53 name: String,
55 value: String,
57 expected: &'static str,
59 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 #[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 #[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 #[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 #[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#[derive(Clone, Debug, PartialEq)]
244#[non_exhaustive]
245pub struct SourceLocation {
246 pub file: Option<PathBuf>,
247 pub location: Location,
248}
249
250impl SourceLocation {
251 #[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 #[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 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}