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        diagnostics
122    }
123}
124
125/// Validate all references in the document
126///
127/// Checks for:
128/// - Broken footnote references `[42]` without matching annotation
129/// - Broken citation references `[@key]` without matching annotation
130/// - Broken session references `[#section]` without matching session
131///
132/// # Arguments
133/// * `document` - The document to validate
134///
135/// # Returns
136/// Vector of diagnostics for broken references
137pub fn validate_references(document: &Document) -> Vec<Diagnostic> {
138    use super::traits::{AstNode, Container};
139    use crate::lex::inlines::ReferenceType;
140
141    let mut diagnostics = Vec::new();
142
143    // Iterate all references in the document
144    for reference in document.iter_all_references() {
145        match &reference.reference_type {
146            ReferenceType::FootnoteNumber { number } => {
147                // Check if annotation with this label exists
148                let label = number.to_string();
149                if document.find_annotation_by_label(&label).is_none() {
150                    // We don't have location info for inline elements yet
151                    // Using document root location as fallback
152                    let range = document.root.range().clone();
153                    let diag = Diagnostic::new(
154                        range,
155                        DiagnosticSeverity::Warning,
156                        format!(
157                            "Broken footnote reference: no annotation found with label '{label}'"
158                        ),
159                    )
160                    .with_code("broken-reference");
161                    diagnostics.push(diag);
162                }
163            }
164            ReferenceType::FootnoteLabeled { label } => {
165                if document.find_annotation_by_label(label).is_none() {
166                    let range = document.root.range().clone();
167                    let diag = Diagnostic::new(
168                        range,
169                        DiagnosticSeverity::Warning,
170                        format!(
171                            "Broken footnote reference: no annotation found with label '{label}'"
172                        ),
173                    )
174                    .with_code("broken-reference");
175                    diagnostics.push(diag);
176                }
177            }
178            ReferenceType::Citation(citation_data) => {
179                for key in &citation_data.keys {
180                    if document.find_annotation_by_label(key).is_none() {
181                        let range = document.root.range().clone();
182                        let diag = Diagnostic::new(
183                            range,
184                            DiagnosticSeverity::Warning,
185                            format!(
186                                "Broken citation reference: no annotation found with label '{key}'"
187                            ),
188                        )
189                        .with_code("broken-citation");
190                        diagnostics.push(diag);
191                    }
192                }
193            }
194            ReferenceType::Session { target } => {
195                // Check if a session with this label exists
196                let sessions: Vec<_> = document.root.iter_sessions_recursive().collect();
197                let found = sessions.iter().any(|s| s.label() == target);
198                if !found {
199                    let range = document.root.range().clone();
200                    let diag = Diagnostic::new(
201                        range,
202                        DiagnosticSeverity::Warning,
203                        format!("Broken session reference: no session found with title '{target}'"),
204                    )
205                    .with_code("broken-session-ref");
206                    diagnostics.push(diag);
207                }
208            }
209            _ => {
210                // URL, File, General, ToCome, and NotSure references don't need validation
211            }
212        }
213    }
214
215    diagnostics
216}
217
218/// Validate document structure for common errors
219///
220/// Checks for:
221/// - Single-item lists (should be paragraphs instead)
222/// - Invalid annotation syntax
223/// - Malformed data nodes
224///
225/// # Arguments
226/// * `document` - The document to validate
227///
228/// # Returns
229/// Vector of diagnostics for structural issues
230pub fn validate_structure(document: &Document) -> Vec<Diagnostic> {
231    use super::elements::content_item::ContentItem;
232    use super::traits::AstNode;
233
234    let mut diagnostics = Vec::new();
235
236    // Iterate all nodes to find structural issues
237    for (item, _depth) in document.root.iter_all_nodes_with_depth() {
238        match item {
239            ContentItem::List(list) => {
240                // Check for single-item lists
241                if list.items.len() == 1 {
242                    let diag = Diagnostic::new(
243                        list.range().clone(),
244                        DiagnosticSeverity::Information,
245                        "Single-item list: consider using a paragraph instead".to_string(),
246                    )
247                    .with_code("single-item-list");
248                    diagnostics.push(diag);
249                }
250            }
251            ContentItem::Annotation(annotation) => {
252                // Check for annotations with empty labels
253                if annotation.data.label.value.is_empty() {
254                    let diag = Diagnostic::new(
255                        annotation.range().clone(),
256                        DiagnosticSeverity::Error,
257                        "Annotation has empty label".to_string(),
258                    )
259                    .with_code("empty-annotation-label");
260                    diagnostics.push(diag);
261                }
262
263                // Check for duplicate parameters
264                let param_names: Vec<_> = annotation
265                    .data
266                    .parameters
267                    .iter()
268                    .map(|p| p.key.as_str())
269                    .collect();
270                for (i, name) in param_names.iter().enumerate() {
271                    if param_names[..i].contains(name) {
272                        let diag = Diagnostic::new(
273                            annotation.range().clone(),
274                            DiagnosticSeverity::Warning,
275                            format!("Duplicate parameter: '{name}'"),
276                        )
277                        .with_code("duplicate-parameter");
278                        diagnostics.push(diag);
279                        break;
280                    }
281                }
282            }
283            ContentItem::VerbatimBlock(verbatim) => {
284                // Check for empty verbatim block label
285                if verbatim.closing_data.label.value.is_empty() {
286                    let diag = Diagnostic::new(
287                        verbatim.range().clone(),
288                        DiagnosticSeverity::Warning,
289                        "Verbatim block has empty closing label".to_string(),
290                    )
291                    .with_code("empty-verbatim-label");
292                    diagnostics.push(diag);
293                }
294            }
295            _ => {
296                // Other content types don't need structural validation
297            }
298        }
299    }
300
301    diagnostics
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::lex::parsing::parse_document;
308
309    #[test]
310    fn test_diagnostic_creation() {
311        use super::super::range::Position;
312
313        let range = Range::new(0..10, Position::new(1, 0), Position::new(1, 10));
314        let diag = Diagnostic::new(range, DiagnosticSeverity::Error, "Test error".to_string())
315            .with_code("test-001");
316
317        assert_eq!(diag.severity, DiagnosticSeverity::Error);
318        assert_eq!(diag.message, "Test error");
319        assert_eq!(diag.code, Some("test-001".to_string()));
320        assert_eq!(diag.source, "lex-parser");
321    }
322
323    #[test]
324    fn test_broken_footnote_reference() {
325        let source = "A paragraph with a footnote reference [42].\n\n";
326        let doc = parse_document(source).unwrap();
327
328        let diagnostics = validate_references(&doc);
329
330        // Should find broken reference to [42]
331        assert!(!diagnostics.is_empty());
332        assert!(
333            diagnostics
334                .iter()
335                .any(|d| d.message.contains("Broken footnote reference")
336                    && d.message.contains("'42'"))
337        );
338    }
339
340    #[test]
341    fn test_valid_footnote_reference() {
342        let source =
343            "A paragraph with a footnote reference [42].\n\n:: 42 :: Footnote content.\n\n";
344        let doc = parse_document(source).unwrap();
345
346        let diagnostics = validate_references(&doc);
347
348        // Should not find broken reference
349        assert!(
350            !diagnostics
351                .iter()
352                .any(|d| d.message.contains("Broken footnote reference")),
353            "Expected no broken footnote reference diagnostics, got: {diagnostics:?}"
354        );
355    }
356
357    #[test]
358    fn test_valid_structure_no_warnings() {
359        let source = ":: note :: A valid annotation.\n\n";
360        let doc = parse_document(source).unwrap();
361
362        let diagnostics = validate_structure(&doc);
363
364        // Should not find any structural issues
365        assert!(diagnostics.is_empty());
366    }
367
368    #[test]
369    fn test_document_diagnostics_api() {
370        let source = "A paragraph with [42].\n\n:: 42 :: Valid footnote.\n\n";
371        let doc = parse_document(source).unwrap();
372
373        let diagnostics = doc.diagnostics();
374
375        // Should NOT find broken reference since annotation with label "42" exists
376        assert!(!diagnostics
377            .iter()
378            .any(|d| d.message.contains("Broken footnote reference")));
379    }
380}