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
122 }
123}
124
125pub 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 for reference in document.iter_all_references() {
145 match &reference.reference_type {
146 ReferenceType::FootnoteNumber { number } => {
147 let label = number.to_string();
149 if document.find_annotation_by_label(&label).is_none() {
150 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 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 }
212 }
213 }
214
215 diagnostics
216}
217
218pub 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 for (item, _depth) in document.root.iter_all_nodes_with_depth() {
238 match item {
239 ContentItem::List(list) => {
240 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 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 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 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 }
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 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 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 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 assert!(!diagnostics
377 .iter()
378 .any(|d| d.message.contains("Broken footnote reference")));
379 }
380}