lex_analysis/
diagnostics.rs

1use crate::inline::{extract_inline_spans, InlineSpanKind};
2use crate::utils::for_each_text_content;
3use lex_core::lex::ast::{Document, Range};
4use lex_core::lex::inlines::ReferenceType;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum DiagnosticKind {
8    MissingFootnoteDefinition,
9    UnusedFootnoteDefinition,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct AnalysisDiagnostic {
14    pub range: Range,
15    pub kind: DiagnosticKind,
16    pub message: String,
17}
18
19pub fn analyze(document: &Document) -> Vec<AnalysisDiagnostic> {
20    let mut diagnostics = Vec::new();
21    check_footnotes(document, &mut diagnostics);
22    diagnostics
23}
24
25fn check_footnotes(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
26    // 1. Collect all footnote references
27    let mut references = Vec::new();
28    for_each_text_content(document, &mut |text| {
29        for span in extract_inline_spans(text) {
30            if let InlineSpanKind::Reference(ReferenceType::FootnoteNumber { number }) = span.kind {
31                references.push((number, span.range));
32            } else if let InlineSpanKind::Reference(ReferenceType::FootnoteLabeled { label: _ }) =
33                span.kind
34            {
35                // We handle numeric footnotes primarily as per request, but let's track labels too if needed.
36                // For now, the user specifically mentioned numeric reordering and validation.
37                // Let's stick to numeric for the specific "footnote" validation if the user context implies it.
38                // Actually, the user said "add diagnotics for mismatched footnotes".
39                // Let's handle both if possible, but the renumbering task implies numeric.
40            }
41        }
42    });
43
44    // 2. Collect all footnote definitions (annotations and list items)
45    let definitions_list = crate::utils::collect_footnote_definitions(document);
46    let mut definitions = std::collections::HashSet::new();
47
48    for (label, _) in definitions_list {
49        if let Ok(number) = label.parse::<u32>() {
50            definitions.insert(number);
51        }
52    }
53
54    // 3. Check for missing definitions
55    for (number, range) in &references {
56        if !definitions.contains(number) {
57            diagnostics.push(AnalysisDiagnostic {
58                range: range.clone(),
59                kind: DiagnosticKind::MissingFootnoteDefinition,
60                message: format!("Footnote [{}] is referenced but not defined", number),
61            });
62        }
63    }
64
65    // Note: Unused definitions (footnotes without references) are intentionally not flagged
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use lex_core::lex::parsing;
72
73    fn parse(source: &str) -> Document {
74        parsing::parse_document(source).expect("parse failed")
75    }
76
77    #[test]
78    fn detects_missing_footnote_definition() {
79        let doc = parse("Text with [1] reference.");
80        let diags = analyze(&doc);
81        assert_eq!(diags.len(), 1);
82        assert_eq!(diags[0].kind, DiagnosticKind::MissingFootnoteDefinition);
83    }
84
85    #[test]
86    fn ignores_valid_footnote() {
87        let doc = parse("Text [1].\n\n:: 1 ::\nNote.\n::\n");
88        let diags = analyze(&doc);
89        assert_eq!(diags.len(), 0);
90    }
91
92    #[test]
93    fn ignores_valid_list_footnote() {
94        // "Notes" session with list item "1."
95        let doc = parse("Text [1].\n\nNotes\n\n1. Note.\n");
96        let diags = analyze(&doc);
97        assert_eq!(diags.len(), 0);
98    }
99}