Skip to main content

lex_analysis/
diagnostics.rs

1use crate::inline::extract_references;
2use lex_core::lex::ast::{
3    Annotation, ContentItem, Document, Range, Session, Table, TableRow, TextContent,
4};
5use lex_core::lex::inlines::ReferenceType;
6use lex_extension_host::Registry;
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DiagnosticKind {
11    MissingFootnoteDefinition,
12    UnusedFootnoteDefinition,
13    TableInconsistentColumns,
14    /// A label invocation failed schema pre-validation before the
15    /// handler was dispatched. The variant carries which of the six
16    /// pre-validation checks tripped.
17    SchemaValidation(SchemaValidationKind),
18    /// A diagnostic emitted by a registered extension handler. The
19    /// `namespace` field is the namespace name (the part before the
20    /// first `.`, e.g., `"acme"` for label `"acme.task"`) — `lex-lsp`
21    /// surfaces it as the diagnostic `source: "lex:<namespace>"` so
22    /// editors can filter by extension. `code` mirrors the wire
23    /// `Diagnostic.code` field.
24    Handler {
25        namespace: String,
26        code: Option<String>,
27    },
28    /// A label uses the reserved `doc.*` prefix (forbidden under
29    /// `comms/specs/general.lex` §4.1). PR 4 of #584 emits this when
30    /// permissive-mode parse lets the label flow through; the LSP
31    /// then offers a quickfix to rewrite to the blessed shortcut
32    /// (`doc.table` → `table`, `doc.image` → `image`, etc.).
33    ForbiddenLabelPrefix,
34    /// A `lex.*` literal that doesn't match any registered canonical
35    /// in [`lex_core::lex::builtins::CANONICAL_LABELS`]. Typically a
36    /// typo (`lex.fooar`) or a label authored against a future
37    /// version of the core schemas.
38    UnknownLexCanonical,
39}
40
41/// Severity for analysis-emitted diagnostics. The analyser populates
42/// it for every diagnostic — `lex-lsp` reads `diag.severity`
43/// directly when mapping onto the LSP wire. (Earlier the LSP layer
44/// derived severity from `DiagnosticKind`; that mapping moved
45/// upstream once the extension-emitted diagnostics needed
46/// per-instance severities.)
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DiagnosticSeverity {
49    Error,
50    Warning,
51    Info,
52    Hint,
53}
54
55/// One of the six schema pre-validation checks the analyser owns
56/// before dispatching to a handler. Wire spec / proposal §13.2.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum SchemaValidationKind {
59    /// The namespace is registered but the schema set for that
60    /// namespace doesn't declare this exact label. The walker emits
61    /// this when `Registry::schema_for(label)` returns `None` while
62    /// `is_namespace_healthy(<ns prefix>)` is `true`. Distinguishes
63    /// "typo / out-of-version label" (this variant, surfaced as a
64    /// document error) from "unknown namespace" (silent pass-through
65    /// per the bounded-extensibility rule).
66    UnknownLabel,
67    MissingParam,
68    ParamTypeMismatch,
69    BadAttachment,
70    BodyShapeMismatch,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct AnalysisDiagnostic {
75    pub range: Range,
76    /// Severity, set by the analyser for every diagnostic it
77    /// produces. `lex-lsp` reads this directly when mapping onto LSP
78    /// wire severities; the kind-to-severity mapping that lived in
79    /// `to_lsp_diagnostic` is no longer authoritative.
80    pub severity: DiagnosticSeverity,
81    pub kind: DiagnosticKind,
82    pub message: String,
83}
84
85/// Run the analyser without an extension registry — equivalent to
86/// running with an empty registry. Provided for callers that haven't
87/// adopted the extension system yet.
88pub fn analyze(document: &Document) -> Vec<AnalysisDiagnostic> {
89    let registry = Registry::new();
90    analyze_with_registry(document, &registry)
91}
92
93/// Run the analyser with a populated extension registry. Labels whose
94/// namespace is registered get pre-validated against their schema and,
95/// if pre-validation passes, dispatched to the handler's `on_validate`
96/// hook. Handler-emitted diagnostics are merged into the same stream as
97/// the built-in checks.
98pub fn analyze_with_registry(document: &Document, registry: &Registry) -> Vec<AnalysisDiagnostic> {
99    let mut diagnostics = Vec::new();
100    check_footnotes(document, &mut diagnostics);
101    check_tables(document, &mut diagnostics);
102    check_labels(document, &mut diagnostics);
103    crate::label_dispatch::dispatch_labels(document, registry, &mut diagnostics);
104    diagnostics
105}
106
107/// Walk every label site in the document and re-classify via
108/// [`classify_label`](lex_core::lex::assembling::stages::normalize_labels::classify_label).
109/// Emits diagnostics for sites that strict-mode parsing would have
110/// rejected — `doc.*` (forbidden) and unknown `lex.*` (not a
111/// registered canonical). The LSP-side permissive parse keeps the
112/// AST building so these surface as in-place diagnostics rather than
113/// as a wholesale parse failure.
114fn check_labels(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
115    use lex_core::lex::assembling::stages::normalize_labels::{
116        classify_label, RejectReason, Resolution,
117    };
118    use lex_core::lex::ast::Label;
119
120    fn emit(label: &Label, diagnostics: &mut Vec<AnalysisDiagnostic>) {
121        if let Resolution::Rejected(reason) = classify_label(&label.value) {
122            // Reuse the normative wording from `RejectReason::message()`
123            // so the strict-mode parser error and the permissive-mode
124            // analysis diagnostic stay literally identical — no chance
125            // of wording drift between the two surfaces.
126            let message = reason.message();
127            let kind = match reason {
128                RejectReason::Forbidden { .. } => DiagnosticKind::ForbiddenLabelPrefix,
129                RejectReason::UnknownCanonical { .. } => DiagnosticKind::UnknownLexCanonical,
130            };
131            diagnostics.push(AnalysisDiagnostic {
132                range: label.location.clone(),
133                severity: DiagnosticSeverity::Error,
134                kind,
135                message,
136            });
137        }
138    }
139
140    // Unified dispatch: every ContentItem flows through `walk_item`,
141    // which emits the type-specific label sites (annotation label,
142    // verbatim closer label, table cells/footnotes) exactly once and
143    // then defers to `attached_annotations` + `item.children()` for
144    // the uniform recursion. The earlier shape had type-specific
145    // walkers (`walk_annotation`, `walk_verbatim`, `walk_table`) that
146    // descended on their own and then `walk_item` descended again —
147    // duplicate-walk regression caught by Copilot's review on PR 589.
148    fn walk_item(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
149        match item {
150            ContentItem::Annotation(a) => emit(&a.data.label, diagnostics),
151            ContentItem::VerbatimBlock(v) => emit(&v.closing_data.label, diagnostics),
152            ContentItem::Table(t) => {
153                for row in t.header_rows.iter().chain(t.body_rows.iter()) {
154                    for cell in &row.cells {
155                        for child in cell.children.iter() {
156                            walk_item(child, diagnostics);
157                        }
158                    }
159                }
160                if let Some(footnotes) = t.footnotes.as_ref() {
161                    for ann in footnotes.annotations() {
162                        walk_annotation(ann, diagnostics);
163                    }
164                    for fn_item in footnotes.items.iter() {
165                        walk_item(fn_item, diagnostics);
166                    }
167                }
168            }
169            _ => {}
170        }
171        // Attached annotations (sessions, paragraphs, lists, list
172        // items, verbatim blocks, tables — see `attached_annotations`).
173        if let Some(attached) = attached_annotations(item) {
174            for annotation in attached {
175                walk_annotation(annotation, diagnostics);
176            }
177        }
178        // Generic child descent. For ContentItem::Annotation,
179        // `item.children()` returns the annotation's body children, so
180        // type-specific walking of nested annotations is not needed.
181        if let Some(children) = item.children() {
182            for child in children {
183                walk_item(child, diagnostics);
184            }
185        }
186    }
187
188    fn walk_annotation(annotation: &Annotation, diagnostics: &mut Vec<AnalysisDiagnostic>) {
189        emit(&annotation.data.label, diagnostics);
190        for child in annotation.children.iter() {
191            walk_item(child, diagnostics);
192        }
193    }
194
195    fn walk_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
196        for annotation in session.annotations() {
197            walk_annotation(annotation, diagnostics);
198        }
199        for child in &session.children {
200            walk_item(child, diagnostics);
201        }
202    }
203
204    fn attached_annotations(item: &ContentItem) -> Option<&[Annotation]> {
205        match item {
206            ContentItem::Session(s) => Some(s.annotations()),
207            ContentItem::Paragraph(p) => Some(p.annotations()),
208            ContentItem::Definition(d) => Some(d.annotations()),
209            ContentItem::List(l) => Some(l.annotations()),
210            ContentItem::ListItem(li) => Some(li.annotations()),
211            ContentItem::VerbatimBlock(v) => Some(v.annotations()),
212            ContentItem::Table(t) => Some(t.annotations()),
213            _ => None,
214        }
215    }
216
217    // Document-level annotations.
218    for annotation in document.annotations() {
219        walk_annotation(annotation, diagnostics);
220    }
221    // Root session walks.
222    walk_session(&document.root, diagnostics);
223}
224
225fn check_footnotes(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
226    // Numbered definitions reachable from outside any table: :: notes ::
227    // annotated lists at document or session scope.
228    let outer_defs: HashSet<u32> = crate::utils::collect_footnote_definitions(document)
229        .into_iter()
230        .filter_map(|(label, _)| label.parse::<u32>().ok())
231        .collect();
232
233    // References outside tables resolve to `outer_defs`; references inside a
234    // table resolve first to that table's own positional footnote list
235    // (`table.footnotes`) and then fall back to `outer_defs`.
236    if let Some(title) = &document.title {
237        check_text(&title.content, &outer_defs, diagnostics);
238    }
239    for annotation in document.annotations() {
240        check_annotation(annotation, &outer_defs, diagnostics);
241    }
242    check_session(&document.root, &outer_defs, diagnostics);
243}
244
245fn check_session(
246    session: &Session,
247    defs: &HashSet<u32>,
248    diagnostics: &mut Vec<AnalysisDiagnostic>,
249) {
250    check_text(&session.title, defs, diagnostics);
251    for annotation in session.annotations() {
252        check_annotation(annotation, defs, diagnostics);
253    }
254    for child in session.children.iter() {
255        check_content(child, defs, diagnostics);
256    }
257}
258
259fn check_content(
260    item: &ContentItem,
261    defs: &HashSet<u32>,
262    diagnostics: &mut Vec<AnalysisDiagnostic>,
263) {
264    match item {
265        ContentItem::Paragraph(p) => {
266            for line in &p.lines {
267                if let ContentItem::TextLine(tl) = line {
268                    check_text(&tl.content, defs, diagnostics);
269                }
270            }
271            for annotation in p.annotations() {
272                check_annotation(annotation, defs, diagnostics);
273            }
274        }
275        ContentItem::Session(s) => check_session(s, defs, diagnostics),
276        ContentItem::List(list) => {
277            for annotation in list.annotations() {
278                check_annotation(annotation, defs, diagnostics);
279            }
280            for entry in &list.items {
281                if let ContentItem::ListItem(li) = entry {
282                    for text in &li.text {
283                        check_text(text, defs, diagnostics);
284                    }
285                    for annotation in li.annotations() {
286                        check_annotation(annotation, defs, diagnostics);
287                    }
288                    for child in li.children.iter() {
289                        check_content(child, defs, diagnostics);
290                    }
291                }
292            }
293        }
294        ContentItem::Definition(def) => {
295            check_text(&def.subject, defs, diagnostics);
296            for annotation in def.annotations() {
297                check_annotation(annotation, defs, diagnostics);
298            }
299            for child in def.children.iter() {
300                check_content(child, defs, diagnostics);
301            }
302        }
303        ContentItem::Annotation(a) => check_annotation(a, defs, diagnostics),
304        ContentItem::VerbatimBlock(v) => {
305            check_text(&v.subject, defs, diagnostics);
306            for annotation in v.annotations() {
307                check_annotation(annotation, defs, diagnostics);
308            }
309        }
310        ContentItem::Table(table) => check_table(table, defs, diagnostics),
311        _ => {}
312    }
313}
314
315fn check_annotation(
316    annotation: &Annotation,
317    defs: &HashSet<u32>,
318    diagnostics: &mut Vec<AnalysisDiagnostic>,
319) {
320    for child in annotation.children.iter() {
321        check_content(child, defs, diagnostics);
322    }
323}
324
325fn check_table(
326    table: &Table,
327    outer_defs: &HashSet<u32>,
328    diagnostics: &mut Vec<AnalysisDiagnostic>,
329) {
330    // Extend the in-scope definitions with the table's positional footnote
331    // list. The table's own numbered items shadow nothing — they just add
332    // table-local numbers that references inside this table may resolve to.
333    // Fast path: most tables have no footnotes, so reuse `outer_defs` rather
334    // than cloning it into a new `HashSet` for every such table.
335    let table_defs = table_footnote_numbers(table);
336    if table_defs.is_empty() {
337        check_table_text(table, outer_defs, diagnostics);
338        return;
339    }
340    let mut scope = outer_defs.clone();
341    scope.extend(table_defs);
342    check_table_text(table, &scope, diagnostics);
343}
344
345fn check_table_text(table: &Table, defs: &HashSet<u32>, diagnostics: &mut Vec<AnalysisDiagnostic>) {
346    check_text(&table.subject, defs, diagnostics);
347    for row in table.all_rows() {
348        for cell in &row.cells {
349            check_text(&cell.content, defs, diagnostics);
350        }
351    }
352    for annotation in table.annotations() {
353        check_annotation(annotation, defs, diagnostics);
354    }
355}
356
357fn table_footnote_numbers(table: &Table) -> HashSet<u32> {
358    let Some(list) = &table.footnotes else {
359        return HashSet::new();
360    };
361    let mut numbers = HashSet::new();
362    for entry in &list.items {
363        if let ContentItem::ListItem(li) = entry {
364            let label = li
365                .marker()
366                .trim()
367                .trim_end_matches(['.', ')', ':'].as_ref())
368                .trim();
369            if let Ok(n) = label.parse::<u32>() {
370                numbers.insert(n);
371            }
372        }
373    }
374    numbers
375}
376
377fn check_text(text: &TextContent, defs: &HashSet<u32>, diagnostics: &mut Vec<AnalysisDiagnostic>) {
378    for reference in extract_references(text) {
379        if let ReferenceType::FootnoteNumber { number } = reference.reference_type {
380            if !defs.contains(&number) {
381                diagnostics.push(AnalysisDiagnostic {
382                    range: reference.range,
383                    severity: DiagnosticSeverity::Error,
384                    kind: DiagnosticKind::MissingFootnoteDefinition,
385                    message: format!(
386                        "Footnote [{number}] has no matching footnote definition in scope"
387                    ),
388                });
389            }
390        }
391    }
392}
393
394fn check_tables(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
395    visit_tables_in_session(&document.root, diagnostics);
396}
397
398fn visit_tables_in_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
399    for child in session.children.iter() {
400        visit_tables_in_content(child, diagnostics);
401    }
402}
403
404fn visit_tables_in_content(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
405    match item {
406        ContentItem::Table(table) => check_table_columns(table, diagnostics),
407        ContentItem::Session(session) => visit_tables_in_session(session, diagnostics),
408        ContentItem::Definition(def) => {
409            for child in def.children.iter() {
410                visit_tables_in_content(child, diagnostics);
411            }
412        }
413        ContentItem::List(list) => {
414            for entry in &list.items {
415                if let ContentItem::ListItem(li) = entry {
416                    for child in li.children.iter() {
417                        visit_tables_in_content(child, diagnostics);
418                    }
419                }
420            }
421        }
422        ContentItem::Annotation(ann) => {
423            for child in ann.children.iter() {
424                visit_tables_in_content(child, diagnostics);
425            }
426        }
427        _ => {}
428    }
429}
430
431/// Check that all rows in a table have the same effective column count.
432///
433/// The effective width of a row accounts for both colspans of its own cells
434/// and rowspan carry-over from cells in prior rows that extend into it.
435/// Rows with different effective widths indicate a structural error (missing
436/// or extra cells).
437fn check_table_columns(table: &Table, diagnostics: &mut Vec<AnalysisDiagnostic>) {
438    let rows: Vec<_> = table.all_rows().collect();
439    if rows.len() < 2 {
440        return;
441    }
442
443    let widths = compute_row_widths(&rows);
444    let expected = widths[0];
445    for (i, &width) in widths.iter().enumerate().skip(1) {
446        if width != expected {
447            diagnostics.push(AnalysisDiagnostic {
448                range: rows[i].location.clone(),
449                severity: DiagnosticSeverity::Warning,
450                kind: DiagnosticKind::TableInconsistentColumns,
451                message: format!(
452                    "Row has {width} columns, expected {expected} (matching first row)"
453                ),
454            });
455        }
456    }
457}
458
459/// Simulate the virtual table grid to compute each row's effective width.
460///
461/// `carry[col]` tracks how many more rows (including the current one) a cell
462/// placed in a prior row still occupies column `col`. Own cells skip columns
463/// where `carry[col] > 0` (those are held by a cell from above via rowspan).
464fn compute_row_widths(rows: &[&TableRow]) -> Vec<usize> {
465    let mut carry: Vec<usize> = Vec::new();
466    let mut widths = Vec::with_capacity(rows.len());
467
468    for row in rows {
469        let mut col = 0;
470        for cell in &row.cells {
471            while col < carry.len() && carry[col] > 0 {
472                col += 1;
473            }
474            let end = col + cell.colspan;
475            if end > carry.len() {
476                carry.resize(end, 0);
477            }
478            for slot in carry.iter_mut().take(end).skip(col) {
479                *slot = cell.rowspan;
480            }
481            col = end;
482        }
483
484        let width = carry
485            .iter()
486            .rposition(|&r| r > 0)
487            .map(|i| i + 1)
488            .unwrap_or(0);
489        widths.push(width);
490
491        // Columns at or beyond `width` are guaranteed 0 (that's how width is
492        // defined), so limit the decrement to the active range and drop the
493        // trailing zeros to keep `carry` proportional to the live grid.
494        for c in carry.iter_mut().take(width) {
495            if *c > 0 {
496                *c -= 1;
497            }
498        }
499        carry.truncate(width);
500    }
501
502    widths
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use lex_core::lex::parsing::process_full_permissive;
509    use lex_core::lex::testing::lexplore::Lexplore;
510
511    fn footnote_diags(doc: &Document) -> Vec<AnalysisDiagnostic> {
512        analyze(doc)
513            .into_iter()
514            .filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
515            .collect()
516    }
517
518    fn label_diags(source: &str) -> Vec<AnalysisDiagnostic> {
519        let doc = process_full_permissive(source).expect("permissive parse");
520        analyze(&doc)
521            .into_iter()
522            .filter(|d| {
523                matches!(
524                    d.kind,
525                    DiagnosticKind::ForbiddenLabelPrefix | DiagnosticKind::UnknownLexCanonical
526                )
527            })
528            .collect()
529    }
530
531    #[test]
532    fn check_labels_emits_for_doc_prefix() {
533        let diags = label_diags(":: doc.table :: x\n\nBody.\n");
534        assert_eq!(diags.len(), 1, "expected 1 forbidden-prefix diagnostic");
535        assert_eq!(diags[0].kind, DiagnosticKind::ForbiddenLabelPrefix);
536        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
537        assert!(
538            diags[0].message.contains("doc.table") && diags[0].message.contains("reserved"),
539            "message names the offending prefix; got: {}",
540            diags[0].message
541        );
542    }
543
544    #[test]
545    fn check_labels_emits_for_unknown_lex_canonical() {
546        let diags = label_diags(":: lex.foobar :: x\n\nBody.\n");
547        assert_eq!(diags.len(), 1, "expected 1 unknown-canonical diagnostic");
548        assert_eq!(diags[0].kind, DiagnosticKind::UnknownLexCanonical);
549        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
550        assert!(
551            diags[0].message.contains("lex.foobar"),
552            "message names the offending label; got: {}",
553            diags[0].message
554        );
555    }
556
557    #[test]
558    fn check_labels_silent_on_accepted_forms() {
559        // Shortcut, prefix-stripped, canonical, and community labels
560        // all accept silently — analysis only flags the two reject
561        // categories from `classify_label`.
562        let sources = [
563            ":: author :: Alice\n\nBody.\n",
564            ":: metadata.author :: Alice\n\nBody.\n",
565            ":: lex.metadata.author :: Alice\n\nBody.\n",
566            ":: acme.task :: x\n\nBody.\n",
567        ];
568        for src in sources {
569            let diags = label_diags(src);
570            assert!(
571                diags.is_empty(),
572                "no label diagnostics expected for {src:?}; got {diags:?}"
573            );
574        }
575    }
576
577    #[test]
578    fn check_labels_finds_verbatim_closer_violations() {
579        let diags =
580            label_diags("Table:\n    | a | b |\n    |---|---|\n    | 1 | 2 |\n:: doc.table ::\n");
581        assert_eq!(diags.len(), 1);
582        assert_eq!(diags[0].kind, DiagnosticKind::ForbiddenLabelPrefix);
583    }
584
585    #[test]
586    fn check_labels_emits_each_offending_site_exactly_once() {
587        // Regression for Copilot's PR 589 callout: the earlier
588        // walker shape descended into a node's children twice (once
589        // via the type-specific helper, once via the generic
590        // `walk_item` fallback), which produced duplicate
591        // diagnostics for any forbidden label nested inside another
592        // label-bearing site. Three nested + adjacent forbidden
593        // labels should produce exactly three diagnostics, not six.
594        let src = ":: doc.outer ::\n    :: doc.inner :: nested body\n\n:: doc.sibling :: x\n";
595        let diags = label_diags(src);
596        assert_eq!(
597            diags.len(),
598            3,
599            "exactly one diagnostic per offending site: {diags:?}"
600        );
601        for d in &diags {
602            assert_eq!(d.kind, DiagnosticKind::ForbiddenLabelPrefix);
603        }
604    }
605
606    #[test]
607    fn detects_missing_footnote_definition() {
608        let doc = Lexplore::footnotes(1).parse().unwrap();
609        let diags = analyze(&doc);
610        assert_eq!(diags.len(), 1);
611        assert_eq!(diags[0].kind, DiagnosticKind::MissingFootnoteDefinition);
612    }
613
614    #[test]
615    fn ignores_valid_footnote_with_notes_annotation() {
616        // :: notes :: annotated list at the document root provides the definitions
617        let doc = Lexplore::footnotes(2).parse().unwrap();
618        assert!(footnote_diags(&doc).is_empty());
619    }
620
621    #[test]
622    fn ignores_valid_list_footnote_in_session() {
623        // :: notes :: inside a session
624        let doc = Lexplore::footnotes(3).parse().unwrap();
625        assert!(footnote_diags(&doc).is_empty());
626    }
627
628    #[test]
629    fn list_without_notes_annotation_is_not_footnotes() {
630        // A "Notes" session without :: notes :: does NOT define footnotes
631        let doc = Lexplore::footnotes(4).parse().unwrap();
632        assert_eq!(footnote_diags(&doc).len(), 1);
633    }
634
635    fn table_diags(doc: &Document) -> Vec<AnalysisDiagnostic> {
636        analyze(doc)
637            .into_iter()
638            .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
639            .collect()
640    }
641
642    #[test]
643    fn detects_inconsistent_table_columns() {
644        // table-13: 3-col header, 2-col row, 3-col row — middle row is short.
645        let doc = Lexplore::table(13).parse().unwrap();
646        let diags = table_diags(&doc);
647        assert_eq!(diags.len(), 1);
648        assert!(diags[0].message.contains("2 columns"));
649        assert!(diags[0].message.contains("expected 3"));
650    }
651
652    #[test]
653    fn consistent_table_no_diagnostic() {
654        // table-01: minimal 2-column table, all rows consistent.
655        let doc = Lexplore::table(1).parse().unwrap();
656        assert!(table_diags(&doc).is_empty());
657    }
658
659    #[test]
660    fn table_with_rowspan_counts_carry_over() {
661        // table-17: rowspan via ^^ — effective widths remain consistent across rows.
662        let doc = Lexplore::table(17).parse().unwrap();
663        let diags = table_diags(&doc);
664        assert!(
665            diags.is_empty(),
666            "rowspan carry-over should not trigger inconsistent-columns, got: {diags:?}"
667        );
668    }
669
670    #[test]
671    fn table_with_colspan_and_rowspan_mixed() {
672        // table-18: combined >> colspan and ^^ rowspan; effective widths stay consistent.
673        let doc = Lexplore::table(18).parse().unwrap();
674        let diags = table_diags(&doc);
675        assert!(
676            diags.is_empty(),
677            "mixed colspan/rowspan should not trigger inconsistent-columns, got: {diags:?}"
678        );
679    }
680
681    #[test]
682    fn table_with_colspan_counts_effective_width() {
683        // table-04: colspan via >> contributes to effective width; all rows consistent.
684        let doc = Lexplore::table(4).parse().unwrap();
685        assert!(table_diags(&doc).is_empty());
686    }
687
688    #[test]
689    fn footnote_ref_in_table_cell_is_checked() {
690        // footnotes-09: table cell contains [1] but no footnote definition
691        // anywhere in scope — document, session, or table-local.
692        let doc = Lexplore::footnotes(9).parse().unwrap();
693        let diags = footnote_diags(&doc);
694        assert_eq!(diags.len(), 1);
695        assert!(diags[0].message.contains("[1]"));
696    }
697
698    #[test]
699    fn table_scoped_footnotes_resolve_cell_refs() {
700        // footnotes-11: cell refs [1] and [2] resolve to the table's own
701        // positional footnote list (no :: notes :: annotation needed).
702        let doc = Lexplore::footnotes(11).parse().unwrap();
703        let diags = footnote_diags(&doc);
704        assert!(
705            diags.is_empty(),
706            "table-scoped cell refs should resolve to table.footnotes, got: {diags:?}"
707        );
708    }
709
710    #[test]
711    fn table_scoped_footnotes_do_not_leak_out() {
712        // footnotes-12: a [1] ref in body text outside the table must NOT
713        // resolve to the table's own positional footnote list even when the
714        // numbers happen to match. The table's list is table-local.
715        let doc = Lexplore::footnotes(12).parse().unwrap();
716        let diags = footnote_diags(&doc);
717        assert_eq!(
718            diags.len(),
719            1,
720            "only the paragraph ref [1] should be unresolved, got: {diags:?}"
721        );
722        assert!(diags[0].message.contains("[1]"));
723    }
724}