1use std::sync::Arc;
40
41use lex_extension::{
42 handler::{HandlerError, LexHandler},
43 schema::{
44 BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, ParamSpec, ParamType, Schema,
45 },
46 wire::{AnnotationBody, FormatCtx, LabelCtx, LexAnnotationOut, Position, Range, WireNode},
47};
48use lex_extension_host::registry::{Registry, RegistryError};
49
50use crate::lex::includes::{Loader, ResolveConfig};
51
52pub mod include;
53pub mod media;
54pub mod metadata;
55pub mod notes;
56pub mod tabular;
57
58pub use include::LexIncludeHandler;
59
60pub const NAMESPACE: &str = "lex";
64
65pub const CANONICAL_LABELS: &[&str] = &[
77 "lex.include",
78 "lex.notes",
79 "lex.metadata.title",
81 "lex.metadata.author",
82 "lex.metadata.date",
83 "lex.metadata.tags",
84 "lex.metadata.category",
85 "lex.metadata.template",
86 "lex.metadata.publishing-date",
87 "lex.metadata.front-matter",
88 "lex.tabular.table",
90 "lex.media.image",
92 "lex.media.video",
93 "lex.media.audio",
94];
95
96pub fn is_canonical_label(label: &str) -> bool {
101 CANONICAL_LABELS.contains(&label)
102}
103
104pub struct LexBuiltinsHandler {
121 include: LexIncludeHandler,
122}
123
124impl LexBuiltinsHandler {
125 pub fn new(loader: Arc<dyn Loader + Send + Sync>, config: ResolveConfig) -> Self {
126 Self {
127 include: LexIncludeHandler::new(loader, config),
128 }
129 }
130}
131
132impl LexHandler for LexBuiltinsHandler {
133 fn on_resolve(&self, ctx: &LabelCtx) -> Result<Option<WireNode>, HandlerError> {
134 match ctx.label.as_str() {
135 "lex.include" => self.include.on_resolve(ctx),
136 "lex.tabular.table" => Ok(Some(resolve_tabular_table(ctx))),
137 "lex.media.image" => Ok(Some(resolve_media_image(ctx))),
138 "lex.media.video" => Ok(Some(resolve_media_video(ctx))),
139 "lex.media.audio" => Ok(Some(resolve_media_audio(ctx))),
140 _ => Ok(None),
141 }
142 }
143
144 fn on_format(&self, ctx: &FormatCtx) -> Result<Option<LexAnnotationOut>, HandlerError> {
153 match ctx.label.as_str() {
154 "lex.tabular.table" | "lex.media.image" | "lex.media.video" | "lex.media.audio" => {
155 verbatim_label_on_format(ctx)
156 }
157 _ => Ok(None),
162 }
163 }
164}
165
166fn resolve_tabular_table(ctx: &LabelCtx) -> WireNode {
183 let body = match &ctx.body {
184 AnnotationBody::Text(s) => s.as_str(),
185 _ => "",
190 };
191 let mut table = tabular::parse_pipe_table_to_wire(body);
192 if let WireNode::Table { range, origin, .. } = &mut table {
196 *range = ctx.node.range;
197 *origin = ctx.node.origin.clone();
198 }
199 table
200}
201
202fn resolve_media_image(ctx: &LabelCtx) -> WireNode {
207 let src = string_param(ctx, "src").unwrap_or_default();
208 let alt = string_param(ctx, "alt").unwrap_or_else(|| match &ctx.body {
209 AnnotationBody::Text(s) => s.trim().to_string(),
210 _ => String::new(),
211 });
212 let title = string_param(ctx, "title");
213 WireNode::Image {
214 range: ctx.node.range,
215 origin: ctx.node.origin.clone(),
216 src,
217 alt,
218 title,
219 }
220}
221
222fn resolve_media_video(ctx: &LabelCtx) -> WireNode {
225 WireNode::Video {
226 range: ctx.node.range,
227 origin: ctx.node.origin.clone(),
228 src: string_param(ctx, "src").unwrap_or_default(),
229 title: string_param(ctx, "title"),
230 poster: string_param(ctx, "poster"),
231 }
232}
233
234fn resolve_media_audio(ctx: &LabelCtx) -> WireNode {
237 WireNode::Audio {
238 range: ctx.node.range,
239 origin: ctx.node.origin.clone(),
240 src: string_param(ctx, "src").unwrap_or_default(),
241 title: string_param(ctx, "title"),
242 }
243}
244
245fn string_param(ctx: &LabelCtx, key: &str) -> Option<String> {
249 ctx.params
250 .get(key)
251 .and_then(|v| v.as_str())
252 .map(|s| s.to_string())
253}
254
255#[allow(dead_code)]
256fn default_resolve_range() -> Range {
257 Range {
258 start: Position(0, 0),
259 end: Position(0, 0),
260 }
261}
262
263fn verbatim_label_on_format(ctx: &FormatCtx) -> Result<Option<LexAnnotationOut>, HandlerError> {
264 let body = match &ctx.node {
265 WireNode::Verbatim { body_text, .. } => body_text.clone(),
266 _ => return Ok(None),
271 };
272 Ok(Some(LexAnnotationOut {
273 label: ctx.label.clone(),
274 params: ctx.params.clone(),
275 body,
276 verbatim_label: true,
277 }))
278}
279
280pub fn register_into(
287 registry: &Registry,
288 loader: Arc<dyn Loader + Send + Sync>,
289 config: ResolveConfig,
290) -> Result<(), RegistryError> {
291 let mut schemas = Vec::with_capacity(14);
292 schemas.push(lex_include_schema());
293 schemas.extend(notes::all_schemas());
294 schemas.extend(metadata::all_schemas());
295 schemas.extend(tabular::all_schemas());
296 schemas.extend(media::all_schemas());
297
298 let handler = Box::new(LexBuiltinsHandler::new(loader, config));
299 registry.register_namespace(NAMESPACE, schemas, handler)
300}
301
302pub fn lex_include_schema() -> Schema {
307 let mut params = std::collections::BTreeMap::new();
308 params.insert(
309 "src".into(),
310 ParamSpec {
311 ty: ParamType::String,
312 required: true,
313 default: None,
314 description: Some(
315 "Path to the file to splice in. Resolves relative to the host file's directory; \
316 leading `/` resolves under the resolution root."
317 .into(),
318 ),
319 pattern: None,
320 values: Vec::new(),
321 },
322 );
323 Schema {
324 schema_version: 1,
325 label: "lex.include".into(),
326 description: Some(
327 "Splice the referenced Lex file's content into the parent container at this \
328 annotation's position."
329 .into(),
330 ),
331 params,
332 attaches_to: vec!["annotation".into()],
333 body: BodyShape {
334 kind: BodyKind::None,
335 presence: BodyPresence::Optional,
336 description: None,
337 },
338 verbatim_label: false,
339 capabilities: Capabilities {
343 fs: true,
344 net: false,
345 },
346 hooks: HookSet {
347 resolve: true,
348 ..HookSet::default()
349 },
350 handler: None,
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::lex::includes::MemoryLoader;
360 use lex_extension::wire::{AnnotationBody, LabelCtx, NodeRef, Position, Range};
361 use std::path::PathBuf;
362
363 fn make_ctx(label: &str, src: Option<&str>, host_origin: Option<&str>) -> LabelCtx {
364 LabelCtx {
365 label: label.into(),
366 params: match src {
367 Some(s) => serde_json::json!({ "src": s }),
368 None => serde_json::json!({}),
369 },
370 body: AnnotationBody::None,
371 node: NodeRef {
372 kind: "annotation".into(),
373 range: Range {
374 start: Position(0, 0),
375 end: Position(0, 0),
376 },
377 origin: host_origin.map(|s| s.to_string()),
378 },
379 }
380 }
381
382 fn fresh_registry() -> Registry {
383 let mut loader = MemoryLoader::new();
384 loader.insert(PathBuf::from("/root/inner.lex"), "Hello.\n");
385 let registry = Registry::new();
386 register_into(
387 ®istry,
388 Arc::new(loader),
389 ResolveConfig::with_root(PathBuf::from("/root")),
390 )
391 .expect("registration ok");
392 registry
393 }
394
395 #[test]
396 fn canonical_labels_matches_registered_schemas() {
397 let mut registered: Vec<String> = Vec::new();
403 registered.push(lex_include_schema().label);
404 registered.extend(notes::all_schemas().into_iter().map(|s| s.label));
405 registered.extend(metadata::all_schemas().into_iter().map(|s| s.label));
406 registered.extend(tabular::all_schemas().into_iter().map(|s| s.label));
407 registered.extend(media::all_schemas().into_iter().map(|s| s.label));
408
409 let constant: Vec<String> = CANONICAL_LABELS.iter().map(|s| (*s).to_string()).collect();
410
411 let mut registered_sorted = registered.clone();
412 registered_sorted.sort();
413 let mut constant_sorted = constant.clone();
414 constant_sorted.sort();
415 assert_eq!(
416 registered_sorted, constant_sorted,
417 "CANONICAL_LABELS and registered schemas must match; \
418 registered={registered:?} constant={constant:?}"
419 );
420 }
421
422 #[test]
423 fn is_canonical_label_recognizes_every_constant() {
424 for label in CANONICAL_LABELS {
425 assert!(is_canonical_label(label), "{label} must be canonical");
426 }
427 assert!(!is_canonical_label(""));
428 assert!(!is_canonical_label("title"));
429 assert!(!is_canonical_label("metadata.title"));
430 assert!(!is_canonical_label("doc.table"));
431 assert!(!is_canonical_label("acme.task"));
432 }
433
434 #[test]
435 fn register_into_attaches_namespace_and_schema() {
436 let registry = fresh_registry();
437 assert_eq!(registry.namespace_count(), 1);
438 assert!(registry.is_namespace_healthy(NAMESPACE));
439 let schema = registry
440 .schema_for("lex.include")
441 .expect("schema indexed under fully-qualified label");
442 assert_eq!(schema.label, "lex.include");
443 assert!(schema.hooks.resolve, "resolve hook must be declared");
444 assert!(
445 schema.params.contains_key("src"),
446 "src parameter must be declared"
447 );
448 }
449
450 #[test]
451 fn dispatch_resolve_round_trip_via_registry() {
452 let registry = fresh_registry();
455 let ctx = make_ctx("lex.include", Some("inner.lex"), Some("/root/host.lex"));
456 let wire = registry
457 .dispatch_resolve(&ctx)
458 .expect("dispatch_resolve ok")
459 .expect("returned Some");
460 match wire {
461 lex_extension::wire::WireNode::Document { children, .. } => {
462 assert!(
463 !children.is_empty(),
464 "registry-routed resolve must surface the included content"
465 );
466 }
467 other => panic!("expected WireNode::Document, got {other:?}"),
468 }
469 }
470
471 #[test]
472 fn dispatch_resolve_load_error_surfaces_diagnostic() {
473 let registry = Registry::new();
474 register_into(
475 ®istry,
476 Arc::new(MemoryLoader::new()),
477 ResolveConfig::with_root(PathBuf::from("/root")),
478 )
479 .expect("registration ok");
480
481 let ctx = make_ctx("lex.include", Some("missing.lex"), Some("/root/host.lex"));
482 let err = registry
483 .dispatch_resolve(&ctx)
484 .expect_err("registry must surface the load error");
485 assert_eq!(err.code.as_deref(), Some("handler.custom"));
486 assert!(
487 err.message.contains("not found"),
488 "diagnostic must mention the load failure"
489 );
490 }
491
492 #[test]
493 fn duplicate_register_into_call_is_rejected() {
494 let registry = Registry::new();
495 register_into(
496 ®istry,
497 Arc::new(MemoryLoader::new()),
498 ResolveConfig::with_root(PathBuf::from("/root")),
499 )
500 .expect("first registration ok");
501 let second = register_into(
502 ®istry,
503 Arc::new(MemoryLoader::new()),
504 ResolveConfig::with_root(PathBuf::from("/root")),
505 );
506 assert!(
507 matches!(
508 second,
509 Err(RegistryError::NamespaceAlreadyRegistered { .. })
510 ),
511 "second register_into must error: {second:?}"
512 );
513 }
514
515 #[test]
516 fn metadata_schemas_are_registered() {
517 let registry = fresh_registry();
518 for label in metadata::METADATA_LABELS {
519 let schema = registry
520 .schema_for(label)
521 .unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
522 assert_eq!(schema.label, *label);
523 assert_eq!(schema.attaches_to, vec!["document".to_string()]);
524 assert!(!schema.verbatim_label);
525 }
526 }
527
528 #[test]
529 fn tabular_table_schema_is_registered() {
530 let registry = fresh_registry();
531 let schema = registry
532 .schema_for(tabular::LEX_TABULAR_TABLE)
533 .expect("lex.tabular.table schema must be registered");
534 assert!(schema.verbatim_label);
535 assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
536 }
537
538 #[test]
539 fn media_schemas_are_registered() {
540 let registry = fresh_registry();
541 for label in [
542 media::LEX_MEDIA_IMAGE,
543 media::LEX_MEDIA_VIDEO,
544 media::LEX_MEDIA_AUDIO,
545 ] {
546 let schema = registry
547 .schema_for(label)
548 .unwrap_or_else(|| panic!("schema_for({label}) must be Some"));
549 assert!(schema.verbatim_label);
550 assert_eq!(schema.attaches_to, vec!["verbatim".to_string()]);
551 assert!(
552 schema
553 .params
554 .get("src")
555 .map(|p| p.required)
556 .unwrap_or(false),
557 "{label} must require src"
558 );
559 }
560 }
561
562 #[test]
563 fn dispatch_resolve_metadata_returns_none() {
564 let registry = fresh_registry();
568 for label in metadata::METADATA_LABELS {
569 let ctx = make_ctx(label, None, None);
570 let result = registry
571 .dispatch_resolve(&ctx)
572 .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"));
573 assert!(
574 result.is_none(),
575 "dispatch_resolve({label}) must return None; got Some(...)"
576 );
577 }
578 }
579
580 #[test]
581 fn dispatch_resolve_media_returns_typed_wire_kinds() {
582 let registry = fresh_registry();
585 for (label, expect_kind) in [
586 (media::LEX_MEDIA_IMAGE, "image"),
587 (media::LEX_MEDIA_VIDEO, "video"),
588 (media::LEX_MEDIA_AUDIO, "audio"),
589 ] {
590 let ctx = make_ctx(label, Some("./asset.media"), None);
591 let result = registry
592 .dispatch_resolve(&ctx)
593 .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"))
594 .unwrap_or_else(|| panic!("dispatch_resolve({label}) must return Some"));
595 let actual = match result {
596 lex_extension::wire::WireNode::Image { .. } => "image",
597 lex_extension::wire::WireNode::Video { .. } => "video",
598 lex_extension::wire::WireNode::Audio { .. } => "audio",
599 other => panic!("dispatch_resolve({label}) produced unexpected variant {other:?}"),
600 };
601 assert_eq!(actual, expect_kind, "wire variant for {label}");
602 }
603 }
604
605 #[test]
606 fn dispatch_resolve_propagates_ctx_range_and_origin() {
607 let registry = fresh_registry();
613 let stamped_range = Range {
614 start: Position(12, 4),
615 end: Position(14, 10),
616 };
617 let stamped_origin = Some("/host/doc.lex".to_string());
618 let cases: &[(&str, &str)] = &[
619 (
620 tabular::LEX_TABULAR_TABLE,
621 "| a | b |\n|---|---|\n| 1 | 2 |",
622 ),
623 (media::LEX_MEDIA_IMAGE, ""),
624 (media::LEX_MEDIA_VIDEO, ""),
625 (media::LEX_MEDIA_AUDIO, ""),
626 ];
627 for (label, body) in cases {
628 let ctx = LabelCtx {
629 label: (*label).into(),
630 params: serde_json::json!({ "src": "x" }),
631 body: AnnotationBody::Text((*body).into()),
632 node: NodeRef {
633 kind: "verbatim".into(),
634 range: stamped_range,
635 origin: stamped_origin.clone(),
636 },
637 };
638 let result = registry
639 .dispatch_resolve(&ctx)
640 .unwrap_or_else(|e| panic!("dispatch_resolve({label}) errored: {e:?}"))
641 .unwrap_or_else(|| panic!("dispatch_resolve({label}) must return Some"));
642 let (got_range, got_origin) = match result {
643 lex_extension::wire::WireNode::Table { range, origin, .. }
644 | lex_extension::wire::WireNode::Image { range, origin, .. }
645 | lex_extension::wire::WireNode::Video { range, origin, .. }
646 | lex_extension::wire::WireNode::Audio { range, origin, .. } => (range, origin),
647 other => panic!("dispatch_resolve({label}) produced unexpected variant {other:?}"),
648 };
649 assert_eq!(
650 got_range, stamped_range,
651 "range must propagate from LabelCtx to WireNode for {label}"
652 );
653 assert_eq!(
654 got_origin, stamped_origin,
655 "origin must propagate from LabelCtx to WireNode for {label}"
656 );
657 }
658 }
659
660 #[test]
661 fn namespace_count_is_one_namespace_with_thirteen_labels() {
662 let registry = fresh_registry();
663 assert_eq!(
664 registry.namespace_count(),
665 1,
666 "all built-ins share the single `lex` namespace"
667 );
668 let expected_labels = [
670 "lex.include",
671 "lex.metadata.title",
672 "lex.metadata.author",
673 "lex.metadata.date",
674 "lex.metadata.tags",
675 "lex.metadata.category",
676 "lex.metadata.template",
677 "lex.metadata.publishing-date",
678 "lex.metadata.front-matter",
679 "lex.tabular.table",
680 "lex.media.image",
681 "lex.media.video",
682 "lex.media.audio",
683 ];
684 for label in expected_labels {
685 assert!(
686 registry.schema_for(label).is_some(),
687 "expected label {label} to be registered"
688 );
689 }
690 }
691
692 fn format_ctx_verbatim(
697 label: &str,
698 params: Vec<(&str, &str)>,
699 body_text: &str,
700 ) -> lex_extension::wire::FormatCtx {
701 use lex_extension::wire::{FormatCtx, WireNode};
702 let owned_params: Vec<(String, String)> = params
703 .into_iter()
704 .map(|(k, v)| (k.to_string(), v.to_string()))
705 .collect();
706 FormatCtx {
707 label: label.into(),
708 params: owned_params.clone(),
709 node: WireNode::Verbatim {
710 range: Range {
711 start: Position(0, 0),
712 end: Position(0, 0),
713 },
714 origin: None,
715 label: label.into(),
716 params: serde_json::Value::Object(
717 owned_params
718 .iter()
719 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
720 .collect(),
721 ),
722 body_text: body_text.into(),
723 subject: String::new(),
724 mode: "inflow".into(),
725 },
726 format_options: None,
727 }
728 }
729
730 #[test]
731 fn dispatch_format_for_lex_tabular_table_returns_verbatim_annotation() {
732 let registry = fresh_registry();
737 let body = "| a | b |\n|---|---|\n| 1 | 2 |";
738 let ctx = format_ctx_verbatim("lex.tabular.table", vec![("header", "1")], body);
739 let out = registry
740 .dispatch_format(&ctx)
741 .expect("dispatch_format ok")
742 .expect("handler returned Some");
743 assert_eq!(out.label, "lex.tabular.table");
744 assert_eq!(out.params, vec![("header".into(), "1".into())]);
745 assert_eq!(out.body, body);
746 assert!(out.verbatim_label);
747 }
748
749 #[test]
750 fn dispatch_format_for_lex_media_image_returns_verbatim_annotation() {
751 let registry = fresh_registry();
752 let ctx = format_ctx_verbatim(
753 "lex.media.image",
754 vec![("src", "chart.png"), ("alt", "Q4 chart")],
755 "",
756 );
757 let out = registry
758 .dispatch_format(&ctx)
759 .expect("dispatch_format ok")
760 .expect("handler returned Some");
761 assert_eq!(out.label, "lex.media.image");
762 let src = out
763 .params
764 .iter()
765 .find(|(k, _)| k == "src")
766 .map(|(_, v)| v.as_str());
767 assert_eq!(src, Some("chart.png"));
768 assert!(out.verbatim_label);
769 }
770
771 #[test]
772 fn dispatch_format_for_lex_media_video_and_audio_return_verbatim_annotation() {
773 let registry = fresh_registry();
774 for label in ["lex.media.video", "lex.media.audio"] {
775 let ctx = format_ctx_verbatim(label, vec![("src", "media.mp4")], "");
776 let out = registry
777 .dispatch_format(&ctx)
778 .expect("dispatch_format ok")
779 .unwrap_or_else(|| panic!("handler must return Some for {label}"));
780 assert_eq!(out.label, label);
781 assert!(out.verbatim_label);
782 }
783 }
784
785 #[test]
786 fn dispatch_format_for_lex_include_returns_none() {
787 let registry = fresh_registry();
790 let ctx = format_ctx_verbatim("lex.include", vec![("src", "other.lex")], "");
791 let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
792 assert!(out.is_none(), "lex.include has no on_format path");
793 }
794
795 #[test]
796 fn dispatch_format_for_lex_metadata_returns_none() {
797 let registry = fresh_registry();
801 let ctx = format_ctx_verbatim("lex.metadata.title", vec![], "My Doc");
802 let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
803 assert!(out.is_none(), "metadata labels fall back to host default");
804 }
805
806 #[test]
807 fn dispatch_format_with_non_verbatim_node_returns_none() {
808 use lex_extension::wire::{FormatCtx, WireNode};
812 let registry = fresh_registry();
813 let ctx = FormatCtx {
814 label: "lex.tabular.table".into(),
815 params: vec![],
816 node: WireNode::Paragraph {
817 range: Range {
818 start: Position(0, 0),
819 end: Position(0, 0),
820 },
821 origin: None,
822 inlines: vec![],
823 },
824 format_options: None,
825 };
826 let out = registry.dispatch_format(&ctx).expect("dispatch_format ok");
827 assert!(
828 out.is_none(),
829 "non-verbatim wire node must fall back to host default"
830 );
831 }
832}