acdc-parser 0.11.0

`AsciiDoc` parser
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Non-fatal parser diagnostics.
//!
//! Read full warnings through [`ParseResult::warnings`](crate::ParseResult::warnings)
//! or [`ParseInlineResult::warnings`](crate::ParseInlineResult::warnings).
//! Tracing records only their categories and line/column positions. Warning
//! messages and file paths can contain document data, so they stay in the result.

use std::{borrow::Cow, fmt};

use crate::SourceLocation;

/// A non-fatal condition detected during parsing.
///
/// Use [`Warning::source_location`] to map to a location for diagnostic
/// rendering, and [`Warning::advice`] for help text mirroring
/// [`Error::advice`](crate::Error::advice).
#[derive(Debug, PartialEq)]
#[non_exhaustive]
pub struct Warning {
    /// The specific non-fatal condition.
    pub kind: WarningKind,
    /// Where the condition was detected, when known. Absent for warnings
    /// raised outside any source context (e.g. preprocessor configuration).
    pub location: Option<SourceLocation>,
}

impl Warning {
    /// Keep document data out of logs. Record only the category and line/column.
    pub(crate) fn emit_trace(&self) {
        let kind = match self.kind {
            WarningKind::SectionLevelOutOfSequence { .. } => "section-level-out-of-sequence",
            WarningKind::NestedSectionInBibliography => "nested-section-in-bibliography",
            WarningKind::NestedSectionInIndex => "nested-section-in-index",
            WarningKind::UnterminatedTable { .. } => "unterminated-table",
            WarningKind::UnterminatedDelimitedBlock { .. } => "unterminated-delimited-block",
            WarningKind::TableUnknownFormat { .. } => "table-unknown-format",
            WarningKind::TableIncompleteRow => "table-incomplete-row",
            WarningKind::TableCellOverflow { .. } => "table-cell-overflow",
            WarningKind::TableColumnCount { .. } => "table-column-count",
            WarningKind::NonStandardAuthorLine { .. } => "non-standard-author-line",
            WarningKind::UnresolvedReference { .. } => "unresolved-reference",
            WarningKind::DuplicateId { .. } => "duplicate-id",
            WarningKind::ConflictingFootnote { .. } => "conflicting-footnote",
            WarningKind::LegacyFloatDiscreteHeading => "legacy-float-discrete-heading",
            WarningKind::InvalidDocumentAttribute { .. } => "invalid-document-attribute",
            WarningKind::ContentRecovery { .. } => "content-recovery",
            WarningKind::Other(_) => "other",
        };
        tracing::warn!(
            kind,
            line = self
                .location
                .as_ref()
                .map(|source| source.location.start.line),
            column = self
                .location
                .as_ref()
                .map(|source| source.location.start.column),
            "parser warning; see returned diagnostics"
        );
    }

    /// Construct a warning tied to a specific source location.
    #[must_use]
    pub(crate) fn new(kind: WarningKind, location: Option<SourceLocation>) -> Self {
        Self { kind, location }
    }

    /// Source location for this warning, when available.
    #[must_use]
    pub fn source_location(&self) -> Option<&SourceLocation> {
        self.location.as_ref()
    }

    /// Advice text mirroring [`Error::advice`](crate::Error::advice).
    /// Returns `None` when there is no canned guidance for this kind.
    #[must_use]
    pub fn advice(&self) -> Option<&'static str> {
        match &self.kind {
            WarningKind::SectionLevelOutOfSequence { .. } => Some(
                "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).",
            ),
            WarningKind::NestedSectionInIndex => Some(
                "Move the nested section outside the index or replace its heading with a non-section block.",
            ),
            WarningKind::NestedSectionInBibliography => Some(
                "Move the nested section outside the bibliography section or replace its heading with a non-section block.",
            ),
            WarningKind::UnterminatedTable { .. }
            | WarningKind::UnterminatedDelimitedBlock { .. } => Some(
                "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.",
            ),
            WarningKind::TableUnknownFormat { .. } => Some(
                "Use a supported table format (`csv`, `dsv`, or `tsv`) or remove the `format` attribute to use the table delimiter's default separator.",
            ),
            WarningKind::TableIncompleteRow => Some(
                "Complete the final table row or remove the trailing cells that do not fill the configured column count.",
            ),
            WarningKind::TableCellOverflow { .. } | WarningKind::TableColumnCount { .. } => Some(
                "Adjust the `cols` attribute or the row's cells/spans so each row occupies the configured number of columns.",
            ),
            WarningKind::NonStandardAuthorLine { .. } => Some(
                "Author lines use `firstname [middlename] [lastname] [<email>]`, with multiple authors separated by `;`. Keeping the whole line as a single author name.",
            ),
            WarningKind::UnresolvedReference { .. } => Some(
                "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.",
            ),
            WarningKind::DuplicateId { .. } => Some(
                "Assign a unique id to each target. Cross-references use the first definition.",
            ),
            WarningKind::ConflictingFootnote { .. } => Some(
                "Use footnote:id[] to reuse the first definition, or choose a different id for a new footnote.",
            ),
            WarningKind::LegacyFloatDiscreteHeading => Some(
                "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.",
            ),
            WarningKind::InvalidDocumentAttribute { .. } => {
                Some("Use a value in the attribute's documented domain")
            }
            WarningKind::ContentRecovery { .. } | WarningKind::Other(_) => None,
        }
    }
}

impl fmt::Display for Warning {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let Some(loc) = &self.location else {
            return write!(f, "{}", self.kind);
        };
        if let Some(name) = loc
            .file
            .as_ref()
            .and_then(|p| p.file_name())
            .and_then(|s| s.to_str())
        {
            write!(f, "{name}: {}: {}", loc, self.kind)
        } else {
            write!(f, "{}: {}", loc, self.kind)
        }
    }
}

/// Categorised non-fatal conditions.
///
/// `Other` is an escape hatch for ad-hoc messages the parser has not yet
/// been taught to categorise. New variants should be added as callers need
/// to assert on them programmatically (e.g. LSP mapping `kind` to LSP
/// diagnostic codes, or tests matching on specific conditions without
/// resorting to string comparison).
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum WarningKind {
    /// A section title skips a level relative to its parent — either the first
    /// section after the document title is deeper than level 1, or a nested
    /// section jumps more than one level below its parent. Matches asciidoctor's
    /// "section title out of sequence" check; the section is still rendered at
    /// its literal level.
    #[error("section title out of sequence: expected level {expected}, got level {got}")]
    SectionLevelOutOfSequence {
        /// The level the section was expected to be (one deeper than its
        /// parent; `1` for the first section under a document title).
        expected: u8,
        /// The observed section level (e.g. `2` for `===`).
        got: u8,
    },

    /// A bibliography section contains a direct child section. The child is
    /// retained in the document, matching Asciidoctor's recovery behavior.
    #[error("bibliography sections do not support nested sections")]
    NestedSectionInBibliography,

    /// An index contains a direct child section. The child remains in the AST;
    /// each converter applies its usual index-content policy.
    #[error("index sections do not support nested sections")]
    NestedSectionInIndex,

    /// A table's opening delimiter was matched but no corresponding
    /// closing delimiter was found before end of input. Matches
    /// asciidoctor's "unterminated table block" warning.
    ///
    /// `delimiter` is the literal opening token as it appeared in the
    /// source (e.g. `"|==="`, `"!====="`).
    #[error("unterminated table block (opened by `{delimiter}`)")]
    UnterminatedTable {
        /// The opening delimiter that was left unmatched.
        delimiter: String,
    },

    /// A delimited block's opening delimiter was matched but no corresponding
    /// closing delimiter was found before end of input. Matches asciidoctor's
    /// "unterminated <kind> block" warning; the block is still rendered,
    /// closed at end of input. Tables use the separate [`UnterminatedTable`]
    /// variant.
    ///
    /// `delimiter` is the literal opening token as it appeared in the source
    /// (e.g. `"===="`, `"----"`).
    ///
    /// [`UnterminatedTable`]: WarningKind::UnterminatedTable
    #[error("unterminated {kind} block (opened by `{delimiter}`)")]
    UnterminatedDelimitedBlock {
        /// The block kind: `"example"`, `"listing"`, `"literal"`,
        /// `"sidebar"`, `"quote"`, `"open"`, `"comment"`, or `"pass"`.
        kind: &'static str,
        /// The opening delimiter that was left unmatched.
        delimiter: String,
    },

    /// A table declared an unsupported `format` attribute, so the parser fell
    /// back to the table delimiter's default separator.
    #[error("unknown table format `{format}`, using default separator")]
    TableUnknownFormat {
        /// The unsupported format value.
        format: String,
    },

    /// A table ended with cells that could not fill a complete row, so those
    /// trailing cells were dropped.
    #[error("dropping cells from incomplete row detected end of table")]
    TableIncompleteRow,

    /// A table row contained a cell/span that exceeded the configured column
    /// count, so the row was dropped.
    #[error(
        "dropping cell because it exceeds specified number of columns: actual={actual}, expected={expected}"
    )]
    TableCellOverflow {
        /// The row's logical column count.
        actual: usize,
        /// The configured column count.
        expected: usize,
    },

    /// A table row's logical column count did not match the configured column
    /// count, so the row was dropped.
    #[error(
        "table row has incorrect column count: actual={actual}, expected={expected}, occupied_from_rowspans={occupied_from_rowspans}"
    )]
    TableColumnCount {
        /// The row's logical column count.
        actual: usize,
        /// The configured column count.
        expected: usize,
        /// Columns already occupied by active rowspans.
        occupied_from_rowspans: usize,
    },

    /// A document header author line did not match the structured
    /// `firstname [middlename] [lastname] [<email>]` form, so the whole line
    /// was kept as a single author's name. asciidoctor accepts this silently;
    /// acdc surfaces it so a malformed author line is easy to spot.
    #[error(
        "author line `{line}` is not in `firstname [middlename] [lastname] [<email>]` form; using the whole line as a single author name"
    )]
    NonStandardAuthorLine {
        /// The author line as parsed (after attribute substitution).
        line: String,
    },

    /// A cross-reference (`<<id>>` / `xref:id[]`) points at an id that is not
    /// defined anywhere in the document. Matches asciidoctor's "invalid
    /// reference" warning.
    #[error("invalid reference: {target}")]
    UnresolvedReference {
        /// The unresolved target id.
        target: String,
    },

    /// More than one source definition uses the same ID. References keep the first.
    #[error("id already in use: {id} (first defined at {})", first_definition(.first))]
    DuplicateId {
        /// The original, unencoded ID.
        id: String,
        /// The first definition, including its original file and position.
        first: Box<SourceLocation>,
    },

    /// A named footnote is repeated with different text. The first body is retained.
    #[error("footnote '{id}' has different text from its first definition at {}; keeping the first definition", first_definition(.first))]
    ConflictingFootnote {
        /// The repeated footnote ID.
        id: String,
        /// The first definition, including its original file and position.
        first: Box<SourceLocation>,
    },

    /// A discrete heading was marked with the legacy `float` attribute rather
    /// than `discrete`. `float` is only supported because an older version of
    /// `AsciiDoc` called discrete headings "floating titles"; the current spec
    /// prefers `discrete`.
    #[error("`float` is a legacy alias for a discrete heading; prefer `discrete`")]
    LegacyFloatDiscreteHeading,

    /// A document attribute value did not match its documented domain.
    /// The previous effective value remains active.
    #[error("invalid document attribute `{name}` value `{value}`: expected {expected}")]
    InvalidDocumentAttribute {
        /// Document attribute name.
        name: String,
        /// Value as supplied by the document.
        value: String,
        /// Short description of the registered value domain.
        expected: &'static str,
    },

    /// Requested source content or substitutions were omitted, replaced, or recovered.
    ///
    /// Rendering can continue, but consumers that require complete source content
    /// must handle this condition before using the recovered document.
    #[error("{message}")]
    ContentRecovery {
        /// The recovery and the source content it affected.
        message: Cow<'static, str>,
    },

    /// Ad-hoc message not yet categorised into a typed variant.
    #[error("{0}")]
    Other(Cow<'static, str>),
}

fn first_definition(location: &SourceLocation) -> String {
    let file = location
        .file
        .as_deref()
        .map_or_else(|| "<input>".into(), std::path::Path::to_string_lossy);
    format!(
        "{file}:{}:{}",
        location.location.start.line, location.location.start.column
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Position;

    #[test]
    fn display_without_location() {
        let w = Warning::new(WarningKind::Other("something happened".into()), None);
        assert_eq!(format!("{w}"), "something happened");
    }

    #[test]
    fn display_with_location_no_file() {
        let loc = SourceLocation {
            file: None,
            location: crate::Location::point(Position::new(5, 1)),
        };
        let w = Warning::new(
            WarningKind::SectionLevelOutOfSequence {
                expected: 2,
                got: 3,
            },
            Some(loc),
        );
        assert_eq!(
            format!("{w}"),
            "line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
        );
    }

    #[test]
    fn display_with_location_and_file() {
        let loc = SourceLocation {
            file: Some(std::path::PathBuf::from("/docs/guide.adoc")),
            location: crate::Location::point(Position::new(5, 1)),
        };
        let w = Warning::new(
            WarningKind::SectionLevelOutOfSequence {
                expected: 2,
                got: 3,
            },
            Some(loc),
        );
        assert_eq!(
            format!("{w}"),
            "guide.adoc: line: 5, column: 1: section title out of sequence: expected level 2, got level 3",
        );
    }

    #[test]
    fn equality_holds_on_kind_and_location() {
        let a = Warning::new(WarningKind::Other("x".into()), None);
        let b = Warning::new(WarningKind::Other("x".into()), None);
        let c = Warning::new(WarningKind::Other("y".into()), None);
        assert_eq!(a, b);
        assert_ne!(a, c);
    }

    #[test]
    fn unterminated_table_display_renders_original_token() {
        let w = Warning::new(
            WarningKind::UnterminatedTable {
                delimiter: "|===".into(),
            },
            None,
        );
        assert_eq!(
            format!("{w}"),
            "unterminated table block (opened by `|===`)",
        );
    }

    #[test]
    fn unterminated_table_display_preserves_longer_tokens() {
        let w = Warning::new(
            WarningKind::UnterminatedTable {
                delimiter: "!=====".into(),
            },
            None,
        );
        assert_eq!(
            format!("{w}"),
            "unterminated table block (opened by `!=====`)",
        );
    }
}