use lex_core::lex::ast::elements::{
inlines::InlineNode, Annotation as LexAnnotation, ContentItem as LexContentItem,
Definition as LexDefinition, Document as LexDocument, List as LexList, ListItem as LexListItem,
Paragraph as LexParagraph, Session as LexSession, TextLine as LexTextLine,
Verbatim as LexVerbatim, VerbatimLine as LexVerbatimLine,
};
use lex_core::lex::ast::TextContent;
use lex_core::lex::wire::{origin_string, range_to_wire};
use lex_extension::wire::{AnnotationBody, HostNodeKind, LabelCtx, NodeRef, WireNode};
use lex_extension_host::registry::Registry;
use super::anchoring::AnchorIndex;
use super::nodes::{
Annotation, Audio, Definition, DocNode, Document, Heading, Image, InlineContent, List,
ListForm, ListItem, ListStyle, Paragraph, Verbatim, Video,
};
struct ConvCtx<'a> {
registry: &'a Registry,
anchors: &'a AnchorIndex,
}
pub fn from_lex_document(doc: &LexDocument, registry: &Registry) -> Document {
let anchors = AnchorIndex::from_document(doc);
let ctx = ConvCtx {
registry,
anchors: &anchors,
};
let title = doc
.title
.as_ref()
.map(|t| convert_inline_content(&t.content));
let subtitle = doc
.title
.as_ref()
.and_then(|t| t.subtitle.as_ref())
.map(convert_inline_content);
let mut children = convert_children(&doc.root.children, 2, &ctx);
splice_self_links(&mut children, &doc.root.children, &anchors);
let document_annotations = doc
.annotations
.iter()
.map(|a| ir_annotation_from_lex(a, &ctx))
.collect();
Document {
title,
subtitle,
children,
document_annotations,
}
}
fn splice_self_links(
ir_children: &mut Vec<DocNode>,
lex_children: &[LexContentItem],
anchors: &AnchorIndex,
) {
let self_links = anchors.self_links_in(0..usize::MAX);
if self_links.is_empty() {
return;
}
splice_self_links_into(ir_children, lex_children, &self_links);
}
fn splice_self_links_into(
ir_children: &mut Vec<DocNode>,
lex_children: &[LexContentItem],
self_links: &[&super::anchoring::SelfLink],
) {
use lex_core::lex::ast::traits::AstNode;
struct Slot {
span: std::ops::Range<usize>,
ir_index: usize,
}
let mut slots: Vec<Slot> = Vec::new();
let mut ir_cursor = 0usize;
for item in lex_children
.iter()
.filter(|c| !matches!(c, LexContentItem::BlankLineGroup(_)))
{
let anno_count = attached_annotations(item).len();
let ir_index = ir_cursor + anno_count;
slots.push(Slot {
span: item.range().span.clone(),
ir_index,
});
ir_cursor = ir_index + ir_node_count(item);
}
let structural: Vec<&LexContentItem> = lex_children
.iter()
.filter(|c| !matches!(c, LexContentItem::BlankLineGroup(_)))
.collect();
let mut siblings: Vec<&super::anchoring::SelfLink> = Vec::new();
let mut nested: Vec<Vec<&super::anchoring::SelfLink>> = vec![Vec::new(); structural.len()];
for &sl in self_links {
let container = structural.iter().enumerate().find(|(_, item)| {
let span = item.range().span.clone();
span.start < sl.span.start && sl.span.start < span.end && has_recursable_children(item)
});
match container {
Some((si, _item)) => nested[si].push(sl),
None => siblings.push(sl),
}
}
for (si, links) in nested.iter().enumerate() {
if links.is_empty() {
continue;
}
let item = structural[si];
let ir_index = slots[si].ir_index;
recurse_into_container(&mut ir_children[ir_index], item, links);
}
let mut siblings_sorted = siblings;
siblings_sorted.sort_by_key(|s| std::cmp::Reverse(s.span.start));
for sl in siblings_sorted {
let preceding = slots
.iter()
.filter(|s| s.span.start < sl.span.start)
.count();
let ir_index = if preceding == 0 {
0
} else {
slots[preceding - 1].ir_index + ir_node_count(structural[preceding - 1])
};
let node = self_link_node(sl);
if ir_index <= ir_children.len() {
ir_children.insert(ir_index, node);
} else {
ir_children.push(node);
}
}
}
fn ir_node_count(item: &LexContentItem) -> usize {
if let LexContentItem::VerbatimBlock(verbatim) = item {
let groups = verbatim.group_len();
if groups > 1 {
return groups;
}
}
1
}
fn has_recursable_children(item: &LexContentItem) -> bool {
matches!(
item,
LexContentItem::Session(_)
| LexContentItem::List(_)
| LexContentItem::ListItem(_)
| LexContentItem::Definition(_)
| LexContentItem::Annotation(_)
)
}
fn recurse_into_container(
ir_node: &mut DocNode,
lex_item: &LexContentItem,
links: &[&super::anchoring::SelfLink],
) {
use lex_core::lex::ast::traits::AstNode;
match (ir_node, lex_item) {
(DocNode::Heading(h), LexContentItem::Session(s)) => {
splice_self_links_into(&mut h.children, &s.children, links);
}
(DocNode::ListItem(li), LexContentItem::ListItem(lex_li)) => {
splice_self_links_into(&mut li.children, &lex_li.children, links);
}
(DocNode::Definition(d), LexContentItem::Definition(lex_d)) => {
splice_self_links_into(&mut d.description, &lex_d.children, links);
}
(DocNode::Annotation(a), LexContentItem::Annotation(lex_a)) => {
splice_self_links_into(&mut a.content, &lex_a.children, links);
}
(DocNode::List(list), LexContentItem::List(lex_list)) => {
let lex_items: Vec<&LexContentItem> = lex_list
.items
.iter()
.filter(|c| matches!(c, LexContentItem::ListItem(_)))
.collect();
let item_starts: Vec<usize> = lex_items.iter().map(|c| c.range().span.start).collect();
let mut buckets: Vec<Vec<&super::anchoring::SelfLink>> =
vec![Vec::new(); lex_items.len()];
for &sl in links {
let target = item_starts.iter().rposition(|&start| start < sl.span.start);
if let Some(ti) = target {
buckets[ti].push(sl);
}
}
for (ir_li, (lex_li, bucket)) in list
.items
.iter_mut()
.zip(lex_items.iter().zip(buckets.iter()))
{
if bucket.is_empty() {
continue;
}
if let LexContentItem::ListItem(lex_li) = lex_li {
splice_self_links_into(&mut ir_li.children, &lex_li.children, bucket);
}
}
}
_ => {
}
}
}
fn self_link_node(sl: &super::anchoring::SelfLink) -> DocNode {
DocNode::Paragraph(Paragraph {
content: vec![InlineContent::Link {
text: sl.raw.clone(),
href: super::anchoring::reference_href(&sl.raw),
}],
})
}
fn ir_annotation_from_lex(annotation: &LexAnnotation, ctx: &ConvCtx) -> Annotation {
let label = annotation.data.label.value.clone();
let form = annotation.data.label.form;
let parameters = annotation
.data
.parameters
.iter()
.map(|p| (p.key.clone(), p.value.clone()))
.collect();
let content = convert_children(&annotation.children, 2, ctx);
Annotation {
label,
parameters,
content,
form,
}
}
fn convert_children(items: &[LexContentItem], level: usize, ctx: &ConvCtx) -> Vec<DocNode> {
items
.iter()
.filter(|item| !matches!(item, LexContentItem::BlankLineGroup(_)))
.flat_map(|item| {
let mut nodes = extract_attached_annotations(item, level, ctx);
if let LexContentItem::VerbatimBlock(verbatim) = item {
if verbatim.group_len() > 1 {
nodes.extend(from_lex_verbatim_groups(verbatim, ctx));
return nodes;
}
}
nodes.push(from_lex_content_item_with_level(item, level, ctx));
nodes
})
.collect()
}
fn attached_annotations(item: &LexContentItem) -> &[LexAnnotation] {
match item {
LexContentItem::Session(session) => session.annotations(),
LexContentItem::Paragraph(paragraph) => paragraph.annotations(),
LexContentItem::List(list) => list.annotations(),
LexContentItem::ListItem(list_item) => list_item.annotations(),
LexContentItem::Definition(definition) => definition.annotations(),
LexContentItem::VerbatimBlock(verbatim) => verbatim.annotations(),
LexContentItem::Table(table) => table.annotations(),
_ => &[],
}
}
fn extract_attached_annotations(
item: &LexContentItem,
level: usize,
ctx: &ConvCtx,
) -> Vec<DocNode> {
attached_annotations(item)
.iter()
.map(|anno| from_lex_annotation(anno, level, ctx))
.collect()
}
fn convert_inline_content(text: &TextContent) -> Vec<InlineContent> {
use crate::ir::anchoring::apply_word_anchors;
let inline_items = text.inline_items();
if inline_items.is_empty() {
return vec![InlineContent::Text(text.as_string().to_string())];
}
apply_word_anchors(&inline_items, &convert_inline_node)
}
fn convert_inline_node(node: &InlineNode) -> InlineContent {
match node {
InlineNode::Plain { text, .. } => InlineContent::Text(text.clone()),
InlineNode::Strong { content, .. } => {
InlineContent::Bold(content.iter().map(convert_inline_node).collect())
}
InlineNode::Emphasis { content, .. } => {
InlineContent::Italic(content.iter().map(convert_inline_node).collect())
}
InlineNode::Code { text, .. } => InlineContent::Code(text.clone()),
InlineNode::Math { text, .. } => InlineContent::Math(text.clone()),
InlineNode::Reference { data, .. } => InlineContent::Reference {
raw: data.raw.clone(),
kind: data.reference_type.clone(),
},
}
}
fn convert_head_line(text: &TextContent, ctx: &ConvCtx) -> Vec<InlineContent> {
if !ctx.anchors.is_empty() {
if let Some(anchor) = ctx.anchors.match_head_line(text) {
return super::anchoring::wrap_head_line(anchor);
}
}
convert_inline_content(text)
}
fn from_lex_content_item_with_level(item: &LexContentItem, level: usize, ctx: &ConvCtx) -> DocNode {
match item {
LexContentItem::Session(session) => from_lex_session(session, level, ctx),
LexContentItem::Paragraph(paragraph) => from_lex_paragraph(paragraph, ctx),
LexContentItem::List(list) => from_lex_list(list, level, ctx),
LexContentItem::ListItem(list_item) => from_lex_list_item(list_item, level, ctx),
LexContentItem::Definition(definition) => from_lex_definition(definition, level, ctx),
LexContentItem::VerbatimBlock(verbatim) => from_lex_verbatim(verbatim, ctx),
LexContentItem::Table(table) => from_lex_table(table, ctx),
LexContentItem::Annotation(annotation) => from_lex_annotation(annotation, level, ctx),
LexContentItem::TextLine(text_line) => from_lex_text_line(text_line),
LexContentItem::VerbatimLine(verbatim_line) => from_lex_verbatim_line(verbatim_line),
LexContentItem::BlankLineGroup(_) => {
DocNode::Paragraph(Paragraph { content: vec![] })
}
}
}
fn from_lex_session(session: &LexSession, level: usize, ctx: &ConvCtx) -> DocNode {
let content = convert_head_line(&session.title, ctx);
let children = convert_children(&session.children, level + 1, ctx);
DocNode::Heading(Heading {
level,
content,
children,
})
}
fn from_lex_paragraph(paragraph: &LexParagraph, ctx: &ConvCtx) -> DocNode {
let mut content = Vec::new();
for line_item in ¶graph.lines {
if let LexContentItem::TextLine(text_line) = line_item {
content.extend(convert_head_line(&text_line.content, ctx));
if line_item != paragraph.lines.last().unwrap() {
content.push(InlineContent::Text("\n".to_string()));
}
}
}
DocNode::Paragraph(Paragraph { content })
}
fn from_lex_list(list: &LexList, level: usize, ctx: &ConvCtx) -> DocNode {
let items: Vec<ListItem> = list
.items
.iter()
.filter_map(|item| {
if let LexContentItem::ListItem(li) = item {
Some(convert_list_item(li, level, ctx))
} else {
None
}
})
.collect();
let style = if let Some(LexContentItem::ListItem(li)) = list.items.first() {
detect_list_style(&li.marker)
} else {
ListStyle::Bullet
};
let ordered = style.is_ordered();
let form = list
.marker
.as_ref()
.map(|m| match m.form {
lex_core::lex::ast::elements::sequence_marker::Form::Extended => ListForm::Extended,
lex_core::lex::ast::elements::sequence_marker::Form::Short => ListForm::Short,
})
.unwrap_or(ListForm::Short);
DocNode::List(List {
items,
ordered,
style,
form,
})
}
fn from_lex_list_item(list_item: &LexListItem, level: usize, ctx: &ConvCtx) -> DocNode {
DocNode::ListItem(convert_list_item(list_item, level, ctx))
}
fn convert_list_item(list_item: &LexListItem, level: usize, ctx: &ConvCtx) -> ListItem {
let mut content = Vec::new();
for text_content in &list_item.text {
content.extend(convert_head_line(text_content, ctx));
}
let children = convert_children(&list_item.children, level, ctx);
ListItem { content, children }
}
fn from_lex_definition(definition: &LexDefinition, level: usize, ctx: &ConvCtx) -> DocNode {
let term = convert_head_line(&definition.subject, ctx);
let description = convert_children(&definition.children, level, ctx);
DocNode::Definition(Definition { term, description })
}
fn from_lex_verbatim(verbatim: &LexVerbatim, ctx: &ConvCtx) -> DocNode {
let subject_str = verbatim.subject.as_string();
let subject = if subject_str.is_empty() {
None
} else {
Some(subject_str.to_string())
};
let subject_href = ctx
.anchors
.match_head_line(&verbatim.subject)
.map(|a| a.href.clone());
let language = Some(verbatim.closing_data.label.value.clone());
let content = verbatim
.children
.iter()
.map(|item| {
if let LexContentItem::VerbatimLine(vl) = item {
vl.content.as_string().to_string()
} else {
"".to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let label = verbatim.closing_data.label.value.clone();
let params_object = serde_json::Value::Object(
verbatim
.closing_data
.parameters
.iter()
.map(|p| (p.key.clone(), serde_json::Value::String(p.unquoted_value())))
.collect(),
);
let label_ctx = LabelCtx {
label,
params: params_object,
body: AnnotationBody::Text(content.clone()),
node: NodeRef {
kind: HostNodeKind::Verbatim.as_str().into(),
range: range_to_wire(&verbatim.location),
origin: origin_string(&verbatim.location),
},
};
if let Ok(Some(mut wire_node)) = ctx.registry.dispatch_ir_build(&label_ctx) {
if let Some(s) = subject.as_deref() {
if !s.is_empty() {
inject_subject_into_wire_node(&mut wire_node, s);
}
}
if let Some(node) = from_wire_typed(&wire_node, ctx, &content) {
return node;
}
}
let parameters = verbatim
.closing_data
.parameters
.iter()
.map(|p| (p.key.clone(), p.value.clone()))
.collect();
DocNode::Verbatim(Verbatim {
subject,
subject_href,
language,
content,
parameters,
})
}
fn from_lex_verbatim_groups(verbatim: &LexVerbatim, ctx: &ConvCtx) -> Vec<DocNode> {
let language = Some(verbatim.closing_data.label.value.clone());
let parameters: Vec<(String, String)> = verbatim
.closing_data
.parameters
.iter()
.map(|p| (p.key.clone(), p.value.clone()))
.collect();
verbatim
.group()
.map(|group| {
let subject_str = group.subject.as_string();
let subject = if subject_str.is_empty() {
None
} else {
Some(subject_str.to_string())
};
let subject_href = ctx
.anchors
.match_head_line(group.subject)
.map(|a| a.href.clone());
let content = group
.children
.iter()
.map(|item| {
if let LexContentItem::VerbatimLine(vl) = item {
vl.content.as_string().to_string()
} else {
String::new()
}
})
.collect::<Vec<_>>()
.join("\n");
DocNode::Verbatim(Verbatim {
subject,
subject_href,
language: language.clone(),
content,
parameters: parameters.clone(),
})
})
.collect()
}
fn inject_subject_into_wire_node(wire_node: &mut WireNode, subject: &str) {
match wire_node {
WireNode::Table { caption, .. } if caption.is_empty() => {
*caption = subject.to_string();
}
WireNode::Image { title, alt, .. } => {
if title.is_none() {
*title = Some(subject.to_string());
}
if alt.is_empty() {
*alt = subject.to_string();
}
}
WireNode::Video { title, .. } if title.is_none() => {
*title = Some(subject.to_string());
}
WireNode::Audio { title, .. } if title.is_none() => {
*title = Some(subject.to_string());
}
_ => {}
}
}
fn from_wire_typed(wire_node: &WireNode, ctx: &ConvCtx, fallback_content: &str) -> Option<DocNode> {
match wire_node {
WireNode::Table { .. } => {
let items = lex_core::lex::wire::from_wire_node(wire_node).ok()?;
let LexContentItem::Table(table) = items.into_iter().next()? else {
return None;
};
Some(from_lex_table(&table, ctx))
}
WireNode::Image {
src, alt, title, ..
} => Some(DocNode::Image(Image {
src: src.clone(),
alt: if alt.is_empty() {
fallback_content.trim().to_string()
} else {
alt.clone()
},
title: title.clone(),
})),
WireNode::Video {
src, title, poster, ..
} => Some(DocNode::Video(Video {
src: src.clone(),
title: title.clone(),
poster: poster.clone(),
})),
WireNode::Audio { src, title, .. } => Some(DocNode::Audio(Audio {
src: src.clone(),
title: title.clone(),
})),
_ => None,
}
}
fn from_lex_annotation(annotation: &LexAnnotation, level: usize, ctx: &ConvCtx) -> DocNode {
let label = annotation.data.label.value.clone();
let form = annotation.data.label.form;
let parameters = annotation
.data
.parameters
.iter()
.map(|p| (p.key.clone(), p.value.clone()))
.collect();
let content = convert_children(&annotation.children, level, ctx);
DocNode::Annotation(Annotation {
label,
parameters,
content,
form,
})
}
fn from_lex_text_line(text_line: &LexTextLine) -> DocNode {
let content = convert_inline_content(&text_line.content);
DocNode::Paragraph(Paragraph { content })
}
fn from_lex_table(table: &lex_core::lex::ast::Table, ctx: &ConvCtx) -> DocNode {
use crate::ir::nodes::{
Table as IrTable, TableCell as IrTableCell, TableCellAlignment as IrAlign,
TableRow as IrTableRow,
};
let convert_align = |a: lex_core::lex::ast::TableCellAlignment| -> IrAlign {
match a {
lex_core::lex::ast::TableCellAlignment::Left => IrAlign::Left,
lex_core::lex::ast::TableCellAlignment::Center => IrAlign::Center,
lex_core::lex::ast::TableCellAlignment::Right => IrAlign::Right,
lex_core::lex::ast::TableCellAlignment::None => IrAlign::None,
}
};
let convert_row = |row: &lex_core::lex::ast::TableRow| -> IrTableRow {
IrTableRow {
cells: row
.cells
.iter()
.map(|cell| {
let content = if cell.has_block_content() {
convert_children(&cell.children, 2, ctx)
} else {
vec![DocNode::Paragraph(Paragraph {
content: convert_inline_content(&cell.content),
})]
};
IrTableCell {
content,
header: cell.header,
align: convert_align(cell.align),
colspan: cell.colspan,
rowspan: cell.rowspan,
}
})
.collect(),
}
};
let header: Vec<IrTableRow> = table.header_rows.iter().map(convert_row).collect();
let rows: Vec<IrTableRow> = table.body_rows.iter().map(convert_row).collect();
let caption = if table.subject.as_string().is_empty() {
None
} else {
Some(convert_inline_content(&table.subject))
};
let footnotes = table
.footnotes
.as_ref()
.map(|list| vec![from_lex_list(list, 2, ctx)])
.unwrap_or_default();
let fullwidth = matches!(
table.mode,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Fullwidth
);
DocNode::Table(IrTable {
rows,
header,
caption,
footnotes,
fullwidth,
})
}
fn from_lex_verbatim_line(verbatim_line: &LexVerbatimLine) -> DocNode {
let content = verbatim_line.content.as_string().to_string();
DocNode::Verbatim(Verbatim {
subject: None,
subject_href: None,
language: None,
content,
parameters: Vec::new(),
})
}
fn detect_list_style(marker: &TextContent) -> ListStyle {
let marker_text = marker.as_string().trim();
if marker_text.is_empty() {
return ListStyle::Bullet;
}
let label = marker_text.trim_end_matches(['.', ')']);
if label.is_empty() {
return ListStyle::Bullet;
}
if matches!(label, "-" | "*" | "+" | "–" | "—") {
return ListStyle::Bullet;
}
if label.chars().all(|c| c.is_ascii_digit()) {
return ListStyle::Numeric;
}
if label
.chars()
.all(|c| matches!(c, 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M'))
{
return ListStyle::RomanUpper;
}
if label
.chars()
.all(|c| matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm'))
{
return ListStyle::RomanLower;
}
if label.chars().all(|c| c.is_ascii_uppercase()) {
return ListStyle::AlphaUpper;
}
if label.chars().all(|c| c.is_ascii_lowercase()) {
return ListStyle::AlphaLower;
}
if marker_text.contains('.') || marker_text.contains(')') {
ListStyle::Numeric
} else {
ListStyle::Bullet
}
}
#[cfg(test)]
mod tests {
use super::*;
use lex_core::lex::ast::elements::{
List as LexList, ListItem as LexListItem, Paragraph as LexParagraph, Session as LexSession,
VerbatimContent,
};
use lex_core::lex::ast::{ContentItem, Document as LexDocument, TextContent};
fn test_registry() -> &'static Registry {
crate::default_registry()
}
fn test_ctx() -> ConvCtx<'static> {
static ANCHORS: std::sync::OnceLock<AnchorIndex> = std::sync::OnceLock::new();
ConvCtx {
registry: test_registry(),
anchors: ANCHORS.get_or_init(AnchorIndex::default),
}
}
#[test]
fn test_simple_paragraph_conversion() {
let lex_para = LexParagraph::from_line("Hello world".to_string());
let ir_node = from_lex_paragraph(&lex_para, &test_ctx());
match ir_node {
DocNode::Paragraph(para) => {
assert_eq!(para.content.len(), 1);
assert!(
matches!(¶.content[0], InlineContent::Text(text) if text == "Hello world")
);
}
_ => panic!("Expected Paragraph node"),
}
}
#[test]
fn test_session_to_heading() {
let session = LexSession::with_title("Test Section".to_string());
let ir_node = from_lex_session(&session, 1, &test_ctx());
match ir_node {
DocNode::Heading(heading) => {
assert_eq!(heading.level, 1);
assert_eq!(heading.content.len(), 1);
assert!(heading.children.is_empty());
}
_ => panic!("Expected Heading node"),
}
}
#[test]
fn test_list_conversion() {
let item1 = LexListItem::new("-".to_string(), "Item 1".to_string());
let item2 = LexListItem::new("-".to_string(), "Item 2".to_string());
let list = LexList::new(vec![item1, item2]);
let ir_node = from_lex_list(&list, 1, &test_ctx());
match ir_node {
DocNode::List(list) => {
assert_eq!(list.items.len(), 2);
}
_ => panic!("Expected List node"),
}
}
#[test]
fn test_verbatim_language_extraction() {
let subject = TextContent::from_string("".to_string(), None);
let content = vec![VerbatimContent::VerbatimLine(LexVerbatimLine::new(
"code here".to_string(),
))];
let closing_data = lex_core::lex::ast::Data::new(
lex_core::lex::ast::elements::Label::new("rust".to_string()),
Vec::new(),
);
let verb = LexVerbatim::new(
subject,
content,
closing_data,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Inflow,
);
let ir_node = from_lex_verbatim(&verb, &test_ctx());
match ir_node {
DocNode::Verbatim(verb) => {
assert_eq!(verb.language, Some("rust".to_string()));
assert_eq!(verb.content, "code here");
}
_ => panic!("Expected Verbatim node"),
}
}
#[test]
fn test_blank_lines_filtered() {
let para = ContentItem::Paragraph(LexParagraph::from_line("Test".to_string()));
let blank = ContentItem::BlankLineGroup(lex_core::lex::ast::elements::BlankLineGroup::new(
1,
Vec::new(),
));
let children = convert_children(&[para, blank], 1, &test_ctx());
assert_eq!(children.len(), 1);
}
#[test]
fn test_full_document_conversion() {
let doc = LexDocument::with_content(vec![ContentItem::Paragraph(LexParagraph::from_line(
"Test paragraph".to_string(),
))]);
let ir_doc = from_lex_document(&doc, test_registry());
assert_eq!(ir_doc.children.len(), 1);
assert!(matches!(ir_doc.children[0], DocNode::Paragraph(_)));
}
fn doc_with_one_annotation(label: &str, value: &str) -> LexDocument {
use lex_core::lex::ast::elements::annotation::Annotation as LexAnnotation;
use lex_core::lex::ast::elements::label::Label;
use lex_core::lex::ast::elements::paragraph::Paragraph;
use lex_core::lex::ast::elements::typed_content::ContentElement;
let body = ContentElement::Paragraph(Paragraph::from_line(value.to_string()));
let ann = LexAnnotation::new(Label::from_string(label), Vec::new(), vec![body]);
let mut doc = LexDocument::new();
doc.annotations.push(ann);
doc
}
#[test]
fn document_annotations_field_is_populated_from_lex_document() {
let doc = doc_with_one_annotation("acme.custom", "Body text.");
let ir_doc = from_lex_document(&doc, test_registry());
assert_eq!(
ir_doc.document_annotations.len(),
1,
"one document-scope annotation must populate document_annotations"
);
let ann = &ir_doc.document_annotations[0];
assert_eq!(ann.label, "acme.custom");
assert_eq!(ann.content.len(), 1);
}
#[test]
fn document_annotations_empty_when_no_document_scope_annotations() {
let doc = LexDocument::with_content(vec![ContentItem::Paragraph(LexParagraph::from_line(
"Body only.".to_string(),
))]);
let ir_doc = from_lex_document(&doc, test_registry());
assert!(
ir_doc.document_annotations.is_empty(),
"empty input must produce empty document_annotations"
);
}
#[test]
fn normalize_labels_pipeline_writes_canonical_labels_into_ast() {
use lex_core::lex::transforms::standard::STRING_TO_AST;
let lex_doc = STRING_TO_AST
.run(":: title :: My Doc\n\nBody.\n".to_string())
.expect("parse ok");
let title_in_ast = lex_doc
.annotations
.first()
.or_else(|| {
lex_doc.root.children.iter().find_map(|item| match item {
lex_core::lex::ast::ContentItem::Annotation(a) => Some(a),
_ => None,
})
})
.expect("title annotation parsed");
assert_eq!(title_in_ast.data.label.value, "lex.metadata.title");
let ir = from_lex_document(&lex_doc, test_registry());
let frontmatter_in_children = ir
.children
.iter()
.any(|c| matches!(c, DocNode::Annotation(a) if a.label == "frontmatter"));
assert!(
!frontmatter_in_children,
"frontmatter must no longer be synthesized in children"
);
}
#[test]
fn verbatim_subject_becomes_image_title_when_handler_left_it_empty() {
let subject = TextContent::from_string("Sunset Photo".to_string(), None);
let label = lex_core::lex::ast::elements::Label::new("lex.media.image".to_string());
let parameters = vec![lex_core::lex::ast::Parameter {
key: "src".to_string(),
value: "sunset.jpg".to_string(),
location: lex_core::lex::ast::Range::default(),
}];
let closing_data = lex_core::lex::ast::Data::new(label, parameters);
let verb = LexVerbatim::new(
subject,
Vec::new(),
closing_data,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Inflow,
);
let ir_node = from_lex_verbatim(&verb, &test_ctx());
match ir_node {
DocNode::Image(image) => {
assert_eq!(image.src, "sunset.jpg");
assert_eq!(
image.title.as_deref(),
Some("Sunset Photo"),
"subject must be restored as the image title"
);
}
other => panic!("expected DocNode::Image, got {other:?}"),
}
}
#[test]
fn verbatim_subject_becomes_table_caption_when_handler_left_it_empty() {
let subject = TextContent::from_string("Quarterly results".to_string(), None);
let content = vec![
VerbatimContent::VerbatimLine(LexVerbatimLine::new("| Q1 | Q2 |".to_string())),
VerbatimContent::VerbatimLine(LexVerbatimLine::new("| -- | -- |".to_string())),
VerbatimContent::VerbatimLine(LexVerbatimLine::new("| 10 | 20 |".to_string())),
];
let closing_data = lex_core::lex::ast::Data::new(
lex_core::lex::ast::elements::Label::new("lex.tabular.table".to_string()),
Vec::new(),
);
let verb = LexVerbatim::new(
subject,
content,
closing_data,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Inflow,
);
let ir_node = from_lex_verbatim(&verb, &test_ctx());
match ir_node {
DocNode::Table(table) => {
let caption = table
.caption
.as_ref()
.map(|inlines| {
inlines
.iter()
.filter_map(|inline| match inline {
crate::ir::nodes::InlineContent::Text(t) => Some(t.as_str()),
_ => None,
})
.collect::<String>()
})
.unwrap_or_default();
assert_eq!(
caption, "Quarterly results",
"subject must be restored as the table caption"
);
}
other => panic!("expected DocNode::Table, got {other:?}"),
}
}
#[test]
fn verbatim_subject_does_not_overwrite_handler_set_image_title() {
let subject = TextContent::from_string("Subject Wins?".to_string(), None);
let label = lex_core::lex::ast::elements::Label::new("lex.media.image".to_string());
let parameters = vec![
lex_core::lex::ast::Parameter {
key: "src".to_string(),
value: "x.jpg".to_string(),
location: lex_core::lex::ast::Range::default(),
},
lex_core::lex::ast::Parameter {
key: "title".to_string(),
value: "Param Wins".to_string(),
location: lex_core::lex::ast::Range::default(),
},
];
let closing_data = lex_core::lex::ast::Data::new(label, parameters);
let verb = LexVerbatim::new(
subject,
Vec::new(),
closing_data,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Inflow,
);
let ir_node = from_lex_verbatim(&verb, &test_ctx());
match ir_node {
DocNode::Image(image) => {
assert_eq!(image.title.as_deref(), Some("Param Wins"));
}
other => panic!("expected DocNode::Image, got {other:?}"),
}
}
#[test]
fn document_annotations_is_source_of_truth_for_doc_metadata() {
let doc = doc_with_one_annotation("author", "Alice");
let ir_doc = from_lex_document(&doc, test_registry());
assert_eq!(ir_doc.document_annotations.len(), 1);
assert_eq!(ir_doc.document_annotations[0].label, "author");
let frontmatter_in_children = ir_doc
.children
.iter()
.any(|c| matches!(c, DocNode::Annotation(a) if a.label == "frontmatter"));
assert!(
!frontmatter_in_children,
"synthetic frontmatter must not appear in children after cleanup"
);
}
#[test]
fn ir_build_dispatch_hydrates_table_verbatim_to_typed_table() {
let subject = TextContent::from_string("Sales".to_string(), None);
let body = vec![
VerbatimContent::VerbatimLine(LexVerbatimLine::new("| q | r |".to_string())),
VerbatimContent::VerbatimLine(LexVerbatimLine::new("|---|---|".to_string())),
VerbatimContent::VerbatimLine(LexVerbatimLine::new("| 7 | 9 |".to_string())),
];
let closing_data = lex_core::lex::ast::Data::new(
lex_core::lex::ast::elements::Label::new("lex.tabular.table".to_string()),
Vec::new(),
);
let verb = LexVerbatim::new(
subject,
body,
closing_data,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Inflow,
);
match from_lex_verbatim(&verb, &test_ctx()) {
DocNode::Table(_) => {}
other => panic!(
"table verbatim must hydrate via dispatch_ir_build to DocNode::Table; got {other:?}"
),
}
}
#[test]
fn ir_build_dispatch_hydrates_video_via_wire_kind_switch() {
let subject = TextContent::from_string("".to_string(), None);
let label = lex_core::lex::ast::elements::Label::new("lex.media.video".to_string());
let parameters = vec![
lex_core::lex::ast::Parameter {
key: "src".to_string(),
value: "intro.mp4".to_string(),
location: lex_core::lex::ast::Range::default(),
},
lex_core::lex::ast::Parameter {
key: "poster".to_string(),
value: "intro.png".to_string(),
location: lex_core::lex::ast::Range::default(),
},
];
let closing_data = lex_core::lex::ast::Data::new(label, parameters);
let verb = LexVerbatim::new(
subject,
Vec::new(),
closing_data,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Inflow,
);
match from_lex_verbatim(&verb, &test_ctx()) {
DocNode::Video(video) => {
assert_eq!(video.src, "intro.mp4");
assert_eq!(video.poster.as_deref(), Some("intro.png"));
}
other => panic!("expected DocNode::Video, got {other:?}"),
}
}
#[test]
fn unhandled_label_falls_back_to_generic_verbatim() {
let subject = TextContent::from_string("".to_string(), None);
let body = vec![VerbatimContent::VerbatimLine(LexVerbatimLine::new(
"raw body".to_string(),
))];
let closing_data = lex_core::lex::ast::Data::new(
lex_core::lex::ast::elements::Label::new("acme.unknown".to_string()),
Vec::new(),
);
let verb = LexVerbatim::new(
subject,
body,
closing_data,
lex_core::lex::ast::elements::verbatim::VerbatimBlockMode::Inflow,
);
match from_lex_verbatim(&verb, &test_ctx()) {
DocNode::Verbatim(v) => {
assert_eq!(v.content, "raw body");
assert_eq!(v.language.as_deref(), Some("acme.unknown"));
}
other => {
panic!("unrouted verbatim label must fall back to generic Verbatim; got {other:?}")
}
}
}
#[test]
fn default_registry_carries_doc_metadata_schemas() {
let r = test_registry();
for label in [
"doc.title",
"doc.author",
"doc.date",
"doc.tags",
"doc.category",
"doc.template",
] {
let schema = r
.schema_for(label)
.unwrap_or_else(|| panic!("default registry must carry {label}"));
assert_eq!(schema.label, label);
let formats: Vec<&str> = schema.hooks.render.iter().map(|h| h.0.as_str()).collect();
assert!(
formats.contains(&"markdown") && formats.contains(&"html"),
"{label} must declare markdown + html render hooks; got {formats:?}"
);
}
}
#[test]
fn doc_render_dispatch_emits_markdown_yaml_line_via_unified_surface() {
use lex_extension::wire::{AnnotationBody, Format, NodeRef, Position, Range, RenderOut};
let r = test_registry();
let ctx = lex_extension::wire::LabelCtx {
label: "doc.author".into(),
params: serde_json::Value::Null,
body: AnnotationBody::Text("Alice".into()),
node: NodeRef {
kind: "document".into(),
range: Range {
start: Position(0, 0),
end: Position(0, 0),
},
origin: None,
},
};
let out = r
.dispatch_render(&ctx, Format::Markdown)
.expect("dispatch_render ok")
.expect("doc.author must produce a rendered output");
match out {
RenderOut::String { string } => assert_eq!(string, "author: \"Alice\"\n"),
other => panic!("expected String, got {other:?}"),
}
}
use lex_core::lex::transforms::standard::STRING_TO_AST;
fn paragraph_link_href(node: &DocNode) -> Option<&str> {
if let DocNode::Paragraph(p) = node {
if let Some(InlineContent::Link { href, .. }) = p.content.first() {
return Some(href.as_str());
}
}
None
}
#[test]
fn self_link_after_paragraph_with_attached_annotation_lands_after_it() {
let src = "First paragraph.\n\n:: note ::\n A side note.\nAnchored paragraph.\n\n[https://after.example]\n\nTrailing paragraph.\n";
let doc = STRING_TO_AST.run(src.to_string()).expect("parse ok");
let ir = from_lex_document(&doc, test_registry());
let sl_pos = ir
.children
.iter()
.position(|c| paragraph_link_href(c) == Some("https://after.example"))
.expect("self-link paragraph must be present");
let before = &ir.children[sl_pos - 1];
let before_is_anchored_para = matches!(
before,
DocNode::Paragraph(p) if p.content.iter().any(|c| matches!(c, InlineContent::Text(t) if t.contains("Anchored paragraph")))
);
assert!(
before_is_anchored_para,
"self-link must follow the annotated paragraph, not split it from its annotation; got {before:?}"
);
let annotation_before_para = ir.children[..sl_pos]
.iter()
.any(|c| matches!(c, DocNode::Annotation(a) if a.label == "note"));
assert!(
annotation_before_para,
"the attached annotation must remain ahead of its paragraph"
);
}
#[test]
fn self_link_nested_in_list_item_lands_in_that_item() {
let src =
"Intro.\n\n- First item\n Nested body.\n\n [https://nested.example]\n- Second item\n\nDone.\n";
let doc = STRING_TO_AST.run(src.to_string()).expect("parse ok");
let ir = from_lex_document(&doc, test_registry());
let at_top = ir
.children
.iter()
.any(|c| paragraph_link_href(c) == Some("https://nested.example"));
assert!(
!at_top,
"nested self-link must not be spliced at the document top level"
);
let list = ir
.children
.iter()
.find_map(|c| match c {
DocNode::List(l) => Some(l),
_ => None,
})
.expect("a list must be present");
let first_item = list.items.first().expect("list has a first item");
let nested = first_item
.children
.iter()
.any(|c| paragraph_link_href(c) == Some("https://nested.example"));
assert!(
nested,
"nested self-link must be spliced into the first list item's children; got {:?}",
first_item.children
);
}
}