lex_analysis/
diagnostics.rs1use 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 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 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 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 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 }
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
119fn 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 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 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 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 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}