1use 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
82pub const NAMESPACE: &str = "lex";
86
87pub const CANONICAL_LABELS: &[&str] = &[
100 "lex.include",
101 "lex.notes",
102 "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 "lex.tabular.table",
113 "lex.media.image",
115 "lex.media.video",
116 "lex.media.audio",
117 "doc.title",
119 "doc.author",
120 "doc.date",
121 "doc.tags",
122 "doc.category",
123 "doc.template",
124];
125
126pub fn is_canonical_label(label: &str) -> bool {
131 CANONICAL_LABELS.contains(&label)
132}
133
134pub 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 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 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 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 _ => Ok(None),
212 }
213 }
214}
215
216pub 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
238fn resolve_tabular_table(ctx: &LabelCtx) -> WireNode {
255 let body = match &ctx.body {
256 AnnotationBody::Text(s) => s.as_str(),
257 _ => "",
262 };
263 let mut table = tabular::parse_pipe_table_to_wire(body);
264 if let WireNode::Table { range, origin, .. } = &mut table {
268 *range = ctx.node.range;
269 *origin = ctx.node.origin.clone();
270 }
271 table
272}
273
274fn 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
294fn 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
306fn 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
317fn 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 _ => 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
352pub 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
384pub const DOC_NAMESPACE: &str = "doc";
388
389pub 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 capabilities: Capabilities {
430 fs: true,
431 net: false,
432 },
433 hooks: HookSet {
434 resolve: true,
435 ..HookSet::default()
436 },
437 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 ®istry,
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 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 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 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 ®istry,
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 ®istry,
589 Arc::new(MemoryLoader::new()),
590 ResolveConfig::with_root(PathBuf::from("/root")),
591 )
592 .expect("first registration ok");
593 let second = register_into(
594 ®istry,
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 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 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 #[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 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 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 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 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 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 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 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}