lex_core/lex/ast/
diagnostics.rs1use super::range::Range;
31use super::Document;
32use std::fmt;
33
34#[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#[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 pub fn diagnostics(&self) -> Vec<Diagnostic> {
113 let mut diagnostics = Vec::new();
114
115 diagnostics.extend(validate_references(self));
117
118 diagnostics.extend(validate_structure(self));
120
121 diagnostics.extend(self.reference_line_diagnostics.iter().cloned());
124
125 diagnostics
126 }
127}
128
129pub 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 for reference in document.iter_all_references() {
149 match &reference.reference_type {
150 ReferenceType::FootnoteNumber { number } => {
151 let label = number.to_string();
153 if document.find_annotation_by_label(&label).is_none() {
154 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 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 }
216 }
217 }
218
219 diagnostics
220}
221
222pub 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 for (item, _depth) in document.root.iter_all_nodes_with_depth() {
242 match item {
243 ContentItem::List(list) => {
244 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 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 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 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 }
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 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 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 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 assert!(!diagnostics
381 .iter()
382 .any(|d| d.message.contains("Broken footnote reference")));
383 }
384}