Skip to main content

acdc_parser/
warning.rs

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