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                "Section levels must increment by at most one. Renumber the heading so it is one level deeper than its parent (the document title counts as level 0).",
48            ),
49            WarningKind::NestedSectionInIndex => Some(
50                "Move the nested section outside the index or replace its heading with a non-section block.",
51            ),
52            WarningKind::NestedSectionInBibliography => Some(
53                "Move the nested section outside the bibliography section or replace its heading with a non-section block.",
54            ),
55            WarningKind::UnterminatedTable { .. }
56            | WarningKind::UnterminatedDelimitedBlock { .. } => Some(
57                "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.",
58            ),
59            WarningKind::TableUnknownFormat { .. } => Some(
60                "Use a supported table format (`csv`, `dsv`, or `tsv`) or remove the `format` attribute to use the table delimiter's default separator.",
61            ),
62            WarningKind::TableIncompleteRow => Some(
63                "Complete the final table row or remove the trailing cells that do not fill the configured column count.",
64            ),
65            WarningKind::TableCellOverflow { .. } | WarningKind::TableColumnCount { .. } => Some(
66                "Adjust the `cols` attribute or the row's cells/spans so each row occupies the configured number of columns.",
67            ),
68            WarningKind::NonStandardAuthorLine { .. } => Some(
69                "Author lines use `firstname [middlename] [lastname] [<email>]`, with multiple authors separated by `;`. Keeping the whole line as a single author name.",
70            ),
71            WarningKind::UnresolvedReference { .. } => Some(
72                "Define an anchor with this id (e.g. `[[id]]` or `[#id]` on a block or section), or fix the reference to point at an existing id.",
73            ),
74            WarningKind::DuplicateId { .. } => Some(
75                "Assign a unique id to each target. Cross-references use the first definition.",
76            ),
77            WarningKind::ConflictingFootnote { .. } => Some(
78                "Use footnote:id[] to reuse the first definition, or choose a different id for a new footnote.",
79            ),
80            WarningKind::LegacyFloatDiscreteHeading => Some(
81                "Replace the `float` attribute with `discrete` (e.g. `[discrete]`). `float` here does not control layout; it is an older name for a discrete (free-floating) heading.",
82            ),
83            WarningKind::InvalidDocumentAttribute { .. } => {
84                Some("Use a value in the attribute's documented domain")
85            }
86            WarningKind::ContentRecovery { .. } | WarningKind::Other(_) => None,
87        }
88    }
89}
90
91impl fmt::Display for Warning {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        let Some(loc) = &self.location else {
94            return write!(f, "{}", self.kind);
95        };
96        if let Some(name) = loc
97            .file
98            .as_ref()
99            .and_then(|p| p.file_name())
100            .and_then(|s| s.to_str())
101        {
102            write!(f, "{name}: {}: {}", loc, self.kind)
103        } else {
104            write!(f, "{}: {}", loc, self.kind)
105        }
106    }
107}
108
109/// Categorised non-fatal conditions.
110///
111/// `Other` is an escape hatch for ad-hoc messages the parser has not yet
112/// been taught to categorise. New variants should be added as callers need
113/// to assert on them programmatically (e.g. LSP mapping `kind` to LSP
114/// diagnostic codes, or tests matching on specific conditions without
115/// resorting to string comparison).
116#[derive(Debug, Clone, PartialEq, thiserror::Error)]
117#[non_exhaustive]
118pub enum WarningKind {
119    /// A section title skips a level relative to its parent — either the first
120    /// section after the document title is deeper than level 1, or a nested
121    /// section jumps more than one level below its parent. Matches asciidoctor's
122    /// "section title out of sequence" check; the section is still rendered at
123    /// its literal level.
124    #[error("section title out of sequence: expected level {expected}, got level {got}")]
125    SectionLevelOutOfSequence {
126        /// The level the section was expected to be (one deeper than its
127        /// parent; `1` for the first section under a document title).
128        expected: u8,
129        /// The observed section level (e.g. `2` for `===`).
130        got: u8,
131    },
132
133    /// A bibliography section contains a direct child section. The child is
134    /// retained in the document, matching Asciidoctor's recovery behavior.
135    #[error("bibliography sections do not support nested sections")]
136    NestedSectionInBibliography,
137
138    /// An index contains a direct child section. The child remains in the AST;
139    /// each converter applies its usual index-content policy.
140    #[error("index sections do not support nested sections")]
141    NestedSectionInIndex,
142
143    /// A table's opening delimiter was matched but no corresponding
144    /// closing delimiter was found before end of input. Matches
145    /// asciidoctor's "unterminated table block" warning.
146    ///
147    /// `delimiter` is the literal opening token as it appeared in the
148    /// source (e.g. `"|==="`, `"!====="`).
149    #[error("unterminated table block (opened by `{delimiter}`)")]
150    UnterminatedTable {
151        /// The opening delimiter that was left unmatched.
152        delimiter: String,
153    },
154
155    /// A delimited block's opening delimiter was matched but no corresponding
156    /// closing delimiter was found before end of input. Matches asciidoctor's
157    /// "unterminated <kind> block" warning; the block is still rendered,
158    /// closed at end of input. Tables use the separate [`UnterminatedTable`]
159    /// variant.
160    ///
161    /// `delimiter` is the literal opening token as it appeared in the source
162    /// (e.g. `"===="`, `"----"`).
163    ///
164    /// [`UnterminatedTable`]: WarningKind::UnterminatedTable
165    #[error("unterminated {kind} block (opened by `{delimiter}`)")]
166    UnterminatedDelimitedBlock {
167        /// The block kind: `"example"`, `"listing"`, `"literal"`,
168        /// `"sidebar"`, `"quote"`, `"open"`, `"comment"`, or `"pass"`.
169        kind: &'static str,
170        /// The opening delimiter that was left unmatched.
171        delimiter: String,
172    },
173
174    /// A table declared an unsupported `format` attribute, so the parser fell
175    /// back to the table delimiter's default separator.
176    #[error("unknown table format `{format}`, using default separator")]
177    TableUnknownFormat {
178        /// The unsupported format value.
179        format: String,
180    },
181
182    /// A table ended with cells that could not fill a complete row, so those
183    /// trailing cells were dropped.
184    #[error("dropping cells from incomplete row detected end of table")]
185    TableIncompleteRow,
186
187    /// A table row contained a cell/span that exceeded the configured column
188    /// count, so the row was dropped.
189    #[error(
190        "dropping cell because it exceeds specified number of columns: actual={actual}, expected={expected}"
191    )]
192    TableCellOverflow {
193        /// The row's logical column count.
194        actual: usize,
195        /// The configured column count.
196        expected: usize,
197    },
198
199    /// A table row's logical column count did not match the configured column
200    /// count, so the row was dropped.
201    #[error(
202        "table row has incorrect column count: actual={actual}, expected={expected}, occupied_from_rowspans={occupied_from_rowspans}"
203    )]
204    TableColumnCount {
205        /// The row's logical column count.
206        actual: usize,
207        /// The configured column count.
208        expected: usize,
209        /// Columns already occupied by active rowspans.
210        occupied_from_rowspans: usize,
211    },
212
213    /// A document header author line did not match the structured
214    /// `firstname [middlename] [lastname] [<email>]` form, so the whole line
215    /// was kept as a single author's name. asciidoctor accepts this silently;
216    /// acdc surfaces it so a malformed author line is easy to spot.
217    #[error(
218        "author line `{line}` is not in `firstname [middlename] [lastname] [<email>]` form; using the whole line as a single author name"
219    )]
220    NonStandardAuthorLine {
221        /// The author line as parsed (after attribute substitution).
222        line: String,
223    },
224
225    /// A cross-reference (`<<id>>` / `xref:id[]`) points at an id that is not
226    /// defined anywhere in the document. Matches asciidoctor's "invalid
227    /// reference" warning.
228    #[error("invalid reference: {target}")]
229    UnresolvedReference {
230        /// The unresolved target id.
231        target: String,
232    },
233
234    /// More than one source definition uses the same ID. References keep the first.
235    #[error("id already in use: {id} (first defined at {})", first_definition(.first))]
236    DuplicateId {
237        /// The original, unencoded ID.
238        id: String,
239        /// The first definition, including its original file and position.
240        first: Box<SourceLocation>,
241    },
242
243    /// A named footnote is repeated with different text. The first body is retained.
244    #[error("footnote '{id}' has different text from its first definition at {}; keeping the first definition", first_definition(.first))]
245    ConflictingFootnote {
246        /// The repeated footnote ID.
247        id: String,
248        /// The first definition, including its original file and position.
249        first: Box<SourceLocation>,
250    },
251
252    /// A discrete heading was marked with the legacy `float` attribute rather
253    /// than `discrete`. `float` is only supported because an older version of
254    /// `AsciiDoc` called discrete headings "floating titles"; the current spec
255    /// prefers `discrete`.
256    #[error("`float` is a legacy alias for a discrete heading; prefer `discrete`")]
257    LegacyFloatDiscreteHeading,
258
259    /// A document attribute value did not match its documented domain.
260    /// The previous effective value remains active.
261    #[error("invalid document attribute `{name}` value `{value}`: expected {expected}")]
262    InvalidDocumentAttribute {
263        /// Document attribute name.
264        name: String,
265        /// Value as supplied by the document.
266        value: String,
267        /// Short description of the registered value domain.
268        expected: &'static str,
269    },
270
271    /// Requested source content or substitutions were omitted, replaced, or recovered.
272    ///
273    /// Rendering can continue, but consumers that require complete source content
274    /// must handle this condition before using the recovered document.
275    #[error("{message}")]
276    ContentRecovery {
277        /// The recovery and the source content it affected.
278        message: Cow<'static, str>,
279    },
280
281    /// Ad-hoc message not yet categorised into a typed variant.
282    #[error("{0}")]
283    Other(Cow<'static, str>),
284}
285
286fn first_definition(location: &SourceLocation) -> String {
287    let file = location
288        .file
289        .as_deref()
290        .map_or_else(|| "<input>".into(), std::path::Path::to_string_lossy);
291    format!(
292        "{file}:{}:{}",
293        location.location.start.line, location.location.start.column
294    )
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::Position;
301
302    #[test]
303    fn display_without_location() {
304        let w = Warning::new(WarningKind::Other("something happened".into()), None);
305        assert_eq!(format!("{w}"), "something happened");
306    }
307
308    #[test]
309    fn display_with_location_no_file() {
310        let loc = SourceLocation {
311            file: None,
312            location: crate::Location::point(Position::new(5, 1)),
313        };
314        let w = Warning::new(
315            WarningKind::SectionLevelOutOfSequence {
316                expected: 2,
317                got: 3,
318            },
319            Some(loc),
320        );
321        assert_eq!(
322            format!("{w}"),
323            "line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
324        );
325    }
326
327    #[test]
328    fn display_with_location_and_file() {
329        let loc = SourceLocation {
330            file: Some(std::path::PathBuf::from("/docs/guide.adoc")),
331            location: crate::Location::point(Position::new(5, 1)),
332        };
333        let w = Warning::new(
334            WarningKind::SectionLevelOutOfSequence {
335                expected: 2,
336                got: 3,
337            },
338            Some(loc),
339        );
340        assert_eq!(
341            format!("{w}"),
342            "guide.adoc: line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
343        );
344    }
345
346    #[test]
347    fn equality_holds_on_kind_and_location() {
348        let a = Warning::new(WarningKind::Other("x".into()), None);
349        let b = Warning::new(WarningKind::Other("x".into()), None);
350        let c = Warning::new(WarningKind::Other("y".into()), None);
351        assert_eq!(a, b);
352        assert_ne!(a, c);
353    }
354
355    #[test]
356    fn unterminated_table_display_renders_original_token() {
357        let w = Warning::new(
358            WarningKind::UnterminatedTable {
359                delimiter: "|===".into(),
360            },
361            None,
362        );
363        assert_eq!(
364            format!("{w}"),
365            "unterminated table block (opened by `|===`)",
366        );
367    }
368
369    #[test]
370    fn unterminated_table_display_preserves_longer_tokens() {
371        let w = Warning::new(
372            WarningKind::UnterminatedTable {
373                delimiter: "!=====".into(),
374            },
375            None,
376        );
377        assert_eq!(
378            format!("{w}"),
379            "unterminated table block (opened by `!=====`)",
380        );
381    }
382}