Skip to main content

acdc_parser/
warning.rs

1//! Non-fatal parser diagnostics.
2//!
3//! Warnings are conditions the parser recovers from but that a caller (CLI,
4//! LSP, editor) may want to surface to the user. They are carried on
5//! [`ParseResult::warnings`](crate::ParseResult::warnings) and also emitted
6//! through `tracing::warn!` as a belt-and-suspenders fallback for callers
7//! that ignore the returned slice.
8
9use std::{borrow::Cow, fmt};
10
11use crate::SourceLocation;
12
13/// A non-fatal condition detected during parsing.
14///
15/// Use [`Warning::source_location`] to map to a location for diagnostic
16/// rendering, and [`Warning::advice`] for help text mirroring
17/// [`Error::advice`](crate::Error::advice).
18#[derive(Debug, PartialEq)]
19#[non_exhaustive]
20pub struct Warning {
21    /// The specific non-fatal condition.
22    pub kind: WarningKind,
23    /// Where the condition was detected, when known. Absent for warnings
24    /// raised outside any source context (e.g. preprocessor configuration).
25    pub location: Option<SourceLocation>,
26}
27
28impl Warning {
29    /// Construct a warning tied to a specific source location.
30    #[must_use]
31    pub(crate) fn new(kind: WarningKind, location: Option<SourceLocation>) -> Self {
32        Self { kind, location }
33    }
34
35    /// Source location for this warning, when available.
36    #[must_use]
37    pub fn source_location(&self) -> Option<&SourceLocation> {
38        self.location.as_ref()
39    }
40
41    /// Advice text mirroring [`Error::advice`](crate::Error::advice).
42    /// Returns `None` when there is no canned guidance for this kind.
43    #[must_use]
44    pub fn advice(&self) -> Option<&'static str> {
45        match &self.kind {
46            WarningKind::SectionLevelOutOfSequence { .. } => Some(
47                "The first section after the document title must be level 1 (==). Renumber the section headings so levels increment by one.",
48            ),
49            WarningKind::UnterminatedTable { .. } => Some(
50                "The opening delimiter was found but no matching closing delimiter was seen before end of document. Add the closing delimiter on its own line, or remove the opening delimiter if not intended.",
51            ),
52            WarningKind::Other(_) => None,
53        }
54    }
55}
56
57impl fmt::Display for Warning {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        let Some(loc) = &self.location else {
60            return write!(f, "{}", self.kind);
61        };
62        if let Some(name) = loc
63            .file
64            .as_ref()
65            .and_then(|p| p.file_name())
66            .and_then(|s| s.to_str())
67        {
68            write!(f, "{name}: {}: {}", loc.positioning, self.kind)
69        } else {
70            write!(f, "{}: {}", loc.positioning, self.kind)
71        }
72    }
73}
74
75/// Categorised non-fatal conditions.
76///
77/// `Other` is an escape hatch for ad-hoc messages the parser has not yet
78/// been taught to categorise. New variants should be added as callers need
79/// to assert on them programmatically (e.g. LSP mapping `kind` to LSP
80/// diagnostic codes, or tests matching on specific conditions without
81/// resorting to string comparison).
82#[derive(Debug, Clone, PartialEq, thiserror::Error)]
83#[non_exhaustive]
84pub enum WarningKind {
85    /// The document has a title (level 0) but the first section after it
86    /// is not level 1. Matches asciidoctor's "section title out of
87    /// sequence" check.
88    #[error("expected level 1 (==) as first section, got level {got} ({markers})")]
89    SectionLevelOutOfSequence {
90        /// The observed section level (e.g. 2 for `===`).
91        got: u8,
92        /// The `=` markers that produced the observed level.
93        markers: String,
94    },
95
96    /// A table's opening delimiter was matched but no corresponding
97    /// closing delimiter was found before end of input. Matches
98    /// asciidoctor's "unterminated table block" warning.
99    ///
100    /// `delimiter` is the literal opening token as it appeared in the
101    /// source (e.g. `"|==="`, `"!====="`).
102    #[error("unterminated table block (opened by `{delimiter}`)")]
103    UnterminatedTable {
104        /// The opening delimiter that was left unmatched.
105        delimiter: String,
106    },
107
108    /// Ad-hoc message not yet categorised into a typed variant.
109    #[error("{0}")]
110    Other(Cow<'static, str>),
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::{Position, Positioning};
117
118    #[test]
119    fn display_without_location() {
120        let w = Warning::new(WarningKind::Other("something happened".into()), None);
121        assert_eq!(format!("{w}"), "something happened");
122    }
123
124    #[test]
125    fn display_with_location_no_file() {
126        let loc = SourceLocation {
127            file: None,
128            positioning: Positioning::Position(Position { line: 5, column: 1 }),
129        };
130        let w = Warning::new(
131            WarningKind::SectionLevelOutOfSequence {
132                got: 3,
133                markers: "====".into(),
134            },
135            Some(loc),
136        );
137        assert_eq!(
138            format!("{w}"),
139            "line: 5, column: 1: expected level 1 (==) as first section, got level 3 (====)",
140        );
141    }
142
143    #[test]
144    fn display_with_location_and_file() {
145        let loc = SourceLocation {
146            file: Some(std::path::PathBuf::from("/docs/guide.adoc")),
147            positioning: Positioning::Position(Position { line: 5, column: 1 }),
148        };
149        let w = Warning::new(
150            WarningKind::SectionLevelOutOfSequence {
151                got: 3,
152                markers: "====".into(),
153            },
154            Some(loc),
155        );
156        assert_eq!(
157            format!("{w}"),
158            "guide.adoc: line: 5, column: 1: expected level 1 (==) as first section, got level 3 (====)",
159        );
160    }
161
162    #[test]
163    fn equality_holds_on_kind_and_location() {
164        let a = Warning::new(WarningKind::Other("x".into()), None);
165        let b = Warning::new(WarningKind::Other("x".into()), None);
166        let c = Warning::new(WarningKind::Other("y".into()), None);
167        assert_eq!(a, b);
168        assert_ne!(a, c);
169    }
170
171    #[test]
172    fn unterminated_table_display_renders_original_token() {
173        let w = Warning::new(
174            WarningKind::UnterminatedTable {
175                delimiter: "|===".into(),
176            },
177            None,
178        );
179        assert_eq!(
180            format!("{w}"),
181            "unterminated table block (opened by `|===`)",
182        );
183    }
184
185    #[test]
186    fn unterminated_table_display_preserves_longer_tokens() {
187        let w = Warning::new(
188            WarningKind::UnterminatedTable {
189                delimiter: "!=====".into(),
190            },
191            None,
192        );
193        assert_eq!(
194            format!("{w}"),
195            "unterminated table block (opened by `!=====`)",
196        );
197    }
198}