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 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 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 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
96fn 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 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 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 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 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 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 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}