Skip to main content

lex_analysis/
label_dispatch.rs

1//! Visit every labelled annotation / verbatim block in a document and
2//! dispatch the registered handler's `on_label` and `on_validate` hooks.
3//!
4//! The walker is the analysis-side glue between the parser and the
5//! extension registry: a label whose namespace is registered goes
6//! through schema pre-validation, then (if pre-validation passes) the
7//! handler is asked for diagnostics. Schema-level failures and
8//! handler-emitted diagnostics both surface as `AnalysisDiagnostic`s on
9//! the same channel as the existing footnote / table checks.
10//!
11//! Bounded extensibility: a label whose namespace is *not* registered
12//! is silently ignored — unknown namespaces are not document errors.
13
14use 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
26/// Walk `document` and append diagnostics for every labelled node whose
27/// namespace is registered in `registry`. No-op on an empty registry.
28pub 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    // Document-level annotations are parsed before the body, so they
37    // come first in the diagnostic stream too.
38    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            // List-level annotations attach to the list itself, NOT
81            // to its items.
82            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            // A standalone annotation node IS an annotation host
98            // (per wire spec §2.2 — `annotation` is itself a node
99            // kind). Schemas declaring `attaches_to: ["annotation"]`
100            // (e.g. the lex.* built-ins like `lex.include`) match
101            // here regardless of the parent container.
102            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        // Unknown label. If the namespace IS registered, this is an
128        // error worth surfacing — the namespace owner doesn't declare
129        // a label by this name. If the namespace is unregistered we
130        // pass through silently (bounded extensibility: unknown
131        // namespaces are never document errors).
132        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    // Build a wire-shaped LabelCtx via the lex-core codec, then run
148    // schema pre-validation against it before bothering the handler.
149    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            // Wire spec §2.1: NodeRef.kind is the host AST kind the
172            // label is attached to, NOT the literal "annotation" tag.
173            // Handlers use this to disambiguate context — e.g., a
174            // commenting label rendered differently when attached to
175            // a paragraph vs. a session.
176            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    // on_label is a notification (no return); fire-and-forget.
188    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    // Register-level root diagnostics (panics, namespace disabled).
208    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        // Mirror the annotation path: an unknown label inside a
224        // registered namespace is an error worth surfacing; an
225        // unregistered namespace passes through silently.
226        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        // Schema declares the label is annotation-only; using it as a
242        // verbatim closing is a schema-validation error.
243        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    // Run schema pre-validation before reaching the handler — same
276    // contract as the annotation path. Without this the handler
277    // received malformed contexts (missing required params, wrong
278    // attachment for the verbatim kind, etc.) and the host bypassed
279    // its own gating layer.
280    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
311/// Schema pre-validation: the six checks the analyser owns before
312/// dispatch reaches the handler. Per the wire spec / proposal §13.2,
313/// these are:
314///
315/// 1. namespace registered (caller already filtered)
316/// 2. label present in the namespace's schema (already true once
317///    `schema_for` returned `Some`)
318/// 3. required params present
319/// 4. param types match schema
320/// 5. attachment kind permitted by `attaches_to`
321/// 6. body shape matches `body.kind` and `body.presence`
322///
323/// Returns `Some(diag)` on the first failure; `None` if the invocation
324/// is well-formed and the handler may run.
325fn 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    // 5. attaches_to (cheaper than param walks; do first).
334    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    // 3 + 4. required params present, types match.
350    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                // The lex parser stores all param values as strings;
368                // for typed schemas we accept either the string form
369                // (e.g., "42" for an int) or the JSON-typed form (a
370                // bare 42). The pragmatic check: a string value
371                // should be parseable as the declared type. Bool +
372                // int + float get a parse-attempt; enum gets a
373                // membership check.
374                if let Some(diag) = check_param_type(name, value, spec, schema, range) {
375                    return Some(diag);
376                }
377            }
378        }
379    }
380
381    // 6. body shape.
382    //
383    // Empty bodies are treated as matching any declared `body.kind`.
384    // The wire codec may emit a `Lex { children: [<empty paragraph>] }`
385    // even for marker-form annotations (`:: foo ::`) that semantically
386    // have no body, so we normalise by ignoring empty content rather
387    // than punishing handlers for a parser quirk. The presence rule
388    // below still fires when the schema declares `body.presence: required`.
389    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            // `body.kind: none` with a non-empty body is the unambiguous
395            // mismatch — the handler said "no body" but one was passed.
396            (_, BodyKind::None) => false,
397            // Cross-shape (Text body declared as lex, or vice versa) is
398            // a genuine mismatch — the handler will see the wrong shape.
399            _ => 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
431/// True when `body` carries no semantically meaningful content. Used to
432/// avoid false body-shape mismatches when the wire codec emits an empty
433/// paragraph for marker-form annotations.
434fn 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            // Accept JSON bool or string "true"/"false".
477            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        // ParamType is #[non_exhaustive]; future variants conservatively
522        // accept until taught.
523        _ => {}
524    }
525    None
526}
527
528/// Map a handler-emitted `lex_extension::Diagnostic` into an
529/// `AnalysisDiagnostic`. Falls back to the labelled-node range when
530/// the handler didn't supply a meaningful range (defensive against a
531/// handler returning the default zero range).
532fn 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 is #[non_exhaustive]; future wire variants
544        // fall back to Info per the wire spec.
545        _ => 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
558/// Convert a wire-format range into a lex-core range. The wire range
559/// only carries 0-indexed line/col, not byte spans, so the byte span
560/// in the result is empty (downstream surfaces — LSP — don't consume
561/// the byte span anyway).
562fn 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, &registry, &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, &registry, &mut diags);
652        // foo.bar is not registered → silent.
653        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, &registry, &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        // Handler that should NOT be called — schema pre-validation
718        // catches the missing param before dispatch.
719        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, &registry, &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        // Schema only allows attachment to definitions; we attach
746        // it to a paragraph.
747        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        // Paragraph-attached annotation:
760        let doc = parse("Some paragraph.\n:: acme.def ::\n");
761        let mut diags = Vec::new();
762        dispatch_labels(&doc, &registry, &mut diags);
763        // We expect a bad-attachment diagnostic.
764        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    /// Regression for the kinds-misalignment bug: a schema that
774    /// attaches to `document` should match a top-level annotation,
775    /// not get a BadAttachment diagnostic. Prior to the
776    /// `HostNodeKind` unification the loader rejected `document`
777    /// schemas outright; with the fix the loader accepts them and
778    /// the walker emits `HostNodeKind::Document` for top-level
779    /// annotations, so the two sides agree.
780    #[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        // Top-level annotation, parsed as a document-level one.
795        let doc = parse(":: acme.docmeta ::\n");
796        let mut diags = Vec::new();
797        dispatch_labels(&doc, &registry, &mut diags);
798        // Expect handler-emitted diagnostic (no BadAttachment).
799        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}