1use crate::inline::extract_references;
2use lex_core::lex::ast::{
3 Annotation, ContentItem, Document, Range, Session, Table, TableRow, TextContent,
4};
5use lex_core::lex::inlines::ReferenceType;
6use lex_extension_host::Registry;
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum DiagnosticKind {
11 MissingFootnoteDefinition,
12 UnusedFootnoteDefinition,
13 TableInconsistentColumns,
14 SchemaValidation(SchemaValidationKind),
18 Handler {
25 namespace: String,
26 code: Option<String>,
27 },
28 ForbiddenLabelPrefix,
34 UnknownLexCanonical,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DiagnosticSeverity {
49 Error,
50 Warning,
51 Info,
52 Hint,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum SchemaValidationKind {
59 UnknownLabel,
67 MissingParam,
68 ParamTypeMismatch,
69 BadAttachment,
70 BodyShapeMismatch,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct AnalysisDiagnostic {
75 pub range: Range,
76 pub severity: DiagnosticSeverity,
81 pub kind: DiagnosticKind,
82 pub message: String,
83}
84
85pub fn analyze(document: &Document) -> Vec<AnalysisDiagnostic> {
89 let registry = Registry::new();
90 analyze_with_registry(document, ®istry)
91}
92
93pub fn analyze_with_registry(document: &Document, registry: &Registry) -> Vec<AnalysisDiagnostic> {
99 let mut diagnostics = Vec::new();
100 check_footnotes(document, &mut diagnostics);
101 check_tables(document, &mut diagnostics);
102 check_labels(document, &mut diagnostics);
103 crate::label_dispatch::dispatch_labels(document, registry, &mut diagnostics);
104 diagnostics
105}
106
107fn check_labels(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
115 use lex_core::lex::assembling::stages::normalize_labels::{
116 classify_label, RejectReason, Resolution,
117 };
118 use lex_core::lex::ast::Label;
119
120 fn emit(label: &Label, diagnostics: &mut Vec<AnalysisDiagnostic>) {
121 if let Resolution::Rejected(reason) = classify_label(&label.value) {
122 let message = reason.message();
127 let kind = match reason {
128 RejectReason::Forbidden { .. } => DiagnosticKind::ForbiddenLabelPrefix,
129 RejectReason::UnknownCanonical { .. } => DiagnosticKind::UnknownLexCanonical,
130 };
131 diagnostics.push(AnalysisDiagnostic {
132 range: label.location.clone(),
133 severity: DiagnosticSeverity::Error,
134 kind,
135 message,
136 });
137 }
138 }
139
140 fn walk_item(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
149 match item {
150 ContentItem::Annotation(a) => emit(&a.data.label, diagnostics),
151 ContentItem::VerbatimBlock(v) => emit(&v.closing_data.label, diagnostics),
152 ContentItem::Table(t) => {
153 for row in t.header_rows.iter().chain(t.body_rows.iter()) {
154 for cell in &row.cells {
155 for child in cell.children.iter() {
156 walk_item(child, diagnostics);
157 }
158 }
159 }
160 if let Some(footnotes) = t.footnotes.as_ref() {
161 for ann in footnotes.annotations() {
162 walk_annotation(ann, diagnostics);
163 }
164 for fn_item in footnotes.items.iter() {
165 walk_item(fn_item, diagnostics);
166 }
167 }
168 }
169 _ => {}
170 }
171 if let Some(attached) = attached_annotations(item) {
174 for annotation in attached {
175 walk_annotation(annotation, diagnostics);
176 }
177 }
178 if let Some(children) = item.children() {
182 for child in children {
183 walk_item(child, diagnostics);
184 }
185 }
186 }
187
188 fn walk_annotation(annotation: &Annotation, diagnostics: &mut Vec<AnalysisDiagnostic>) {
189 emit(&annotation.data.label, diagnostics);
190 for child in annotation.children.iter() {
191 walk_item(child, diagnostics);
192 }
193 }
194
195 fn walk_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
196 for annotation in session.annotations() {
197 walk_annotation(annotation, diagnostics);
198 }
199 for child in &session.children {
200 walk_item(child, diagnostics);
201 }
202 }
203
204 fn attached_annotations(item: &ContentItem) -> Option<&[Annotation]> {
205 match item {
206 ContentItem::Session(s) => Some(s.annotations()),
207 ContentItem::Paragraph(p) => Some(p.annotations()),
208 ContentItem::Definition(d) => Some(d.annotations()),
209 ContentItem::List(l) => Some(l.annotations()),
210 ContentItem::ListItem(li) => Some(li.annotations()),
211 ContentItem::VerbatimBlock(v) => Some(v.annotations()),
212 ContentItem::Table(t) => Some(t.annotations()),
213 _ => None,
214 }
215 }
216
217 for annotation in document.annotations() {
219 walk_annotation(annotation, diagnostics);
220 }
221 walk_session(&document.root, diagnostics);
223}
224
225fn check_footnotes(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
226 let outer_defs: HashSet<u32> = crate::utils::collect_footnote_definitions(document)
229 .into_iter()
230 .filter_map(|(label, _)| label.parse::<u32>().ok())
231 .collect();
232
233 if let Some(title) = &document.title {
237 check_text(&title.content, &outer_defs, diagnostics);
238 }
239 for annotation in document.annotations() {
240 check_annotation(annotation, &outer_defs, diagnostics);
241 }
242 check_session(&document.root, &outer_defs, diagnostics);
243}
244
245fn check_session(
246 session: &Session,
247 defs: &HashSet<u32>,
248 diagnostics: &mut Vec<AnalysisDiagnostic>,
249) {
250 check_text(&session.title, defs, diagnostics);
251 for annotation in session.annotations() {
252 check_annotation(annotation, defs, diagnostics);
253 }
254 for child in session.children.iter() {
255 check_content(child, defs, diagnostics);
256 }
257}
258
259fn check_content(
260 item: &ContentItem,
261 defs: &HashSet<u32>,
262 diagnostics: &mut Vec<AnalysisDiagnostic>,
263) {
264 match item {
265 ContentItem::Paragraph(p) => {
266 for line in &p.lines {
267 if let ContentItem::TextLine(tl) = line {
268 check_text(&tl.content, defs, diagnostics);
269 }
270 }
271 for annotation in p.annotations() {
272 check_annotation(annotation, defs, diagnostics);
273 }
274 }
275 ContentItem::Session(s) => check_session(s, defs, diagnostics),
276 ContentItem::List(list) => {
277 for annotation in list.annotations() {
278 check_annotation(annotation, defs, diagnostics);
279 }
280 for entry in &list.items {
281 if let ContentItem::ListItem(li) = entry {
282 for text in &li.text {
283 check_text(text, defs, diagnostics);
284 }
285 for annotation in li.annotations() {
286 check_annotation(annotation, defs, diagnostics);
287 }
288 for child in li.children.iter() {
289 check_content(child, defs, diagnostics);
290 }
291 }
292 }
293 }
294 ContentItem::Definition(def) => {
295 check_text(&def.subject, defs, diagnostics);
296 for annotation in def.annotations() {
297 check_annotation(annotation, defs, diagnostics);
298 }
299 for child in def.children.iter() {
300 check_content(child, defs, diagnostics);
301 }
302 }
303 ContentItem::Annotation(a) => check_annotation(a, defs, diagnostics),
304 ContentItem::VerbatimBlock(v) => {
305 check_text(&v.subject, defs, diagnostics);
306 for annotation in v.annotations() {
307 check_annotation(annotation, defs, diagnostics);
308 }
309 }
310 ContentItem::Table(table) => check_table(table, defs, diagnostics),
311 _ => {}
312 }
313}
314
315fn check_annotation(
316 annotation: &Annotation,
317 defs: &HashSet<u32>,
318 diagnostics: &mut Vec<AnalysisDiagnostic>,
319) {
320 for child in annotation.children.iter() {
321 check_content(child, defs, diagnostics);
322 }
323}
324
325fn check_table(
326 table: &Table,
327 outer_defs: &HashSet<u32>,
328 diagnostics: &mut Vec<AnalysisDiagnostic>,
329) {
330 let table_defs = table_footnote_numbers(table);
336 if table_defs.is_empty() {
337 check_table_text(table, outer_defs, diagnostics);
338 return;
339 }
340 let mut scope = outer_defs.clone();
341 scope.extend(table_defs);
342 check_table_text(table, &scope, diagnostics);
343}
344
345fn check_table_text(table: &Table, defs: &HashSet<u32>, diagnostics: &mut Vec<AnalysisDiagnostic>) {
346 check_text(&table.subject, defs, diagnostics);
347 for row in table.all_rows() {
348 for cell in &row.cells {
349 check_text(&cell.content, defs, diagnostics);
350 }
351 }
352 for annotation in table.annotations() {
353 check_annotation(annotation, defs, diagnostics);
354 }
355}
356
357fn table_footnote_numbers(table: &Table) -> HashSet<u32> {
358 let Some(list) = &table.footnotes else {
359 return HashSet::new();
360 };
361 let mut numbers = HashSet::new();
362 for entry in &list.items {
363 if let ContentItem::ListItem(li) = entry {
364 let label = li
365 .marker()
366 .trim()
367 .trim_end_matches(['.', ')', ':'].as_ref())
368 .trim();
369 if let Ok(n) = label.parse::<u32>() {
370 numbers.insert(n);
371 }
372 }
373 }
374 numbers
375}
376
377fn check_text(text: &TextContent, defs: &HashSet<u32>, diagnostics: &mut Vec<AnalysisDiagnostic>) {
378 for reference in extract_references(text) {
379 if let ReferenceType::FootnoteNumber { number } = reference.reference_type {
380 if !defs.contains(&number) {
381 diagnostics.push(AnalysisDiagnostic {
382 range: reference.range,
383 severity: DiagnosticSeverity::Error,
384 kind: DiagnosticKind::MissingFootnoteDefinition,
385 message: format!(
386 "Footnote [{number}] has no matching footnote definition in scope"
387 ),
388 });
389 }
390 }
391 }
392}
393
394fn check_tables(document: &Document, diagnostics: &mut Vec<AnalysisDiagnostic>) {
395 visit_tables_in_session(&document.root, diagnostics);
396}
397
398fn visit_tables_in_session(session: &Session, diagnostics: &mut Vec<AnalysisDiagnostic>) {
399 for child in session.children.iter() {
400 visit_tables_in_content(child, diagnostics);
401 }
402}
403
404fn visit_tables_in_content(item: &ContentItem, diagnostics: &mut Vec<AnalysisDiagnostic>) {
405 match item {
406 ContentItem::Table(table) => check_table_columns(table, diagnostics),
407 ContentItem::Session(session) => visit_tables_in_session(session, diagnostics),
408 ContentItem::Definition(def) => {
409 for child in def.children.iter() {
410 visit_tables_in_content(child, diagnostics);
411 }
412 }
413 ContentItem::List(list) => {
414 for entry in &list.items {
415 if let ContentItem::ListItem(li) = entry {
416 for child in li.children.iter() {
417 visit_tables_in_content(child, diagnostics);
418 }
419 }
420 }
421 }
422 ContentItem::Annotation(ann) => {
423 for child in ann.children.iter() {
424 visit_tables_in_content(child, diagnostics);
425 }
426 }
427 _ => {}
428 }
429}
430
431fn check_table_columns(table: &Table, diagnostics: &mut Vec<AnalysisDiagnostic>) {
438 let rows: Vec<_> = table.all_rows().collect();
439 if rows.len() < 2 {
440 return;
441 }
442
443 let widths = compute_row_widths(&rows);
444 let expected = widths[0];
445 for (i, &width) in widths.iter().enumerate().skip(1) {
446 if width != expected {
447 diagnostics.push(AnalysisDiagnostic {
448 range: rows[i].location.clone(),
449 severity: DiagnosticSeverity::Warning,
450 kind: DiagnosticKind::TableInconsistentColumns,
451 message: format!(
452 "Row has {width} columns, expected {expected} (matching first row)"
453 ),
454 });
455 }
456 }
457}
458
459fn compute_row_widths(rows: &[&TableRow]) -> Vec<usize> {
465 let mut carry: Vec<usize> = Vec::new();
466 let mut widths = Vec::with_capacity(rows.len());
467
468 for row in rows {
469 let mut col = 0;
470 for cell in &row.cells {
471 while col < carry.len() && carry[col] > 0 {
472 col += 1;
473 }
474 let end = col + cell.colspan;
475 if end > carry.len() {
476 carry.resize(end, 0);
477 }
478 for slot in carry.iter_mut().take(end).skip(col) {
479 *slot = cell.rowspan;
480 }
481 col = end;
482 }
483
484 let width = carry
485 .iter()
486 .rposition(|&r| r > 0)
487 .map(|i| i + 1)
488 .unwrap_or(0);
489 widths.push(width);
490
491 for c in carry.iter_mut().take(width) {
495 if *c > 0 {
496 *c -= 1;
497 }
498 }
499 carry.truncate(width);
500 }
501
502 widths
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use lex_core::lex::parsing::process_full_permissive;
509 use lex_core::lex::testing::lexplore::Lexplore;
510
511 fn footnote_diags(doc: &Document) -> Vec<AnalysisDiagnostic> {
512 analyze(doc)
513 .into_iter()
514 .filter(|d| d.kind == DiagnosticKind::MissingFootnoteDefinition)
515 .collect()
516 }
517
518 fn label_diags(source: &str) -> Vec<AnalysisDiagnostic> {
519 let doc = process_full_permissive(source).expect("permissive parse");
520 analyze(&doc)
521 .into_iter()
522 .filter(|d| {
523 matches!(
524 d.kind,
525 DiagnosticKind::ForbiddenLabelPrefix | DiagnosticKind::UnknownLexCanonical
526 )
527 })
528 .collect()
529 }
530
531 #[test]
532 fn check_labels_emits_for_doc_prefix() {
533 let diags = label_diags(":: doc.table :: x\n\nBody.\n");
534 assert_eq!(diags.len(), 1, "expected 1 forbidden-prefix diagnostic");
535 assert_eq!(diags[0].kind, DiagnosticKind::ForbiddenLabelPrefix);
536 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
537 assert!(
538 diags[0].message.contains("doc.table") && diags[0].message.contains("reserved"),
539 "message names the offending prefix; got: {}",
540 diags[0].message
541 );
542 }
543
544 #[test]
545 fn check_labels_emits_for_unknown_lex_canonical() {
546 let diags = label_diags(":: lex.foobar :: x\n\nBody.\n");
547 assert_eq!(diags.len(), 1, "expected 1 unknown-canonical diagnostic");
548 assert_eq!(diags[0].kind, DiagnosticKind::UnknownLexCanonical);
549 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
550 assert!(
551 diags[0].message.contains("lex.foobar"),
552 "message names the offending label; got: {}",
553 diags[0].message
554 );
555 }
556
557 #[test]
558 fn check_labels_silent_on_accepted_forms() {
559 let sources = [
563 ":: author :: Alice\n\nBody.\n",
564 ":: metadata.author :: Alice\n\nBody.\n",
565 ":: lex.metadata.author :: Alice\n\nBody.\n",
566 ":: acme.task :: x\n\nBody.\n",
567 ];
568 for src in sources {
569 let diags = label_diags(src);
570 assert!(
571 diags.is_empty(),
572 "no label diagnostics expected for {src:?}; got {diags:?}"
573 );
574 }
575 }
576
577 #[test]
578 fn check_labels_finds_verbatim_closer_violations() {
579 let diags =
580 label_diags("Table:\n | a | b |\n |---|---|\n | 1 | 2 |\n:: doc.table ::\n");
581 assert_eq!(diags.len(), 1);
582 assert_eq!(diags[0].kind, DiagnosticKind::ForbiddenLabelPrefix);
583 }
584
585 #[test]
586 fn check_labels_emits_each_offending_site_exactly_once() {
587 let src = ":: doc.outer ::\n :: doc.inner :: nested body\n\n:: doc.sibling :: x\n";
595 let diags = label_diags(src);
596 assert_eq!(
597 diags.len(),
598 3,
599 "exactly one diagnostic per offending site: {diags:?}"
600 );
601 for d in &diags {
602 assert_eq!(d.kind, DiagnosticKind::ForbiddenLabelPrefix);
603 }
604 }
605
606 #[test]
607 fn detects_missing_footnote_definition() {
608 let doc = Lexplore::footnotes(1).parse().unwrap();
609 let diags = analyze(&doc);
610 assert_eq!(diags.len(), 1);
611 assert_eq!(diags[0].kind, DiagnosticKind::MissingFootnoteDefinition);
612 }
613
614 #[test]
615 fn ignores_valid_footnote_with_notes_annotation() {
616 let doc = Lexplore::footnotes(2).parse().unwrap();
618 assert!(footnote_diags(&doc).is_empty());
619 }
620
621 #[test]
622 fn ignores_valid_list_footnote_in_session() {
623 let doc = Lexplore::footnotes(3).parse().unwrap();
625 assert!(footnote_diags(&doc).is_empty());
626 }
627
628 #[test]
629 fn list_without_notes_annotation_is_not_footnotes() {
630 let doc = Lexplore::footnotes(4).parse().unwrap();
632 assert_eq!(footnote_diags(&doc).len(), 1);
633 }
634
635 fn table_diags(doc: &Document) -> Vec<AnalysisDiagnostic> {
636 analyze(doc)
637 .into_iter()
638 .filter(|d| d.kind == DiagnosticKind::TableInconsistentColumns)
639 .collect()
640 }
641
642 #[test]
643 fn detects_inconsistent_table_columns() {
644 let doc = Lexplore::table(13).parse().unwrap();
646 let diags = table_diags(&doc);
647 assert_eq!(diags.len(), 1);
648 assert!(diags[0].message.contains("2 columns"));
649 assert!(diags[0].message.contains("expected 3"));
650 }
651
652 #[test]
653 fn consistent_table_no_diagnostic() {
654 let doc = Lexplore::table(1).parse().unwrap();
656 assert!(table_diags(&doc).is_empty());
657 }
658
659 #[test]
660 fn table_with_rowspan_counts_carry_over() {
661 let doc = Lexplore::table(17).parse().unwrap();
663 let diags = table_diags(&doc);
664 assert!(
665 diags.is_empty(),
666 "rowspan carry-over should not trigger inconsistent-columns, got: {diags:?}"
667 );
668 }
669
670 #[test]
671 fn table_with_colspan_and_rowspan_mixed() {
672 let doc = Lexplore::table(18).parse().unwrap();
674 let diags = table_diags(&doc);
675 assert!(
676 diags.is_empty(),
677 "mixed colspan/rowspan should not trigger inconsistent-columns, got: {diags:?}"
678 );
679 }
680
681 #[test]
682 fn table_with_colspan_counts_effective_width() {
683 let doc = Lexplore::table(4).parse().unwrap();
685 assert!(table_diags(&doc).is_empty());
686 }
687
688 #[test]
689 fn footnote_ref_in_table_cell_is_checked() {
690 let doc = Lexplore::footnotes(9).parse().unwrap();
693 let diags = footnote_diags(&doc);
694 assert_eq!(diags.len(), 1);
695 assert!(diags[0].message.contains("[1]"));
696 }
697
698 #[test]
699 fn table_scoped_footnotes_resolve_cell_refs() {
700 let doc = Lexplore::footnotes(11).parse().unwrap();
703 let diags = footnote_diags(&doc);
704 assert!(
705 diags.is_empty(),
706 "table-scoped cell refs should resolve to table.footnotes, got: {diags:?}"
707 );
708 }
709
710 #[test]
711 fn table_scoped_footnotes_do_not_leak_out() {
712 let doc = Lexplore::footnotes(12).parse().unwrap();
716 let diags = footnote_diags(&doc);
717 assert_eq!(
718 diags.len(),
719 1,
720 "only the paragraph ref [1] should be unresolved, got: {diags:?}"
721 );
722 assert!(diags[0].message.contains("[1]"));
723 }
724}