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 #[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 #[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 #[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#[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#[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}