Skip to main content

lex_core/lex/builtins/
mod.rs

1//! Built-in `LexHandler` implementations for the `lex.*` namespace.
2//!
3//! Built-ins flow through the same `lex_extension::LexHandler` trait and
4//! `lex_extension_host::Registry` dispatch fabric as third-party namespaces.
5//! Their only privilege is being compiled-in: at host startup, the CLI
6//! and LSP call this module's [`register_into`] helper to attach the
7//! bundled `lex.*` schemas and handlers.
8//!
9//! # What ships today
10//!
11//! | Label family            | Handler                | Status                                |
12//! |-------------------------|------------------------|---------------------------------------|
13//! | `lex.include`           | [`LexIncludeHandler`]  | Registrable; resolve pass runs        |
14//! |                         |                        | through the legacy inline path until  |
15//! |                         |                        | PR 3d (#533).                         |
16//! | `lex.metadata.*` (×8)   | [`LexBuiltinsHandler`] | Schemas registered (#570 Phase 1).    |
17//! |                         |                        | Legacy frontmatter promotion in       |
18//! |                         |                        | `lex-babel/src/ir/from_lex.rs` still  |
19//! |                         |                        | owns the IR work; on_format returns   |
20//! |                         |                        | None for this family in Phase 4b.     |
21//! | `lex.tabular.table`     | [`LexBuiltinsHandler`] | Schemas + on_resolve + on_format      |
22//! |                         |                        | implemented. lex-babel's              |
23//! |                         |                        | `from_lex_verbatim` and `to_lex_table`|
24//! |                         |                        | both route through this handler; the  |
25//! |                         |                        | legacy `VerbatimRegistry::TableHandler|
26//! |                         |                        | ` reformat path is retired (#594).    |
27//! | `lex.media.{image,…}`   | [`LexBuiltinsHandler`] | Same shape as `lex.tabular.table`.    |
28//! |                         |                        | The legacy media `VerbatimHandler`    |
29//! |                         |                        | impls in lex-babel are retired; only  |
30//! |                         |                        | the free `*_from_params` helpers      |
31//! |                         |                        | survive, called from the resolved-    |
32//! |                         |                        | verbatim decode path.                 |
33//!
34//! The single `lex` namespace is shared by every built-in label; the
35//! composite [`LexBuiltinsHandler`] routes each dispatch by
36//! [`LabelCtx::label`](lex_extension::wire::LabelCtx::label) to the right
37//! sub-handler.
38
39use std::sync::Arc;
40
41use lex_extension::{
42    handler::{HandlerError, LexHandler},
43    schema::{
44        BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, ParamSpec, ParamType, Schema,
45    },
46    wire::{AnnotationBody, FormatCtx, LabelCtx, LexAnnotationOut, Position, Range, WireNode},
47};
48use lex_extension_host::registry::{Registry, RegistryError};
49
50use crate::lex::includes::{Loader, ResolveConfig};
51
52pub mod include;
53pub mod media;
54pub mod metadata;
55pub mod notes;
56pub mod tabular;
57
58pub use include::LexIncludeHandler;
59
60/// The reserved namespace owned by the lex core. Its prefix is
61/// `lex.` (with the trailing dot), so registered labels look like
62/// `lex.include`, `lex.metadata.title`, `lex.tabular.table`, etc.
63pub const NAMESPACE: &str = "lex";
64
65/// Every canonical `lex.*` label the core ships. Aggregated from
66/// `include`, `metadata::METADATA_LABELS`, `tabular::LEX_TABULAR_TABLE`,
67/// and `media::{LEX_MEDIA_IMAGE, LEX_MEDIA_VIDEO, LEX_MEDIA_AUDIO}` so
68/// the parse-time `NormalizeLabels` stage in `assembling::stages` can
69/// resolve user-authored bare and prefix-stripped forms to the
70/// canonical registry without depending on a runtime registry handle.
71///
72/// Adding a new `lex.*` canonical requires adding it here too — the
73/// builtin-tests in each family enforce the corresponding ordering /
74/// presence checks. Order within the slice is informational only;
75/// lookups are unordered.
76pub const CANONICAL_LABELS: &[&str] = &[
77    "lex.include",
78    "lex.notes",
79    // metadata family
80    "lex.metadata.title",
81    "lex.metadata.author",
82    "lex.metadata.date",
83    "lex.metadata.tags",
84    "lex.metadata.category",
85    "lex.metadata.template",
86    "lex.metadata.publishing-date",
87    "lex.metadata.front-matter",
88    // tabular family
89    "lex.tabular.table",
90    // media family
91    "lex.media.image",
92    "lex.media.video",
93    "lex.media.audio",
94];
95
96/// Return `true` if `label` names a canonical built-in. Lookup is a
97/// linear scan of [`CANONICAL_LABELS`]; the slice is small (13 entries
98/// today) so this stays cheaper than a `HashSet` materialised at
99/// startup.
100pub fn is_canonical_label(label: &str) -> bool {
101    CANONICAL_LABELS.contains(&label)
102}
103
104/// Composite handler for the `lex.*` namespace.
105///
106/// `Registry::register_namespace` accepts one handler per namespace; the
107/// composite shape lets every `lex.*` built-in live under a single
108/// namespace registration while keeping per-label logic isolated.
109///
110/// Implementations across hooks:
111///
112/// - `on_resolve`: only [`LexIncludeHandler`] (#532) — the
113///   `lex.tabular.*` / `lex.media.*` / `lex.metadata.*` labels return
114///   the default `Ok(None)` because the legacy `from_lex` direction
115///   in `lex-babel` already hydrates the AST.
116/// - `on_format`: implemented for `lex.tabular.table` and
117///   `lex.media.{image,video,audio}` (#570 Phase 4b). `lex.include` is
118///   resolve-only and falls back; `lex.metadata.*` flows through the
119///   render hook + legacy frontmatter promotion.
120pub struct LexBuiltinsHandler {
121    include: LexIncludeHandler,
122}
123
124impl LexBuiltinsHandler {
125    pub fn new(loader: Arc<dyn Loader + Send + Sync>, config: ResolveConfig) -> Self {
126        Self {
127            include: LexIncludeHandler::new(loader, config),
128        }
129    }
130}
131
132impl LexHandler for LexBuiltinsHandler {
133    fn on_resolve(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
134        match ctx.label.as_str() {
135            "lex.include" => self.include.on_resolve(ctx),
136            "lex.tabular.table" => Ok(Some(resolve_tabular_table(ctx))),
137            "lex.media.image" => Ok(Some(resolve_media_image(ctx))),
138            "lex.media.video" => Ok(Some(resolve_media_video(ctx))),
139            "lex.media.audio" => Ok(Some(resolve_media_audio(ctx))),
140            _ => Ok(None),
141        }
142    }
143
144    /// Phase 4b of #570: emit the canonical Lex-source shape for the
145    /// built-in `lex.tabular.*` and `lex.media.*` labels.
146    ///
147    /// The verbatim labels round-trip as `:: lex.<family>.<kind> ::`
148    /// closers with the body text and parameters carried verbatim from
149    /// the supplied `WireNode::Verbatim`. Anything else (e.g.
150    /// `lex.include`, metadata labels, unrecognised labels) returns
151    /// `Ok(None)` so the host falls back to its built-in formatter.
152    fn on_format(&self, ctx: &FormatCtx) -> Result<Option<LexAnnotationOut>, HandlerError> {
153        match ctx.label.as_str() {
154            "lex.tabular.table" | "lex.media.image" | "lex.media.video" | "lex.media.audio" => {
155                verbatim_label_on_format(ctx)
156            }
157            // `lex.include` is the resolve-only direction — it splices
158            // content in via on_resolve; no IR→Lex emission path. The
159            // metadata labels (`lex.metadata.*`) and any unrecognised
160            // label fall back to the host's default formatter.
161            _ => Ok(None),
162        }
163    }
164}
165
166/// Shared `on_format` body for the four built-in verbatim labels
167/// (`lex.tabular.table`, `lex.media.{image,video,audio}`). Each one
168/// has the same wire shape: a `WireNode::Verbatim` whose `body_text`
169/// carries the verbatim source (pipe-table syntax, alt-text fallback,
170/// etc.).
171///
172/// The handler uses `ctx.params` directly — the wire spec
173/// (`lex-extension-wire.lex` §4.8) treats `FormatCtx::params` as the
174/// authoritative originating parameters, with `WireNode::Verbatim.params`
175/// being a wire-internal copy of the same data. A well-formed host
176/// fills both with the same `(key, value)` pairs; in the
177/// hypothetical case where they diverge, `ctx.params` wins.
178/// `on_resolve` for `lex.tabular.table`: parse the verbatim body
179/// (pipe-table source) into a typed [`WireNode::Table`]. The wire
180/// table carries per-column alignment in `column_aligns` — no
181/// fidelity loss on mixed-alignment tables.
182fn resolve_tabular_table(ctx: &LabelCtx) -> WireNode {
183    let body = match &ctx.body {
184        AnnotationBody::Text(s) => s.as_str(),
185        // No body or a `Lex`-shaped body — fall back to an empty
186        // table. (Verbatim labels can't legitimately have a `Lex`
187        // body; schema enforcement keeps this branch unreachable in
188        // well-formed inputs.)
189        _ => "",
190    };
191    let mut table = tabular::parse_pipe_table_to_wire(body);
192    // Stamp the host's range + origin onto the wire node — the
193    // parser builds with `(0,0)` defaults since it has no source
194    // context of its own.
195    if let WireNode::Table { range, origin, .. } = &mut table {
196        *range = ctx.node.range;
197        *origin = ctx.node.origin.clone();
198    }
199    table
200}
201
202/// `on_resolve` for `lex.media.image`: read `src`/`alt`/`title` from
203/// `ctx.params`. Falls back to the verbatim body for `alt` when the
204/// `alt=` param is missing — mirrors the lex-babel
205/// `image_from_params` contract.
206fn resolve_media_image(ctx: &LabelCtx) -> WireNode {
207    let src = string_param(ctx, "src").unwrap_or_default();
208    let alt = string_param(ctx, "alt").unwrap_or_else(|| match &ctx.body {
209        AnnotationBody::Text(s) => s.trim().to_string(),
210        _ => String::new(),
211    });
212    let title = string_param(ctx, "title");
213    WireNode::Image {
214        range: ctx.node.range,
215        origin: ctx.node.origin.clone(),
216        src,
217        alt,
218        title,
219    }
220}
221
222/// `on_resolve` for `lex.media.video`: read `src`/`title`/`poster`
223/// from `ctx.params`.
224fn resolve_media_video(ctx: &LabelCtx) -> WireNode {
225    WireNode::Video {
226        range: ctx.node.range,
227        origin: ctx.node.origin.clone(),
228        src: string_param(ctx, "src").unwrap_or_default(),
229        title: string_param(ctx, "title"),
230        poster: string_param(ctx, "poster"),
231    }
232}
233
234/// `on_resolve` for `lex.media.audio`: read `src`/`title` from
235/// `ctx.params`.
236fn resolve_media_audio(ctx: &LabelCtx) -> WireNode {
237    WireNode::Audio {
238        range: ctx.node.range,
239        origin: ctx.node.origin.clone(),
240        src: string_param(ctx, "src").unwrap_or_default(),
241        title: string_param(ctx, "title"),
242    }
243}
244
245/// Extract a string-typed parameter from `ctx.params`. Returns `None`
246/// when the key is missing or the value isn't a string. Used by the
247/// media resolve helpers.
248fn string_param(ctx: &LabelCtx, key: &str) -> Option<String> {
249    ctx.params
250        .get(key)
251        .and_then(|v| v.as_str())
252        .map(|s| s.to_string())
253}
254
255#[allow(dead_code)]
256fn default_resolve_range() -> Range {
257    Range {
258        start: Position(0, 0),
259        end: Position(0, 0),
260    }
261}
262
263fn verbatim_label_on_format(ctx: &FormatCtx) -> Result<Option<LexAnnotationOut>, HandlerError> {
264    let body = match &ctx.node {
265        WireNode::Verbatim { body_text, .. } => body_text.clone(),
266        // For non-verbatim wire nodes (e.g. a typed Table kind that a
267        // future wire-spec revision adds), the built-in handler
268        // doesn't have a serializer yet — return None so the host
269        // falls back. Phase 4b ships the verbatim path only.
270        _ => return Ok(None),
271    };
272    Ok(Some(LexAnnotationOut {
273        label: ctx.label.clone(),
274        params: ctx.params.clone(),
275        body,
276        verbatim_label: true,
277    }))
278}
279
280/// Register every built-in `lex.*` schema and handler into `registry`.
281///
282/// `loader` and `config` are forwarded verbatim to the composite
283/// handler; today they're consumed only by [`LexIncludeHandler`] but
284/// future built-ins may need filesystem access too (e.g. an asset
285/// resolver for `lex.media.*`).
286pub fn register_into(
287    registry: &Registry,
288    loader: Arc<dyn Loader + Send + Sync>,
289    config: ResolveConfig,
290) -> Result<(), RegistryError> {
291    let mut schemas = Vec::with_capacity(14);
292    schemas.push(lex_include_schema());
293    schemas.extend(notes::all_schemas());
294    schemas.extend(metadata::all_schemas());
295    schemas.extend(tabular::all_schemas());
296    schemas.extend(media::all_schemas());
297
298    let handler = Box::new(LexBuiltinsHandler::new(loader, config));
299    registry.register_namespace(NAMESPACE, schemas, handler)
300}
301
302/// Schema for the `lex.include` label. Inlined here because v1 has
303/// exactly one built-in label of its kind; once the YAML schema loader
304/// lands in PR 4 (#520), built-ins will share the same load path as
305/// third parties (a baked-in `lex.yaml` shipped with the crate).
306pub fn lex_include_schema() -> Schema {
307    let mut params = std::collections::BTreeMap::new();
308    params.insert(
309        "src".into(),
310        ParamSpec {
311            ty: ParamType::String,
312            required: true,
313            default: None,
314            description: Some(
315                "Path to the file to splice in. Resolves relative to the host file's directory; \
316                 leading `/` resolves under the resolution root."
317                    .into(),
318            ),
319            pattern: None,
320            values: Vec::new(),
321        },
322    );
323    Schema {
324        schema_version: 1,
325        label: "lex.include".into(),
326        description: Some(
327            "Splice the referenced Lex file's content into the parent container at this \
328             annotation's position."
329                .into(),
330        ),
331        params,
332        attaches_to: vec!["annotation".into()],
333        body: BodyShape {
334            kind: BodyKind::None,
335            presence: BodyPresence::Optional,
336            description: None,
337        },
338        verbatim_label: false,
339        // Built-ins read from the filesystem; once the trust matrix
340        // gates third-party fs access in δ (PR 12), built-ins remain
341        // trusted by linkage.
342        capabilities: Capabilities {
343            fs: true,
344            net: false,
345        },
346        hooks: HookSet {
347            resolve: true,
348            ..HookSet::default()
349        },
350        // Native built-ins skip the handler-spec field — the registry
351        // dispatches in-process via `Box<dyn LexHandler>`.
352        handler: None,
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use crate::lex::includes::MemoryLoader;
360    use lex_extension::wire::{AnnotationBody, LabelCtx, NodeRef, Position, Range};
361    use std::path::PathBuf;
362
363    fn make_ctx(label: &str, src: Option<&str>, host_origin: Option<&str>) -> LabelCtx {
364        LabelCtx {
365            label: label.into(),
366            params: match src {
367                Some(s) => serde_json::json!({ "src": s }),
368                None => serde_json::json!({}),
369            },
370            body: AnnotationBody::None,
371            node: NodeRef {
372                kind: "annotation".into(),
373                range: Range {
374                    start: Position(0, 0),
375                    end: Position(0, 0),
376                },
377                origin: host_origin.map(|s| s.to_string()),
378            },
379        }
380    }
381
382    fn fresh_registry() -> Registry {
383        let mut loader = MemoryLoader::new();
384        loader.insert(PathBuf::from("/root/inner.lex"), "Hello.\n");
385        let registry = Registry::new();
386        register_into(
387            &registry,
388            Arc::new(loader),
389            ResolveConfig::with_root(PathBuf::from("/root")),
390        )
391        .expect("registration ok");
392        registry
393    }
394
395    #[test]
396    fn canonical_labels_matches_registered_schemas() {
397        // CANONICAL_LABELS feeds the parse-time NormalizeLabels stage —
398        // it MUST contain exactly the same labels that register_into
399        // registers, in any order. If a new lex.* schema is added without
400        // updating CANONICAL_LABELS, NormalizeLabels will start rejecting
401        // valid documents authored using its canonical or stripped forms.
402        let mut registered: Vec<String> = Vec::new();
403        registered.push(lex_include_schema().label);
404        registered.extend(notes::all_schemas().into_iter().map(|s| s.label));
405        registered.extend(metadata::all_schemas().into_iter().map(|s| s.label));
406        registered.extend(tabular::all_schemas().into_iter().map(|s| s.label));
407        registered.extend(media::all_schemas().into_iter().map(|s| s.label));
408
409        let constant: Vec<String> = CANONICAL_LABELS.iter().map(|s| (*s).to_string()).collect();
410
411        let mut registered_sorted = registered.clone();
412        registered_sorted.sort();
413        let mut constant_sorted = constant.clone();
414        constant_sorted.sort();
415        assert_eq!(
416            registered_sorted, constant_sorted,
417            "CANONICAL_LABELS and registered schemas must match; \
418             registered={registered:?} constant={constant:?}"
419        );
420    }
421
422    #[test]
423    fn is_canonical_label_recognizes_every_constant() {
424        for label in CANONICAL_LABELS {
425            assert!(is_canonical_label(label), "{label} must be canonical");
426        }
427        assert!(!is_canonical_label(""));
428        assert!(!is_canonical_label("title"));
429        assert!(!is_canonical_label("metadata.title"));
430        assert!(!is_canonical_label("doc.table"));
431        assert!(!is_canonical_label("acme.task"));
432    }
433
434    #[test]
435    fn register_into_attaches_namespace_and_schema() {
436        let registry = fresh_registry();
437        assert_eq!(registry.namespace_count(), 1);
438        assert!(registry.is_namespace_healthy(NAMESPACE));
439        let schema = registry
440            .schema_for("lex.include")
441            .expect("schema indexed under fully-qualified label");
442        assert_eq!(schema.label, "lex.include");
443        assert!(schema.hooks.resolve, "resolve hook must be declared");
444        assert!(
445            schema.params.contains_key("src"),
446            "src parameter must be declared"
447        );
448    }
449
450    #[test]
451    fn dispatch_resolve_round_trip_via_registry() {
452        // End-to-end through the registry: register handler, dispatch
453        // a resolve via dispatch_resolve, get back Some(WireNode).
454        let registry = fresh_registry();
455        let ctx = make_ctx("lex.include", Some("inner.lex"), Some("/root/host.lex"));
456        let wire = registry
457            .dispatch_resolve(&ctx)
458            .expect("dispatch_resolve ok")
459            .expect("returned Some");
460        match wire {
461            lex_extension::wire::WireNode::Document { children, .. } => {
462                assert!(
463                    !children.is_empty(),
464                    "registry-routed resolve must surface the included content"
465                );
466            }
467            other => panic!("expected WireNode::Document, got {other:?}"),
468        }
469    }
470
471    #[test]
472    fn dispatch_resolve_load_error_surfaces_diagnostic() {
473        let registry = Registry::new();
474        register_into(
475            &registry,
476            Arc::new(MemoryLoader::new()),
477            ResolveConfig::with_root(PathBuf::from("/root")),
478        )
479        .expect("registration ok");
480
481        let ctx = make_ctx("lex.include", Some("missing.lex"), Some("/root/host.lex"));
482        let err = registry
483            .dispatch_resolve(&ctx)
484            .expect_err("registry must surface the load error");
485        assert_eq!(err.code.as_deref(), Some("handler.custom"));
486        assert!(
487            err.message.contains("not found"),
488            "diagnostic must mention the load failure"
489        );
490    }
491
492    #[test]
493    fn duplicate_register_into_call_is_rejected() {
494        let registry = Registry::new();
495        register_into(
496            &registry,
497            Arc::new(MemoryLoader::new()),
498            ResolveConfig::with_root(PathBuf::from("/root")),
499        )
500        .expect("first registration ok");
501        let second = register_into(
502            &registry,
503            Arc::new(MemoryLoader::new()),
504            ResolveConfig::with_root(PathBuf::from("/root")),
505        );
506        assert!(
507            matches!(
508                second,
509                Err(RegistryError::NamespaceAlreadyRegistered { .. })
510            ),
511            "second register_into must error: {second:?}"
512        );
513    }
514
515    #[test]
516    fn metadata_schemas_are_registered() {
517        let registry = fresh_registry();
518        for label in metadata::METADATA_LABELS {
519            let schema = registry
520                .schema_for(label)
521                .unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
522            assert_eq!(schema.label, *label);
523            assert_eq!(schema.attaches_to, vec!["document".to_string()]);
524            assert!(!schema.verbatim_label);
525        }
526    }
527
528    #[test]
529    fn tabular_table_schema_is_registered() {
530        let registry = fresh_registry();
531        let schema = registry
532            .schema_for(tabular::LEX_TABULAR_TABLE)
533            .expect("lex.tabular.table schema must be registered");
534        assert!(schema.verbatim_label);
535        assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
536    }
537
538    #[test]
539    fn media_schemas_are_registered() {
540        let registry = fresh_registry();
541        for label in [
542            media::LEX_MEDIA_IMAGE,
543            media::LEX_MEDIA_VIDEO,
544            media::LEX_MEDIA_AUDIO,
545        ] {
546            let schema = registry
547                .schema_for(label)
548                .unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
549            assert!(schema.verbatim_label);
550            assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
551            assert!(
552                schema
553                    .params
554                    .get("src")
555                    .map(|p| p.required)
556                    .unwrap_or(false),
557                "{label} must require src"
558            );
559        }
560    }
561
562    #[test]
563    fn dispatch_resolve_metadata_returns_none() {
564        // Metadata schemas don't declare `on_resolve` (they're
565        // attached to the document, consumed by analysis). Dispatch
566        // must short-circuit to None for each one.
567        let registry = fresh_registry();
568        for label in metadata::METADATA_LABELS {
569            let ctx = make_ctx(label, None, None);
570            let result = registry
571                .dispatch_resolve(&ctx)
572                .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"));
573            assert!(
574                result.is_none(),
575                "dispatch_resolve({label}) must return None; got Some(...)"
576            );
577        }
578    }
579
580    #[test]
581    fn dispatch_resolve_media_returns_typed_wire_kinds() {
582        // Phase 3 of #570: `lex.media.*` resolve to typed
583        // `WireNode::{Image, Video, Audio}` variants.
584        let registry = fresh_registry();
585        for (label, expect_kind) in [
586            (media::LEX_MEDIA_IMAGE, "image"),
587            (media::LEX_MEDIA_VIDEO, "video"),
588            (media::LEX_MEDIA_AUDIO, "audio"),
589        ] {
590            let ctx = make_ctx(label, Some("./asset.media"), None);
591            let result = registry
592                .dispatch_resolve(&ctx)
593                .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"))
594                .unwrap_or_else(|| panic!("dispatch_resolve({label}) must return Some"));
595            let actual = match result {
596                lex_extension::wire::WireNode::Image { .. } => "image",
597                lex_extension::wire::WireNode::Video { .. } => "video",
598                lex_extension::wire::WireNode::Audio { .. } => "audio",
599                other => panic!("dispatch_resolve({label}) produced unexpected variant {other:?}"),
600            };
601            assert_eq!(actual, expect_kind, "wire variant for {label}");
602        }
603    }
604
605    #[test]
606    fn dispatch_resolve_propagates_ctx_range_and_origin() {
607        // Resolve handlers must stamp `ctx.node.range` and
608        // `ctx.node.origin` onto the WireNode they return so
609        // downstream diagnostics can attribute back to source. Hard-
610        // coded `(0,0)` and `origin: None` would silently break LSP
611        // hover / go-to-def for handler-emitted nodes.
612        let registry = fresh_registry();
613        let stamped_range = Range {
614            start: Position(12, 4),
615            end: Position(14, 10),
616        };
617        let stamped_origin = Some("/host/doc.lex".to_string());
618        let cases: &[(&str, &str)] = &[
619            (
620                tabular::LEX_TABULAR_TABLE,
621                "| a | b |\n|---|---|\n| 1 | 2 |",
622            ),
623            (media::LEX_MEDIA_IMAGE, ""),
624            (media::LEX_MEDIA_VIDEO, ""),
625            (media::LEX_MEDIA_AUDIO, ""),
626        ];
627        for (label, body) in cases {
628            let ctx = LabelCtx {
629                label: (*label).into(),
630                params: serde_json::json!({ "src": "x" }),
631                body: AnnotationBody::Text((*body).into()),
632                node: NodeRef {
633                    kind: "verbatim".into(),
634                    range: stamped_range,
635                    origin: stamped_origin.clone(),
636                },
637            };
638            let result = registry
639                .dispatch_resolve(&ctx)
640                .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"))
641                .unwrap_or_else(|| panic!("dispatch_resolve({label}) must return Some"));
642            let (got_range, got_origin) = match result {
643                lex_extension::wire::WireNode::Table { range, origin, .. }
644                | lex_extension::wire::WireNode::Image { range, origin, .. }
645                | lex_extension::wire::WireNode::Video { range, origin, .. }
646                | lex_extension::wire::WireNode::Audio { range, origin, .. } => (range, origin),
647                other => panic!("dispatch_resolve({label}) produced unexpected variant {other:?}"),
648            };
649            assert_eq!(
650                got_range, stamped_range,
651                "range must propagate from LabelCtx to WireNode for {label}"
652            );
653            assert_eq!(
654                got_origin, stamped_origin,
655                "origin must propagate from LabelCtx to WireNode for {label}"
656            );
657        }
658    }
659
660    #[test]
661    fn namespace_count_is_one_namespace_with_thirteen_labels() {
662        let registry = fresh_registry();
663        assert_eq!(
664            registry.namespace_count(),
665            1,
666            "all built-ins share the single `lex` namespace"
667        );
668        // 1 include + 8 metadata + 1 tabular + 3 media = 13.
669        let expected_labels = [
670            "lex.include",
671            "lex.metadata.title",
672            "lex.metadata.author",
673            "lex.metadata.date",
674            "lex.metadata.tags",
675            "lex.metadata.category",
676            "lex.metadata.template",
677            "lex.metadata.publishing-date",
678            "lex.metadata.front-matter",
679            "lex.tabular.table",
680            "lex.media.image",
681            "lex.media.video",
682            "lex.media.audio",
683        ];
684        for label in expected_labels {
685            assert!(
686                registry.schema_for(label).is_some(),
687                "expected label {label} to be registered"
688            );
689        }
690    }
691
692    /// Build a `FormatCtx` whose `node` is a `WireNode::Verbatim`
693    /// carrying the supplied body text and params. The four built-in
694    /// verbatim labels share this shape; this helper keeps each test
695    /// to a single line of meaningful setup.
696    fn format_ctx_verbatim(
697        label: &str,
698        params: Vec<(&str, &str)>,
699        body_text: &str,
700    ) -> lex_extension::wire::FormatCtx {
701        use lex_extension::wire::{FormatCtx, WireNode};
702        let owned_params: Vec<(String, String)> = params
703            .into_iter()
704            .map(|(k, v)| (k.to_string(), v.to_string()))
705            .collect();
706        FormatCtx {
707            label: label.into(),
708            params: owned_params.clone(),
709            node: WireNode::Verbatim {
710                range: Range {
711                    start: Position(0, 0),
712                    end: Position(0, 0),
713                },
714                origin: None,
715                label: label.into(),
716                params: serde_json::Value::Object(
717                    owned_params
718                        .iter()
719                        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
720                        .collect(),
721                ),
722                body_text: body_text.into(),
723                subject: String::new(),
724                mode: "inflow".into(),
725            },
726            format_options: None,
727        }
728    }
729
730    #[test]
731    fn dispatch_format_for_lex_tabular_table_returns_verbatim_annotation() {
732        // Phase 4b of #570: the built-in `lex.tabular.table` handler
733        // takes a WireNode::Verbatim whose body_text carries the
734        // pipe-table source and emits a LexAnnotationOut that the
735        // caller (`to_lex` etc.) can splice as `:: lex.tabular.table ::`.
736        let registry = fresh_registry();
737        let body = "| a | b |\n|---|---|\n| 1 | 2 |";
738        let ctx = format_ctx_verbatim("lex.tabular.table", vec![("header", "1")], body);
739        let out = registry
740            .dispatch_format(&ctx)
741            .expect("dispatch_format ok")
742            .expect("handler returned Some");
743        assert_eq!(out.label, "lex.tabular.table");
744        assert_eq!(out.params, vec![("header".into(), "1".into())]);
745        assert_eq!(out.body, body);
746        assert!(out.verbatim_label);
747    }
748
749    #[test]
750    fn dispatch_format_for_lex_media_image_returns_verbatim_annotation() {
751        let registry = fresh_registry();
752        let ctx = format_ctx_verbatim(
753            "lex.media.image",
754            vec![("src", "chart.png"), ("alt", "Q4 chart")],
755            "",
756        );
757        let out = registry
758            .dispatch_format(&ctx)
759            .expect("dispatch_format ok")
760            .expect("handler returned Some");
761        assert_eq!(out.label, "lex.media.image");
762        let src = out
763            .params
764            .iter()
765            .find(|(k, _)| k == "src")
766            .map(|(_, v)| v.as_str());
767        assert_eq!(src, Some("chart.png"));
768        assert!(out.verbatim_label);
769    }
770
771    #[test]
772    fn dispatch_format_for_lex_media_video_and_audio_return_verbatim_annotation() {
773        let registry = fresh_registry();
774        for label in ["lex.media.video", "lex.media.audio"] {
775            let ctx = format_ctx_verbatim(label, vec![("src", "media.mp4")], "");
776            let out = registry
777                .dispatch_format(&ctx)
778                .expect("dispatch_format ok")
779                .unwrap_or_else(|| panic!("handler must return Some for {label}"));
780            assert_eq!(out.label, label);
781            assert!(out.verbatim_label);
782        }
783    }
784
785    #[test]
786    fn dispatch_format_for_lex_include_returns_none() {
787        // `lex.include` is the resolve-only direction; on_format is
788        // not implemented for it.
789        let registry = fresh_registry();
790        let ctx = format_ctx_verbatim("lex.include", vec![("src", "other.lex")], "");
791        let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
792        assert!(out.is_none(), "lex.include has no on_format path");
793    }
794
795    #[test]
796    fn dispatch_format_for_lex_metadata_returns_none() {
797        // Metadata labels fall back to the host's built-in formatter
798        // in Phase 4b — they're not verbatim and the wire shape isn't
799        // a Verbatim node, so the shared verbatim-label helper bails.
800        let registry = fresh_registry();
801        let ctx = format_ctx_verbatim("lex.metadata.title", vec![], "My Doc");
802        let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
803        assert!(out.is_none(), "metadata labels fall back to host default");
804    }
805
806    #[test]
807    fn dispatch_format_with_non_verbatim_node_returns_none() {
808        // Even for a built-in verbatim label, if the host passes a
809        // non-verbatim WireNode (e.g. a Paragraph), the handler bails
810        // rather than emit a malformed annotation.
811        use lex_extension::wire::{FormatCtx, WireNode};
812        let registry = fresh_registry();
813        let ctx = FormatCtx {
814            label: "lex.tabular.table".into(),
815            params: vec![],
816            node: WireNode::Paragraph {
817                range: Range {
818                    start: Position(0, 0),
819                    end: Position(0, 0),
820                },
821                origin: None,
822                inlines: vec![],
823            },
824            format_options: None,
825        };
826        let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
827        assert!(
828            out.is_none(),
829            "non-verbatim wire node must fall back to host default"
830        );
831    }
832}