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    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::lex::includes::MemoryLoader;
447    use lex_extension::wire::{AnnotationBody, LabelCtx, NodeRef, Position, Range};
448    use std::path::PathBuf;
449
450    fn make_ctx(label: &str, src: Option<&str>, host_origin: Option<&str>) -> LabelCtx {
451        LabelCtx {
452            label: label.into(),
453            params: match src {
454                Some(s) => serde_json::json!({ "src": s }),
455                None => serde_json::json!({}),
456            },
457            body: AnnotationBody::None,
458            node: NodeRef {
459                kind: "annotation".into(),
460                range: Range {
461                    start: Position(0, 0),
462                    end: Position(0, 0),
463                },
464                origin: host_origin.map(|s| s.to_string()),
465            },
466        }
467    }
468
469    fn fresh_registry() -> Registry {
470        let mut loader = MemoryLoader::new();
471        loader.insert(PathBuf::from("/root/inner.lex"), "Hello.\n");
472        let registry = Registry::new();
473        register_into(
474            &registry,
475            Arc::new(loader),
476            ResolveConfig::with_root(PathBuf::from("/root")),
477        )
478        .expect("registration ok");
479        registry
480    }
481
482    #[test]
483    fn canonical_labels_matches_registered_schemas() {
484        // CANONICAL_LABELS feeds the parse-time NormalizeLabels stage —
485        // it MUST contain exactly the same labels that register_into
486        // registers, in any order. If a new lex.* schema is added without
487        // updating CANONICAL_LABELS, NormalizeLabels will start rejecting
488        // valid documents authored using its canonical or stripped forms.
489        let mut registered: Vec<String> = Vec::new();
490        registered.push(lex_include_schema().label);
491        registered.extend(notes::all_schemas().into_iter().map(|s| s.label));
492        registered.extend(metadata::all_schemas().into_iter().map(|s| s.label));
493        registered.extend(tabular::all_schemas().into_iter().map(|s| s.label));
494        registered.extend(media::all_schemas().into_iter().map(|s| s.label));
495        registered.extend(doc::all_schemas().into_iter().map(|s| s.label));
496
497        let constant: Vec<String> = CANONICAL_LABELS.iter().map(|s| (*s).to_string()).collect();
498
499        let mut registered_sorted = registered.clone();
500        registered_sorted.sort();
501        let mut constant_sorted = constant.clone();
502        constant_sorted.sort();
503        assert_eq!(
504            registered_sorted, constant_sorted,
505            "CANONICAL_LABELS and registered schemas must match; \
506             registered={registered:?} constant={constant:?}"
507        );
508    }
509
510    #[test]
511    fn is_canonical_label_recognizes_every_constant() {
512        for label in CANONICAL_LABELS {
513            assert!(is_canonical_label(label), "{label} must be canonical");
514        }
515        assert!(!is_canonical_label(""));
516        assert!(!is_canonical_label("title"));
517        assert!(!is_canonical_label("metadata.title"));
518        assert!(!is_canonical_label("doc.table"));
519        assert!(!is_canonical_label("acme.task"));
520    }
521
522    #[test]
523    fn register_into_attaches_namespace_and_schema() {
524        let registry = fresh_registry();
525        // Two namespaces post-#615: the long-standing `lex` namespace
526        // for `lex.*` built-ins, plus the new `doc` namespace for the
527        // six document-level metadata canonicals.
528        assert_eq!(registry.namespace_count(), 2);
529        assert!(registry.is_namespace_healthy(NAMESPACE));
530        assert!(registry.is_namespace_healthy(DOC_NAMESPACE));
531        let schema = registry
532            .schema_for("lex.include")
533            .expect("schema indexed under fully-qualified label");
534        assert_eq!(schema.label, "lex.include");
535        assert!(schema.hooks.resolve, "resolve hook must be declared");
536        assert!(
537            schema.params.contains_key("src"),
538            "src parameter must be declared"
539        );
540    }
541
542    #[test]
543    fn dispatch_resolve_round_trip_via_registry() {
544        // End-to-end through the registry: register handler, dispatch
545        // a resolve via dispatch_resolve, get back Some(WireNode).
546        let registry = fresh_registry();
547        let ctx = make_ctx("lex.include", Some("inner.lex"), Some("/root/host.lex"));
548        let wire = registry
549            .dispatch_resolve(&ctx)
550            .expect("dispatch_resolve ok")
551            .expect("returned Some");
552        match wire {
553            lex_extension::wire::WireNode::Document { children, .. } => {
554                assert!(
555                    !children.is_empty(),
556                    "registry-routed resolve must surface the included content"
557                );
558            }
559            other => panic!("expected WireNode::Document, got {other:?}"),
560        }
561    }
562
563    #[test]
564    fn dispatch_resolve_load_error_surfaces_diagnostic() {
565        let registry = Registry::new();
566        register_into(
567            &registry,
568            Arc::new(MemoryLoader::new()),
569            ResolveConfig::with_root(PathBuf::from("/root")),
570        )
571        .expect("registration ok");
572
573        let ctx = make_ctx("lex.include", Some("missing.lex"), Some("/root/host.lex"));
574        let err = registry
575            .dispatch_resolve(&ctx)
576            .expect_err("registry must surface the load error");
577        assert_eq!(err.code.as_deref(), Some("handler.custom"));
578        assert!(
579            err.message.contains("not found"),
580            "diagnostic must mention the load failure"
581        );
582    }
583
584    #[test]
585    fn duplicate_register_into_call_is_rejected() {
586        let registry = Registry::new();
587        register_into(
588            &registry,
589            Arc::new(MemoryLoader::new()),
590            ResolveConfig::with_root(PathBuf::from("/root")),
591        )
592        .expect("first registration ok");
593        let second = register_into(
594            &registry,
595            Arc::new(MemoryLoader::new()),
596            ResolveConfig::with_root(PathBuf::from("/root")),
597        );
598        assert!(
599            matches!(
600                second,
601                Err(RegistryError::NamespaceAlreadyRegistered { .. })
602            ),
603            "second register_into must error: {second:?}"
604        );
605    }
606
607    #[test]
608    fn metadata_schemas_are_registered() {
609        let registry = fresh_registry();
610        for label in metadata::METADATA_LABELS {
611            let schema = registry
612                .schema_for(label)
613                .unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
614            assert_eq!(schema.label, *label);
615            assert_eq!(schema.attaches_to, vec!["document".to_string()]);
616            assert!(!schema.verbatim_label);
617        }
618    }
619
620    #[test]
621    fn tabular_table_schema_is_registered() {
622        let registry = fresh_registry();
623        let schema = registry
624            .schema_for(tabular::LEX_TABULAR_TABLE)
625            .expect("lex.tabular.table schema must be registered");
626        assert!(schema.verbatim_label);
627        assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
628    }
629
630    #[test]
631    fn media_schemas_are_registered() {
632        let registry = fresh_registry();
633        for label in [
634            media::LEX_MEDIA_IMAGE,
635            media::LEX_MEDIA_VIDEO,
636            media::LEX_MEDIA_AUDIO,
637        ] {
638            let schema = registry
639                .schema_for(label)
640                .unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
641            assert!(schema.verbatim_label);
642            assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
643            assert!(
644                schema
645                    .params
646                    .get("src")
647                    .map(|p| p.required)
648                    .unwrap_or(false),
649                "{label} must require src"
650            );
651        }
652    }
653
654    #[test]
655    fn dispatch_resolve_metadata_returns_none() {
656        // Metadata schemas don't declare `on_resolve` (they're
657        // attached to the document, consumed by analysis). Dispatch
658        // must short-circuit to None for each one.
659        let registry = fresh_registry();
660        for label in metadata::METADATA_LABELS {
661            let ctx = make_ctx(label, None, None);
662            let result = registry
663                .dispatch_resolve(&ctx)
664                .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"));
665            assert!(
666                result.is_none(),
667                "dispatch_resolve({label}) must return None; got Some(...)"
668            );
669        }
670    }
671
672    #[test]
673    fn dispatch_ir_build_media_returns_typed_wire_kinds() {
674        // #615: `lex.media.*` hydrate to typed `WireNode::{Image, Video,
675        // Audio}` variants on the IR-build lifecycle hook (was
676        // `dispatch_resolve` pre-#615; the unified registry surface
677        // routes verbatim hydration through `dispatch_ir_build`).
678        let registry = fresh_registry();
679        for (label, expect_kind) in [
680            (media::LEX_MEDIA_IMAGE, "image"),
681            (media::LEX_MEDIA_VIDEO, "video"),
682            (media::LEX_MEDIA_AUDIO, "audio"),
683        ] {
684            let ctx = make_ctx(label, Some("./asset.media"), None);
685            let result = registry
686                .dispatch_ir_build(&ctx)
687                .unwrap_or_else(|e| panic!("dispatch_ir_build({label}) errored: {e:?}"))
688                .unwrap_or_else(|| panic!("dispatch_ir_build({label}) must return Some"));
689            let actual = match result {
690                lex_extension::wire::WireNode::Image { .. } => "image",
691                lex_extension::wire::WireNode::Video { .. } => "video",
692                lex_extension::wire::WireNode::Audio { .. } => "audio",
693                other => {
694                    panic!("dispatch_ir_build({label}) produced unexpected variant {other:?}")
695                }
696            };
697            assert_eq!(actual, expect_kind, "wire variant for {label}");
698        }
699    }
700
701    /// Post-#615: `dispatch_resolve` on the migrated media + tabular
702    /// labels is a no-op. The handler returns `Ok(None)` so anything
703    /// still calling the resolve path falls back to the host's generic
704    /// IR. Pinning this contract guards against accidental dual
705    /// implementation of the same logic on both hooks.
706    #[test]
707    fn dispatch_resolve_returns_none_for_migrated_labels() {
708        let registry = fresh_registry();
709        for label in [
710            tabular::LEX_TABULAR_TABLE,
711            media::LEX_MEDIA_IMAGE,
712            media::LEX_MEDIA_VIDEO,
713            media::LEX_MEDIA_AUDIO,
714        ] {
715            let ctx = make_ctx(label, Some("./asset"), None);
716            let result = registry
717                .dispatch_resolve(&ctx)
718                .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"));
719            assert!(
720                result.is_none(),
721                "{label} must NOT respond to dispatch_resolve post-#615; got Some(...)"
722            );
723        }
724    }
725
726    #[test]
727    fn dispatch_ir_build_propagates_ctx_range_and_origin() {
728        // IR-build handlers must stamp `ctx.node.range` and
729        // `ctx.node.origin` onto the WireNode they return so
730        // downstream diagnostics can attribute back to source. Hard-
731        // coded `(0,0)` and `origin: None` would silently break LSP
732        // hover / go-to-def for handler-emitted nodes.
733        //
734        // (Pre-#615 this test ran via `dispatch_resolve`; #615
735        // migrated tabular/media to `dispatch_ir_build`.)
736        let registry = fresh_registry();
737        let stamped_range = Range {
738            start: Position(12, 4),
739            end: Position(14, 10),
740        };
741        let stamped_origin = Some("/host/doc.lex".to_string());
742        let cases: &[(&str, &str)] = &[
743            (
744                tabular::LEX_TABULAR_TABLE,
745                "| a | b |\n|---|---|\n| 1 | 2 |",
746            ),
747            (media::LEX_MEDIA_IMAGE, ""),
748            (media::LEX_MEDIA_VIDEO, ""),
749            (media::LEX_MEDIA_AUDIO, ""),
750        ];
751        for (label, body) in cases {
752            let ctx = LabelCtx {
753                label: (*label).into(),
754                params: serde_json::json!({ "src": "x" }),
755                body: AnnotationBody::Text((*body).into()),
756                node: NodeRef {
757                    kind: "verbatim".into(),
758                    range: stamped_range,
759                    origin: stamped_origin.clone(),
760                },
761            };
762            let result = registry
763                .dispatch_ir_build(&ctx)
764                .unwrap_or_else(|e| panic!("dispatch_ir_build({label}) errored: {e:?}"))
765                .unwrap_or_else(|| panic!("dispatch_ir_build({label}) must return Some"));
766            let (got_range, got_origin) = match result {
767                lex_extension::wire::WireNode::Table { range, origin, .. }
768                | lex_extension::wire::WireNode::Image { range, origin, .. }
769                | lex_extension::wire::WireNode::Video { range, origin, .. }
770                | lex_extension::wire::WireNode::Audio { range, origin, .. } => (range, origin),
771                other => {
772                    panic!("dispatch_ir_build({label}) produced unexpected variant {other:?}")
773                }
774            };
775            assert_eq!(
776                got_range, stamped_range,
777                "range must propagate from LabelCtx to WireNode for {label}"
778            );
779            assert_eq!(
780                got_origin, stamped_origin,
781                "origin must propagate from LabelCtx to WireNode for {label}"
782            );
783        }
784    }
785
786    #[test]
787    fn namespace_count_is_two_namespaces_with_twenty_labels() {
788        let registry = fresh_registry();
789        assert_eq!(
790            registry.namespace_count(),
791            2,
792            "post-#615: built-ins occupy two namespaces — `lex` and `doc`"
793        );
794        // 1 include + 1 notes + 8 metadata + 1 tabular + 3 media + 6 doc = 20.
795        let expected_labels = [
796            "lex.include",
797            "lex.notes",
798            "lex.metadata.title",
799            "lex.metadata.author",
800            "lex.metadata.date",
801            "lex.metadata.tags",
802            "lex.metadata.category",
803            "lex.metadata.template",
804            "lex.metadata.publishing-date",
805            "lex.metadata.front-matter",
806            "lex.tabular.table",
807            "lex.media.image",
808            "lex.media.video",
809            "lex.media.audio",
810            "doc.title",
811            "doc.author",
812            "doc.date",
813            "doc.tags",
814            "doc.category",
815            "doc.template",
816        ];
817        for label in expected_labels {
818            assert!(
819                registry.schema_for(label).is_some(),
820                "expected label {label} to be registered"
821            );
822        }
823    }
824
825    /// Build a `FormatCtx` whose `node` is a `WireNode::Verbatim`
826    /// carrying the supplied body text and params. The four built-in
827    /// verbatim labels share this shape; this helper keeps each test
828    /// to a single line of meaningful setup.
829    fn format_ctx_verbatim(
830        label: &str,
831        params: Vec<(&str, &str)>,
832        body_text: &str,
833    ) -> lex_extension::wire::FormatCtx {
834        use lex_extension::wire::{FormatCtx, WireNode};
835        let owned_params: Vec<(String, String)> = params
836            .into_iter()
837            .map(|(k, v)| (k.to_string(), v.to_string()))
838            .collect();
839        FormatCtx {
840            label: label.into(),
841            params: owned_params.clone(),
842            node: WireNode::Verbatim {
843                range: Range {
844                    start: Position(0, 0),
845                    end: Position(0, 0),
846                },
847                origin: None,
848                label: label.into(),
849                params: serde_json::Value::Object(
850                    owned_params
851                        .iter()
852                        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
853                        .collect(),
854                ),
855                body_text: body_text.into(),
856                subject: String::new(),
857                mode: "inflow".into(),
858            },
859            format_options: None,
860        }
861    }
862
863    #[test]
864    fn dispatch_format_for_lex_tabular_table_returns_verbatim_annotation() {
865        // Phase 4b of #570: the built-in `lex.tabular.table` handler
866        // takes a WireNode::Verbatim whose body_text carries the
867        // pipe-table source and emits a LexAnnotationOut that the
868        // caller (`to_lex` etc.) can splice as `:: lex.tabular.table ::`.
869        let registry = fresh_registry();
870        let body = "| a | b |\n|---|---|\n| 1 | 2 |";
871        let ctx = format_ctx_verbatim("lex.tabular.table", vec![("header", "1")], body);
872        let out = registry
873            .dispatch_format(&ctx)
874            .expect("dispatch_format ok")
875            .expect("handler returned Some");
876        assert_eq!(out.label, "lex.tabular.table");
877        assert_eq!(out.params, vec![("header".into(), "1".into())]);
878        assert_eq!(out.body, body);
879        assert!(out.verbatim_label);
880    }
881
882    #[test]
883    fn dispatch_format_for_lex_media_image_returns_verbatim_annotation() {
884        let registry = fresh_registry();
885        let ctx = format_ctx_verbatim(
886            "lex.media.image",
887            vec![("src", "chart.png"), ("alt", "Q4 chart")],
888            "",
889        );
890        let out = registry
891            .dispatch_format(&ctx)
892            .expect("dispatch_format ok")
893            .expect("handler returned Some");
894        assert_eq!(out.label, "lex.media.image");
895        let src = out
896            .params
897            .iter()
898            .find(|(k, _)| k == "src")
899            .map(|(_, v)| v.as_str());
900        assert_eq!(src, Some("chart.png"));
901        assert!(out.verbatim_label);
902    }
903
904    #[test]
905    fn dispatch_format_for_lex_media_video_and_audio_return_verbatim_annotation() {
906        let registry = fresh_registry();
907        for label in ["lex.media.video", "lex.media.audio"] {
908            let ctx = format_ctx_verbatim(label, vec![("src", "media.mp4")], "");
909            let out = registry
910                .dispatch_format(&ctx)
911                .expect("dispatch_format ok")
912                .unwrap_or_else(|| panic!("handler must return Some for {label}"));
913            assert_eq!(out.label, label);
914            assert!(out.verbatim_label);
915        }
916    }
917
918    #[test]
919    fn dispatch_format_for_lex_include_returns_none() {
920        // `lex.include` is the resolve-only direction; on_format is
921        // not implemented for it.
922        let registry = fresh_registry();
923        let ctx = format_ctx_verbatim("lex.include", vec![("src", "other.lex")], "");
924        let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
925        assert!(out.is_none(), "lex.include has no on_format path");
926    }
927
928    #[test]
929    fn dispatch_format_for_lex_metadata_returns_none() {
930        // Metadata labels fall back to the host's built-in formatter
931        // in Phase 4b — they're not verbatim and the wire shape isn't
932        // a Verbatim node, so the shared verbatim-label helper bails.
933        let registry = fresh_registry();
934        let ctx = format_ctx_verbatim("lex.metadata.title", vec![], "My Doc");
935        let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
936        assert!(out.is_none(), "metadata labels fall back to host default");
937    }
938
939    #[test]
940    fn dispatch_format_with_non_verbatim_node_returns_none() {
941        // Even for a built-in verbatim label, if the host passes a
942        // non-verbatim WireNode (e.g. a Paragraph), the handler bails
943        // rather than emit a malformed annotation.
944        use lex_extension::wire::{FormatCtx, WireNode};
945        let registry = fresh_registry();
946        let ctx = FormatCtx {
947            label: "lex.tabular.table".into(),
948            params: vec![],
949            node: WireNode::Paragraph {
950                range: Range {
951                    start: Position(0, 0),
952                    end: Position(0, 0),
953                },
954                origin: None,
955                inlines: vec![],
956            },
957            format_options: None,
958        };
959        let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
960        assert!(
961            out.is_none(),
962            "non-verbatim wire node must fall back to host default"
963        );
964    }
965}