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);
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);
}
shadow_scope(&file.instances, &file.links, &[], &id_paths, out);
}
fn shadow_scope(
children: &[Child],
links: &[Link],
prefix: &[String],
id_paths: &HashMap<String, Vec<String>>,
out: &mut Vec<Diagnostic>,
) {
let declared = declared_ids(children);
let link_refs: Vec<&Link> = links.iter().collect();
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}'"
),
));
}
}
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, out);
}
}
}
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 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"))
);
}
}