use std::{fmt, path::PathBuf};
use crate::model::{Location, Position, SectionLevel};
#[non_exhaustive]
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Invalid include path: {1}, position: {0}")]
InvalidIncludePath(Box<SourceLocation>, PathBuf),
#[error("Invalid line range: {1}, position: {0}")]
InvalidLineRange(Box<SourceLocation>, String),
#[error("Parsing error: {1}, position: {0}")]
Parse(Box<SourceLocation>, String),
#[error("PEG parsing error: {1}, position {0}")]
PegParse(Box<SourceLocation>, String),
#[error("Parsing error: {0}")]
ParseGrammar(#[from] peg::error::ParseError<peg::str::LineCol>),
#[error("section level mismatch: {1} (expected '{2}'), position: {0}")]
NestedSectionLevelMismatch(Box<SourceLocation>, SectionLevel, SectionLevel),
#[error("mismatched delimiters: {1}, position: {0}")]
MismatchedDelimiters(Box<SourceLocation>, String),
#[error("Invalid admonition variant: {1}, position: {0}")]
InvalidAdmonitionVariant(Box<SourceLocation>, String),
#[error("Invalid conditional directive, position: {0}")]
InvalidConditionalDirective(Box<SourceLocation>),
#[error("Invalid include directive: {1}, position: {0}")]
InvalidIncludeDirective(Box<SourceLocation>, String),
#[error("Invalid indent: {1}, position: {0}")]
InvalidIndent(Box<SourceLocation>, String),
#[error("Invalid level offset: {1}, position: {0}")]
InvalidLevelOffset(Box<SourceLocation>, String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("URL error: {0}")]
Url(#[from] url::ParseError),
#[error("ParseInt error: {0}")]
ParseInt(#[from] std::num::ParseIntError),
#[error("Invalid ifeval directive, position: {0}")]
InvalidIfEvalDirectiveMismatchedTypes(Box<SourceLocation>),
#[error("Unknown encoding: {0}")]
UnknownEncoding(String),
#[error("Unrecognized encoding in file: {0}")]
UnrecognizedEncodingInFile(String),
#[cfg(feature = "network")]
#[error("Unable to retrieve HTTP response: {0}")]
HttpRequest(String),
#[cfg(not(feature = "network"))]
#[error(
"Network support is disabled (compile with 'network' feature to enable remote includes)"
)]
NetworkDisabled,
#[error("Could not convert from int: {0}")]
TryFromIntError(#[from] std::num::TryFromIntError),
#[error("Non-conforming manpage title: {1}, position: {0}")]
NonConformingManpageTitle(Box<SourceLocation>, String),
}
impl Error {
#[must_use]
pub(crate) fn mismatched_delimiters(detail: SourceLocation, block_type: &str) -> Self {
Self::MismatchedDelimiters(Box::new(detail), block_type.to_string())
}
#[must_use]
pub fn source_location(&self) -> Option<&SourceLocation> {
match self {
Self::NestedSectionLevelMismatch(detail, ..)
| Self::MismatchedDelimiters(detail, ..)
| Self::InvalidAdmonitionVariant(detail, ..)
| Self::Parse(detail, ..)
| Self::PegParse(detail, ..)
| Self::InvalidIncludePath(detail, ..)
| Self::InvalidLineRange(detail, ..)
| Self::InvalidConditionalDirective(detail)
| Self::InvalidIncludeDirective(detail, ..)
| Self::InvalidIndent(detail, ..)
| Self::InvalidLevelOffset(detail, ..)
| Self::InvalidIfEvalDirectiveMismatchedTypes(detail)
| Self::NonConformingManpageTitle(detail, ..) => Some(detail),
Self::ParseGrammar(_)
| Self::Io(_)
| Self::Url(_)
| Self::ParseInt(_)
| Self::UnknownEncoding(_)
| Self::UnrecognizedEncodingInFile(_)
| Self::TryFromIntError(_) => None,
#[cfg(feature = "network")]
Self::HttpRequest(_) => None,
#[cfg(not(feature = "network"))]
Self::NetworkDisabled => None,
}
}
#[must_use]
pub fn advice(&self) -> Option<&'static str> {
match self {
Self::NestedSectionLevelMismatch(..) => Some(
"Section levels must increment by at most 1. For example, level 2 (==) cannot be followed directly by level 4 (====)",
),
Self::MismatchedDelimiters(..) => Some(
"Delimited blocks must use the same delimiter to open and close (e.g., '====' to open, '====' to close)",
),
Self::InvalidAdmonitionVariant(..) => {
Some("Valid admonition types are: NOTE, TIP, IMPORTANT, WARNING, CAUTION")
}
Self::InvalidIfEvalDirectiveMismatchedTypes(..) => Some(
"ifeval expressions must compare values of the same type (both numbers or both strings)",
),
Self::InvalidConditionalDirective(..) => Some(
"Valid conditional directives are: ifdef, ifndef, ifeval, endif. Check the syntax of your conditional block.",
),
Self::InvalidLineRange(..) => Some(
"Line ranges must be in the format 'start..end' where start and end are positive integers",
),
Self::InvalidIncludeDirective(..) => Some(
"Valid include directive attributes are: leveloffset, lines, tag, tags, indent, encoding, opts",
),
Self::InvalidIndent(..) => Some(
"The indent attribute must be a non-negative integer specifying the number of spaces to indent included content",
),
Self::InvalidLevelOffset(..) => Some(
"The leveloffset attribute must be a signed integer (e.g., +1, -1, 0) to adjust section levels in included content",
),
Self::InvalidIncludePath(..) => Some(
"Include paths must have a valid parent directory. Check that the path is not empty or relative to a non-existent location",
),
Self::Parse(..) => Some(
"Check the AsciiDoc syntax at the indicated location. Common issues: incorrect block delimiters, malformed section headings, or invalid attribute syntax",
),
Self::PegParse(..) => Some(
"The parser encountered unexpected syntax. Verify that block delimiters match, section levels increment correctly, and all syntax follows AsciiDoc specification",
),
Self::Url(..) => Some(
"Verify the URL syntax is correct (e.g., https://example.com/file.adoc). Check for typos in the protocol, domain, or path",
),
#[cfg(feature = "network")]
Self::HttpRequest(..) => Some(
"Check that the URL is accessible, the server is reachable, and you have network connectivity. For includes, consider using safe mode restrictions",
),
#[cfg(not(feature = "network"))]
Self::NetworkDisabled => Some(
"Remote includes require the 'network' feature. Rebuild with `cargo build --features network` or use local file includes instead",
),
Self::UnknownEncoding(..) | Self::UnrecognizedEncodingInFile(..) => Some(
"We only support UTF-8 or UTF-16 encoded files. Ensure the specified encoding is correct and the file is saved with that encoding",
),
Self::NonConformingManpageTitle(..) => Some(
"Manpage document titles must be in the format 'name(volume)', e.g., 'git-commit(1)'. Remove --strict flag to use fallback values.",
),
Self::ParseGrammar(_) | Self::Io(_) | Self::ParseInt(_) | Self::TryFromIntError(_) => {
None
}
}
}
}
#[derive(Debug, PartialEq)]
pub enum Positioning {
Location(Location),
Position(Position),
}
impl fmt::Display for Positioning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Positioning::Location(location) => write!(
f,
"start(line: {}, column: {}), end(line: {}, column: {})",
location.start.line, location.start.column, location.end.line, location.end.column
),
Positioning::Position(position) => {
write!(f, "line: {}, column: {}", position.line, position.column)
}
}
}
}
#[derive(Debug, PartialEq)]
#[non_exhaustive]
pub struct SourceLocation {
pub file: Option<PathBuf>,
pub positioning: Positioning,
}
impl fmt::Display for SourceLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.positioning)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_detail_display() {
let detail = SourceLocation {
file: None,
positioning: Positioning::Location(Location {
absolute_start: 2,
absolute_end: 20,
start: Position { line: 1, column: 2 },
end: Position { line: 3, column: 4 },
}),
};
assert_eq!(
format!("{detail}"),
"start(line: 1, column: 2), end(line: 3, column: 4)"
);
}
#[test]
fn test_error_nested_section_level_mismatch_display() {
let error = Error::NestedSectionLevelMismatch(
Box::new(SourceLocation {
file: None,
positioning: Positioning::Location(Location {
absolute_start: 2,
absolute_end: 20,
start: Position { line: 1, column: 2 },
end: Position { line: 3, column: 4 },
}),
}),
1,
2,
);
assert_eq!(
format!("{error}"),
"section level mismatch: 1 (expected '2'), position: start(line: 1, column: 2), end(line: 3, column: 4)"
);
}
#[test]
fn test_error_invalid_admonition_variant_display() {
let error = Error::InvalidAdmonitionVariant(
Box::new(SourceLocation {
file: None,
positioning: Positioning::Location(Location {
absolute_start: 10,
absolute_end: 25,
start: Position { line: 2, column: 1 },
end: Position {
line: 2,
column: 15,
},
}),
}),
"INVALID".to_string(),
);
assert_eq!(
format!("{error}"),
"Invalid admonition variant: INVALID, position: start(line: 2, column: 1), end(line: 2, column: 15)"
);
}
#[test]
fn test_error_mismatched_delimiters_display() {
let error = Error::MismatchedDelimiters(
Box::new(SourceLocation {
file: None,
positioning: Positioning::Location(Location {
absolute_start: 0,
absolute_end: 50,
start: Position { line: 1, column: 1 },
end: Position { line: 5, column: 5 },
}),
}),
"example".to_string(),
);
assert_eq!(
format!("{error}"),
"mismatched delimiters: example, position: start(line: 1, column: 1), end(line: 5, column: 5)"
);
}
#[test]
fn test_error_parse_display() {
let error = Error::Parse(
Box::new(SourceLocation {
file: None,
positioning: Positioning::Position(Position { line: 1, column: 6 }),
}),
"unexpected token".to_string(),
);
assert_eq!(
format!("{error}"),
"Parsing error: unexpected token, position: line: 1, column: 6"
);
}
}