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;