use lex_extension::wire::{Format, HostNodeKind};
use lex_extension::{
schema::{BodyKind, Schema},
AnnotationBody, LabelCtx, NodeRef, RenderOut,
};
use lex_extension_host::Registry;
use crate::ir::nodes::{Annotation, DocNode, Document, InlineContent, Verbatim};
use crate::ir::to_wire::{ir_annotation_body, ir_params_to_json};
pub struct RenderedNode {
pub label: String,
pub output: Option<String>,
pub diagnostic: Option<String>,
}
pub struct RenderPlan {
pub nodes: Vec<RenderedNode>,
pub doc_scope_count: usize,
pub root_diagnostics: Vec<String>,
}
pub fn dispatch_render(document: &Document, registry: &Registry, format_name: &str) -> RenderPlan {
let mut nodes = Vec::new();
if registry.namespace_count() == 0 {
return RenderPlan {
nodes,
doc_scope_count: 0,
root_diagnostics: Vec::new(),
};
}
let format = format_for_name(format_name);
let mut ctx = WalkCtx {
registry,
format: &format,
out: &mut nodes,
};
for ann in &document.document_annotations {
visit_annotation(ann, HostNodeKind::Document, &mut ctx);
}
let doc_scope_count = ctx.out.len();
for child in &document.children {
walk_doc_node(child, HostNodeKind::Session, &mut ctx);
}
let root_diagnostics = registry
.take_root_diagnostics()
.into_iter()
.map(|d| d.message)
.collect();
RenderPlan {
nodes,
doc_scope_count,
root_diagnostics,
}
}
fn format_for_name(name: &str) -> Format {
match name.to_ascii_lowercase().as_str() {
"html" => Format::Html,
"markdown" | "md" => Format::Markdown,
"latex" | "tex" => Format::Latex,
"pdf" => Format::Pdf,
other => Format::Custom(other.to_string()),
}
}
struct WalkCtx<'a> {
registry: &'a Registry,
format: &'a Format,
out: &'a mut Vec<RenderedNode>,
}
fn walk_doc_node(node: &DocNode, parent_kind: HostNodeKind, ctx: &mut WalkCtx<'_>) {
match node {
DocNode::Heading(h) => {
for child in &h.children {
walk_doc_node(child, HostNodeKind::Session, ctx);
}
}
DocNode::Paragraph(_) | DocNode::Inline(_) => {}
DocNode::List(l) => {
for item in &l.items {
for child in &item.children {
walk_doc_node(child, HostNodeKind::ListItem, ctx);
}
}
}
DocNode::ListItem(li) => {
for child in &li.children {
walk_doc_node(child, HostNodeKind::ListItem, ctx);
}
}
DocNode::Definition(d) => {
for child in &d.description {
walk_doc_node(child, HostNodeKind::Definition, ctx);
}
}
DocNode::Annotation(a) => {
visit_annotation(a, parent_kind, ctx);
}
DocNode::Verbatim(v) => {
visit_verbatim(v, ctx);
}
DocNode::Table(t) => {
for cell in t.header.iter().chain(t.rows.iter()).flat_map(|r| &r.cells) {
for child in &cell.content {
walk_doc_node(child, HostNodeKind::Table, ctx);
}
}
for child in &t.footnotes {
walk_doc_node(child, HostNodeKind::List, ctx);
}
}
DocNode::Image(_) | DocNode::Video(_) | DocNode::Audio(_) | DocNode::Document(_) => {}
}
}
fn visit_annotation(annotation: &Annotation, attached_to: HostNodeKind, ctx: &mut WalkCtx<'_>) {
let label = annotation.label.clone();
if let Some(schema) = ctx.registry.schema_for(&label) {
if schema_has_render(&schema, ctx.format.as_str()) {
let body = body_for_schema(&annotation.content, &schema);
let label_ctx = LabelCtx {
label: label.clone(),
params: ir_params_to_json(&annotation.parameters),
body,
node: NodeRef {
kind: attached_to.as_str().to_string(),
range: zero_range(),
origin: None,
},
};
ctx.out
.push(dispatch_one(&label, ctx.registry, &label_ctx, ctx.format));
}
}
for child in &annotation.content {
walk_doc_node(child, HostNodeKind::Annotation, ctx);
}
}
fn visit_verbatim(v: &Verbatim, ctx: &mut WalkCtx<'_>) {
let Some(label) = v.language.as_deref() else {
return;
};
if label.is_empty() {
return;
}
let Some(schema) = ctx.registry.schema_for(label) else {
return;
};
if !schema.verbatim_label || !schema_has_render(&schema, ctx.format.as_str()) {
return;
}
let params_object = serde_json::Value::Object(
v.parameters
.iter()
.map(|(k, val)| (k.clone(), serde_json::Value::String(val.clone())))
.collect(),
);
let label_ctx = LabelCtx {
label: label.to_string(),
params: params_object,
body: AnnotationBody::Text(v.content.clone()),
node: NodeRef {
kind: HostNodeKind::Verbatim.as_str().to_string(),
range: zero_range(),
origin: None,
},
};
ctx.out
.push(dispatch_one(label, ctx.registry, &label_ctx, ctx.format));
}
fn dispatch_one(label: &str, registry: &Registry, ctx: &LabelCtx, format: &Format) -> RenderedNode {
match registry.dispatch_render(ctx, format.clone()) {
Ok(Some(RenderOut::String { string })) => RenderedNode {
label: label.to_string(),
output: Some(string),
diagnostic: None,
},
Ok(Some(RenderOut::WireAst { .. })) => RenderedNode {
label: label.to_string(),
output: None,
diagnostic: Some(format!(
"handler returned WireAst output for label `{label}` but the requested format is string-shaped; falling back to default rendering"
)),
},
Ok(None) => RenderedNode {
label: label.to_string(),
output: None,
diagnostic: None,
},
Err(diag) => RenderedNode {
label: label.to_string(),
output: None,
diagnostic: Some(diag.message),
},
}
}
fn schema_has_render(schema: &Schema, format_name: &str) -> bool {
schema
.hooks
.render
.iter()
.any(|h| h.0.eq_ignore_ascii_case(format_name))
}
fn body_for_schema(content: &[DocNode], schema: &Schema) -> AnnotationBody {
if schema.body.kind == BodyKind::Text {
let flat = flatten_text_body(content);
if flat.is_empty() {
AnnotationBody::None
} else {
AnnotationBody::Text(flat)
}
} else {
ir_annotation_body(content)
}
}
fn flatten_text_body(content: &[DocNode]) -> String {
fn flatten_inlines(inlines: &[InlineContent], buf: &mut String) {
for inline in inlines {
match inline {
InlineContent::Text(t)
| InlineContent::Code(t)
| InlineContent::Math(t)
| InlineContent::Reference { raw: t, .. } => buf.push_str(t),
InlineContent::Link { text, .. } => buf.push_str(text),
InlineContent::Bold(children) | InlineContent::Italic(children) => {
flatten_inlines(children, buf);
}
InlineContent::Image(_) => {}
}
}
}
let mut buf = String::new();
let mut first = true;
for node in content {
match node {
DocNode::Paragraph(p) => {
if !first {
buf.push(' ');
}
first = false;
flatten_inlines(&p.content, &mut buf);
}
DocNode::Inline(i) => {
flatten_inlines(std::slice::from_ref(i), &mut buf);
}
_ => {}
}
}
buf
}
fn zero_range() -> lex_extension::wire::Range {
use lex_extension::wire::{Position, Range};
Range::new(Position::new(0, 0), Position::new(0, 0))
}
#[cfg(test)]
mod tests {
use super::*;
use lex_core::lex::loader::DocumentLoader;
use lex_extension::schema::{
BodyKind, BodyPresence, BodyShape, Capabilities, HookSet, RenderHook, Schema,
};
use lex_extension::wire::WireNode;
use lex_extension::{HandlerError, LexHandler};
use std::collections::BTreeMap;
fn parse(src: &str) -> Document {
let ast = DocumentLoader::from_string(src).parse().expect("parse");
crate::to_ir(&ast)
}
fn schema(label: &str, formats: &[&str]) -> Schema {
Schema {
schema_version: 1,
label: label.into(),
description: None,
params: BTreeMap::new(),
attaches_to: vec![
"annotation".into(),
"document".into(),
"session".into(),
"paragraph".into(),
],
body: BodyShape {
kind: BodyKind::None,
presence: BodyPresence::Optional,
description: None,
},
verbatim_label: false,
capabilities: Capabilities::default(),
hooks: HookSet {
render: formats.iter().map(|s| RenderHook::new(*s)).collect(),
..HookSet::default()
},
handler: None,
diagnostics: Vec::new(),
}
}
struct EchoRender;
impl LexHandler for EchoRender {
fn on_render(
&self,
ctx: &LabelCtx,
_fmt: Format,
) -> Result<Option<RenderOut>, HandlerError> {
Ok(Some(RenderOut::String {
string: format!("<RENDERED label=\"{}\"/>", ctx.label),
}))
}
}
#[test]
fn empty_registry_yields_empty_plan() {
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
let plan = dispatch_render(&doc, ®istry, "html");
assert!(plan.nodes.is_empty());
}
#[test]
fn registered_label_with_html_render_hook_dispatches() {
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["html"])],
Box::new(EchoRender),
)
.unwrap();
let plan = dispatch_render(&doc, ®istry, "html");
assert_eq!(plan.nodes.len(), 1);
assert_eq!(plan.nodes[0].label, "acme.task");
assert_eq!(
plan.nodes[0].output.as_deref(),
Some(r#"<RENDERED label="acme.task"/>"#)
);
}
#[test]
fn label_without_html_in_render_hooks_is_skipped() {
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["markdown"])],
Box::new(EchoRender),
)
.unwrap();
let plan = dispatch_render(&doc, ®istry, "html");
assert!(plan.nodes.is_empty());
}
#[test]
fn alias_format_name_matches_canonical_schema_render_hook() {
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["markdown"])],
Box::new(EchoRender),
)
.unwrap();
let plan = dispatch_render(&doc, ®istry, "md");
assert_eq!(plan.nodes.len(), 1);
assert_eq!(plan.nodes[0].label, "acme.task");
}
#[test]
fn handler_error_yields_diagnostic_and_no_html() {
struct Boom;
impl LexHandler for Boom {
fn on_render(
&self,
_: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
Err(HandlerError::internal("render failed"))
}
}
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
registry
.register_namespace("acme", vec![schema("acme.task", &["html"])], Box::new(Boom))
.unwrap();
let plan = dispatch_render(&doc, ®istry, "html");
assert_eq!(plan.nodes.len(), 1);
assert!(plan.nodes[0].output.is_none());
let diag = plan.nodes[0].diagnostic.as_deref().expect("diagnostic");
assert!(diag.contains("render failed"));
}
#[test]
fn wire_ast_output_for_string_target_falls_back_with_diagnostic() {
struct WireOut;
impl LexHandler for WireOut {
fn on_render(
&self,
_: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
Ok(Some(RenderOut::WireAst {
ast: WireNode::Document {
range: lex_extension::wire::Range::new(
lex_extension::wire::Position(0, 0),
lex_extension::wire::Position(0, 0),
),
origin: None,
children: vec![],
},
}))
}
}
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["html"])],
Box::new(WireOut),
)
.unwrap();
let plan = dispatch_render(&doc, ®istry, "html");
assert_eq!(plan.nodes.len(), 1);
assert!(plan.nodes[0].output.is_none());
assert!(plan.nodes[0]
.diagnostic
.as_deref()
.is_some_and(|d| d.contains("WireAst")));
}
#[test]
fn end_to_end_html_pipeline_surfaces_diagnostics() {
use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
struct Boom;
impl LexHandler for Boom {
fn on_render(
&self,
_: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
Err(HandlerError::internal("rendering failed"))
}
}
let ast = DocumentLoader::from_string(":: acme.task ::\n Body content.\n")
.parse()
.expect("parse");
let registry = Registry::new();
registry
.register_namespace("acme", vec![schema("acme.task", &["html"])], Box::new(Boom))
.unwrap();
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
assert!(
outcome
.diagnostics
.iter()
.any(|d| d.contains("rendering failed")),
"expected handler diagnostic in outcome, got: {:?}",
outcome.diagnostics
);
assert!(!outcome.html.is_empty());
}
#[test]
fn handler_sees_host_node_kind_in_label_ctx() {
use std::sync::Mutex;
struct CaptureKind {
seen: std::sync::Arc<Mutex<Vec<String>>>,
}
impl LexHandler for CaptureKind {
fn on_render(
&self,
ctx: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
self.seen.lock().unwrap().push(ctx.node.kind.clone());
Ok(None)
}
}
let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["html"])],
Box::new(CaptureKind { seen: seen.clone() }),
)
.unwrap();
let doc = parse(":: acme.task ::\n");
let _ = dispatch_render(&doc, ®istry, "html");
let kinds = seen.lock().unwrap().clone();
assert_eq!(
kinds.as_slice(),
&["document"],
"top-level annotation must surface as host kind \"document\"",
);
}
#[test]
fn end_to_end_html_pipeline_is_passthrough_when_no_hooks_match() {
use crate::formats::html::{
serialize_to_html, serialize_to_html_with_registry, HtmlOptions, HtmlTheme,
};
let ast = DocumentLoader::from_string(":: acme.task ::\n Body.\n")
.parse()
.expect("parse");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["markdown"])],
Box::new(EchoRender),
)
.unwrap();
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
let baseline = serialize_to_html(&ast, HtmlTheme::default()).expect("baseline");
assert_eq!(outcome.html, baseline);
assert!(outcome.diagnostics.is_empty());
}
fn registry_with_string_handler(label: &str, html_output: &'static str) -> Registry {
struct Fixed(&'static str);
impl LexHandler for Fixed {
fn on_render(
&self,
_: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
Ok(Some(RenderOut::String {
string: self.0.to_string(),
}))
}
}
let registry = Registry::new();
registry
.register_namespace(
label.split_once('.').map(|(ns, _)| ns).unwrap_or(label),
vec![schema(label, &["html"])],
Box::new(Fixed(html_output)),
)
.unwrap();
registry
}
const DOC_WITH_SCOPED_ANNOTATION: &str =
"1. Heading\n\n :: acme.task ::\n Body that should be replaced.\n";
#[test]
fn splice_replaces_default_annotation_rendering_with_handler_html() {
use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
.parse()
.expect("parse");
let registry = registry_with_string_handler(
"acme.task",
"<div class=\"acme-task\">handler-rendered</div>",
);
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
assert!(
outcome
.html
.contains("<div class=\"acme-task\">handler-rendered</div>"),
"handler HTML should be spliced into the output. got:\n{}",
outcome.html
);
assert!(
!outcome.html.contains("<!-- lex:acme.task"),
"default start comment should be replaced by splice. got:\n{}",
outcome.html
);
assert!(
!outcome.html.contains("<!-- /lex:acme.task"),
"default end comment should be replaced by splice. got:\n{}",
outcome.html
);
}
#[test]
fn splice_consumes_annotation_body_so_default_content_does_not_render() {
use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
.parse()
.expect("parse");
let registry = registry_with_string_handler("acme.task", "<div>HANDLER</div>");
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
assert!(outcome.html.contains("<div>HANDLER</div>"));
assert!(
!outcome.html.contains("Body that should be replaced."),
"annotation body must be suppressed inside the handler-owned region. got:\n{}",
outcome.html
);
}
#[test]
fn no_splice_when_handler_returns_none_falls_through_to_default() {
use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
struct AlwaysNone;
impl LexHandler for AlwaysNone {
fn on_render(
&self,
_: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
Ok(None)
}
}
let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
.parse()
.expect("parse");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["html"])],
Box::new(AlwaysNone),
)
.unwrap();
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
assert!(
outcome.html.contains("<!-- lex:acme.task"),
"Ok(None) should fall through to default rendering. got:\n{}",
outcome.html
);
}
#[test]
fn no_splice_when_handler_returns_wire_ast_with_diagnostic() {
use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
use lex_extension::wire::{Range as WireRange, WireNode};
use lex_extension::Position;
struct WireOnly;
impl LexHandler for WireOnly {
fn on_render(
&self,
_: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
Ok(Some(RenderOut::WireAst {
ast: WireNode::Paragraph {
range: WireRange::new(Position::new(0, 0), Position::new(0, 0)),
inlines: Vec::new(),
origin: None,
},
}))
}
}
let ast = DocumentLoader::from_string(DOC_WITH_SCOPED_ANNOTATION)
.parse()
.expect("parse");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["html"])],
Box::new(WireOnly),
)
.unwrap();
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
assert!(outcome.html.contains("<!-- lex:acme.task"));
assert!(
outcome
.diagnostics
.iter()
.any(|d| d.contains("WireAst") || d.contains("wire_ast")),
"expected WireAst-shape-mismatch diagnostic, got: {:?}",
outcome.diagnostics
);
}
#[test]
fn unregistered_namespace_renders_default_unchanged() {
use crate::formats::html::{
serialize_to_html, serialize_to_html_with_registry, HtmlOptions, HtmlTheme,
};
let ast =
DocumentLoader::from_string("1. Heading\n\n :: unknown.label ::\n body.\n")
.parse()
.expect("parse");
let registry = Registry::new();
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
let baseline = serialize_to_html(&ast, HtmlTheme::default()).expect("baseline");
assert_eq!(outcome.html, baseline);
}
#[test]
fn doc_scope_annotation_does_not_misroute_body_splice() {
use crate::formats::html::{serialize_to_html_with_registry, HtmlOptions, HtmlTheme};
struct ByLabel;
impl LexHandler for ByLabel {
fn on_render(
&self,
ctx: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
let marker = match ctx.label.as_str() {
"acme.docscope" => "<DOCSCOPE_HANDLER_OUTPUT/>",
"acme.body" => "<BODY_HANDLER_OUTPUT/>",
_ => return Ok(None),
};
Ok(Some(RenderOut::String {
string: marker.to_string(),
}))
}
}
let src = ":: acme.docscope ::\n\
\n\
1. Heading\n\
\n \
:: acme.body ::\n \
Body that should be replaced.\n";
let ast = DocumentLoader::from_string(src).parse().expect("parse");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![
schema("acme.docscope", &["html"]),
schema("acme.body", &["html"]),
],
Box::new(ByLabel),
)
.unwrap();
let ir_doc = crate::to_ir(&ast);
let plan = dispatch_render(&ir_doc, ®istry, "html");
assert_eq!(
plan.doc_scope_count, 1,
"dispatch_render should report one doc-scope plan entry"
);
assert_eq!(
plan.nodes.len(),
2,
"plan should have one doc-scope + one body entry"
);
let outcome = serialize_to_html_with_registry(
&ast,
HtmlOptions::new(HtmlTheme::default()),
®istry,
)
.expect("serialise");
assert!(
outcome.html.contains("<BODY_HANDLER_OUTPUT/>"),
"body annotation must splice the body handler's output. got:\n{}",
outcome.html
);
assert!(
!outcome.html.contains("<DOCSCOPE_HANDLER_OUTPUT/>"),
"doc-scope handler output must not be spliced into the body. got:\n{}",
outcome.html
);
assert!(
!outcome.html.contains("Body that should be replaced."),
"body handler must own its body content. got:\n{}",
outcome.html
);
}
#[test]
fn dispatch_render_fires_from_markdown_path() {
let doc = parse(":: acme.task ::\n");
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["markdown"])],
Box::new(EchoRender),
)
.unwrap();
let plan = dispatch_render(&doc, ®istry, "markdown");
assert_eq!(plan.nodes.len(), 1, "markdown dispatch should fire");
assert_eq!(plan.nodes[0].label, "acme.task");
assert_eq!(
plan.nodes[0].output.as_deref(),
Some(r#"<RENDERED label="acme.task"/>"#),
"markdown handler output should surface in the plan"
);
}
#[test]
fn annotation_body_surfaces_lex_children_to_handler() {
use std::sync::Mutex;
struct CaptureBody {
seen: std::sync::Arc<Mutex<Option<AnnotationBody>>>,
}
impl LexHandler for CaptureBody {
fn on_render(
&self,
ctx: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
*self.seen.lock().unwrap() = Some(ctx.body.clone());
Ok(None)
}
}
let seen = std::sync::Arc::new(Mutex::new(None));
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![schema("acme.task", &["html"])],
Box::new(CaptureBody { seen: seen.clone() }),
)
.unwrap();
let doc = parse(":: acme.task ::\n inside the body.\n");
let _ = dispatch_render(&doc, ®istry, "html");
let body = seen.lock().unwrap().clone().expect("handler ran");
match body {
AnnotationBody::Lex { children } => {
assert!(
!children.is_empty(),
"lex body must carry annotation children"
);
}
other => panic!("expected AnnotationBody::Lex, got {other:?}"),
}
}
#[test]
fn verbatim_label_with_on_render_only_dispatches_via_language_field() {
let registry = Registry::new();
let mut s = schema("acme.snippet", &["html"]);
s.verbatim_label = true;
registry
.register_namespace("acme", vec![s], Box::new(EchoRender))
.unwrap();
let doc = parse("Code:\n let x = 1;\n:: acme.snippet ::\n");
let plan = dispatch_render(&doc, ®istry, "html");
assert_eq!(
plan.nodes.len(),
1,
"labelled verbatim with on_render hook should produce a plan entry"
);
assert_eq!(plan.nodes[0].label, "acme.snippet");
assert!(plan.nodes[0]
.output
.as_deref()
.is_some_and(|s| s.contains("acme.snippet")));
}
#[test]
fn table_header_cells_walked_before_body_cells() {
use std::sync::Mutex;
struct OrderedCapture {
seen: std::sync::Arc<Mutex<Vec<String>>>,
}
impl LexHandler for OrderedCapture {
fn on_render(
&self,
ctx: &LabelCtx,
_: Format,
) -> Result<Option<RenderOut>, HandlerError> {
self.seen.lock().unwrap().push(ctx.label.clone());
Ok(None)
}
}
let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
let registry = Registry::new();
registry
.register_namespace(
"acme",
vec![
schema("acme.in_header", &["html"]),
schema("acme.in_body", &["html"]),
],
Box::new(OrderedCapture { seen: seen.clone() }),
)
.unwrap();
use crate::ir::nodes::{
Annotation as IrAnn, DocNode as IrNode, Document as IrDoc, LabelForm,
Paragraph as IrPara, Table as IrTable, TableCell as IrCell, TableCellAlignment,
TableRow as IrRow,
};
fn cell_with_annotation(label: &str) -> IrCell {
IrCell {
content: vec![
IrNode::Annotation(IrAnn {
label: label.into(),
parameters: Vec::new(),
content: Vec::new(),
form: LabelForm::Canonical,
}),
IrNode::Paragraph(IrPara {
content: vec![crate::ir::nodes::InlineContent::Text("cell".into())],
}),
],
header: false,
align: TableCellAlignment::None,
colspan: 1,
rowspan: 1,
}
}
let doc = IrDoc {
title: None,
subtitle: None,
children: vec![IrNode::Table(IrTable {
rows: vec![IrRow {
cells: vec![cell_with_annotation("acme.in_body")],
}],
header: vec![IrRow {
cells: vec![cell_with_annotation("acme.in_header")],
}],
caption: None,
footnotes: Vec::new(),
fullwidth: false,
})],
document_annotations: Vec::new(),
};
let _ = dispatch_render(&doc, ®istry, "html");
let kinds = seen.lock().unwrap().clone();
assert_eq!(
kinds.as_slice(),
&["acme.in_header", "acme.in_body"],
"header cells must be walked before body cells"
);
}
#[test]
fn flatten_text_body_joins_paragraphs_with_space_not_newline() {
use crate::ir::nodes::{DocNode as IrNode, InlineContent, Paragraph};
let body = vec![
IrNode::Paragraph(Paragraph {
content: vec![InlineContent::Text("First line.".into())],
}),
IrNode::Paragraph(Paragraph {
content: vec![InlineContent::Text("Second line.".into())],
}),
];
let flat = flatten_text_body(&body);
assert_eq!(flat, "First line. Second line.");
assert!(
!flat.contains('\n'),
"flattened scalar must not contain raw newlines"
);
}
#[test]
fn flatten_text_body_processes_bare_inline_doc_nodes() {
use crate::ir::nodes::{DocNode as IrNode, InlineContent};
let body = vec![
IrNode::Inline(InlineContent::Text("Hello, ".into())),
IrNode::Inline(InlineContent::Text("world.".into())),
];
assert_eq!(flatten_text_body(&body), "Hello, world.");
}
#[test]
fn flatten_text_body_recurses_through_bold_and_italic() {
use crate::ir::nodes::{DocNode as IrNode, InlineContent, Paragraph};
let body = vec![IrNode::Paragraph(Paragraph {
content: vec![
InlineContent::Italic(vec![InlineContent::Text("Important".into())]),
InlineContent::Text(" ".into()),
InlineContent::Bold(vec![
InlineContent::Text("very ".into()),
InlineContent::Italic(vec![InlineContent::Text("bold".into())]),
]),
InlineContent::Text(" Title".into()),
],
})];
assert_eq!(flatten_text_body(&body), "Important very bold Title");
}
}