use std::sync::Arc;
use lex_extension::{
handler::{HandlerError, LexHandler},
schema::{
BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, ParamSpec, ParamType, Schema,
},
wire::{
AnnotationBody, Format, FormatCtx, LabelCtx, LexAnnotationOut, Position, Range, RenderOut,
WireNode,
},
};
use lex_extension_host::registry::{Registry, RegistryError};
use crate::lex::includes::{Loader, ResolveConfig};
pub mod doc;
pub mod include;
pub mod media;
pub mod metadata;
pub mod notes;
pub mod tabular;
pub use include::LexIncludeHandler;
pub const NAMESPACE: &str = "lex";
pub const CANONICAL_LABELS: &[&str] = &[
"lex.include",
"lex.notes",
"lex.metadata.title",
"lex.metadata.author",
"lex.metadata.date",
"lex.metadata.tags",
"lex.metadata.category",
"lex.metadata.template",
"lex.metadata.publishing-date",
"lex.metadata.front-matter",
"lex.tabular.table",
"lex.media.image",
"lex.media.video",
"lex.media.audio",
"doc.title",
"doc.author",
"doc.date",
"doc.tags",
"doc.category",
"doc.template",
];
pub fn is_canonical_label(label: &str) -> bool {
CANONICAL_LABELS.contains(&label)
}
pub struct LexBuiltinsHandler {
include: LexIncludeHandler,
}
impl LexBuiltinsHandler {
pub fn new(loader: Arc<dyn Loader + Send + Sync>, config: ResolveConfig) -> Self {
Self {
include: LexIncludeHandler::new(loader, config),
}
}
}
impl LexHandler for LexBuiltinsHandler {
fn on_resolve(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
match ctx.label.as_str() {
"lex.include" => self.include.on_resolve(ctx),
_ => Ok(None),
}
}
fn on_ir_build(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
match ctx.label.as_str() {
"lex.tabular.table" => Ok(Some(resolve_tabular_table(ctx))),
"lex.media.image" => Ok(Some(resolve_media_image(ctx))),
"lex.media.video" => Ok(Some(resolve_media_video(ctx))),
"lex.media.audio" => Ok(Some(resolve_media_audio(ctx))),
_ => Ok(None),
}
}
fn on_format(&self, ctx: &FormatCtx) -> Result<Option<LexAnnotationOut>, HandlerError> {
match ctx.label.as_str() {
"lex.tabular.table" | "lex.media.image" | "lex.media.video" | "lex.media.audio" => {
verbatim_label_on_format(ctx)
}
_ => Ok(None),
}
}
}
pub struct DocBuiltinsHandler;
impl LexHandler for DocBuiltinsHandler {
fn on_render(&self, ctx: &LabelCtx, fmt: Format) -> Result<Option<RenderOut>, HandlerError> {
doc::render_doc_annotation(ctx, &fmt)
}
}
fn resolve_tabular_table(ctx: &LabelCtx) -> WireNode {
let body = match &ctx.body {
AnnotationBody::Text(s) => s.as_str(),
_ => "",
};
let mut table = tabular::parse_pipe_table_to_wire(body);
if let WireNode::Table { range, origin, .. } = &mut table {
*range = ctx.node.range;
*origin = ctx.node.origin.clone();
}
table
}
fn resolve_media_image(ctx: &LabelCtx) -> WireNode {
let src = string_param(ctx, "src").unwrap_or_default();
let alt = string_param(ctx, "alt").unwrap_or_else(|| match &ctx.body {
AnnotationBody::Text(s) => s.trim().to_string(),
_ => String::new(),
});
let title = string_param(ctx, "title");
WireNode::Image {
range: ctx.node.range,
origin: ctx.node.origin.clone(),
src,
alt,
title,
}
}
fn resolve_media_video(ctx: &LabelCtx) -> WireNode {
WireNode::Video {
range: ctx.node.range,
origin: ctx.node.origin.clone(),
src: string_param(ctx, "src").unwrap_or_default(),
title: string_param(ctx, "title"),
poster: string_param(ctx, "poster"),
}
}
fn resolve_media_audio(ctx: &LabelCtx) -> WireNode {
WireNode::Audio {
range: ctx.node.range,
origin: ctx.node.origin.clone(),
src: string_param(ctx, "src").unwrap_or_default(),
title: string_param(ctx, "title"),
}
}
fn string_param(ctx: &LabelCtx, key: &str) -> Option<String> {
ctx.params
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
#[allow(dead_code)]
fn default_resolve_range() -> Range {
Range {
start: Position(0, 0),
end: Position(0, 0),
}
}
fn verbatim_label_on_format(ctx: &FormatCtx) -> Result<Option<LexAnnotationOut>, HandlerError> {
let body = match &ctx.node {
WireNode::Verbatim { body_text, .. } => body_text.clone(),
_ => return Ok(None),
};
Ok(Some(LexAnnotationOut {
label: ctx.label.clone(),
params: ctx.params.clone(),
body,
verbatim_label: true,
}))
}
pub fn register_into(
registry: &Registry,
loader: Arc<dyn Loader + Send + Sync>,
config: ResolveConfig,
) -> Result<(), RegistryError> {
let mut lex_schemas = Vec::with_capacity(14);
lex_schemas.push(lex_include_schema());
lex_schemas.extend(notes::all_schemas());
lex_schemas.extend(metadata::all_schemas());
lex_schemas.extend(tabular::all_schemas());
lex_schemas.extend(media::all_schemas());
let lex_handler = Box::new(LexBuiltinsHandler::new(loader, config));
registry.register_namespace(NAMESPACE, lex_schemas, lex_handler)?;
let doc_handler = Box::new(DocBuiltinsHandler);
registry.register_namespace(DOC_NAMESPACE, doc::all_schemas(), doc_handler)
}
pub const DOC_NAMESPACE: &str = "doc";
pub fn lex_include_schema() -> Schema {
let mut params = std::collections::BTreeMap::new();
params.insert(
"src".into(),
ParamSpec {
ty: ParamType::String,
required: true,
default: None,
description: Some(
"Path to the file to splice in. Resolves relative to the host file's directory; \
leading `/` resolves under the resolution root."
.into(),
),
pattern: None,
values: Vec::new(),
},
);
Schema {
schema_version: 1,
label: "lex.include".into(),
description: Some(
"Splice the referenced Lex file's content into the parent container at this \
annotation's position."
.into(),
),
params,
attaches_to: vec!["annotation".into()],
body: BodyShape {
kind: BodyKind::None,
presence: BodyPresence::Optional,
description: None,
},
verbatim_label: false,
capabilities: Capabilities {
fs: true,
net: false,
},
hooks: HookSet {
resolve: true,
..HookSet::default()
},
handler: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lex::includes::MemoryLoader;
use lex_extension::wire::{AnnotationBody, LabelCtx, NodeRef, Position, Range};
use std::path::PathBuf;
fn make_ctx(label: &str, src: Option<&str>, host_origin: Option<&str>) -> LabelCtx {
LabelCtx {
label: label.into(),
params: match src {
Some(s) => serde_json::json!({ "src": s }),
None => serde_json::json!({}),
},
body: AnnotationBody::None,
node: NodeRef {
kind: "annotation".into(),
range: Range {
start: Position(0, 0),
end: Position(0, 0),
},
origin: host_origin.map(|s| s.to_string()),
},
}
}
fn fresh_registry() -> Registry {
let mut loader = MemoryLoader::new();
loader.insert(PathBuf::from("/root/inner.lex"), "Hello.\n");
let registry = Registry::new();
register_into(
®istry,
Arc::new(loader),
ResolveConfig::with_root(PathBuf::from("/root")),
)
.expect("registration ok");
registry
}
#[test]
fn canonical_labels_matches_registered_schemas() {
let mut registered: Vec<String> = Vec::new();
registered.push(lex_include_schema().label);
registered.extend(notes::all_schemas().into_iter().map(|s| s.label));
registered.extend(metadata::all_schemas().into_iter().map(|s| s.label));
registered.extend(tabular::all_schemas().into_iter().map(|s| s.label));
registered.extend(media::all_schemas().into_iter().map(|s| s.label));
registered.extend(doc::all_schemas().into_iter().map(|s| s.label));
let constant: Vec<String> = CANONICAL_LABELS.iter().map(|s| (*s).to_string()).collect();
let mut registered_sorted = registered.clone();
registered_sorted.sort();
let mut constant_sorted = constant.clone();
constant_sorted.sort();
assert_eq!(
registered_sorted, constant_sorted,
"CANONICAL_LABELS and registered schemas must match; \
registered={registered:?} constant={constant:?}"
);
}
#[test]
fn is_canonical_label_recognizes_every_constant() {
for label in CANONICAL_LABELS {
assert!(is_canonical_label(label), "{label} must be canonical");
}
assert!(!is_canonical_label(""));
assert!(!is_canonical_label("title"));
assert!(!is_canonical_label("metadata.title"));
assert!(!is_canonical_label("doc.table"));
assert!(!is_canonical_label("acme.task"));
}
#[test]
fn register_into_attaches_namespace_and_schema() {
let registry = fresh_registry();
assert_eq!(registry.namespace_count(), 2);
assert!(registry.is_namespace_healthy(NAMESPACE));
assert!(registry.is_namespace_healthy(DOC_NAMESPACE));
let schema = registry
.schema_for("lex.include")
.expect("schema indexed under fully-qualified label");
assert_eq!(schema.label, "lex.include");
assert!(schema.hooks.resolve, "resolve hook must be declared");
assert!(
schema.params.contains_key("src"),
"src parameter must be declared"
);
}
#[test]
fn dispatch_resolve_round_trip_via_registry() {
let registry = fresh_registry();
let ctx = make_ctx("lex.include", Some("inner.lex"), Some("/root/host.lex"));
let wire = registry
.dispatch_resolve(&ctx)
.expect("dispatch_resolve ok")
.expect("returned Some");
match wire {
lex_extension::wire::WireNode::Document { children, .. } => {
assert!(
!children.is_empty(),
"registry-routed resolve must surface the included content"
);
}
other => panic!("expected WireNode::Document, got {other:?}"),
}
}
#[test]
fn dispatch_resolve_load_error_surfaces_diagnostic() {
let registry = Registry::new();
register_into(
®istry,
Arc::new(MemoryLoader::new()),
ResolveConfig::with_root(PathBuf::from("/root")),
)
.expect("registration ok");
let ctx = make_ctx("lex.include", Some("missing.lex"), Some("/root/host.lex"));
let err = registry
.dispatch_resolve(&ctx)
.expect_err("registry must surface the load error");
assert_eq!(err.code.as_deref(), Some("handler.custom"));
assert!(
err.message.contains("not found"),
"diagnostic must mention the load failure"
);
}
#[test]
fn duplicate_register_into_call_is_rejected() {
let registry = Registry::new();
register_into(
®istry,
Arc::new(MemoryLoader::new()),
ResolveConfig::with_root(PathBuf::from("/root")),
)
.expect("first registration ok");
let second = register_into(
®istry,
Arc::new(MemoryLoader::new()),
ResolveConfig::with_root(PathBuf::from("/root")),
);
assert!(
matches!(
second,
Err(RegistryError::NamespaceAlreadyRegistered { .. })
),
"second register_into must error: {second:?}"
);
}
#[test]
fn metadata_schemas_are_registered() {
let registry = fresh_registry();
for label in metadata::METADATA_LABELS {
let schema = registry
.schema_for(label)
.unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
assert_eq!(schema.label, *label);
assert_eq!(schema.attaches_to, vec!["document".to_string()]);
assert!(!schema.verbatim_label);
}
}
#[test]
fn tabular_table_schema_is_registered() {
let registry = fresh_registry();
let schema = registry
.schema_for(tabular::LEX_TABULAR_TABLE)
.expect("lex.tabular.table schema must be registered");
assert!(schema.verbatim_label);
assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
}
#[test]
fn media_schemas_are_registered() {
let registry = fresh_registry();
for label in [
media::LEX_MEDIA_IMAGE,
media::LEX_MEDIA_VIDEO,
media::LEX_MEDIA_AUDIO,
] {
let schema = registry
.schema_for(label)
.unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
assert!(schema.verbatim_label);
assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
assert!(
schema
.params
.get("src")
.map(|p| p.required)
.unwrap_or(false),
"{label} must require src"
);
}
}
#[test]
fn dispatch_resolve_metadata_returns_none() {
let registry = fresh_registry();
for label in metadata::METADATA_LABELS {
let ctx = make_ctx(label, None, None);
let result = registry
.dispatch_resolve(&ctx)
.unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"));
assert!(
result.is_none(),
"dispatch_resolve({label}) must return None; got Some(...)"
);
}
}
#[test]
fn dispatch_ir_build_media_returns_typed_wire_kinds() {
let registry = fresh_registry();
for (label, expect_kind) in [
(media::LEX_MEDIA_IMAGE, "image"),
(media::LEX_MEDIA_VIDEO, "video"),
(media::LEX_MEDIA_AUDIO, "audio"),
] {
let ctx = make_ctx(label, Some("./asset.media"), None);
let result = registry
.dispatch_ir_build(&ctx)
.unwrap_or_else(|e| panic!("dispatch_ir_build({label}) errored: {e:?}"))
.unwrap_or_else(|| panic!("dispatch_ir_build({label}) must return Some"));
let actual = match result {
lex_extension::wire::WireNode::Image { .. } => "image",
lex_extension::wire::WireNode::Video { .. } => "video",
lex_extension::wire::WireNode::Audio { .. } => "audio",
other => {
panic!("dispatch_ir_build({label}) produced unexpected variant {other:?}")
}
};
assert_eq!(actual, expect_kind, "wire variant for {label}");
}
}
#[test]
fn dispatch_resolve_returns_none_for_migrated_labels() {
let registry = fresh_registry();
for label in [
tabular::LEX_TABULAR_TABLE,
media::LEX_MEDIA_IMAGE,
media::LEX_MEDIA_VIDEO,
media::LEX_MEDIA_AUDIO,
] {
let ctx = make_ctx(label, Some("./asset"), None);
let result = registry
.dispatch_resolve(&ctx)
.unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"));
assert!(
result.is_none(),
"{label} must NOT respond to dispatch_resolve post-#615; got Some(...)"
);
}
}
#[test]
fn dispatch_ir_build_propagates_ctx_range_and_origin() {
let registry = fresh_registry();
let stamped_range = Range {
start: Position(12, 4),
end: Position(14, 10),
};
let stamped_origin = Some("/host/doc.lex".to_string());
let cases: &[(&str, &str)] = &[
(
tabular::LEX_TABULAR_TABLE,
"| a | b |\n|---|---|\n| 1 | 2 |",
),
(media::LEX_MEDIA_IMAGE, ""),
(media::LEX_MEDIA_VIDEO, ""),
(media::LEX_MEDIA_AUDIO, ""),
];
for (label, body) in cases {
let ctx = LabelCtx {
label: (*label).into(),
params: serde_json::json!({ "src": "x" }),
body: AnnotationBody::Text((*body).into()),
node: NodeRef {
kind: "verbatim".into(),
range: stamped_range,
origin: stamped_origin.clone(),
},
};
let result = registry
.dispatch_ir_build(&ctx)
.unwrap_or_else(|e| panic!("dispatch_ir_build({label}) errored: {e:?}"))
.unwrap_or_else(|| panic!("dispatch_ir_build({label}) must return Some"));
let (got_range, got_origin) = match result {
lex_extension::wire::WireNode::Table { range, origin, .. }
| lex_extension::wire::WireNode::Image { range, origin, .. }
| lex_extension::wire::WireNode::Video { range, origin, .. }
| lex_extension::wire::WireNode::Audio { range, origin, .. } => (range, origin),
other => {
panic!("dispatch_ir_build({label}) produced unexpected variant {other:?}")
}
};
assert_eq!(
got_range, stamped_range,
"range must propagate from LabelCtx to WireNode for {label}"
);
assert_eq!(
got_origin, stamped_origin,
"origin must propagate from LabelCtx to WireNode for {label}"
);
}
}
#[test]
fn namespace_count_is_two_namespaces_with_twenty_labels() {
let registry = fresh_registry();
assert_eq!(
registry.namespace_count(),
2,
"post-#615: built-ins occupy two namespaces — `lex` and `doc`"
);
let expected_labels = [
"lex.include",
"lex.notes",
"lex.metadata.title",
"lex.metadata.author",
"lex.metadata.date",
"lex.metadata.tags",
"lex.metadata.category",
"lex.metadata.template",
"lex.metadata.publishing-date",
"lex.metadata.front-matter",
"lex.tabular.table",
"lex.media.image",
"lex.media.video",
"lex.media.audio",
"doc.title",
"doc.author",
"doc.date",
"doc.tags",
"doc.category",
"doc.template",
];
for label in expected_labels {
assert!(
registry.schema_for(label).is_some(),
"expected label {label} to be registered"
);
}
}
fn format_ctx_verbatim(
label: &str,
params: Vec<(&str, &str)>,
body_text: &str,
) -> lex_extension::wire::FormatCtx {
use lex_extension::wire::{FormatCtx, WireNode};
let owned_params: Vec<(String, String)> = params
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
FormatCtx {
label: label.into(),
params: owned_params.clone(),
node: WireNode::Verbatim {
range: Range {
start: Position(0, 0),
end: Position(0, 0),
},
origin: None,
label: label.into(),
params: serde_json::Value::Object(
owned_params
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect(),
),
body_text: body_text.into(),
subject: String::new(),
mode: "inflow".into(),
},
format_options: None,
}
}
#[test]
fn dispatch_format_for_lex_tabular_table_returns_verbatim_annotation() {
let registry = fresh_registry();
let body = "| a | b |\n|---|---|\n| 1 | 2 |";
let ctx = format_ctx_verbatim("lex.tabular.table", vec![("header", "1")], body);
let out = registry
.dispatch_format(&ctx)
.expect("dispatch_format ok")
.expect("handler returned Some");
assert_eq!(out.label, "lex.tabular.table");
assert_eq!(out.params, vec![("header".into(), "1".into())]);
assert_eq!(out.body, body);
assert!(out.verbatim_label);
}
#[test]
fn dispatch_format_for_lex_media_image_returns_verbatim_annotation() {
let registry = fresh_registry();
let ctx = format_ctx_verbatim(
"lex.media.image",
vec![("src", "chart.png"), ("alt", "Q4 chart")],
"",
);
let out = registry
.dispatch_format(&ctx)
.expect("dispatch_format ok")
.expect("handler returned Some");
assert_eq!(out.label, "lex.media.image");
let src = out
.params
.iter()
.find(|(k, _)| k == "src")
.map(|(_, v)| v.as_str());
assert_eq!(src, Some("chart.png"));
assert!(out.verbatim_label);
}
#[test]
fn dispatch_format_for_lex_media_video_and_audio_return_verbatim_annotation() {
let registry = fresh_registry();
for label in ["lex.media.video", "lex.media.audio"] {
let ctx = format_ctx_verbatim(label, vec![("src", "media.mp4")], "");
let out = registry
.dispatch_format(&ctx)
.expect("dispatch_format ok")
.unwrap_or_else(|| panic!("handler must return Some for {label}"));
assert_eq!(out.label, label);
assert!(out.verbatim_label);
}
}
#[test]
fn dispatch_format_for_lex_include_returns_none() {
let registry = fresh_registry();
let ctx = format_ctx_verbatim("lex.include", vec![("src", "other.lex")], "");
let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
assert!(out.is_none(), "lex.include has no on_format path");
}
#[test]
fn dispatch_format_for_lex_metadata_returns_none() {
let registry = fresh_registry();
let ctx = format_ctx_verbatim("lex.metadata.title", vec![], "My Doc");
let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
assert!(out.is_none(), "metadata labels fall back to host default");
}
#[test]
fn dispatch_format_with_non_verbatim_node_returns_none() {
use lex_extension::wire::{FormatCtx, WireNode};
let registry = fresh_registry();
let ctx = FormatCtx {
label: "lex.tabular.table".into(),
params: vec![],
node: WireNode::Paragraph {
range: Range {
start: Position(0, 0),
end: Position(0, 0),
},
origin: None,
inlines: vec![],
},
format_options: None,
};
let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
assert!(
out.is_none(),
"non-verbatim wire node must fall back to host default"
);
}
}