use super::classes::{has_two_class_rule, lini_class};
use super::types::Types;
use crate::ast::{ChainOp, LineStyle, LinkMarker, LinkOp};
use crate::error::Error;
use crate::ledger::defaults::{MINDMAP_BRANCH_FONT, MINDMAP_LEAF_FONT, MINDMAP_MAX_WIDTH};
use crate::span::Span;
use crate::syntax::ast::{
Child, Decl, Endpoint, EndpointGroup, File, Link, Node, PointRef, Rule, SelUnit, Selector,
StyleItem, Value,
};
use std::collections::BTreeSet;
#[derive(Clone, Copy)]
enum Growth {
Column,
Row,
BiRight,
BiLeft,
}
impl Growth {
fn sides(self) -> (&'static str, &'static str) {
match self {
Growth::Column => ("bottom", "top"),
Growth::Row | Growth::BiRight => ("right", "left"),
Growth::BiLeft => ("left", "right"),
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Dir {
Column,
Row,
Bilateral,
}
fn dir_of(style: &[Decl]) -> Dir {
dir_from(
style
.iter()
.rev()
.find(|d| d.name == "direction")
.and_then(decl_ident),
)
}
fn dir_from(ident: Option<&str>) -> Dir {
match ident {
Some("row") => Dir::Row,
Some("bilateral") => Dir::Bilateral,
_ => Dir::Column,
}
}
#[derive(Clone, Copy, PartialEq)]
enum Half {
Right,
Left,
}
impl Half {
fn class(self) -> &'static str {
match self {
Half::Right => "side-right",
Half::Left => "side-left",
}
}
fn growth(self) -> Growth {
match self {
Half::Right => Growth::BiRight,
Half::Left => Growth::BiLeft,
}
}
}
pub(crate) fn validate(file: &File) -> Result<(), Error> {
let Ok(types) = Types::build(file) else {
return Ok(());
};
let root_tree = matches!(root_layout(&file.stylesheet), Some(l) if l == "tree");
if root_tree {
check_root_count(&file.instances, Span::empty())?;
}
let ctx = root_tree.then(|| TreeCtx {
dir: root_direction(&file.stylesheet),
depth: 0,
});
for c in &file.instances {
check(c, ctx, &types)?;
}
Ok(())
}
#[derive(Clone, Copy)]
struct TreeCtx {
dir: Dir,
depth: usize,
}
fn check(child: &Child, ctx: Option<TreeCtx>, types: &Types) -> Result<(), Error> {
let Child::Box(n) = child else {
return Ok(());
};
let topic = is_topic_ast(n, types);
let mindmap = topic && is_mindmap_ast(n, types);
if topic && !mindmap && ctx.is_none() {
return Err(Error::at(
n.span,
"'|topic|' builds a tree — it belongs in a 'layout: tree'",
));
}
if let (true, Some(c)) = (topic, ctx) {
check_side(n, c)?;
}
let tree = is_tree_style(&n.style);
if tree {
check_root_count(&n.children, n.span)?;
}
let child_ctx = if tree {
Some(TreeCtx {
dir: dir_of(&n.style),
depth: 0,
})
} else if let (true, Some(c)) = (topic, ctx) {
Some(TreeCtx {
dir: c.dir,
depth: c.depth + 1,
})
} else if mindmap {
Some(TreeCtx {
dir: mindmap_dir_of(&n.style),
depth: 1,
})
} else {
None
};
for c in &n.children {
check(c, child_ctx, types)?;
}
Ok(())
}
fn check_side(n: &Node, c: TreeCtx) -> Result<(), Error> {
let Some(val) = n
.style
.iter()
.rev()
.find(|d| d.name == "side")
.and_then(decl_ident)
else {
return Ok(());
};
match c.dir {
Dir::Row | Dir::Column => Err(Error::at(
n.span,
"'side' picks a bilateral branch's half — this tree has one growth direction",
)),
Dir::Bilateral => match val {
"left" | "right" if c.depth == 1 => Ok(()),
"left" | "right" => Err(Error::at(
n.span,
"'side' picks a bilateral branch's half — this tree has one growth direction",
)),
_ => Err(Error::at(
n.span,
"a bilateral tree grows left and right — 'side' takes left or right",
)),
},
}
}
fn check_root_count(children: &[Child], scope_span: Span) -> Result<(), Error> {
let mut roots = children.iter().filter_map(|c| match c {
Child::Box(n)
if (n.classes.iter().any(|k| k == "lini-topic") || is_topic_node(n))
&& !wears_deeper_level(n) =>
{
Some(n)
}
_ => None,
});
if roots.next().is_none() {
return Err(Error::at(
scope_span,
"a tree needs exactly one root '|topic|'",
));
}
if let Some(second) = roots.next() {
let label = second
.label
.as_ref()
.map(|l| l.text.as_str())
.unwrap_or_default();
return Err(Error::at(
second.span,
format!("a tree has one root — '|topic|' '{label}' is a second"),
));
}
Ok(())
}
fn wears_deeper_level(n: &Node) -> bool {
n.classes
.iter()
.any(|c| matches!(c.strip_prefix("lini-level-"), Some(d) if d != "0"))
}
fn is_topic_ast(n: &Node, types: &Types) -> bool {
let ty = n.ty.as_deref().unwrap_or("box");
types
.resolve(ty, n.span)
.map(|i| i.chain.iter().any(|c| c == "topic"))
.unwrap_or(false)
}
fn is_mindmap_ast(n: &Node, types: &Types) -> bool {
let ty = n.ty.as_deref().unwrap_or("box");
types
.resolve(ty, n.span)
.map(|i| i.chain.iter().any(|c| c == "mindmap"))
.unwrap_or(false)
}
fn mindmap_dir_of(style: &[Decl]) -> Dir {
match style
.iter()
.rev()
.find(|d| d.name == "direction")
.and_then(decl_ident)
{
Some("row") => Dir::Row,
Some("column") => Dir::Column,
_ => Dir::Bilateral,
}
}
fn is_topic_node(n: &Node) -> bool {
matches!(n.ty.as_deref(), Some(t) if t == "topic" || t.ends_with("::topic"))
}
pub(crate) fn is_tree_scope(style: &[Decl]) -> bool {
is_tree_style(style)
}
pub(crate) fn build_tree(children: &mut [Child], links: &mut Vec<Link>, style: &[Decl]) {
let already = children
.iter()
.any(|c| matches!(c, Child::Box(n) if is_topic(n) && has_level_class(n)));
if already {
return;
}
match dir_of(style) {
Dir::Bilateral => build_bilateral(children, links),
Dir::Row => build_scope(children, links, 0, Growth::Row, None),
Dir::Column => build_scope(children, links, 0, Growth::Column, None),
}
}
fn build_scope(
children: &mut [Child],
links: &mut Vec<Link>,
level: usize,
g: Growth,
hue: Option<&str>,
) {
mint_ids(children);
for c in children.iter_mut() {
if let Child::Box(t) = c
&& is_topic(t)
{
build_topic(t, links, level, g, hue);
}
}
links.sort_by_key(|l| l.span.start);
}
fn build_topic(t: &mut Node, links: &mut Vec<Link>, level: usize, g: Growth, hue: Option<&str>) {
if level == 0 && is_mindmap(t) {
return build_mindmap_root(t, links, g);
}
t.classes.push(lini_class(&format!("level-{level}")));
if let Some(h) = hue {
t.classes.push(h.to_string());
}
mint_ids(&mut t.children);
let kids: Vec<String> = t
.children
.iter()
.filter_map(|cc| match cc {
Child::Box(k) if is_topic(k) => k.id.clone(),
_ => None,
})
.collect();
if let Some(pid) = t.id.clone()
&& !kids.is_empty()
{
let mut fan = branch_fan(&pid, &kids, g, t.span);
if let Some(h) = hue {
fan.classes.push(h.to_string());
}
push_unique(links, fan);
}
build_scope(&mut t.children, &mut t.links, level + 1, g, hue);
}
fn walk_hue(pos: usize) -> String {
let hues: Vec<&str> = crate::palette::walk_hues().collect();
lini_class(&format!("hue-{}", hues[pos % hues.len()]))
}
fn build_mindmap_root(t: &mut Node, links: &mut Vec<Link>, g: Growth) {
t.classes.push(lini_class("level-0"));
mint_ids(&mut t.children);
let first: Vec<usize> = topic_positions(&t.children);
let pid = t.id.clone();
for (pos, &idx) in first.iter().enumerate() {
let hue = walk_hue(pos);
let Child::Box(k) = &mut t.children[idx] else {
continue;
};
if let (Some(pid), Some(kid)) = (&pid, k.id.clone()) {
let mut arm = branch_fan(pid, &[kid], g, k.span);
arm.classes.push(hue.clone());
push_unique(links, arm);
}
build_topic(k, &mut t.links, 1, g, Some(&hue));
}
t.links.sort_by_key(|l| l.span.start);
}
fn topic_positions(children: &[Child]) -> Vec<usize> {
children
.iter()
.enumerate()
.filter_map(|(i, cc)| match cc {
Child::Box(k) if is_topic(k) => Some(i),
_ => None,
})
.collect()
}
fn build_bilateral(children: &mut [Child], links: &mut Vec<Link>) {
mint_ids(children);
for c in children.iter_mut() {
let Child::Box(root) = c else { continue };
if !is_topic(root) {
continue;
}
let mindmap = is_mindmap(root);
root.classes.push(lini_class("level-0"));
mint_ids(&mut root.children);
let first: Vec<usize> = topic_positions(&root.children);
let n = first.len();
let mut halves: Vec<Half> = (0..n)
.map(|i| {
if i < n.div_ceil(2) {
Half::Right
} else {
Half::Left
}
})
.collect();
for (pos, &idx) in first.iter().enumerate() {
let Child::Box(k) = &mut root.children[idx] else {
continue;
};
if let Some(s) = take_ident(&mut k.style, "side") {
halves[pos] = if s == "left" { Half::Left } else { Half::Right };
}
k.classes.push(lini_class(halves[pos].class()));
}
if let Some(pid) = root.id.clone() {
if mindmap {
for (pos, &idx) in first.iter().enumerate() {
let Child::Box(k) = &root.children[idx] else {
continue;
};
if let Some(kid) = k.id.clone() {
let mut arm = branch_fan(&pid, &[kid], halves[pos].growth(), k.span);
arm.classes.push(walk_hue(pos));
push_unique(links, arm);
}
}
} else {
for (half, g) in [(Half::Right, Growth::BiRight), (Half::Left, Growth::BiLeft)] {
let kids: Vec<String> = first
.iter()
.zip(&halves)
.filter(|&(_, &h)| h == half)
.filter_map(|(&i, _)| match &root.children[i] {
Child::Box(k) => k.id.clone(),
_ => None,
})
.collect();
if !kids.is_empty() {
push_unique(links, branch_fan(&pid, &kids, g, root.span));
}
}
}
}
for (pos, &idx) in first.iter().enumerate() {
let hue = mindmap.then(|| walk_hue(pos));
let Child::Box(k) = &mut root.children[idx] else {
continue;
};
build_topic(k, &mut root.links, 1, halves[pos].growth(), hue.as_deref());
}
root.links.sort_by_key(|l| l.span.start);
}
links.sort_by_key(|l| l.span.start);
}
fn push_unique(links: &mut Vec<Link>, link: Link) {
if !links.iter().any(|l| same_link(l, &link)) {
links.push(link);
}
}
fn mint_ids(children: &mut [Child]) {
let mut nth = 0usize;
for c in children.iter_mut() {
if let Child::Box(t) = c
&& is_topic(t)
{
nth += 1;
if t.id.is_none() {
t.id = Some(format!("lini-topic-{nth}"));
}
}
}
}
fn branch_fan(parent: &str, children: &[String], g: Growth, span: Span) -> Link {
let (ps, cs) = g.sides();
Link {
chain: vec![
EndpointGroup {
endpoints: vec![endpoint(vec![parent.to_string()], ps)],
},
EndpointGroup {
endpoints: children
.iter()
.map(|c| endpoint(vec![parent.to_string(), c.clone()], cs))
.collect(),
},
],
ops: vec![ChainOp::Wire(LinkOp {
line: LineStyle::Solid,
start: LinkMarker::None,
end: LinkMarker::None,
})],
classes: Vec::new(),
style: Vec::new(),
style_span: None,
label: None,
labels: Vec::new(),
span,
}
}
fn endpoint(path: Vec<String>, side: &str) -> Endpoint {
Endpoint {
path,
point: Some(PointRef {
name: side.to_string(),
span: Span::empty(),
}),
span: Span::empty(),
}
}
fn same_link(a: &Link, b: &Link) -> bool {
a.ops == b.ops
&& a.chain.len() == b.chain.len()
&& a.chain.iter().zip(&b.chain).all(|(x, y)| {
x.endpoints.len() == y.endpoints.len()
&& x.endpoints.iter().zip(&y.endpoints).all(|(p, q)| {
p.path == q.path
&& p.point.as_ref().map(|r| &r.name) == q.point.as_ref().map(|r| &r.name)
})
})
}
pub(crate) fn seat_mindmap(style: &mut Vec<Decl>, children: &mut [Child]) {
let Some(mm) = children.iter_mut().find_map(|c| match c {
Child::Box(n) if is_mindmap(n) => Some(n),
_ => None,
}) else {
return;
};
if !style.iter().any(|d| d.name == "layout") {
style.push(ident_decl("layout", "tree"));
}
if !is_tree_style(style) {
return;
}
if !style.iter().any(|d| d.name == "direction") {
let dir = take_ident(&mut mm.style, "direction").unwrap_or_else(|| "bilateral".into());
style.push(ident_decl("direction", &dir));
}
if !style.iter().any(|d| d.name == "routing") {
style.push(ident_decl("routing", "natural"));
}
}
pub(crate) fn mindmap_rules(present: &BTreeSet<String>, user_rules: &[Rule]) -> Vec<Rule> {
if !present.contains("mindmap") {
return Vec::new();
}
let mut out: Vec<Rule> = Vec::new();
let mut push = |class: &str, decls: Vec<Decl>| {
if !has_two_class_rule(user_rules, "lini-mindmap", class) {
out.push(Rule {
selector: Selector {
units: vec![
SelUnit::Class("lini-mindmap".to_string()),
SelUnit::Class(class.to_string()),
],
},
decls,
span: Span::empty(),
});
}
};
push(
"lini-topic",
vec![
number_decl("max-width", MINDMAP_MAX_WIDTH),
ident_decl("font-weight", "normal"),
],
);
let mut levels: Vec<usize> = present
.iter()
.filter_map(|p| p.strip_prefix("level-")?.parse().ok())
.filter(|&n| n >= 1)
.collect();
levels.sort_unstable();
for n in levels {
let size = if n == 1 {
MINDMAP_BRANCH_FONT
} else {
MINDMAP_LEAF_FONT
};
push(
&format!("lini-level-{n}"),
vec![number_decl("font-size", size)],
);
}
for hue in crate::palette::walk_hues() {
if present.contains(&format!("hue-{hue}")) {
push(
&format!("lini-hue-{hue}"),
vec![
var_decl("fill", &format!("{hue}-wash")),
var_decl("stroke", &format!("{hue}-deep")),
var_decl("color", &format!("{hue}-ink")),
],
);
}
}
out
}
fn take_ident(style: &mut Vec<Decl>, name: &str) -> Option<String> {
let val = style
.iter()
.rev()
.find(|d| d.name == name)
.and_then(decl_ident)
.map(str::to_string);
if val.is_some() {
style.retain(|d| d.name != name);
}
val
}
fn ident_decl(name: &str, v: &str) -> Decl {
Decl {
name: name.into(),
groups: vec![vec![Value::Ident(v.into())]],
span: Span::empty(),
}
}
fn number_decl(name: &str, v: f64) -> Decl {
Decl {
name: name.into(),
groups: vec![vec![Value::Number(v)]],
span: Span::empty(),
}
}
fn var_decl(name: &str, v: &str) -> Decl {
Decl {
name: name.into(),
groups: vec![vec![Value::Var(v.into())]],
span: Span::empty(),
}
}
fn is_topic(n: &Node) -> bool {
n.classes.iter().any(|c| c == "lini-topic")
}
fn is_mindmap(n: &Node) -> bool {
n.classes.iter().any(|c| c == "lini-mindmap")
}
fn has_level_class(n: &Node) -> bool {
n.classes.iter().any(|c| c.starts_with("lini-level-"))
}
pub(crate) fn ensure_gap(style: &mut Vec<Decl>) {
if style.iter().any(|d| d.name == "gap") {
return;
}
style.push(Decl {
name: "gap".into(),
groups: vec![vec![
Value::Number(crate::ledger::defaults::TREE_GAP_GEN),
Value::Number(crate::ledger::defaults::TREE_GAP_SIB),
]],
span: Span::empty(),
});
}
fn is_tree_style(style: &[Decl]) -> bool {
style
.iter()
.any(|d| d.name == "layout" && decl_ident(d) == Some("tree"))
}
fn decl_ident(d: &Decl) -> Option<&str> {
match d.groups.first().and_then(|g| g.first()) {
Some(Value::Ident(s)) => Some(s),
_ => None,
}
}
fn root_layout(stylesheet: &[StyleItem]) -> Option<&str> {
stylesheet.iter().find_map(|it| match it {
StyleItem::RootDecl(d) if d.name == "layout" => decl_ident(d),
_ => None,
})
}
fn root_direction(stylesheet: &[StyleItem]) -> Dir {
dir_from(stylesheet.iter().rev().find_map(|it| match it {
StyleItem::RootDecl(d) if d.name == "direction" => decl_ident(d),
_ => None,
}))
}