Skip to main content

lex_babel/
render_dispatch.rs

1//! Render-hook dispatch: walk the IR, ask registered handlers to
2//! render their labelled annotations / verbatim blocks, and surface
3//! the results to the format-specific serializer.
4//!
5//! This module is format-independent: it builds a [`RenderPlan`] of
6//! `(label, rendered string, optional diagnostic)` triples in document
7//! order. The serializer for each format (HTML first, others later) is
8//! responsible for splicing the rendered strings into its output and
9//! routing the diagnostics onto the host's diagnostic channel.
10//!
11//! Bounded extensibility: a label whose namespace is *not* registered
12//! is left untouched — the default rendering applies.
13//!
14//! # Status (#616)
15//!
16//! Render dispatch now walks the IR (`ir::nodes::Document`), not the
17//! lex-core AST. This brings the "centralize hard logic in the IR"
18//! principle from `crates/lex-babel/src/lib.rs:60-64` back to a single
19//! center: the same IR feeds both the event-stream serializer and the
20//! render-hook dispatch.
21//!
22//! ## Behavioural note: `NodeRef.kind` for attached annotations
23//!
24//! The wire spec §2.1 says `LabelCtx.node.kind` is the host AST kind
25//! the label is attached to. The IR flattens attached annotations into
26//! sibling DocNodes (see `ir::from_lex::extract_attached_annotations`),
27//! so the original attachment kind is not recoverable from the IR
28//! alone. Handlers receiving an attached annotation see the kind of
29//! its IR *container* (the session, list-item, definition, or outer
30//! annotation it sits inside) rather than the kind of the element it
31//! was attached to in the source.
32//!
33//! Doc-scope annotations — those that live in
34//! `Document::document_annotations` — continue to surface as
35//! `kind="document"`.
36//!
37//! Restoring source-attachment precision needs an IR-side change (a
38//! per-Annotation `attached_to: Option<HostNodeKind>` tag set by
39//! `from_lex`); that belongs to the IR symmetry work-stream
40//! (#614 / Sub A territory), not the render-dispatch migration.
41
42use lex_extension::wire::{Format, HostNodeKind};
43use lex_extension::{
44    schema::{BodyKind, Schema},
45    AnnotationBody, LabelCtx, NodeRef, RenderOut,
46};
47use lex_extension_host::Registry;
48
49use crate::ir::nodes::{Annotation, DocNode, Document, InlineContent, Verbatim};
50use crate::ir::to_wire::{ir_annotation_body, ir_params_to_json};
51
52/// One render result for a labelled node, captured during the IR
53/// walk so the format-specific serializer can splice it into the
54/// final output.
55pub struct RenderedNode {
56    /// Fully-qualified label.
57    pub label: String,
58    /// Format-shaped string the handler returned (HTML for the HTML
59    /// pipeline, Markdown for the Markdown pipeline, etc.). `None`
60    /// means "fall back to default rendering" — either because the
61    /// handler said `Ok(None)` or because it errored / returned a
62    /// wrong-shape value.
63    pub output: Option<String>,
64    /// Optional diagnostic the handler produced via `Err`. Surfaced
65    /// to the caller alongside the rendered output so they can route
66    /// it onto whatever channel the host uses (stderr in `lexd`,
67    /// `publishDiagnostics` in `lex-lsp`).
68    pub diagnostic: Option<String>,
69}
70
71/// Outcome of the render-dispatch walk: a list of per-node render
72/// results in document order, plus any document-root diagnostics
73/// (panic, namespace disabled).
74pub struct RenderPlan {
75    pub nodes: Vec<RenderedNode>,
76    /// How many entries at the head of [`nodes`](Self::nodes) came
77    /// from `document.document_annotations` (the doc-scope slot)
78    /// rather than the body walk. `tree_to_events` does **not** emit
79    /// events for that slot (Phase 3b of #614 made doc-scope IR-only),
80    /// so an event-indexed splice consumer (the HTML serializer) must
81    /// skip these prefix entries to keep its index aligned with the
82    /// `StartAnnotation` events it actually receives. Routing doc-
83    /// scope handler output back into the rendered HTML is still a
84    /// follow-up — the entries are surfaced in the plan and their
85    /// diagnostics flow through, but no splice site exists for them
86    /// yet.
87    pub doc_scope_count: usize,
88    /// Root-level diagnostics from the registry (e.g., a namespace
89    /// disabled after a panic).
90    pub root_diagnostics: Vec<String>,
91}
92
93/// Walk `document` and dispatch `on_render` for every labelled
94/// annotation / verbatim whose schema declares the format in
95/// `hooks.render`. Returns the plan; the caller (HTML serializer) is
96/// responsible for splicing.
97///
98/// `format_name` is matched case-insensitively against entries in
99/// `schema.hooks.render`. Today the HTML pipeline passes `"html"`;
100/// future formats pass `"markdown"`, `"latex"`, etc.
101pub fn dispatch_render(document: &Document, registry: &Registry, format_name: &str) -> RenderPlan {
102    let mut nodes = Vec::new();
103    if registry.namespace_count() == 0 {
104        return RenderPlan {
105            nodes,
106            doc_scope_count: 0,
107            root_diagnostics: Vec::new(),
108        };
109    }
110    let format = format_for_name(format_name);
111    let mut ctx = WalkCtx {
112        registry,
113        format: &format,
114        out: &mut nodes,
115    };
116    // Document-scope annotations (the `document_annotations` slot
117    // populated by Phase 3b) come first so the plan reflects source
118    // order. Track how many entries this prefix produced so event-
119    // indexed splice consumers can skip them — `tree_to_events`
120    // doesn't emit events for this slot.
121    for ann in &document.document_annotations {
122        visit_annotation(ann, HostNodeKind::Document, &mut ctx);
123    }
124    let doc_scope_count = ctx.out.len();
125    for child in &document.children {
126        walk_doc_node(child, HostNodeKind::Session, &mut ctx);
127    }
128    let root_diagnostics = registry
129        .take_root_diagnostics()
130        .into_iter()
131        .map(|d| d.message)
132        .collect();
133    RenderPlan {
134        nodes,
135        doc_scope_count,
136        root_diagnostics,
137    }
138}
139
140fn format_for_name(name: &str) -> Format {
141    match name.to_ascii_lowercase().as_str() {
142        "html" => Format::Html,
143        "markdown" | "md" => Format::Markdown,
144        "latex" | "tex" => Format::Latex,
145        "pdf" => Format::Pdf,
146        other => Format::Custom(other.to_string()),
147    }
148}
149
150/// Bundle the parameters that thread through every walker frame so
151/// the function signatures don't grow another argument every time we
152/// add a piece of context. Borrowed-only — the walk doesn't own
153/// anything that needs lifetime management here.
154struct WalkCtx<'a> {
155    registry: &'a Registry,
156    /// The canonical [`Format`] for the dispatch pass. Schema gating
157    /// uses `format.as_str()` so callers can pass aliases (`"md"`,
158    /// `"tex"`) at the entry point without breaking schema lookup —
159    /// `format_for_name` normalises before we get here.
160    format: &'a Format,
161    out: &'a mut Vec<RenderedNode>,
162}
163
164fn walk_doc_node(node: &DocNode, parent_kind: HostNodeKind, ctx: &mut WalkCtx<'_>) {
165    match node {
166        DocNode::Heading(h) => {
167            for child in &h.children {
168                walk_doc_node(child, HostNodeKind::Session, ctx);
169            }
170        }
171        DocNode::Paragraph(_) | DocNode::Inline(_) => {}
172        DocNode::List(l) => {
173            for item in &l.items {
174                for child in &item.children {
175                    walk_doc_node(child, HostNodeKind::ListItem, ctx);
176                }
177            }
178        }
179        DocNode::ListItem(li) => {
180            for child in &li.children {
181                walk_doc_node(child, HostNodeKind::ListItem, ctx);
182            }
183        }
184        DocNode::Definition(d) => {
185            for child in &d.description {
186                walk_doc_node(child, HostNodeKind::Definition, ctx);
187            }
188        }
189        DocNode::Annotation(a) => {
190            visit_annotation(a, parent_kind, ctx);
191        }
192        DocNode::Verbatim(v) => {
193            visit_verbatim(v, ctx);
194        }
195        DocNode::Table(t) => {
196            // Header rows before body rows — must match
197            // `nested_to_flat::tree_to_events`'s emit order so the
198            // event-indexed splice walker stays aligned with the
199            // dispatch plan.
200            for cell in t.header.iter().chain(t.rows.iter()).flat_map(|r| &r.cells) {
201                for child in &cell.content {
202                    walk_doc_node(child, HostNodeKind::Table, ctx);
203                }
204            }
205            for child in &t.footnotes {
206                walk_doc_node(child, HostNodeKind::List, ctx);
207            }
208        }
209        DocNode::Image(_) | DocNode::Video(_) | DocNode::Audio(_) | DocNode::Document(_) => {}
210    }
211}
212
213fn visit_annotation(annotation: &Annotation, attached_to: HostNodeKind, ctx: &mut WalkCtx<'_>) {
214    let label = annotation.label.clone();
215    if let Some(schema) = ctx.registry.schema_for(&label) {
216        if schema_has_render(&schema, ctx.format.as_str()) {
217            let body = body_for_schema(&annotation.content, &schema);
218            let label_ctx = LabelCtx {
219                label: label.clone(),
220                params: ir_params_to_json(&annotation.parameters),
221                body,
222                node: NodeRef {
223                    kind: attached_to.as_str().to_string(),
224                    range: zero_range(),
225                    origin: None,
226                },
227            };
228            ctx.out
229                .push(dispatch_one(&label, ctx.registry, &label_ctx, ctx.format));
230        }
231    }
232    // Long-form annotations carry nested content (further annotations,
233    // verbatim blocks, …) that also needs render dispatch. Walk
234    // children unconditionally — even when this annotation's own
235    // schema doesn't match, a registered label inside its body still
236    // needs its handler called.
237    for child in &annotation.content {
238        walk_doc_node(child, HostNodeKind::Annotation, ctx);
239    }
240}
241
242fn visit_verbatim(v: &Verbatim, ctx: &mut WalkCtx<'_>) {
243    // A labelled verbatim with `on_ir_build` gets hydrated into a
244    // typed `DocNode` (Table / Image / Video / Audio) and never
245    // reaches here. A labelled verbatim without `on_ir_build` —
246    // typical for third-party `verbatim_label: true` schemas that
247    // only declare `on_render` — falls back to `DocNode::Verbatim`
248    // with the closing label preserved in `language` and the
249    // closing-data parameters preserved in `parameters` (#614 IR
250    // symmetry follow-up). Resurrect render dispatch for that case
251    // via the `language` field and surface `parameters` to handlers
252    // exactly as the pre-#616 AST walk did.
253    let Some(label) = v.language.as_deref() else {
254        return;
255    };
256    if label.is_empty() {
257        return;
258    }
259    let Some(schema) = ctx.registry.schema_for(label) else {
260        return;
261    };
262    if !schema.verbatim_label || !schema_has_render(&schema, ctx.format.as_str()) {
263        return;
264    }
265    let params_object = serde_json::Value::Object(
266        v.parameters
267            .iter()
268            .map(|(k, val)| (k.clone(), serde_json::Value::String(val.clone())))
269            .collect(),
270    );
271    let label_ctx = LabelCtx {
272        label: label.to_string(),
273        params: params_object,
274        body: AnnotationBody::Text(v.content.clone()),
275        node: NodeRef {
276            kind: HostNodeKind::Verbatim.as_str().to_string(),
277            range: zero_range(),
278            origin: None,
279        },
280    };
281    ctx.out
282        .push(dispatch_one(label, ctx.registry, &label_ctx, ctx.format));
283}
284
285fn dispatch_one(label: &str, registry: &Registry, ctx: &LabelCtx, format: &Format) -> RenderedNode {
286    match registry.dispatch_render(ctx, format.clone()) {
287        Ok(Some(RenderOut::String { string })) => RenderedNode {
288            label: label.to_string(),
289            output: Some(string),
290            diagnostic: None,
291        },
292        Ok(Some(RenderOut::WireAst { .. })) => RenderedNode {
293            label: label.to_string(),
294            output: None,
295            diagnostic: Some(format!(
296                "handler returned WireAst output for label `{label}` but the requested format is string-shaped; falling back to default rendering"
297            )),
298        },
299        Ok(None) => RenderedNode {
300            label: label.to_string(),
301            output: None,
302            diagnostic: None,
303        },
304        Err(diag) => RenderedNode {
305            label: label.to_string(),
306            output: None,
307            diagnostic: Some(diag.message),
308        },
309    }
310}
311
312fn schema_has_render(schema: &Schema, format_name: &str) -> bool {
313    schema
314        .hooks
315        .render
316        .iter()
317        .any(|h| h.0.eq_ignore_ascii_case(format_name))
318}
319
320/// Build an [`AnnotationBody`] from the IR annotation's children,
321/// honouring the schema's declared `body.kind`. A schema that declares
322/// `BodyKind::Text` (the `doc.*` family) expects the body as a flat
323/// string — without this, `ir_annotation_body` would always pack a
324/// non-empty body as `AnnotationBody::Lex` and the handler's text-only
325/// branch would never fire. For `Lex` / `None` body kinds the standard
326/// IR → Wire packing applies.
327fn body_for_schema(content: &[DocNode], schema: &Schema) -> AnnotationBody {
328    if schema.body.kind == BodyKind::Text {
329        let flat = flatten_text_body(content);
330        if flat.is_empty() {
331            AnnotationBody::None
332        } else {
333            AnnotationBody::Text(flat)
334        }
335    } else {
336        ir_annotation_body(content)
337    }
338}
339
340/// Flatten an annotation's IR body into a single plain-text string for
341/// `BodyKind::Text` schemas. Recurses through `Bold`/`Italic` formatting
342/// containers so a metadata value like `:: doc.title :: *Important*
343/// Title` keeps the "Important" text instead of dropping it (Copilot
344/// review on PR #625). `Image` is skipped — its alt text isn't usually
345/// what an author intended as the metadata value.
346///
347/// Multi-paragraph bodies are joined with a single space so the
348/// resulting scalar stays single-line — a literal newline in a YAML
349/// scalar would orphan the trailing text from its key. `DocNode::Inline`
350/// siblings (bare inlines outside a paragraph wrapper) are processed
351/// too, so an unusual doc-scope annotation shape doesn't silently drop
352/// content.
353fn flatten_text_body(content: &[DocNode]) -> String {
354    fn flatten_inlines(inlines: &[InlineContent], buf: &mut String) {
355        for inline in inlines {
356            match inline {
357                InlineContent::Text(t)
358                | InlineContent::Code(t)
359                | InlineContent::Math(t)
360                | InlineContent::Reference { raw: t, .. } => buf.push_str(t),
361                InlineContent::Link { text, .. } => buf.push_str(text),
362                InlineContent::Bold(children) | InlineContent::Italic(children) => {
363                    flatten_inlines(children, buf);
364                }
365                InlineContent::Image(_) => {}
366            }
367        }
368    }
369
370    let mut buf = String::new();
371    let mut first = true;
372    for node in content {
373        match node {
374            DocNode::Paragraph(p) => {
375                if !first {
376                    buf.push(' ');
377                }
378                first = false;
379                flatten_inlines(&p.content, &mut buf);
380            }
381            DocNode::Inline(i) => {
382                flatten_inlines(std::slice::from_ref(i), &mut buf);
383            }
384            _ => {}
385        }
386    }
387    buf
388}
389
390fn zero_range() -> lex_extension::wire::Range {
391    use lex_extension::wire::{Position, Range};
392    Range::new(Position::new(0, 0), Position::new(0, 0))
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use lex_core::lex::loader::DocumentLoader;
399    use lex_extension::schema::{
400        BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, RenderHook, Schema,
401    };
402    use lex_extension::wire::WireNode;
403    use lex_extension::{HandlerError, LexHandler};
404    use std::collections::BTreeMap;
405
406    fn parse(src: &str) -> Document {
407        let ast = DocumentLoader::from_string(src).parse().expect("parse");
408        crate::to_ir(&ast)
409    }
410
411    fn schema(label: &str, formats: &[&str]) -> Schema {
412        Schema {
413            schema_version: 1,
414            label: label.into(),
415            description: None,
416            params: BTreeMap::new(),
417            attaches_to: vec![
418                "annotation".into(),
419                "document".into(),
420                "session".into(),
421                "paragraph".into(),
422            ],
423            body: BodyShape {
424                kind: BodyKind::None,
425                presence: BodyPresence::Optional,
426                description: None,
427            },
428            verbatim_label: false,
429            capabilities: Capabilities::default(),
430            hooks: HookSet {
431                render: formats.iter().map(|s| RenderHook::new(*s)).collect(),
432                ..HookSet::default()
433            },
434            handler: None,
435            diagnostics: Vec::new(),
436        }
437    }
438
439    struct EchoRender;
440    impl LexHandler for EchoRender {
441        fn on_render(
442            &self,
443            ctx: &LabelCtx,
444            _fmt: Format,
445        ) -> Result<Option<RenderOut>, HandlerError> {
446            Ok(Some(RenderOut::String {
447                string: format!("<RENDERED label=\"{}\"/>", ctx.label),
448            }))
449        }
450    }
451
452    #[test]
453    fn empty_registry_yields_empty_plan() {
454        let doc = parse(":: acme.task ::\n");
455        let registry = Registry::new();
456        let plan = dispatch_render(&doc, &registry, "html");
457        assert!(plan.nodes.is_empty());
458    }
459
460    #[test]
461    fn registered_label_with_html_render_hook_dispatches() {
462        let doc = parse(":: acme.task ::\n");
463        let registry = Registry::new();
464        registry
465            .register_namespace(
466                "acme",
467                vec![schema("acme.task", &["html"])],
468                Box::new(EchoRender),
469            )
470            .unwrap();
471        let plan = dispatch_render(&doc, &registry, "html");
472        assert_eq!(plan.nodes.len(), 1);
473        assert_eq!(plan.nodes[0].label, "acme.task");
474        assert_eq!(
475            plan.nodes[0].output.as_deref(),
476            Some(r#"<RENDERED label="acme.task"/>"#)
477        );
478    }
479
480    #[test]
481    fn label_without_html_in_render_hooks_is_skipped() {
482        let doc = parse(":: acme.task ::\n");
483        let registry = Registry::new();
484        registry
485            .register_namespace(
486                "acme",
487                vec![schema("acme.task", &["markdown"])],
488                Box::new(EchoRender),
489            )
490            .unwrap();
491        let plan = dispatch_render(&doc, &registry, "html");
492        // Schema declares markdown only — html dispatch skipped.
493        assert!(plan.nodes.is_empty());
494    }
495
496    /// Regression for the format-alias mismatch: `format_for_name`
497    /// normalises `"md"` → `Format::Markdown`, but the schema's
498    /// `hooks.render` list contains the canonical `"markdown"`.
499    /// Schema gating must compare against the canonical
500    /// `Format::as_str()`, not the raw caller input — otherwise an
501    /// alias caller would never match a canonical schema.
502    #[test]
503    fn alias_format_name_matches_canonical_schema_render_hook() {
504        let doc = parse(":: acme.task ::\n");
505        let registry = Registry::new();
506        registry
507            .register_namespace(
508                "acme",
509                vec![schema("acme.task", &["markdown"])],
510                Box::new(EchoRender),
511            )
512            .unwrap();
513        // Caller passes the alias "md"; schema declares "markdown".
514        let plan = dispatch_render(&doc, &registry, "md");
515        assert_eq!(plan.nodes.len(), 1);
516        assert_eq!(plan.nodes[0].label, "acme.task");
517    }
518
519    #[test]
520    fn handler_error_yields_diagnostic_and_no_html() {
521        struct Boom;
522        impl LexHandler for Boom {
523            fn on_render(
524                &self,
525                _: &LabelCtx,
526                _: Format,
527            ) -> Result<Option<RenderOut>, HandlerError> {
528                Err(HandlerError::internal("render failed"))
529            }
530        }
531        let doc = parse(":: acme.task ::\n");
532        let registry = Registry::new();
533        registry
534            .register_namespace("acme", vec![schema("acme.task", &["html"])], Box::new(Boom))
535            .unwrap();
536        let plan = dispatch_render(&doc, &registry, "html");
537        assert_eq!(plan.nodes.len(), 1);
538        assert!(plan.nodes[0].output.is_none());
539        let diag = plan.nodes[0].diagnostic.as_deref().expect("diagnostic");
540        assert!(diag.contains("render failed"));
541    }
542
543    #[test]
544    fn wire_ast_output_for_string_target_falls_back_with_diagnostic() {
545        struct WireOut;
546        impl LexHandler for WireOut {
547            fn on_render(
548                &self,
549                _: &LabelCtx,
550                _: Format,
551            ) -> Result<Option<RenderOut>, HandlerError> {
552                Ok(Some(RenderOut::WireAst {
553                    ast: WireNode::Document {
554                        range: lex_extension::wire::Range::new(
555                            lex_extension::wire::Position(0, 0),
556                            lex_extension::wire::Position(0, 0),
557                        ),
558                        origin: None,
559                        children: vec![],
560                    },
561                }))
562            }
563        }
564        let doc = parse(":: acme.task ::\n");
565        let registry = Registry::new();
566        registry
567            .register_namespace(
568                "acme",
569                vec![schema("acme.task", &["html"])],
570                Box::new(WireOut),
571            )
572            .unwrap();
573        let plan = dispatch_render(&doc, &registry, "html");
574        assert_eq!(plan.nodes.len(), 1);
575        assert!(plan.nodes[0].output.is_none());
576        assert!(plan.nodes[0]
577            .diagnostic
578            .as_deref()
579            .is_some_and(|d| d.contains("WireAst")));
580    }
581
582    /// End-to-end: registry-aware HTML serialization runs the dispatch
583    /// pass, surfaces handler diagnostics in the outcome, and produces
584    /// the default-rendered HTML.
585    #[test]
586    fn end_to_end_html_pipeline_surfaces_diagnostics() {
587        use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
588        struct Boom;
589        impl LexHandler for Boom {
590            fn on_render(
591                &self,
592                _: &LabelCtx,
593                _: Format,
594            ) -> Result<Option<RenderOut>, HandlerError> {
595                Err(HandlerError::internal("rendering failed"))
596            }
597        }
598        let ast = DocumentLoader::from_string(":: acme.task ::\n    Body content.\n")
599            .parse()
600            .expect("parse");
601        let registry = Registry::new();
602        registry
603            .register_namespace("acme", vec![schema("acme.task", &["html"])], Box::new(Boom))
604            .unwrap();
605        let outcome = serialize_to_html_with_registry(
606            &ast,
607            HtmlOptions::new(HtmlTheme::default()),
608            &registry,
609        )
610        .expect("serialise");
611        assert!(
612            outcome
613                .diagnostics
614                .iter()
615                .any(|d| d.contains("rendering failed")),
616            "expected handler diagnostic in outcome, got: {:?}",
617            outcome.diagnostics
618        );
619        assert!(!outcome.html.is_empty());
620    }
621
622    /// Wire spec §2.1: `LabelCtx.node.kind` carries the host kind. For
623    /// a doc-scope annotation (`Document::document_annotations`) we
624    /// surface `"document"`.
625    #[test]
626    fn handler_sees_host_node_kind_in_label_ctx() {
627        use std::sync::Mutex;
628        struct CaptureKind {
629            seen: std::sync::Arc<Mutex<Vec<String>>>,
630        }
631        impl LexHandler for CaptureKind {
632            fn on_render(
633                &self,
634                ctx: &LabelCtx,
635                _: Format,
636            ) -> Result<Option<RenderOut>, HandlerError> {
637                self.seen.lock().unwrap().push(ctx.node.kind.clone());
638                Ok(None)
639            }
640        }
641        let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
642        let registry = Registry::new();
643        registry
644            .register_namespace(
645                "acme",
646                vec![schema("acme.task", &["html"])],
647                Box::new(CaptureKind { seen: seen.clone() }),
648            )
649            .unwrap();
650        let doc = parse(":: acme.task ::\n");
651        let _ = dispatch_render(&doc, &registry, "html");
652        let kinds = seen.lock().unwrap().clone();
653        assert_eq!(
654            kinds.as_slice(),
655            &["document"],
656            "top-level annotation must surface as host kind \"document\"",
657        );
658    }
659
660    #[test]
661    fn end_to_end_html_pipeline_is_passthrough_when_no_hooks_match() {
662        use crate::formats::html::{
663            serialize_to_html, serialize_to_html_with_registry, HtmlOptions, HtmlTheme,
664        };
665        let ast = DocumentLoader::from_string(":: acme.task ::\n    Body.\n")
666            .parse()
667            .expect("parse");
668        let registry = Registry::new();
669        // Schema declares only markdown — html dispatch skipped.
670        registry
671            .register_namespace(
672                "acme",
673                vec![schema("acme.task", &["markdown"])],
674                Box::new(EchoRender),
675            )
676            .unwrap();
677        let outcome = serialize_to_html_with_registry(
678            &ast,
679            HtmlOptions::new(HtmlTheme::default()),
680            &registry,
681        )
682        .expect("serialise");
683        let baseline = serialize_to_html(&ast, HtmlTheme::default()).expect("baseline");
684        assert_eq!(outcome.html, baseline);
685        assert!(outcome.diagnostics.is_empty());
686    }
687
688    // -- Splice tests. The splice mechanism replaces the default
689    // `<!-- lex:label -->` ... `<!-- /lex:label -->` comment pair (and
690    // any content between) with the handler's raw HTML when the
691    // handler returns `RenderOut::String`. WireAst and `Ok(None)`
692    // continue to fall through to default rendering.
693
694    fn registry_with_string_handler(label: &str, html_output: &'static str) -> Registry {
695        struct Fixed(&'static str);
696        impl LexHandler for Fixed {
697            fn on_render(
698                &self,
699                _: &LabelCtx,
700                _: Format,
701            ) -> Result<Option<RenderOut>, HandlerError> {
702                Ok(Some(RenderOut::String {
703                    string: self.0.to_string(),
704                }))
705            }
706        }
707        let registry = Registry::new();
708        registry
709            .register_namespace(
710                label.split_once('.').map(|(ns, _)| ns).unwrap_or(label),
711                vec![schema(label, &["html"])],
712                Box::new(Fixed(html_output)),
713            )
714            .unwrap();
715        registry
716    }
717
718    const DOC_WITH_SCOPED_ANNOTATION: &str =
719        "1. Heading\n\n    :: acme.task ::\n        Body that should be replaced.\n";
720
721    #[test]
722    fn splice_replaces_default_annotation_rendering_with_handler_html() {
723        use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
724        let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
725            .parse()
726            .expect("parse");
727        let registry = registry_with_string_handler(
728            "acme.task",
729            "<div class=\"acme-task\">handler-rendered</div>",
730        );
731        let outcome = serialize_to_html_with_registry(
732            &ast,
733            HtmlOptions::new(HtmlTheme::default()),
734            &registry,
735        )
736        .expect("serialise");
737        assert!(
738            outcome
739                .html
740                .contains("<div class=\"acme-task\">handler-rendered</div>"),
741            "handler HTML should be spliced into the output. got:\n{}",
742            outcome.html
743        );
744        assert!(
745            !outcome.html.contains("<!-- lex:acme.task"),
746            "default start comment should be replaced by splice. got:\n{}",
747            outcome.html
748        );
749        assert!(
750            !outcome.html.contains("<!-- /lex:acme.task"),
751            "default end comment should be replaced by splice. got:\n{}",
752            outcome.html
753        );
754    }
755
756    #[test]
757    fn splice_consumes_annotation_body_so_default_content_does_not_render() {
758        use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
759        let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
760            .parse()
761            .expect("parse");
762        let registry = registry_with_string_handler("acme.task", "<div>HANDLER</div>");
763        let outcome = serialize_to_html_with_registry(
764            &ast,
765            HtmlOptions::new(HtmlTheme::default()),
766            &registry,
767        )
768        .expect("serialise");
769        assert!(outcome.html.contains("<div>HANDLER</div>"));
770        assert!(
771            !outcome.html.contains("Body that should be replaced."),
772            "annotation body must be suppressed inside the handler-owned region. got:\n{}",
773            outcome.html
774        );
775    }
776
777    #[test]
778    fn no_splice_when_handler_returns_none_falls_through_to_default() {
779        use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
780        struct AlwaysNone;
781        impl LexHandler for AlwaysNone {
782            fn on_render(
783                &self,
784                _: &LabelCtx,
785                _: Format,
786            ) -> Result<Option<RenderOut>, HandlerError> {
787                Ok(None)
788            }
789        }
790        let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
791            .parse()
792            .expect("parse");
793        let registry = Registry::new();
794        registry
795            .register_namespace(
796                "acme",
797                vec![schema("acme.task", &["html"])],
798                Box::new(AlwaysNone),
799            )
800            .unwrap();
801        let outcome = serialize_to_html_with_registry(
802            &ast,
803            HtmlOptions::new(HtmlTheme::default()),
804            &registry,
805        )
806        .expect("serialise");
807        assert!(
808            outcome.html.contains("<!-- lex:acme.task"),
809            "Ok(None) should fall through to default rendering. got:\n{}",
810            outcome.html
811        );
812    }
813
814    #[test]
815    fn no_splice_when_handler_returns_wire_ast_with_diagnostic() {
816        use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
817        use lex_extension::wire::{Range as WireRange, WireNode};
818        use lex_extension::Position;
819        struct WireOnly;
820        impl LexHandler for WireOnly {
821            fn on_render(
822                &self,
823                _: &LabelCtx,
824                _: Format,
825            ) -> Result<Option<RenderOut>, HandlerError> {
826                Ok(Some(RenderOut::WireAst {
827                    ast: WireNode::Paragraph {
828                        range: WireRange::new(Position::new(0, 0), Position::new(0, 0)),
829                        inlines: Vec::new(),
830                        origin: None,
831                    },
832                }))
833            }
834        }
835        let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
836            .parse()
837            .expect("parse");
838        let registry = Registry::new();
839        registry
840            .register_namespace(
841                "acme",
842                vec![schema("acme.task", &["html"])],
843                Box::new(WireOnly),
844            )
845            .unwrap();
846        let outcome = serialize_to_html_with_registry(
847            &ast,
848            HtmlOptions::new(HtmlTheme::default()),
849            &registry,
850        )
851        .expect("serialise");
852        assert!(outcome.html.contains("<!-- lex:acme.task"));
853        assert!(
854            outcome
855                .diagnostics
856                .iter()
857                .any(|d| d.contains("WireAst") || d.contains("wire_ast")),
858            "expected WireAst-shape-mismatch diagnostic, got: {:?}",
859            outcome.diagnostics
860        );
861    }
862
863    #[test]
864    fn unregistered_namespace_renders_default_unchanged() {
865        use crate::formats::html::{
866            serialize_to_html, serialize_to_html_with_registry, HtmlOptions, HtmlTheme,
867        };
868        let ast =
869            DocumentLoader::from_string("1. Heading\n\n    :: unknown.label ::\n        body.\n")
870                .parse()
871                .expect("parse");
872        let registry = Registry::new();
873        let outcome = serialize_to_html_with_registry(
874            &ast,
875            HtmlOptions::new(HtmlTheme::default()),
876            &registry,
877        )
878        .expect("serialise");
879        let baseline = serialize_to_html(&ast, HtmlTheme::default()).expect("baseline");
880        assert_eq!(outcome.html, baseline);
881    }
882
883    /// Regression for the doc-scope/body splice misalignment that
884    /// Copilot flagged on PR #621.
885    ///
886    /// `dispatch_render` walks `document_annotations` first and emits
887    /// plan entries, but `tree_to_events` doesn't synthesise events
888    /// for that slot (Phase 3b of #614). The HTML serializer must
889    /// slice `plan.doc_scope_count` entries off the front before
890    /// handing the plan to the splice walker. This test locks that
891    /// contract.
892    #[test]
893    fn doc_scope_annotation_does_not_misroute_body_splice() {
894        use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
895
896        struct ByLabel;
897        impl LexHandler for ByLabel {
898            fn on_render(
899                &self,
900                ctx: &LabelCtx,
901                _: Format,
902            ) -> Result<Option<RenderOut>, HandlerError> {
903                let marker = match ctx.label.as_str() {
904                    "acme.docscope" => "<DOCSCOPE_HANDLER_OUTPUT/>",
905                    "acme.body" => "<BODY_HANDLER_OUTPUT/>",
906                    _ => return Ok(None),
907                };
908                Ok(Some(RenderOut::String {
909                    string: marker.to_string(),
910                }))
911            }
912        }
913
914        let src = ":: acme.docscope ::\n\
915                   \n\
916                   1. Heading\n\
917                   \n    \
918                   :: acme.body ::\n        \
919                   Body that should be replaced.\n";
920        let ast = DocumentLoader::from_string(src).parse().expect("parse");
921        let registry = Registry::new();
922        registry
923            .register_namespace(
924                "acme",
925                vec![
926                    schema("acme.docscope", &["html"]),
927                    schema("acme.body", &["html"]),
928                ],
929                Box::new(ByLabel),
930            )
931            .unwrap();
932        let ir_doc = crate::to_ir(&ast);
933        let plan = dispatch_render(&ir_doc, &registry, "html");
934        assert_eq!(
935            plan.doc_scope_count, 1,
936            "dispatch_render should report one doc-scope plan entry"
937        );
938        assert_eq!(
939            plan.nodes.len(),
940            2,
941            "plan should have one doc-scope + one body entry"
942        );
943
944        let outcome = serialize_to_html_with_registry(
945            &ast,
946            HtmlOptions::new(HtmlTheme::default()),
947            &registry,
948        )
949        .expect("serialise");
950
951        assert!(
952            outcome.html.contains("<BODY_HANDLER_OUTPUT/>"),
953            "body annotation must splice the body handler's output. got:\n{}",
954            outcome.html
955        );
956        assert!(
957            !outcome.html.contains("<DOCSCOPE_HANDLER_OUTPUT/>"),
958            "doc-scope handler output must not be spliced into the body. got:\n{}",
959            outcome.html
960        );
961        assert!(
962            !outcome.html.contains("Body that should be replaced."),
963            "body handler must own its body content. got:\n{}",
964            outcome.html
965        );
966    }
967
968    /// Acceptance criterion for #616: a render handler must fire from
969    /// a markdown serializer path with the same signature it uses for
970    /// HTML. Sub D (#617) will wire splicing on the markdown side;
971    /// this test only proves format-agnosticism — the same
972    /// `dispatch_render` call, against the same IR, with a different
973    /// `format_name`, routes to the markdown-declared handler.
974    #[test]
975    fn dispatch_render_fires_from_markdown_path() {
976        let doc = parse(":: acme.task ::\n");
977        let registry = Registry::new();
978        registry
979            .register_namespace(
980                "acme",
981                vec![schema("acme.task", &["markdown"])],
982                Box::new(EchoRender),
983            )
984            .unwrap();
985        let plan = dispatch_render(&doc, &registry, "markdown");
986        assert_eq!(plan.nodes.len(), 1, "markdown dispatch should fire");
987        assert_eq!(plan.nodes[0].label, "acme.task");
988        assert_eq!(
989            plan.nodes[0].output.as_deref(),
990            Some(r#"<RENDERED label="acme.task"/>"#),
991            "markdown handler output should surface in the plan"
992        );
993    }
994
995    /// Annotation body is passed to handlers as
996    /// `AnnotationBody::Lex { children }` when the IR annotation has
997    /// content. Locks the IR→Wire bridge so handlers see the body
998    /// they expect.
999    #[test]
1000    fn annotation_body_surfaces_lex_children_to_handler() {
1001        use std::sync::Mutex;
1002        struct CaptureBody {
1003            seen: std::sync::Arc<Mutex<Option<AnnotationBody>>>,
1004        }
1005        impl LexHandler for CaptureBody {
1006            fn on_render(
1007                &self,
1008                ctx: &LabelCtx,
1009                _: Format,
1010            ) -> Result<Option<RenderOut>, HandlerError> {
1011                *self.seen.lock().unwrap() = Some(ctx.body.clone());
1012                Ok(None)
1013            }
1014        }
1015        let seen = std::sync::Arc::new(Mutex::new(None));
1016        let registry = Registry::new();
1017        registry
1018            .register_namespace(
1019                "acme",
1020                vec![schema("acme.task", &["html"])],
1021                Box::new(CaptureBody { seen: seen.clone() }),
1022            )
1023            .unwrap();
1024        // Annotation with a body paragraph — the IR carries the body
1025        // in `Annotation::content`, and the IR→Wire bridge surfaces
1026        // it as `AnnotationBody::Lex { children: [Paragraph] }`.
1027        let doc = parse(":: acme.task ::\n    inside the body.\n");
1028        let _ = dispatch_render(&doc, &registry, "html");
1029        let body = seen.lock().unwrap().clone().expect("handler ran");
1030        match body {
1031            AnnotationBody::Lex { children } => {
1032                assert!(
1033                    !children.is_empty(),
1034                    "lex body must carry annotation children"
1035                );
1036            }
1037            other => panic!("expected AnnotationBody::Lex, got {other:?}"),
1038        }
1039    }
1040
1041    /// Regression for Copilot's review on PR #623: a labelled verbatim
1042    /// block without an `on_ir_build` handler still needs render
1043    /// dispatch — `from_lex_verbatim` falls back to `DocNode::Verbatim`
1044    /// with the closing label preserved in `language`, and
1045    /// `visit_verbatim` resurrects dispatch via that field.
1046    #[test]
1047    fn verbatim_label_with_on_render_only_dispatches_via_language_field() {
1048        let registry = Registry::new();
1049        // `verbatim_label: true`, html render hook, no `on_ir_build` —
1050        // the schema declares the label as a verbatim closer but
1051        // doesn't hydrate it into a typed IR node, so it stays a
1052        // generic `DocNode::Verbatim`.
1053        let mut s = schema("acme.snippet", &["html"]);
1054        s.verbatim_label = true;
1055        registry
1056            .register_namespace("acme", vec![s], Box::new(EchoRender))
1057            .unwrap();
1058        // Note the closing `::` line — that's the verbatim form's
1059        // closer carrying the label.
1060        let doc = parse("Code:\n    let x = 1;\n:: acme.snippet ::\n");
1061        let plan = dispatch_render(&doc, &registry, "html");
1062        assert_eq!(
1063            plan.nodes.len(),
1064            1,
1065            "labelled verbatim with on_render hook should produce a plan entry"
1066        );
1067        assert_eq!(plan.nodes[0].label, "acme.snippet");
1068        assert!(plan.nodes[0]
1069            .output
1070            .as_deref()
1071            .is_some_and(|s| s.contains("acme.snippet")));
1072    }
1073
1074    /// Regression for Copilot's review on PR #623: the dispatch walk
1075    /// must visit table header rows before body rows so the plan
1076    /// indexing matches `tree_to_events`'s emit order (header-first).
1077    /// Otherwise an annotation inside a header cell would get its
1078    /// handler output spliced into the wrong cell.
1079    #[test]
1080    fn table_header_cells_walked_before_body_cells() {
1081        use std::sync::Mutex;
1082        struct OrderedCapture {
1083            seen: std::sync::Arc<Mutex<Vec<String>>>,
1084        }
1085        impl LexHandler for OrderedCapture {
1086            fn on_render(
1087                &self,
1088                ctx: &LabelCtx,
1089                _: Format,
1090            ) -> Result<Option<RenderOut>, HandlerError> {
1091                self.seen.lock().unwrap().push(ctx.label.clone());
1092                Ok(None)
1093            }
1094        }
1095        let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
1096        let registry = Registry::new();
1097        registry
1098            .register_namespace(
1099                "acme",
1100                vec![
1101                    schema("acme.in_header", &["html"]),
1102                    schema("acme.in_body", &["html"]),
1103                ],
1104                Box::new(OrderedCapture { seen: seen.clone() }),
1105            )
1106            .unwrap();
1107        // Synthesise an IR Table with a labelled annotation in a
1108        // header cell and another in a body cell. Test the walker
1109        // directly rather than parsing — keeps the regression
1110        // hermetic to the dispatch order.
1111        use crate::ir::nodes::{
1112            Annotation as IrAnn, DocNode as IrNode, Document as IrDoc, LabelForm,
1113            Paragraph as IrPara, Table as IrTable, TableCell as IrCell, TableCellAlignment,
1114            TableRow as IrRow,
1115        };
1116        fn cell_with_annotation(label: &str) -> IrCell {
1117            IrCell {
1118                content: vec![
1119                    IrNode::Annotation(IrAnn {
1120                        label: label.into(),
1121                        parameters: Vec::new(),
1122                        content: Vec::new(),
1123                        form: LabelForm::Canonical,
1124                    }),
1125                    IrNode::Paragraph(IrPara {
1126                        content: vec![crate::ir::nodes::InlineContent::Text("cell".into())],
1127                    }),
1128                ],
1129                header: false,
1130                align: TableCellAlignment::None,
1131                colspan: 1,
1132                rowspan: 1,
1133            }
1134        }
1135        let doc = IrDoc {
1136            title: None,
1137            subtitle: None,
1138            children: vec![IrNode::Table(IrTable {
1139                rows: vec![IrRow {
1140                    cells: vec![cell_with_annotation("acme.in_body")],
1141                }],
1142                header: vec![IrRow {
1143                    cells: vec![cell_with_annotation("acme.in_header")],
1144                }],
1145                caption: None,
1146                footnotes: Vec::new(),
1147                fullwidth: false,
1148            })],
1149            document_annotations: Vec::new(),
1150        };
1151        let _ = dispatch_render(&doc, &registry, "html");
1152        let kinds = seen.lock().unwrap().clone();
1153        assert_eq!(
1154            kinds.as_slice(),
1155            &["acme.in_header", "acme.in_body"],
1156            "header cells must be walked before body cells"
1157        );
1158    }
1159
1160    /// Gemini review on PR #625: multi-paragraph bodies must join with
1161    /// a single space (not `\n`) so the resulting YAML scalar stays
1162    /// single-line. A literal newline orphans the trailing text from
1163    /// its key when the handler embeds the value as `key: "<scalar>"`.
1164    #[test]
1165    fn flatten_text_body_joins_paragraphs_with_space_not_newline() {
1166        use crate::ir::nodes::{DocNode as IrNode, InlineContent, Paragraph};
1167        let body = vec![
1168            IrNode::Paragraph(Paragraph {
1169                content: vec![InlineContent::Text("First line.".into())],
1170            }),
1171            IrNode::Paragraph(Paragraph {
1172                content: vec![InlineContent::Text("Second line.".into())],
1173            }),
1174        ];
1175        let flat = flatten_text_body(&body);
1176        assert_eq!(flat, "First line. Second line.");
1177        assert!(
1178            !flat.contains('\n'),
1179            "flattened scalar must not contain raw newlines"
1180        );
1181    }
1182
1183    /// Gemini review on PR #625: bare `DocNode::Inline` siblings (inlines
1184    /// not wrapped in a Paragraph) are an unusual doc-scope shape, but
1185    /// the flatten must include them rather than silently dropping content.
1186    #[test]
1187    fn flatten_text_body_processes_bare_inline_doc_nodes() {
1188        use crate::ir::nodes::{DocNode as IrNode, InlineContent};
1189        let body = vec![
1190            IrNode::Inline(InlineContent::Text("Hello, ".into())),
1191            IrNode::Inline(InlineContent::Text("world.".into())),
1192        ];
1193        assert_eq!(flatten_text_body(&body), "Hello, world.");
1194    }
1195
1196    /// Copilot review on PR #625: text inside `Bold` and `Italic`
1197    /// formatting containers must be preserved when flattening for a
1198    /// `BodyKind::Text` schema. A metadata value like
1199    /// `:: doc.title :: *Important* Title` would otherwise reach the
1200    /// handler as `" Title"` — user-authored content silently dropped.
1201    #[test]
1202    fn flatten_text_body_recurses_through_bold_and_italic() {
1203        use crate::ir::nodes::{DocNode as IrNode, InlineContent, Paragraph};
1204        let body = vec![IrNode::Paragraph(Paragraph {
1205            content: vec![
1206                InlineContent::Italic(vec![InlineContent::Text("Important".into())]),
1207                InlineContent::Text(" ".into()),
1208                InlineContent::Bold(vec![
1209                    InlineContent::Text("very ".into()),
1210                    InlineContent::Italic(vec![InlineContent::Text("bold".into())]),
1211                ]),
1212                InlineContent::Text(" Title".into()),
1213            ],
1214        })];
1215        assert_eq!(flatten_text_body(&body), "Important very bold Title");
1216    }
1217}