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 #[error("Invalid document attribute `{name}` value `{value}`: expected {expected}")]
49 InvalidDocumentAttribute {
50 name: String,
52 value: String,
54 expected: &'static str,
56 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 #[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 #[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 #[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#[derive(Clone, Debug, PartialEq)]
212#[non_exhaustive]
213pub struct SourceLocation {
214 pub file: Option<PathBuf>,
215 pub location: Location,
216}
217
218impl SourceLocation {
219 #[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 #[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 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}