pub(crate) mod bundles;
mod classes;
mod labels;
pub(crate) mod scene;
mod types;
use crate::error::Error;
use crate::resolve::NodeKind;
use crate::span::Span;
use crate::syntax::ast::{
Child, Decl, File, Link, Node, Rule, SelUnit, Selector, StyleItem, TextNode, Value,
};
use bundles::root_defaults;
use classes::{class_defs, is_lini_class, lini_class, merge_decls, worn_classes};
use std::collections::{BTreeSet, HashMap};
use types::{Types, is_template};
type Bodies = HashMap<String, (Vec<Child>, Vec<Link>)>;
pub fn desugar(file: &File) -> Result<File, Error> {
let types = Types::build(file)?;
let mut element_rules: HashMap<String, Vec<Decl>> = HashMap::new();
let mut bodies: Bodies = HashMap::new();
let mut extra_order: Vec<String> = Vec::new();
let mut user_root: Vec<Decl> = Vec::new();
let mut user_vars: Vec<Decl> = Vec::new();
let mut user_rules: Vec<Rule> = Vec::new();
let mut user_funcs: Vec<crate::syntax::ast::FuncDef> = Vec::new();
for item in &file.stylesheet {
match item {
StyleItem::RootDecl(d) => user_root.push(d.clone()),
StyleItem::Func(f) => user_funcs.push(f.clone()),
StyleItem::Var(d) => user_vars.push(d.clone()),
StyleItem::Define(d) => {
element_rules
.entry(d.name.clone())
.or_default()
.extend(d.style.iter().cloned());
bodies.insert(d.name.clone(), (d.children.clone(), d.links.clone()));
push_unique(&mut extra_order, &d.name);
}
StyleItem::Rule(r) => match r.selector.units.as_slice() {
[SelUnit::Type { name, id: None }] => element_rules
.entry(name.clone())
.or_default()
.extend(r.decls.iter().cloned()),
[SelUnit::Class(c)] if is_lini_class(c) && c == "lini-link" => {
user_rules.push(rewrite_selector(r, &types)?)
}
[SelUnit::Class(c)] if is_lini_class(c) => {
let x = c.strip_prefix("lini-").unwrap().to_string();
element_rules
.entry(x.clone())
.or_default()
.extend(r.decls.iter().cloned());
if NodeKind::parse(&x).is_none() && !is_template(&x) {
push_unique(&mut extra_order, &x);
}
}
_ => user_rules.push(rewrite_selector(r, &types)?),
},
}
}
let mut instances = Vec::new();
for child in &file.instances {
instances.push(lower_child(child, &types, &bodies)?);
}
let declared = scene::declared_ids(&instances);
let mut root_msgs: Vec<&Link> = file.links.iter().collect();
root_msgs.extend(gather_frame_messages(&instances));
for (id, span) in scene::auto_created_ids(&root_msgs, &declared) {
instances.push(Child::Box(lower_node(
&scene::auto_box(&id, span),
&types,
&bodies,
)?));
}
let mut present: BTreeSet<String> = BTreeSet::new();
for c in &instances {
mark_present(c, &mut present);
}
let mut stylesheet: Vec<StyleItem> = Vec::new();
let base = merge_decls(
root_defaults(),
&bundles::root_layout_defaults(root_layout(&user_root)),
);
for d in merge_decls(base, &user_root) {
stylesheet.push(StyleItem::RootDecl(d));
}
for d in user_vars {
stylesheet.push(StyleItem::Var(d));
}
for f in user_funcs {
stylesheet.push(StyleItem::Func(f));
}
let synthesizes_shapes = ["chart", "pie", "sequence"]
.iter()
.any(|t| present.contains(*t))
|| root_layout(&user_root) == Some("sequence");
for r in class_defs(&present, &element_rules, &extra_order, synthesizes_shapes) {
stylesheet.push(StyleItem::Rule(r));
}
for r in user_rules {
stylesheet.push(StyleItem::Rule(r));
}
Ok(File {
stylesheet,
stylesheet_span: Span::empty(),
instances,
links: file.links.iter().map(labels::lower_link).collect(),
})
}
pub(crate) const FRAME_TYPES: [&str; 4] = ["loop", "opt", "alt", "else"];
fn is_frame_classes(classes: &[String]) -> bool {
classes.iter().any(|c| {
c.strip_prefix("lini-")
.is_some_and(|x| FRAME_TYPES.contains(&x))
})
}
fn gather_frame_messages(children: &[Child]) -> Vec<&Link> {
let mut out = Vec::new();
for c in children {
if let Child::Box(n) = c
&& is_frame_classes(&n.classes)
{
out.extend(n.links.iter());
out.extend(gather_frame_messages(&n.children));
}
}
out
}
fn lower_child(child: &Child, types: &Types, bodies: &Bodies) -> Result<Child, Error> {
match child {
Child::Box(n) => Ok(Child::Box(lower_node(n, types, bodies)?)),
Child::Text(t) => Ok(Child::Text(t.clone())),
}
}
fn decl(name: &str, values: Vec<Value>) -> Decl {
Decl {
name: name.into(),
groups: vec![values],
span: Span::empty(),
}
}
fn column_count(style: &[Decl], chain: &[String]) -> Option<usize> {
if let Some(d) = style.iter().find(|d| d.name == "columns") {
let n = count_tracks(d);
if n > 0 {
return Some(n);
}
}
chain.iter().rev().find_map(|name| {
let n = count_tracks(
bundles::template_bundle(name)
.iter()
.find(|d| d.name == "columns")?,
);
(n > 0).then_some(n)
})
}
fn count_tracks(d: &Decl) -> usize {
d.groups
.iter()
.flatten()
.map(|v| match v {
Value::Call(c) if c.name == "repeat" => match c.args.first() {
Some(Value::Number(n)) if *n >= 1.0 => *n as usize,
_ => 1,
},
_ => 1,
})
.sum()
}
fn header_node(text: &TextNode, span: Option<usize>) -> Node {
let style = match span {
Some(cols) => vec![
decl("cell", vec![Value::Number(1.0), Value::Number(1.0)]),
decl("span", vec![Value::Number(cols as f64)]),
],
None => Vec::new(),
};
Node {
id: None,
ty: Some("header".into()),
label: Some(text.clone()),
classes: Vec::new(),
style,
style_span: None,
children: Vec::new(),
links: Vec::new(),
span: text.span,
}
}
fn block_cell(text: &TextNode) -> Node {
Node {
id: None,
ty: Some("cell".into()),
label: None,
classes: Vec::new(),
style: Vec::new(),
style_span: None,
children: vec![Child::Text(text.clone())],
links: Vec::new(),
span: text.span,
}
}
fn wrap_body_cells(children: &mut [Child], types: &Types, bodies: &Bodies) -> Result<(), Error> {
for c in children.iter_mut() {
if let Child::Text(t) = c {
*c = Child::Box(lower_node(&block_cell(t), types, bodies)?);
}
}
Ok(())
}
fn distribute_cell_alignment(children: &mut [Child], table_style: &[Decl], cols: usize) {
let h = per_column(table_style, "align", cols);
let v = per_column(table_style, "justify", cols);
if h.is_none() && v.is_none() {
return;
}
for (i, child) in children.iter_mut().enumerate() {
let Child::Box(cell) = child else { continue };
let col = i % cols;
for (list, axis) in [(&h, "align"), (&v, "justify")] {
if let Some(vals) = list
&& matches!(vals[col].as_str(), "start" | "end")
{
let class = lini_class(&format!("{axis}-{}", vals[col]));
if !cell.classes.contains(&class) {
cell.classes.push(class);
}
}
}
}
}
fn per_column(style: &[Decl], name: &str, cols: usize) -> Option<Vec<String>> {
let d = style.iter().find(|d| d.name == name)?;
let vals: Vec<String> = d
.groups
.iter()
.flatten()
.filter_map(|v| match v {
Value::Ident(s) => Some(s.clone()),
_ => None,
})
.collect();
let first = vals.first()?.clone();
Some(
(0..cols)
.map(|c| vals.get(c).cloned().unwrap_or_else(|| first.clone()))
.collect(),
)
}
fn wrap_header_row(
children: &mut [Child],
cols: usize,
types: &Types,
bodies: &Bodies,
) -> Result<(), Error> {
let row_end = cols.min(children.len());
if row_end == 0
|| !children[..row_end]
.iter()
.all(|c| matches!(c, Child::Text(_)))
{
return Ok(());
}
for c in &mut children[..row_end] {
if let Child::Text(t) = c {
*c = Child::Box(lower_node(&header_node(t, None), types, bodies)?);
}
}
Ok(())
}
fn lower_node(node: &Node, types: &Types, bodies: &Bodies) -> Result<Node, Error> {
let ty = node.ty.as_deref().unwrap_or("box");
let info = types.resolve(ty, node.span)?;
let kind = info.kind;
let already = NodeKind::parse(ty).is_some()
&& node.classes.iter().any(|c| *c == lini_class(kind.as_str()));
let classes = if already {
node.classes.clone()
} else {
let mut cs = worn_classes(&info);
cs.extend(node.classes.iter().cloned());
cs
};
let new_ty = if already {
node.ty.clone()
} else {
Some(kind.as_str().to_string())
};
let mut children = Vec::new();
if !already {
for name in &info.chain {
if let Some((body, _)) = bodies.get(name) {
for c in body {
children.push(lower_child(c, types, bodies)?);
}
}
}
}
for c in &node.children {
children.push(lower_child(c, types, bodies)?);
}
let is_entity = info.chain.iter().any(|n| n == "entity");
let is_table = !is_entity && info.chain.iter().any(|n| n == "table");
let cols = column_count(&node.style, &info.chain);
if is_table && let Some(cols) = cols {
wrap_header_row(&mut children, cols, types, bodies)?;
}
if is_table || is_entity {
wrap_body_cells(&mut children, types, bodies)?;
}
if (is_table || is_entity)
&& let Some(cols) = cols
{
distribute_cell_alignment(&mut children, &node.style, cols);
}
let text_capable = !matches!(
kind,
NodeKind::Line | NodeKind::Poly | NodeKind::Path | NodeKind::Image
);
let is_icon = kind == NodeKind::Icon;
let is_container = info.chain.iter().any(|n| n == "group");
let mut style: Vec<Decl> = if is_table || is_entity {
node.style
.iter()
.filter(|d| d.name != "align" && d.name != "justify")
.cloned()
.collect()
} else {
node.style.clone()
};
let mut kept_label = None;
if let Some(label) = node.label.as_ref().filter(|l| !l.text.is_empty()) {
if is_icon {
if style.iter().any(|d| d.name == "symbol") {
return Err(Error::at(
node.span,
"an icon's symbol is its label or 'symbol:', not both",
));
}
style.push(labels::symbol_decl(&label.text, node.span));
} else if is_entity {
let title = header_node(label, Some(cols.unwrap_or(2)));
children.insert(0, Child::Box(lower_node(&title, types, bodies)?));
} else if is_container {
let caption = lower_node(&labels::caption_node(label), types, bodies)?;
children.insert(0, Child::Box(caption));
} else if text_capable {
children.insert(0, Child::Text(label.clone()));
} else {
kept_label = Some(label.clone());
}
}
if is_entity && let Some(cols) = cols {
for child in &mut children {
if let Child::Box(n) = child
&& n.classes
.iter()
.any(|c| c == "lini-header" || c == "lini-footer")
&& !n.style.iter().any(|d| d.name == "span")
{
n.style.push(decl("span", vec![Value::Number(cols as f64)]));
}
}
}
let mut links = Vec::new();
if !already {
for name in &info.chain {
if let Some((_, body)) = bodies.get(name) {
for w in body {
links.push(labels::lower_link(w));
}
}
}
}
for w in &node.links {
links.push(labels::lower_link(w));
}
if !already && !is_frame_classes(&classes) {
let declared = scene::declared_ids(&children);
let to_create = {
let mut msgs: Vec<&Link> = node.links.iter().collect();
msgs.extend(gather_frame_messages(&children));
scene::auto_created_ids(&msgs, &declared)
};
for (auto_id, auto_span) in to_create {
let created = lower_node(&scene::auto_box(&auto_id, auto_span), types, bodies)?;
children.push(Child::Box(created));
}
}
Ok(Node {
id: node.id.clone(),
ty: new_ty,
label: kept_label,
classes,
style,
style_span: node.style_span,
children,
links,
span: node.span,
})
}
fn rewrite_selector(rule: &Rule, types: &Types) -> Result<Rule, Error> {
let mut units = Vec::with_capacity(rule.selector.units.len());
for unit in &rule.selector.units {
match unit {
SelUnit::Type { name, id } => {
let class = if is_lini_class(name) {
name.clone()
} else if types.is_known(name) {
lini_class(name)
} else {
return Err(Error::at(
rule.span,
format!("unknown type '{}' in selector", name),
));
};
match id {
Some(_) => units.push(SelUnit::Type {
name: class,
id: id.clone(),
}),
None => units.push(SelUnit::Class(class)),
}
}
SelUnit::Class(c) => units.push(SelUnit::Class(c.clone())),
SelUnit::Id(i) => units.push(SelUnit::Id(i.clone())),
SelUnit::Link => units.push(SelUnit::Class(lini_class("link"))),
}
}
Ok(Rule {
selector: Selector { units },
decls: rule.decls.clone(),
span: rule.span,
})
}
fn mark_present(child: &Child, present: &mut BTreeSet<String>) {
if let Child::Box(n) = child {
for c in &n.classes {
if let Some(x) = c.strip_prefix("lini-") {
present.insert(x.to_string());
}
}
for ch in &n.children {
mark_present(ch, present);
}
}
}
fn root_layout(user_root: &[Decl]) -> Option<&str> {
user_root
.iter()
.rev()
.find(|d| d.name == "layout")
.and_then(|d| match d.groups.as_slice() {
[group] => match group.as_slice() {
[Value::Ident(s)] => Some(s.as_str()),
_ => None,
},
_ => None,
})
}
fn push_unique(v: &mut Vec<String>, name: &str) {
if !v.iter().any(|x| x == name) {
v.push(name.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn lower(src: &str) -> File {
let toks = crate::lexer::lex(src).expect("lex");
let file = crate::syntax::parser::parse(&toks).expect("parse");
desugar(&file).expect("desugar")
}
fn root_box<'a>(f: &'a File, id: &str) -> &'a Node {
f.instances
.iter()
.find_map(|c| match c {
Child::Box(n) if n.id.as_deref() == Some(id) => Some(n),
_ => None,
})
.expect("node")
}
fn is_header(c: &Child) -> bool {
matches!(c, Child::Box(n) if n.classes.iter().any(|x| x == "lini-header"))
}
fn is_block_cell(c: &Child) -> bool {
matches!(c, Child::Box(n)
if n.classes.iter().any(|x| x == "lini-block")
&& matches!(n.children.as_slice(), [Child::Text(_)]))
}
#[test]
fn table_first_row_becomes_header_cells() {
let f = lower("|table#t| { columns: 30 30 } [\n\"a\"\n\"b\"\n\"c\"\n\"d\"\n]\n");
let t = root_box(&f, "t");
assert!(
is_header(&t.children[0]) && is_header(&t.children[1]),
"first row is header"
);
assert!(
is_block_cell(&t.children[2]) && is_block_cell(&t.children[3]),
"body cells wrap in |block|"
);
}
#[test]
fn entity_label_is_a_spanning_header_fields_wrap_in_blocks() {
let f = lower("|entity#e| \"Users\" [\n\"id\"\n\"int\"\n]\n");
let e = root_box(&f, "e");
let Child::Box(title) = &e.children[0] else {
panic!("the entity title is a box");
};
assert!(title.classes.iter().any(|c| c == "lini-header"));
assert!(
title.style.iter().any(|d| d.name == "span"),
"the title spans its columns"
);
assert!(is_block_cell(&e.children[1]) && is_block_cell(&e.children[2]));
}
#[test]
fn table_distributes_per_column_align_to_cells() {
let f = lower(
"|table#t| { columns: 40 40; align: start end } [\n\"a\"\n\"b\"\n\"c\"\n\"d\"\n]\n",
);
let t = root_box(&f, "t");
assert!(
t.style.iter().all(|d| d.name != "align"),
"the table's own align is consumed"
);
let cell_class = |i: usize| match &t.children[i] {
Child::Box(n) => n
.classes
.iter()
.find(|c| c.starts_with("lini-align-"))
.cloned(),
_ => None,
};
assert_eq!(cell_class(0).as_deref(), Some("lini-align-start"));
assert_eq!(cell_class(1).as_deref(), Some("lini-align-end"));
assert_eq!(cell_class(2).as_deref(), Some("lini-align-start"));
assert_eq!(cell_class(3).as_deref(), Some("lini-align-end"));
}
#[test]
fn table_cells_get_lini_cell_but_the_caption_does_not() {
let f = lower("|table#t| \"Cap\" { columns: 30 30 } [\n\"a\"\n\"b\"\n\"c\"\n\"d\"\n]\n");
let t = root_box(&f, "t");
let Child::Box(cap) = &t.children[0] else {
panic!("the caption is a box");
};
assert!(cap.classes.iter().any(|c| c == "lini-caption"));
assert!(
!cap.classes.iter().any(|c| c == "lini-cell"),
"the caption is not a cell"
);
assert!(
t.children[1..].iter().all(|c| matches!(
c, Child::Box(n) if n.classes.iter().any(|x| x == "lini-cell"))),
"every cell carries lini-cell"
);
}
#[test]
fn bare_grid_does_not_auto_header_or_wrap() {
let f = lower("|grid#g| { columns: 30 30 } [\n\"a\"\n\"b\"\n]\n");
let g = root_box(&f, "g");
assert!(
g.children.iter().all(|c| !is_header(c)),
"a bare grid is not a table — no auto-header"
);
assert!(
g.children.iter().all(|c| matches!(c, Child::Text(_))),
"bare grid cells stay bare text"
);
}
}