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;