Skip to main content

lex_core/lex/builtins/
mod.rs

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