Skip to main content

libmandoc_rs/
diagnostics.rs

1//! Converts libmandoc's textual findings into stable structured diagnostics.
2
3/// Severity assigned by libmandoc's validation diagnostics.
4#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub enum DiagnosticLevel {
7    /// A construct is valid roff but unsupported by libmandoc.
8    Unsupported,
9    /// The source contains an error that may make output incomplete.
10    Error,
11    /// The source is recoverable but suspicious or non-portable.
12    Warning,
13    /// The source violates a style recommendation without changing meaning.
14    Style,
15}
16
17/// Optional source location extracted from a libmandoc diagnostic prefix.
18#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct SourceLocation {
21    /// One-based source line.
22    pub line: u32,
23    /// One-based source column.
24    pub column: u32,
25}
26
27/// One non-fatal finding emitted while parsing a manual source.
28#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct Diagnostic {
31    /// Severity classified from libmandoc's diagnostic marker.
32    pub level: DiagnosticLevel,
33    /// Human-readable finding with the location prefix removed.
34    pub message: String,
35    /// Source position when libmandoc supplied a parseable prefix.
36    pub location: Option<SourceLocation>,
37}
38
39pub(crate) fn parse_diagnostics(output: &str) -> Vec<Diagnostic> {
40    output.lines().filter_map(parse_diagnostic).collect()
41}
42
43fn parse_diagnostic(line: &str) -> Option<Diagnostic> {
44    let line = line.trim();
45    if line.is_empty() {
46        return None;
47    }
48    let (level, marker) = [
49        (DiagnosticLevel::Unsupported, ": UNSUPP: "),
50        (DiagnosticLevel::Error, ": ERROR: "),
51        (DiagnosticLevel::Error, ": BADARG: "),
52        (DiagnosticLevel::Error, ": SYSERR: "),
53        (DiagnosticLevel::Warning, ": WARNING: "),
54        (DiagnosticLevel::Style, ": STYLE: "),
55    ]
56    .into_iter()
57    .find(|(_, marker)| line.contains(marker))
58    .unwrap_or((DiagnosticLevel::Warning, ": "));
59    let (prefix, message) = line.split_once(marker).unwrap_or(("", line));
60    Some(Diagnostic {
61        level,
62        message: message.to_owned(),
63        location: source_location(prefix),
64    })
65}
66
67fn source_location(prefix: &str) -> Option<SourceLocation> {
68    let mut fields = prefix.rsplitn(3, ':');
69    let column = fields.next()?.trim().parse().ok()?;
70    let line = fields.next()?.trim().parse().ok()?;
71    Some(SourceLocation { line, column })
72}
73
74#[cfg(test)]
75mod tests {
76    use super::{DiagnosticLevel, SourceLocation, parse_diagnostics};
77
78    #[test]
79    fn preserves_each_finding_and_classifies_known_levels() {
80        let diagnostics = parse_diagnostics(
81            "mant: page.1:8:2: UNSUPP: unsupported roff request: ab\n\
82             mant: page.1:9:1: WARNING: skipping paragraph macro\n",
83        );
84
85        assert_eq!(diagnostics.len(), 2);
86        assert_eq!(diagnostics[0].level, DiagnosticLevel::Unsupported);
87        assert_eq!(diagnostics[0].message, "unsupported roff request: ab");
88        assert_eq!(
89            diagnostics[0].location,
90            Some(SourceLocation { line: 8, column: 2 })
91        );
92        assert_eq!(diagnostics[1].level, DiagnosticLevel::Warning);
93    }
94}