M7: missing fonts: follow each font variable's aliases once (#752)

A file whose font family variables alias each other in several modes made
the missing-fonts walk branch in every mode, billions of steps before the
depth limit stopped it, freezing the app on open and `figcraft-cli info`.
The walk now visits each variable once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RealtaNua
2026-10-22 15:19:11 +07:00
co-authored by Claude Opus 5.5
parent a86897dc91
commit 85010e491a
2 changed files with 27 additions and 4 deletions
+8 -4
View File
@@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
}
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
/// Every string value of variable `id` in any mode, following aliases (each variable once).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut todo = vec![(id.to_string(), 0)];
while let Some((id, depth)) = todo.pop() {
let mut seen = BTreeSet::new();
let mut todo = vec![id.to_string()];
while let Some(id) = todo.pop() {
if !seen.insert(id.clone()) {
continue;
}
let Some(var) = vars.get(&id) else { continue };
for v in var.values.values() {
match v {
VariableValue::String(s) => out.push(s.clone()),
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
VariableValue::Alias(a) => todo.push(a.clone()),
_ => {}
}
}
+19
View File
@@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() {
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
}
/// Aliases in several modes are followed once per variable: a chain that branches in every mode
/// would otherwise grow to billions of steps (a file can hold one).
#[test]
fn branching_alias_chains_are_walked_once_per_variable() {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
for i in 0..32 {
s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap();
}
let doc = s.doc_mut().unwrap();
let ids: Vec<String> = doc.variables.variables.iter().map(|v| v.id.clone()).collect();
for (i, var) in doc.variables.variables.iter_mut().enumerate() {
let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into());
let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) };
var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into();
}
assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]);
}
#[test]
fn needs_an_open_document() {
let mut s = Session::empty();