1use lex_core::lex::ast::{
15 Annotation, ContentItem, Document, Range as CoreRange, Session, Verbatim,
16};
17use lex_core::lex::wire::to_wire_node;
18use lex_extension::wire::{HostNodeKind, Range as WireRange, WireNode};
19use lex_extension::{schema::Schema, AnnotationBody, LabelCtx, NodeRef};
20use lex_extension_host::Registry;
21
22use crate::diagnostics::{
23 AnalysisDiagnostic, DiagnosticKind, DiagnosticSeverity, SchemaValidationKind,
24};
25
26pub fn dispatch_labels(
29 document: &Document,
30 registry: &Registry,
31 diagnostics: &mut Vec<AnalysisDiagnostic>,
32) {
33 if registry.namespace_count() == 0 {
34 return;
35 }
36 for annotation in document.annotations() {
39 visit_annotation(annotation, HostNodeKind::Document, registry, diagnostics);
40 }
41 walk_session(&document.root, HostNodeKind::Session, registry, diagnostics);
42}
43
44fn walk_session(
45 session: &Session,
46 self_kind: HostNodeKind,
47 registry: &Registry,
48 diagnostics: &mut Vec<AnalysisDiagnostic>,
49) {
50 for annotation in session.annotations() {
51 visit_annotation(annotation, self_kind, registry, diagnostics);
52 }
53 for child in session.children.iter() {
54 visit_content(child, HostNodeKind::Session, registry, diagnostics);
55 }
56}
57
58fn visit_content(
59 item: &ContentItem,
60 _parent_kind: HostNodeKind,
61 registry: &Registry,
62 diagnostics: &mut Vec<AnalysisDiagnostic>,
63) {
64 match item {
65 ContentItem::Paragraph(p) => {
66 for ann in p.annotations() {
67 visit_annotation(ann, HostNodeKind::Paragraph, registry, diagnostics);
68 }
69 }
70 ContentItem::Session(s) => walk_session(s, HostNodeKind::Session, registry, diagnostics),
71 ContentItem::Definition(def) => {
72 for ann in def.annotations() {
73 visit_annotation(ann, HostNodeKind::Definition, registry, diagnostics);
74 }
75 for child in def.children.iter() {
76 visit_content(child, HostNodeKind::Definition, registry, diagnostics);
77 }
78 }
79 ContentItem::List(list) => {
80 for ann in list.annotations() {
83 visit_annotation(ann, HostNodeKind::List, registry, diagnostics);
84 }
85 for entry in &list.items {
86 if let ContentItem::ListItem(li) = entry {
87 for ann in li.annotations() {
88 visit_annotation(ann, HostNodeKind::ListItem, registry, diagnostics);
89 }
90 for child in li.children.iter() {
91 visit_content(child, HostNodeKind::ListItem, registry, diagnostics);
92 }
93 }
94 }
95 }
96 ContentItem::Annotation(a) => {
97 visit_annotation(a, HostNodeKind::Annotation, registry, diagnostics);
103 }
104 ContentItem::VerbatimBlock(v) => {
105 visit_verbatim(v, registry, diagnostics);
106 for ann in v.annotations() {
107 visit_annotation(ann, HostNodeKind::Verbatim, registry, diagnostics);
108 }
109 }
110 ContentItem::Table(t) => {
111 for ann in t.annotations() {
112 visit_annotation(ann, HostNodeKind::Table, registry, diagnostics);
113 }
114 }
115 _ => {}
116 }
117}
118
119fn visit_annotation(
120 annotation: &Annotation,
121 attached_to: HostNodeKind,
122 registry: &Registry,
123 diagnostics: &mut Vec<AnalysisDiagnostic>,
124) {
125 let label = annotation.data.label.value.clone();
126 let Some(schema) = registry.schema_for(&label) else {
127 if let Some((ns, _)) = label.split_once('.') {
133 if registry.is_namespace_healthy(ns) {
134 diagnostics.push(AnalysisDiagnostic {
135 range: annotation.location.clone(),
136 severity: DiagnosticSeverity::Error,
137 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::UnknownLabel),
138 message: format!(
139 "label `{label}` is not declared in registered namespace `{ns}`"
140 ),
141 });
142 }
143 }
144 return;
145 };
146
147 let wire = to_wire_node(&ContentItem::Annotation(annotation.clone()));
150 let WireNode::Annotation {
151 label: _,
152 params,
153 body,
154 range,
155 origin,
156 } = wire
157 else {
158 return;
159 };
160
161 let body = match serde_json::from_value::<AnnotationBody>(body.clone()) {
162 Ok(b) => b,
163 Err(_) => AnnotationBody::None,
164 };
165
166 let ctx = LabelCtx {
167 label: label.clone(),
168 params: params.clone(),
169 body,
170 node: NodeRef {
171 kind: attached_to.as_str().to_string(),
177 range,
178 origin,
179 },
180 };
181
182 if let Some(diag) = pre_validate(&schema, &ctx, attached_to, &annotation.location) {
183 diagnostics.push(diag);
184 return;
185 }
186
187 if schema.hooks.label {
189 registry.dispatch_label(&ctx);
190 }
191
192 let namespace = label
193 .split_once('.')
194 .map(|(n, _)| n.to_string())
195 .unwrap_or_else(|| label.clone());
196
197 if schema.hooks.validate {
198 for d in registry.dispatch_validate(&ctx) {
199 diagnostics.push(handler_diagnostic_to_analysis(
200 d,
201 namespace.clone(),
202 annotation.location.clone(),
203 ));
204 }
205 }
206
207 for d in registry.take_root_diagnostics() {
209 diagnostics.push(handler_diagnostic_to_analysis(
210 d,
211 namespace.clone(),
212 annotation.location.clone(),
213 ));
214 }
215}
216
217fn visit_verbatim(v: &Verbatim, registry: &Registry, diagnostics: &mut Vec<AnalysisDiagnostic>) {
218 let label = v.closing_data.label.value.clone();
219 if label.is_empty() {
220 return;
221 }
222 let Some(schema) = registry.schema_for(&label) else {
223 if let Some((ns, _)) = label.split_once('.') {
227 if registry.is_namespace_healthy(ns) {
228 diagnostics.push(AnalysisDiagnostic {
229 range: v.location.clone(),
230 severity: DiagnosticSeverity::Error,
231 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::UnknownLabel),
232 message: format!(
233 "verbatim label `{label}` is not declared in registered namespace `{ns}`"
234 ),
235 });
236 }
237 }
238 return;
239 };
240 if !schema.verbatim_label {
241 diagnostics.push(AnalysisDiagnostic {
244 range: v.location.clone(),
245 severity: DiagnosticSeverity::Error,
246 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment),
247 message: format!(
248 "label `{label}` is not declared as a verbatim closing (verbatim_label: false)"
249 ),
250 });
251 return;
252 }
253 let wire = to_wire_node(&ContentItem::VerbatimBlock(Box::new(v.clone())));
254 let WireNode::Verbatim {
255 params,
256 body_text,
257 range,
258 origin,
259 ..
260 } = wire
261 else {
262 return;
263 };
264 let ctx = LabelCtx {
265 label: label.clone(),
266 params,
267 body: AnnotationBody::Text(body_text),
268 node: NodeRef {
269 kind: HostNodeKind::Verbatim.as_str().to_string(),
270 range,
271 origin,
272 },
273 };
274
275 if let Some(diag) = pre_validate(&schema, &ctx, HostNodeKind::Verbatim, &v.location) {
281 diagnostics.push(diag);
282 return;
283 }
284
285 let namespace = label
286 .split_once('.')
287 .map(|(n, _)| n.to_string())
288 .unwrap_or_else(|| label.clone());
289
290 if schema.hooks.label {
291 registry.dispatch_label(&ctx);
292 }
293 if schema.hooks.validate {
294 for d in registry.dispatch_validate(&ctx) {
295 diagnostics.push(handler_diagnostic_to_analysis(
296 d,
297 namespace.clone(),
298 v.location.clone(),
299 ));
300 }
301 }
302 for d in registry.take_root_diagnostics() {
303 diagnostics.push(handler_diagnostic_to_analysis(
304 d,
305 namespace.clone(),
306 v.location.clone(),
307 ));
308 }
309}
310
311fn pre_validate(
326 schema: &Schema,
327 ctx: &LabelCtx,
328 attached_to: HostNodeKind,
329 range: &CoreRange,
330) -> Option<AnalysisDiagnostic> {
331 use lex_extension::schema::{BodyKind, BodyPresence};
332
333 let attached_str = attached_to.as_str();
335 if !schema.attaches_to.is_empty() && !schema.attaches_to.iter().any(|kind| kind == attached_str)
336 {
337 return Some(AnalysisDiagnostic {
338 range: range.clone(),
339 severity: DiagnosticSeverity::Error,
340 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment),
341 message: format!(
342 "label `{}` is not permitted on `{attached_str}` (attaches_to: {})",
343 schema.label,
344 schema.attaches_to.join(", ")
345 ),
346 });
347 }
348
349 let params_obj = ctx.params.as_object();
351 for (name, spec) in &schema.params {
352 let provided = params_obj.and_then(|m| m.get(name));
353 match (provided, spec.required) {
354 (None, true) => {
355 return Some(AnalysisDiagnostic {
356 range: range.clone(),
357 severity: DiagnosticSeverity::Error,
358 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::MissingParam),
359 message: format!(
360 "label `{}` is missing required param `{name}`",
361 schema.label
362 ),
363 });
364 }
365 (None, false) => continue,
366 (Some(value), _) => {
367 if let Some(diag) = check_param_type(name, value, spec, schema, range) {
375 return Some(diag);
376 }
377 }
378 }
379 }
380
381 let body_effectively_empty = body_is_empty(&ctx.body);
390 if !body_effectively_empty {
391 let kind_matches = match (&ctx.body, schema.body.kind) {
392 (AnnotationBody::Text(_), BodyKind::Text) => true,
393 (AnnotationBody::Lex { .. }, BodyKind::Lex) => true,
394 (_, BodyKind::None) => false,
397 _ => false,
400 };
401 if !kind_matches {
402 return Some(AnalysisDiagnostic {
403 range: range.clone(),
404 severity: DiagnosticSeverity::Error,
405 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BodyShapeMismatch),
406 message: format!(
407 "label `{}` body does not match declared body.kind: {:?}",
408 schema.label, schema.body.kind
409 ),
410 });
411 }
412 }
413 if matches!(schema.body.presence, BodyPresence::Required)
414 && body_effectively_empty
415 && !matches!(schema.body.kind, BodyKind::None)
416 {
417 return Some(AnalysisDiagnostic {
418 range: range.clone(),
419 severity: DiagnosticSeverity::Error,
420 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::BodyShapeMismatch),
421 message: format!(
422 "label `{}` declares body.presence: required but no body was provided",
423 schema.label
424 ),
425 });
426 }
427
428 None
429}
430
431fn body_is_empty(body: &AnnotationBody) -> bool {
435 match body {
436 AnnotationBody::None => true,
437 AnnotationBody::Text(s) => s.trim().is_empty(),
438 AnnotationBody::Lex { children } => {
439 children.is_empty() || children.iter().all(is_blank_wire_node)
440 }
441 }
442}
443
444fn is_blank_wire_node(node: &WireNode) -> bool {
445 match node {
446 WireNode::Paragraph { inlines, .. } => inlines.is_empty(),
447 WireNode::Blank { .. } => true,
448 _ => false,
449 }
450}
451
452fn check_param_type(
453 name: &str,
454 value: &serde_json::Value,
455 spec: &lex_extension::schema::ParamSpec,
456 schema: &Schema,
457 range: &CoreRange,
458) -> Option<AnalysisDiagnostic> {
459 use lex_extension::schema::ParamType;
460 let mismatch = |reason: String| AnalysisDiagnostic {
461 range: range.clone(),
462 severity: DiagnosticSeverity::Error,
463 kind: DiagnosticKind::SchemaValidation(SchemaValidationKind::ParamTypeMismatch),
464 message: format!(
465 "label `{}` param `{name}` type mismatch: {reason}",
466 schema.label
467 ),
468 };
469 match spec.ty {
470 ParamType::String => {
471 if !value.is_string() {
472 return Some(mismatch(format!("expected string, got {value}")));
473 }
474 }
475 ParamType::Bool => {
476 if !value.is_boolean()
478 && !matches!(
479 value.as_str().map(str::to_ascii_lowercase).as_deref(),
480 Some("true") | Some("false")
481 )
482 {
483 return Some(mismatch(format!("expected bool, got {value}")));
484 }
485 }
486 ParamType::Int => {
487 let ok = value.is_i64()
488 || value.is_u64()
489 || value
490 .as_str()
491 .map(|s| s.parse::<i64>().is_ok())
492 .unwrap_or(false);
493 if !ok {
494 return Some(mismatch(format!("expected int, got {value}")));
495 }
496 }
497 ParamType::Float => {
498 let ok = value.is_number()
499 || value
500 .as_str()
501 .map(|s| s.parse::<f64>().is_ok())
502 .unwrap_or(false);
503 if !ok {
504 return Some(mismatch(format!("expected float, got {value}")));
505 }
506 }
507 ParamType::Enum => {
508 let s = value.as_str().unwrap_or("").to_string();
509 let known = spec.values.iter().any(|v| v.name == s);
510 if !known {
511 return Some(mismatch(format!(
512 "value {value} is not in declared enum (allowed: {})",
513 spec.values
514 .iter()
515 .map(|v| v.name.as_str())
516 .collect::<Vec<_>>()
517 .join(", ")
518 )));
519 }
520 }
521 _ => {}
524 }
525 None
526}
527
528fn handler_diagnostic_to_analysis(
533 diag: lex_extension::Diagnostic,
534 namespace_label: String,
535 fallback_range: CoreRange,
536) -> AnalysisDiagnostic {
537 let range = wire_range_to_core(&diag.range, &fallback_range);
538 let severity = match diag.severity {
539 lex_extension::DiagnosticSeverity::Error => DiagnosticSeverity::Error,
540 lex_extension::DiagnosticSeverity::Warning => DiagnosticSeverity::Warning,
541 lex_extension::DiagnosticSeverity::Info => DiagnosticSeverity::Info,
542 lex_extension::DiagnosticSeverity::Hint => DiagnosticSeverity::Hint,
543 _ => DiagnosticSeverity::Info,
546 };
547 AnalysisDiagnostic {
548 range,
549 severity,
550 kind: DiagnosticKind::Handler {
551 namespace: namespace_label,
552 code: diag.code,
553 },
554 message: diag.message,
555 }
556}
557
558fn wire_range_to_core(wire: &WireRange, fallback: &CoreRange) -> CoreRange {
563 use lex_core::lex::ast::Position as CorePosition;
564 let zero = wire.start.0 == 0 && wire.start.1 == 0 && wire.end.0 == 0 && wire.end.1 == 0;
565 if zero {
566 return fallback.clone();
567 }
568 CoreRange::new(
569 0..0,
570 CorePosition::new(wire.start.0 as usize, wire.start.1 as usize),
571 CorePosition::new(wire.end.0 as usize, wire.end.1 as usize),
572 )
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use lex_core::lex::loader::DocumentLoader;
579
580 fn parse(src: &str) -> Document {
581 DocumentLoader::from_string(src)
582 .parse()
583 .expect("parse fixture")
584 }
585 use lex_extension::schema::{
586 BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, ParamSpec, ParamType, Schema,
587 };
588 use lex_extension::{HandlerError, LexHandler};
589 use std::collections::BTreeMap;
590
591 fn schema(label: &str, attaches_to: Vec<&str>, hooks: HookSet) -> Schema {
592 Schema {
593 schema_version: 1,
594 label: label.into(),
595 description: None,
596 params: BTreeMap::new(),
597 attaches_to: attaches_to.into_iter().map(String::from).collect(),
598 body: BodyShape {
599 kind: BodyKind::None,
600 presence: BodyPresence::Optional,
601 description: None,
602 },
603 verbatim_label: false,
604 capabilities: Capabilities::default(),
605 hooks,
606 handler: None,
607 }
608 }
609
610 struct EchoValidate;
611 impl LexHandler for EchoValidate {
612 fn on_validate(
613 &self,
614 ctx: &LabelCtx,
615 ) -> Result<Vec<lex_extension::Diagnostic>, HandlerError> {
616 Ok(vec![lex_extension::Diagnostic {
617 severity: lex_extension::DiagnosticSeverity::Warning,
618 message: format!("validate {}", ctx.label),
619 range: ctx.node.range,
620 code: Some("test.code".into()),
621 related: Vec::new(),
622 }])
623 }
624 }
625
626 #[test]
627 fn empty_registry_is_a_noop() {
628 let doc = parse(":: acme.task ::\n");
629 let registry = Registry::new();
630 let mut diags = Vec::new();
631 dispatch_labels(&doc, ®istry, &mut diags);
632 assert!(diags.is_empty());
633 }
634
635 #[test]
636 fn unknown_namespace_silently_passes_through() {
637 let doc = parse(":: foo.bar ::\n");
638 let registry = Registry::new();
639 let acme = schema(
640 "acme.task",
641 vec!["paragraph", "annotation"],
642 HookSet {
643 validate: true,
644 ..HookSet::default()
645 },
646 );
647 registry
648 .register_namespace("acme", vec![acme], Box::new(EchoValidate))
649 .unwrap();
650 let mut diags = Vec::new();
651 dispatch_labels(&doc, ®istry, &mut diags);
652 assert!(diags.is_empty());
654 }
655
656 #[test]
657 fn registered_label_dispatches_validate_and_collects_diagnostic() {
658 let doc = parse(":: acme.task ::\n");
659 let registry = Registry::new();
660 let s = schema(
661 "acme.task",
662 vec!["annotation", "document"],
663 HookSet {
664 validate: true,
665 ..HookSet::default()
666 },
667 );
668 registry
669 .register_namespace("acme", vec![s], Box::new(EchoValidate))
670 .unwrap();
671 let mut diags = Vec::new();
672 dispatch_labels(&doc, ®istry, &mut diags);
673 assert_eq!(diags.len(), 1);
674 assert!(diags[0].message.contains("acme.task"));
675 match &diags[0].kind {
676 DiagnosticKind::Handler { namespace, code } => {
677 assert_eq!(namespace, "acme");
678 assert_eq!(code.as_deref(), Some("test.code"));
679 }
680 other => panic!("expected Handler, got {other:?}"),
681 }
682 }
683
684 #[test]
685 fn missing_required_param_produces_schema_diagnostic_without_dispatching() {
686 let mut params = BTreeMap::new();
687 params.insert(
688 "src".into(),
689 ParamSpec {
690 ty: ParamType::String,
691 required: true,
692 default: None,
693 description: None,
694 pattern: None,
695 values: Vec::new(),
696 },
697 );
698 let s = Schema {
699 schema_version: 1,
700 label: "acme.thing".into(),
701 description: None,
702 params,
703 attaches_to: vec!["annotation".into(), "document".into()],
704 body: BodyShape {
705 kind: BodyKind::None,
706 presence: BodyPresence::Optional,
707 description: None,
708 },
709 verbatim_label: false,
710 capabilities: Capabilities::default(),
711 hooks: HookSet {
712 validate: true,
713 ..HookSet::default()
714 },
715 handler: None,
716 };
717 struct Boom;
720 impl LexHandler for Boom {
721 fn on_validate(
722 &self,
723 _ctx: &LabelCtx,
724 ) -> Result<Vec<lex_extension::Diagnostic>, HandlerError> {
725 panic!("handler must not be called; schema pre-validation failed");
726 }
727 }
728 let registry = Registry::new();
729 registry
730 .register_namespace("acme", vec![s], Box::new(Boom))
731 .unwrap();
732 let doc = parse(":: acme.thing ::\n");
733 let mut diags = Vec::new();
734 dispatch_labels(&doc, ®istry, &mut diags);
735 assert_eq!(diags.len(), 1);
736 match &diags[0].kind {
737 DiagnosticKind::SchemaValidation(SchemaValidationKind::MissingParam) => {}
738 other => panic!("expected MissingParam, got {other:?}"),
739 }
740 assert!(diags[0].message.contains("src"));
741 }
742
743 #[test]
744 fn bad_attachment_produces_schema_diagnostic() {
745 let s = schema(
748 "acme.def",
749 vec!["definition"],
750 HookSet {
751 validate: true,
752 ..HookSet::default()
753 },
754 );
755 let registry = Registry::new();
756 registry
757 .register_namespace("acme", vec![s], Box::new(EchoValidate))
758 .unwrap();
759 let doc = parse("Some paragraph.\n:: acme.def ::\n");
761 let mut diags = Vec::new();
762 dispatch_labels(&doc, ®istry, &mut diags);
763 assert!(
765 diags.iter().any(|d| matches!(
766 d.kind,
767 DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment)
768 )),
769 "expected at least one BadAttachment diag, got: {diags:?}"
770 );
771 }
772
773 #[test]
781 fn document_level_annotation_matches_document_attaches_to() {
782 let s = schema(
783 "acme.docmeta",
784 vec!["document"],
785 HookSet {
786 validate: true,
787 ..HookSet::default()
788 },
789 );
790 let registry = Registry::new();
791 registry
792 .register_namespace("acme", vec![s], Box::new(EchoValidate))
793 .unwrap();
794 let doc = parse(":: acme.docmeta ::\n");
796 let mut diags = Vec::new();
797 dispatch_labels(&doc, ®istry, &mut diags);
798 assert!(
800 !diags.iter().any(|d| matches!(
801 d.kind,
802 DiagnosticKind::SchemaValidation(SchemaValidationKind::BadAttachment)
803 )),
804 "document-level annotation should match attaches_to: [document], got: {diags:?}"
805 );
806 }
807}