use crate::ast::ChainOp;
use crate::desugar::scene::{auto_created_ids, declared_ids};
use crate::error::Diagnostic;
use crate::syntax::ast::{Child, File, Link};
use std::collections::HashMap;
pub fn lint(file: &File) -> Vec<Diagnostic> {
let mut out = Vec::new();
lint_split_labels(file, &mut out);
lint_auto_create_shadows(file, &mut out);
lint_pinned_mates(file, &mut out);
out
}
fn lint_pinned_mates(file: &File, out: &mut Vec<Diagnostic>) {
fn scan(children: &[Child], links: &[Link], out: &mut Vec<Diagnostic>) {
let pinned: Vec<&str> = children
.iter()
.filter_map(|c| match c {
Child::Box(n) if n.style.iter().any(|d| d.name == "pin") && n.id.is_some() => {
n.id.as_deref()
}
_ => None,
})
.collect();
for w in links.iter().filter(|w| matches!(w.op(), ChainOp::Mate)) {
for ep in w.chain.iter().flat_map(|g| &g.endpoints) {
if let [first, ..] = ep.path.as_slice()
&& pinned.contains(&first.as_str())
{
out.push(Diagnostic::warn(
w.span,
format!("'pin' on '{first}' is ignored — the mate seats it"),
));
}
}
}
for c in children {
if let Child::Box(n) = c {
scan(&n.children, &n.links, out);
}
}
}
scan(&file.instances, &file.links, out);
}
fn lint_split_labels(file: &File, out: &mut Vec<Diagnostic>) {
let mut visit = |w: &Link| {
if w.label.is_some() && !w.labels.is_empty() {
out.push(Diagnostic::warn(
w.span,
"keep a link's labels together — write 'a -> b [ \"x\" \"y\" ]'",
));
}
};
for_each_link(file, &mut visit);
}
fn lint_auto_create_shadows(file: &File, out: &mut Vec<Diagnostic>) {
let mut id_paths: HashMap<String, Vec<String>> = HashMap::new();
for c in &file.instances {
collect_paths(c, &mut Vec::new(), &mut id_paths);
}
let root_drawing = matches!(root_layout(&file.stylesheet), Some(l) if l == "drawing");
shadow_scope(
&file.instances,
&file.links,
&[],
&id_paths,
root_drawing,
out,
);
}
fn shadow_scope(
children: &[Child],
links: &[Link],
prefix: &[String],
id_paths: &HashMap<String, Vec<String>>,
scope_is_drawing: bool,
out: &mut Vec<Diagnostic>,
) {
if scope_is_drawing {
for c in children {
if let Child::Box(n) = c {
let mut sub = prefix.to_vec();
if let Some(id) = &n.id {
sub.push(id.clone());
}
shadow_scope(
&n.children,
&n.links,
&sub,
id_paths,
is_drawing_node(n),
out,
);
}
}
return;
}
let declared = declared_ids(children);
let link_refs: Vec<&Link> = links.iter().collect();
let mut known: Vec<String> = declared.iter().cloned().collect();
known.sort();
for (id, span) in auto_created_ids(&link_refs, &declared) {
let here = join_path(prefix, &id);
if let Some(other) = id_paths
.get(&id)
.and_then(|paths| paths.iter().find(|p| **p != here))
{
let scope = if prefix.is_empty() {
"scene root".to_string()
} else {
prefix.join(".")
};
out.push(Diagnostic::warn(
span,
format!(
"endpoint '{id}' auto-created at {scope} — a node '{id}' also exists at '{other}'"
),
));
}
if let Some(target) = near_miss(&id, known.iter().map(String::as_str)) {
out.push(Diagnostic::warn(
span,
format!("'{id}' auto-creates a new box; did you mean '{target}'?"),
));
}
known.push(id);
}
for c in children {
if let Child::Box(n) = c {
let mut sub = prefix.to_vec();
if let Some(id) = &n.id {
sub.push(id.clone());
}
shadow_scope(
&n.children,
&n.links,
&sub,
id_paths,
is_drawing_node(n),
out,
);
}
}
}
fn near_miss<'a>(id: &str, known: impl Iterator<Item = &'a str> + Clone) -> Option<&'a str> {
if let Some(fold) = known.clone().find(|k| k.eq_ignore_ascii_case(id)) {
return Some(fold);
}
let stem = |s: &str| s.trim_end_matches(|c: char| c.is_ascii_digit()).len();
let candidates = known.filter(|k| id[..stem(id)] != k[..stem(k)]);
let near = crate::suggest::nearest(id, candidates, 1);
near.first()
.filter(|k| crate::suggest::edit_distance(id, k) < id.chars().count())
.copied()
}
fn is_drawing_node(n: &crate::syntax::ast::Node) -> bool {
n.ty.as_deref() == Some("drawing")
|| n.classes.iter().any(|c| c == "lini-drawing")
|| n.style
.iter()
.any(|d| d.name == "layout" && decl_ident(d) == Some("drawing"))
}
fn root_layout(stylesheet: &[crate::syntax::ast::StyleItem]) -> Option<String> {
stylesheet.iter().find_map(|it| match it {
crate::syntax::ast::StyleItem::RootDecl(d) if d.name == "layout" => {
decl_ident(d).map(str::to_string)
}
_ => None,
})
}
fn decl_ident(d: &crate::syntax::ast::Decl) -> Option<&str> {
match d.groups.first().and_then(|g| g.first()) {
Some(crate::syntax::ast::Value::Ident(s)) => Some(s),
_ => None,
}
}
fn collect_paths(child: &Child, stack: &mut Vec<String>, out: &mut HashMap<String, Vec<String>>) {
if let Child::Box(n) = child {
if let Some(id) = &n.id {
stack.push(id.clone());
out.entry(id.clone()).or_default().push(stack.join("."));
}
for c in &n.children {
collect_paths(c, stack, out);
}
if n.id.is_some() {
stack.pop();
}
}
}
fn join_path(prefix: &[String], id: &str) -> String {
if prefix.is_empty() {
id.to_string()
} else {
format!("{}.{}", prefix.join("."), id)
}
}
fn for_each_link(file: &File, visit: &mut impl FnMut(&Link)) {
for w in &file.links {
visit(w);
}
for c in &file.instances {
for_each_link_child(c, visit);
}
}
fn for_each_link_child(child: &Child, visit: &mut impl FnMut(&Link)) {
if let Child::Box(n) = child {
for w in &n.links {
visit(w);
}
for c in &n.children {
for_each_link_child(c, visit);
}
}
}
#[cfg(test)]
mod tests {
fn warnings(src: &str) -> Vec<String> {
crate::lint_str(src)
.expect("lint")
.into_iter()
.map(|d| d.message)
.collect()
}
#[test]
fn inline_paint_is_not_linted() {
assert!(warnings("|box#x| { fill: red; stroke: blue; }\n").is_empty());
}
#[test]
fn pin_on_a_mated_child_warns() {
let w = warnings(
"{ layout: drawing }\n|rect#a| { width: 20; height: 20 }\n|rect#b| { width: 20; height: 20; pin: center }\na:right || b:left\n",
);
assert!(
w.iter()
.any(|m| m == "'pin' on 'b' is ignored — the mate seats it"),
"{w:?}"
);
assert!(
warnings("{ layout: drawing }\n|rect#a| { width: 20; height: 20 }\n|rect#b| { width: 20; height: 20 }\na:right || b:left\n")
.is_empty()
);
}
#[test]
fn a_split_link_label_warns() {
let w = warnings("a -> b \"x\" [ \"y\" ]\n");
assert!(
w.iter()
.any(|m| m.contains("keep a link's labels together")),
"{w:?}"
);
}
#[test]
fn together_link_labels_do_not_warn() {
assert!(
warnings("a -> b [ \"x\" \"y\" ]\n")
.iter()
.all(|m| !m.contains("together"))
);
assert!(
warnings("a -> b \"x\"\n")
.iter()
.all(|m| !m.contains("together"))
);
}
#[test]
fn auto_create_shadowing_a_deeper_node_warns() {
let w = warnings("|group#kitchen| [ |box#cat| ]\ncat -> dog\n");
assert!(
w.iter().any(|m| m.contains("also exists at 'kitchen.cat'")),
"{w:?}"
);
}
#[test]
fn a_clean_auto_create_does_not_warn() {
assert!(
warnings("cat -> dog\n")
.iter()
.all(|m| !m.contains("also exists"))
);
}
}