Skip to main content

lex_core/lex/ast/
diagnostics.rs

1//! Diagnostic collection and validation for LSP support
2//!
3//! This module provides structured error and warning information that can be consumed
4//! by LSP implementations to provide diagnostics in editors.
5//!
6//! ## Problem
7//!
8//! The LSP diagnostics feature needs structured error/warning information, but the parser currently:
9//! - Only tracks fatal `ParserError::InvalidNesting`
10//! - Doesn't collect indentation errors
11//! - Doesn't validate references
12//! - Doesn't detect malformed structures
13//!
14//! ## Solution
15//!
16//! This module provides:
17//! - `Diagnostic` struct matching LSP protocol
18//! - Validation functions for different error types
19//! - Collection API for gathering diagnostics from Documents
20//!
21//! ## Validation Checks
22//!
23//! 1. **Reference validation**: Broken footnote/citation references
24//! 2. **Structure validation**: Single-item lists, malformed elements
25//! 3. **Annotation validation**: Invalid annotation syntax
26//!
27//! Note: Indentation validation requires access to source text and is implemented
28//! separately in the validation functions.
29
30use super::range::Range;
31use super::Document;
32use std::fmt;
33
34/// Diagnostic severity levels matching LSP protocol
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum DiagnosticSeverity {
37    Error,
38    Warning,
39    Information,
40    Hint,
41}
42
43impl fmt::Display for DiagnosticSeverity {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            DiagnosticSeverity::Error => write!(f, "error"),
47            DiagnosticSeverity::Warning => write!(f, "warning"),
48            DiagnosticSeverity::Information => write!(f, "info"),
49            DiagnosticSeverity::Hint => write!(f, "hint"),
50        }
51    }
52}
53
54/// Structured diagnostic for LSP consumption
55#[derive(Debug, Clone, PartialEq)]
56pub struct Diagnostic {
57    pub range: Range,
58    pub severity: DiagnosticSeverity,
59    pub message: String,
60    pub code: Option<String>,
61    pub source: String,
62}
63
64impl Diagnostic {
65    pub fn new(range: Range, severity: DiagnosticSeverity, message: String) -> Self {
66        Self {
67            range,
68            severity,
69            message,
70            code: None,
71            source: "lex-parser".to_string(),
72        }
73    }
74
75    pub fn with_code(mut self, code: impl Into<String>) -> Self {
76        self.code = Some(code.into());
77        self
78    }
79
80    pub fn with_source(mut self, source: impl Into<String>) -> Self {
81        self.source = source.into();
82        self
83    }
84}
85
86impl fmt::Display for Diagnostic {
87    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88        write!(
89            f,
90            "{} [{}]: {} at {}",
91            self.severity, self.source, self.message, self.range.start
92        )
93    }
94}
95
96impl Document {
97    /// Get all diagnostics for this document
98    ///
99    /// This collects diagnostics from various validation checks:
100    /// - Broken references (footnotes, citations, session links)
101    /// - Malformed structures (single-item lists, etc.)
102    /// - Invalid annotation syntax
103    ///
104    /// # Example
105    /// ```rust,ignore
106    /// let doc = parse_document(source)?;
107    /// let diagnostics = doc.diagnostics();
108    /// for diag in diagnostics {
109    ///     eprintln!("{}", diag);
110    /// }
111    /// ```
112    pub fn diagnostics(&self) -> Vec<Diagnostic> {
113        let mut diagnostics = Vec::new();
114
115        // Collect reference validation errors
116        diagnostics.extend(validate_references(self));
117
118        // Collect structure validation errors
119        diagnostics.extend(validate_structure(self));
120
121        // Reference-line anchoring warnings (overlap / stacking, ยง2.3.3) are
122        // computed during parsing and stored on the document.
123        diagnostics.extend(self.reference_line_diagnostics.iter().cloned());
124
125        diagnostics
126    }
127}
128
129/// Validate all references in the document
130///
131/// Checks for:
132/// - Broken footnote references `[42]` without matching annotation
133/// - Broken citation references `[@key]` without matching annotation
134/// - Broken session references `[#section]` without matching session
135///
136/// # Arguments
137/// * `document` - The document to validate
138///
139/// # Returns
140/// Vector of diagnostics for broken references
141pub fn validate_references(document: &Document) -> Vec<Diagnostic> {
142    use super::traits::{AstNode, Container};
143    use crate::lex::inlines::ReferenceType;
144
145    let mut diagnostics = Vec::new();
146
147    // Iterate all references in the document
148    for reference in document.iter_all_references() {
149        match &reference.reference_type {
150            ReferenceType::FootnoteNumber { number } => {
151                // Check if annotation with this label exists
152                let label = number.to_string();
153                if document.find_annotation_by_label(&label).is_none() {
154                    // We don't have location info for inline elements yet
155                    // Using document root location as fallback
156                    let range = document.root.range().clone();
157                    let diag = Diagnostic::new(
158                        range,
159                        DiagnosticSeverity::Warning,
160                        format!(
161                            "Broken footnote reference: no annotation found with label '{label}'"
162                        ),
163                    )
164                    .with_code("broken-reference");
165                    diagnostics.push(diag);
166                }
167            }
168            ReferenceType::AnnotationReference { label } => {
169                if document.find_annotation_by_label(label).is_none() {
170                    let range = document.root.range().clone();
171                    let diag = Diagnostic::new(
172                        range,
173                        DiagnosticSeverity::Warning,
174                        format!(
175                            "Broken annotation reference: no annotation found with label '{label}'"
176                        ),
177                    )
178                    .with_code("broken-reference");
179                    diagnostics.push(diag);
180                }
181            }
182            ReferenceType::Citation(citation_data) => {
183                for key in &citation_data.keys {
184                    if document.find_annotation_by_label(key).is_none() {
185                        let range = document.root.range().clone();
186                        let diag = Diagnostic::new(
187                            range,
188                            DiagnosticSeverity::Warning,
189                            format!(
190                                "Broken citation reference: no annotation found with label '{key}'"
191                            ),
192                        )
193                        .with_code("broken-citation");
194                        diagnostics.push(diag);
195                    }
196                }
197            }
198            ReferenceType::Session { target } => {
199                // Check if a session with this label exists
200                let sessions: Vec<_> = document.root.iter_sessions_recursive().collect();
201                let found = sessions.iter().any(|s| s.label() == target);
202                if !found {
203                    let range = document.root.range().clone();
204                    let diag = Diagnostic::new(
205                        range,
206                        DiagnosticSeverity::Warning,
207                        format!("Broken session reference: no session found with title '{target}'"),
208                    )
209                    .with_code("broken-session-ref");
210                    diagnostics.push(diag);
211                }
212            }
213            _ => {
214                // URL, File, General, ToCome, and NotSure references don't need validation
215            }
216        }
217    }
218
219    diagnostics
220}
221
222/// Validate document structure for common errors
223///
224/// Checks for:
225/// - Single-item lists (should be paragraphs instead)
226/// - Invalid annotation syntax
227/// - Malformed data nodes
228///
229/// # Arguments
230/// * `document` - The document to validate
231///
232/// # Returns
233/// Vector of diagnostics for structural issues
234pub fn validate_structure(document: &Document) -> Vec<Diagnostic> {
235    use super::elements::content_item::ContentItem;
236    use super::traits::AstNode;
237
238    let mut diagnostics = Vec::new();
239
240    // Iterate all nodes to find structural issues
241    for (item, _depth) in document.root.iter_all_nodes_with_depth() {
242        match item {
243            ContentItem::List(list) => {
244                // Check for single-item lists
245                if list.items.len() == 1 {
246                    let diag = Diagnostic::new(
247                        list.range().clone(),
248                        DiagnosticSeverity::Information,
249                        "Single-item list: consider using a paragraph instead".to_string(),
250                    )
251                    .with_code("single-item-list");
252                    diagnostics.push(diag);
253                }
254            }
255            ContentItem::Annotation(annotation) => {
256                // Check for annotations with empty labels
257                if annotation.data.label.value.is_empty() {
258                    let diag = Diagnostic::new(
259                        annotation.range().clone(),
260                        DiagnosticSeverity::Error,
261                        "Annotation has empty label".to_string(),
262                    )
263                    .with_code("empty-annotation-label");
264                    diagnostics.push(diag);
265                }
266
267                // Check for duplicate parameters
268                let param_names: Vec<_> = annotation
269                    .data
270                    .parameters
271                    .iter()
272                    .map(|p| p.key.as_str())
273                    .collect();
274                for (i, name) in param_names.iter().enumerate() {
275                    if param_names[..i].contains(name) {
276                        let diag = Diagnostic::new(
277                            annotation.range().clone(),
278                            DiagnosticSeverity::Warning,
279                            format!("Duplicate parameter: '{name}'"),
280                        )
281                        .with_code("duplicate-parameter");
282                        diagnostics.push(diag);
283                        break;
284                    }
285                }
286            }
287            ContentItem::VerbatimBlock(verbatim) => {
288                // Check for empty verbatim block label
289                if verbatim.closing_data.label.value.is_empty() {
290                    let diag = Diagnostic::new(
291                        verbatim.range().clone(),
292                        DiagnosticSeverity::Warning,
293                        "Verbatim block has empty closing label".to_string(),
294                    )
295                    .with_code("empty-verbatim-label");
296                    diagnostics.push(diag);
297                }
298            }
299            _ => {
300                // Other content types don't need structural validation
301            }
302        }
303    }
304
305    diagnostics
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::lex::parsing::parse_document;
312
313    #[test]
314    fn test_diagnostic_creation() {
315        use super::super::range::Position;
316
317        let range = Range::new(0..10, Position::new(1, 0), Position::new(1, 10));
318        let diag = Diagnostic::new(range, DiagnosticSeverity::Error, "Test error".to_string())
319            .with_code("test-001");
320
321        assert_eq!(diag.severity, DiagnosticSeverity::Error);
322        assert_eq!(diag.message, "Test error");
323        assert_eq!(diag.code, Some("test-001".to_string()));
324        assert_eq!(diag.source, "lex-parser");
325    }
326
327    #[test]
328    fn test_broken_footnote_reference() {
329        let source = "A paragraph with a footnote reference [42].\n\n";
330        let doc = parse_document(source).unwrap();
331
332        let diagnostics = validate_references(&doc);
333
334        // Should find broken reference to [42]
335        assert!(!diagnostics.is_empty());
336        assert!(
337            diagnostics
338                .iter()
339                .any(|d| d.message.contains("Broken footnote reference")
340                    && d.message.contains("'42'"))
341        );
342    }
343
344    #[test]
345    fn test_valid_footnote_reference() {
346        let source =
347            "A paragraph with a footnote reference [42].\n\n:: 42 :: Footnote content.\n\n";
348        let doc = parse_document(source).unwrap();
349
350        let diagnostics = validate_references(&doc);
351
352        // Should not find broken reference
353        assert!(
354            !diagnostics
355                .iter()
356                .any(|d| d.message.contains("Broken footnote reference")),
357            "Expected no broken footnote reference diagnostics, got: {diagnostics:?}"
358        );
359    }
360
361    #[test]
362    fn test_valid_structure_no_warnings() {
363        let source = ":: test.note :: A valid annotation.\n\n";
364        let doc = parse_document(source).unwrap();
365
366        let diagnostics = validate_structure(&doc);
367
368        // Should not find any structural issues
369        assert!(diagnostics.is_empty());
370    }
371
372    #[test]
373    fn test_document_diagnostics_api() {
374        let source = "A paragraph with [42].\n\n:: 42 :: Valid footnote.\n\n";
375        let doc = parse_document(source).unwrap();
376
377        let diagnostics = doc.diagnostics();
378
379        // Should NOT find broken reference since annotation with label "42" exists
380        assert!(!diagnostics
381            .iter()
382            .any(|d| d.message.contains("Broken footnote reference")));
383    }
384}