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 footnote references (both numbered and labeled)
29    let mut numbered_refs = Vec::new();
30    let mut labeled_refs = Vec::new();
31    for_each_text_content(document, &mut |text| {
32        for reference in extract_references(text) {
33            match &reference.reference_type {
34                ReferenceType::FootnoteNumber { number } => {
35                    numbered_refs.push((*number, reference.range));
36                }
37                ReferenceType::FootnoteLabeled { label } => {
38                    labeled_refs.push((label.clone(), reference.range));
39                }
40                _ => {}
41            }
42        }
43    });
44
45    // 2. Collect all footnote definitions (annotations and list items)
46    let definitions_list = crate::utils::collect_footnote_definitions(document);
47    let mut numeric_definitions = std::collections::HashSet::new();
48    let mut label_definitions = std::collections::HashSet::new();
49
50    for (label, _) in &definitions_list {
51        label_definitions.insert(label.to_lowercase());
52        if let Ok(number) = label.parse::<u32>() {
53            numeric_definitions.insert(number);
54        }
55    }
56
57    // 3. Check for missing definitions (numbered)
58    for (number, range) in &numbered_refs {
59        if !numeric_definitions.contains(number) {
60            diagnostics.push(AnalysisDiagnostic {
61                range: range.clone(),
62                kind: DiagnosticKind::MissingFootnoteDefinition,
63                message: format!("Footnote [{number}] is referenced but not defined"),
64            });
65        }
66    }
67
68    // 4. Check for missing definitions (labeled)
69    for (label, range) in &labeled_refs {
70        if !label_definitions.contains(&label.to_lowercase()) {
71            diagnostics.push(AnalysisDiagnostic {
72                range: range.clone(),
73                kind: DiagnosticKind::MissingFootnoteDefinition,
74                message: format!("Footnote [^{label}] is referenced but not defined"),
75            });
76        }
77    }
78
79    // Note: Unused definitions (footnotes without references) are intentionally not flagged
80}
81
82fn check_tables(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
83    visit_tables_in_session(&document.root, diagnostics);
84}
85
86fn visit_tables_in_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
87    for child in session.children.iter() {
88        visit_tables_in_content(child, diagnostics);
89    }
90}
91
92fn visit_tables_in_content(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
93    match item {
94        ContentItem::Table(table) => check_table_columns(table, diagnostics),
95        ContentItem::Session(session) => visit_tables_in_session(session, diagnostics),
96        ContentItem::Definition(def) => {
97            for child in def.children.iter() {
98                visit_tables_in_content(child, diagnostics);
99            }
100        }
101        ContentItem::List(list) => {
102            for entry in &list.items {
103                if let ContentItem::ListItem(li) = entry {
104                    for child in li.children.iter() {
105                        visit_tables_in_content(child, diagnostics);
106                    }
107                }
108            }
109        }
110        ContentItem::Annotation(ann) => {
111            for child in ann.children.iter() {
112                visit_tables_in_content(child, diagnostics);
113            }
114        }
115        _ => {}
116    }
117}
118
119/// Check that all rows in a table have the same effective column count.
120///
121/// The effective width of a row is the sum of colspans across its cells.
122/// Rows with different effective widths indicate a structural error (missing
123/// or extra cells).
124fn check_table_columns(table: &Table, diagnostics: &mut Vec<AnalysisDiagnostic>) {
125    let rows: Vec<_> = table.all_rows().collect();
126    if rows.len() < 2 {
127        return;
128    }
129
130    // Compute effective width per row (sum of colspans)
131    let widths: Vec<usize> = rows
132        .iter()
133        .map(|row| row.cells.iter().map(|c| c.colspan).sum())
134        .collect();
135
136    let expected = widths[0];
137    for (i, &width) in widths.iter().enumerate().skip(1) {
138        if width != expected {
139            diagnostics.push(AnalysisDiagnostic {
140                range: rows[i].location.clone(),
141                kind: DiagnosticKind::TableInconsistentColumns,
142                message: format!(
143                    "Row has {width} columns, expected {expected} (matching first row)"
144                ),
145            });
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use lex_core::lex::parsing;
154
155    fn parse(source: &str) -> Document {
156        parsing::parse_document(source).expect("parse failed")
157    }
158
159    #[test]
160    fn detects_missing_footnote_definition() {
161        let doc = parse("Text with [1] reference.");
162        let diags = analyze(&doc);
163        assert_eq!(diags.len(), 1);
164        assert_eq!(diags[0].kind, DiagnosticKind::MissingFootnoteDefinition);
165    }
166
167    #[test]
168    fn ignores_valid_footnote() {
169        let doc = parse("Text [1].\n\n:: 1 ::\nNote.\n::\n");
170        let diags = analyze(&doc);
171        assert_eq!(diags.len(), 0);
172    }
173
174    #[test]
175    fn ignores_valid_list_footnote() {
176        // "Notes" session with indented list item "1."
177        let doc = parse("Text [1].\n\nNotes\n\n    1. Note.\n    2. Another.\n");
178        let diags = analyze(&doc);
179        assert_eq!(diags.len(), 0);
180    }
181
182    #[test]
183    fn detects_missing_labeled_footnote_definition() {
184        let doc = parse("Text with [^source] reference.");
185        let diags = analyze(&doc);
186        assert_eq!(diags.len(), 1);
187        assert_eq!(diags[0].kind, DiagnosticKind::MissingFootnoteDefinition);
188        assert!(diags[0].message.contains("[^source]"));
189    }
190
191    #[test]
192    fn ignores_valid_labeled_footnote() {
193        let doc = parse("Text [^source].\n\n:: source :: The source material.\n");
194        let diags = analyze(&doc);
195        assert_eq!(diags.len(), 0);
196    }
197
198    #[test]
199    fn labeled_footnote_match_is_case_insensitive() {
200        let doc = parse("Text [^Source].\n\n:: source :: The source material.\n");
201        let diags = analyze(&doc);
202        assert_eq!(diags.len(), 0);
203    }
204
205    #[test]
206    fn detects_inconsistent_table_columns() {
207        let doc = parse("Data:\n    | A | B | C |\n    | 1 | 2 |\n:: table ::\n");
208        let diags = analyze(&doc);
209        let table_diags: Vec<_> = diags
210            .iter()
211            .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
212            .collect();
213        assert_eq!(table_diags.len(), 1);
214        assert!(table_diags[0].message.contains("2 columns"));
215        assert!(table_diags[0].message.contains("expected 3"));
216    }
217
218    #[test]
219    fn consistent_table_no_diagnostic() {
220        let doc = parse("Data:\n    | A | B |\n    | 1 | 2 |\n:: table ::\n");
221        let diags = analyze(&doc);
222        let table_diags: Vec<_> = diags
223            .iter()
224            .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
225            .collect();
226        assert!(table_diags.is_empty());
227    }
228
229    #[test]
230    fn table_with_colspan_counts_effective_width() {
231        // Row 1: A + >> = 2 effective columns (colspan=2)
232        // Row 2: B + C = 2 columns
233        // After merge resolution: row 1 has 1 cell (colspan=2), row 2 has 2 cells (colspan=1 each)
234        // Effective widths: 2 and 2 — consistent
235        let doc = parse("Data:\n    | A  | >> |\n    | B  | C  |\n:: table ::\n");
236        let diags = analyze(&doc);
237        let table_diags: Vec<_> = diags
238            .iter()
239            .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
240            .collect();
241        assert!(table_diags.is_empty());
242    }
243
244    #[test]
245    fn footnote_ref_in_table_cell_is_checked() {
246        // Table cell contains [1] but no footnote definition exists
247        let doc = parse("Data:\n    | Item  | Note |\n    | Alpha | [1]  |\n:: table ::\n");
248        let diags = analyze(&doc);
249        let footnote_diags: Vec<_> = diags
250            .iter()
251            .filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
252            .collect();
253        assert_eq!(footnote_diags.len(), 1);
254        assert!(footnote_diags[0].message.contains("[1]"));
255    }
256}