Skip to main content

lex_analysis/
diagnostics.rs

1use crate::inline::extract_references;
2use crate::utils::for_each_text_content;
3use lex_core::lex::ast::{ContentItem, Document, Range, Session, Table};
4use lex_core::lex::inlines::ReferenceType;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum DiagnosticKind {
8    MissingFootnoteDefinition,
9    UnusedFootnoteDefinition,
10    TableInconsistentColumns,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct AnalysisDiagnostic {
15    pub range: Range,
16    pub kind: DiagnosticKind,
17    pub message: String,
18}
19
20pub fn analyze(document: &Document) -> Vec<AnalysisDiagnostic> {
21    let mut diagnostics = Vec::new();
22    check_footnotes(document, &mut diagnostics);
23    check_tables(document, &mut diagnostics);
24    diagnostics
25}
26
27fn check_footnotes(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
28    // 1. Collect all numbered footnote references
29    let mut numbered_refs = Vec::new();
30    for_each_text_content(document, &mut |text| {
31        for reference in extract_references(text) {
32            if let ReferenceType::FootnoteNumber { number } = reference.reference_type {
33                numbered_refs.push((number, reference.range));
34            }
35        }
36    });
37
38    // 2. Collect footnote definitions from :: notes ::-annotated lists
39    let definitions_list = crate::utils::collect_footnote_definitions(document);
40    let mut numeric_definitions = std::collections::HashSet::new();
41    for (label, _) in &definitions_list {
42        if let Ok(number) = label.parse::<u32>() {
43            numeric_definitions.insert(number);
44        }
45    }
46
47    // 3. Check for missing definitions
48    for (number, range) in &numbered_refs {
49        if !numeric_definitions.contains(number) {
50            diagnostics.push(AnalysisDiagnostic {
51                range: range.clone(),
52                kind: DiagnosticKind::MissingFootnoteDefinition,
53                message: format!("Footnote [{number}] has no matching item in a :: notes :: list"),
54            });
55        }
56    }
57}
58
59fn check_tables(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
60    visit_tables_in_session(&document.root, diagnostics);
61}
62
63fn visit_tables_in_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
64    for child in session.children.iter() {
65        visit_tables_in_content(child, diagnostics);
66    }
67}
68
69fn visit_tables_in_content(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
70    match item {
71        ContentItem::Table(table) => check_table_columns(table, diagnostics),
72        ContentItem::Session(session) => visit_tables_in_session(session, diagnostics),
73        ContentItem::Definition(def) => {
74            for child in def.children.iter() {
75                visit_tables_in_content(child, diagnostics);
76            }
77        }
78        ContentItem::List(list) => {
79            for entry in &list.items {
80                if let ContentItem::ListItem(li) = entry {
81                    for child in li.children.iter() {
82                        visit_tables_in_content(child, diagnostics);
83                    }
84                }
85            }
86        }
87        ContentItem::Annotation(ann) => {
88            for child in ann.children.iter() {
89                visit_tables_in_content(child, diagnostics);
90            }
91        }
92        _ => {}
93    }
94}
95
96/// Check that all rows in a table have the same effective column count.
97///
98/// The effective width of a row is the sum of colspans across its cells.
99/// Rows with different effective widths indicate a structural error (missing
100/// or extra cells).
101fn check_table_columns(table: &Table, diagnostics: &mut Vec<AnalysisDiagnostic>) {
102    let rows: Vec<_> = table.all_rows().collect();
103    if rows.len() < 2 {
104        return;
105    }
106
107    // Compute effective width per row (sum of colspans)
108    let widths: Vec<usize> = rows
109        .iter()
110        .map(|row| row.cells.iter().map(|c| c.colspan).sum())
111        .collect();
112
113    let expected = widths[0];
114    for (i, &width) in widths.iter().enumerate().skip(1) {
115        if width != expected {
116            diagnostics.push(AnalysisDiagnostic {
117                range: rows[i].location.clone(),
118                kind: DiagnosticKind::TableInconsistentColumns,
119                message: format!(
120                    "Row has {width} columns, expected {expected} (matching first row)"
121                ),
122            });
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use lex_core::lex::parsing;
131
132    fn parse(source: &str) -> Document {
133        parsing::parse_document(source).expect("parse failed")
134    }
135
136    #[test]
137    fn detects_missing_footnote_definition() {
138        let doc = parse("Text with [1] reference.");
139        let diags = analyze(&doc);
140        assert_eq!(diags.len(), 1);
141        assert_eq!(diags[0].kind, DiagnosticKind::MissingFootnoteDefinition);
142    }
143
144    #[test]
145    fn ignores_valid_footnote_with_notes_annotation() {
146        // :: notes :: annotated list provides the definitions
147        let doc = parse("Text [1].\n\n:: notes ::\n1. Note.\n2. Another.\n");
148        let diags = analyze(&doc);
149        let footnote_diags: Vec<_> = diags
150            .iter()
151            .filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
152            .collect();
153        assert!(footnote_diags.is_empty());
154    }
155
156    #[test]
157    fn ignores_valid_list_footnote_in_session() {
158        // :: notes :: inside a session
159        let doc = parse("Text [1].\n\nNotes\n\n    :: notes ::\n\n    1. Note.\n    2. Another.\n");
160        let diags = analyze(&doc);
161        let footnote_diags: Vec<_> = diags
162            .iter()
163            .filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
164            .collect();
165        assert!(footnote_diags.is_empty());
166    }
167
168    #[test]
169    fn list_without_notes_annotation_is_not_footnotes() {
170        // A "Notes" session without :: notes :: does NOT define footnotes
171        let doc = parse("Text [1].\n\nNotes\n\n    1. Note.\n    2. Another.\n");
172        let diags = analyze(&doc);
173        let footnote_diags: Vec<_> = diags
174            .iter()
175            .filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
176            .collect();
177        assert_eq!(footnote_diags.len(), 1);
178    }
179
180    #[test]
181    fn detects_inconsistent_table_columns() {
182        let doc = parse("Data:\n    | A | B | C |\n    | 1 | 2 |\n:: table ::\n");
183        let diags = analyze(&doc);
184        let table_diags: Vec<_> = diags
185            .iter()
186            .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
187            .collect();
188        assert_eq!(table_diags.len(), 1);
189        assert!(table_diags[0].message.contains("2 columns"));
190        assert!(table_diags[0].message.contains("expected 3"));
191    }
192
193    #[test]
194    fn consistent_table_no_diagnostic() {
195        let doc = parse("Data:\n    | A | B |\n    | 1 | 2 |\n:: table ::\n");
196        let diags = analyze(&doc);
197        let table_diags: Vec<_> = diags
198            .iter()
199            .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
200            .collect();
201        assert!(table_diags.is_empty());
202    }
203
204    #[test]
205    fn table_with_colspan_counts_effective_width() {
206        // Row 1: A + >> = 2 effective columns (colspan=2)
207        // Row 2: B + C = 2 columns
208        // After merge resolution: row 1 has 1 cell (colspan=2), row 2 has 2 cells (colspan=1 each)
209        // Effective widths: 2 and 2 — consistent
210        let doc = parse("Data:\n    | A  | >> |\n    | B  | C  |\n:: table ::\n");
211        let diags = analyze(&doc);
212        let table_diags: Vec<_> = diags
213            .iter()
214            .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
215            .collect();
216        assert!(table_diags.is_empty());
217    }
218
219    #[test]
220    fn footnote_ref_in_table_cell_is_checked() {
221        // Table cell contains [1] but no footnote definition exists
222        let doc = parse("Data:\n    | Item  | Note |\n    | Alpha | [1]  |\n:: table ::\n");
223        let diags = analyze(&doc);
224        let footnote_diags: Vec<_> = diags
225            .iter()
226            .filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
227            .collect();
228        assert_eq!(footnote_diags.len(), 1);
229        assert!(footnote_diags[0].message.contains("[1]"));
230    }
231}