M6: calculation checks: a widget number past a field's last widget is a missing field

A read like price.12 counted as found whenever price existed. A widget
number now finds something only below the field's widget count; a
field with one widget, or none, still answers to .0 (from CodeRabbit's
review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grzegorz Martens
2026-10-29 20:48:37 +01:00
co-authored by Claude Opus 5.5
parent 36e16de470
commit 91a4db27be
2 changed files with 28 additions and 16 deletions
+24 -14
View File
@@ -161,7 +161,7 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
.collect();
let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads);
let component = components(&links);
let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
let mut all: Vec<(&str, usize)> = fields.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect();
all.sort_unstable();
let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default();
let mut out = Vec::with_capacity(calculated.len());
@@ -193,14 +193,22 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
CalculationOrder { fields: out, extra, fixed }
}
/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name, a
/// field under it (`row` for `row.1`), or a widget of one (`price.0`, as `getField` numbers them).
fn found(names: &[&str], name: &str) -> bool {
let has = |n: &str| names.binary_search(&n).is_ok();
/// Whether a formula's read `name` finds a field in `fields` (names with their widget counts,
/// sorted): the field of that name, a field under it (`row` for `row.1`), or one of a field's
/// widgets (`price.0`, as Acrobat's `getField` numbers them, up to its last widget).
fn found(fields: &[(&str, usize)], name: &str) -> bool {
// The most widgets a field called `n` has (a malformed form can have two such fields).
let widgets = |n: &str| {
let at = fields.partition_point(|(f, _)| *f < n);
fields.get(at..).unwrap_or_default().iter().take_while(|(f, _)| *f == n).map(|&(_, w)| w).max()
};
let prefix = format!("{name}.");
let under = names.partition_point(|n| *n < prefix.as_str());
let widget = name.rsplit_once('.').is_some_and(|(base, i)| !i.is_empty() && i.bytes().all(|b| b.is_ascii_digit()) && has(base));
has(name) || names.get(under).is_some_and(|n| n.starts_with(&prefix)) || widget
let under = fields.partition_point(|(f, _)| *f < prefix.as_str());
// A field with one widget, or none, is still `.0` (digits only: `parse` would take `+0`).
let widget = name.rsplit_once('.').is_some_and(|(base, i)| {
i.bytes().all(|b| b.is_ascii_digit()) && i.parse::<usize>().is_ok_and(|i| widgets(base).is_some_and(|w| i < w.max(1)))
});
widgets(name).is_some() || fields.get(under).is_some_and(|(f, _)| f.starts_with(&prefix)) || widget
}
/// For each calculated field (by its place in `names`), the places of the calculated fields it
@@ -410,14 +418,16 @@ mod tests {
#[test]
fn a_read_name_finds_a_field_a_group_or_a_widget() {
let mut names = vec!["price", "row.1", "row.2", "rows", "total"];
names.sort_unstable();
for name in ["price", "row", "row.1", "rows", "price.0", "price.12"] {
assert!(found(&names, name), "{name}");
let mut fields = vec![("price", 1), ("qty", 3), ("row.1", 1), ("row.2", 1), ("rows", 1), ("total", 1), ("bare", 0)];
fields.sort_unstable();
for name in ["price", "row", "row.1", "rows", "price.0", "qty.0", "qty.2", "bare", "bare.0"] {
assert!(found(&fields, name), "{name}");
}
for name in ["pric", "ro", "row.3", "price.", "price.x", "price.-1", "total.0.1", ""] {
assert!(!found(&names, name), "{name}");
// Past a field's last widget there is no widget to find.
for name in ["pric", "ro", "row.3", "price.", "price.x", "price.-1", "price.+0", "price.1", "price.12", "qty.3", "bare.1", "total.0.1", ""] {
assert!(!found(&fields, name), "{name}");
}
assert!(!found(&fields, "qty.99999999999999999999999"), "a number too big for usize is no widget");
}
/// Two thousand formulas each reading a group of two thousand fields would be four million
+4 -2
View File
@@ -1727,7 +1727,9 @@ fn missing_fields_and_built_names_are_flagged() {
let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() };
set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap();
set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap();
set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"qty\").value + getField(\"ghost\").value;")).unwrap();
// qty has one widget: qty.0 is it, and there is no qty.1.
let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;";
set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap();
set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap();
let issues = |doc: &Document| {
let order = calculation_order(doc, &fields(doc));
@@ -1738,7 +1740,7 @@ fn missing_fields_and_built_names_are_flagged() {
vec![
("sum".to_owned(), sum),
("notation".to_owned(), vec![MissingField { field: "nope".into() }]),
("script".to_owned(), vec![MissingField { field: "ghost".into() }]),
("script".to_owned(), vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.1".into() }]),
("built".to_owned(), vec![BuiltNames]),
]
};