Engine: a reference on a sheet named like a token stays a reference

is_token looked only at the leading identifier, so on a workbook with a
sheet called Output (or Row, Column, or the name of a declared input),
"Output!A1:B2" was read as the Output token, and the step failed with
"the macro has no output anchor" or "unexpected '!A1:B2'". This hit
reference keys, $ref and {{...}} alike, including references the
recorder writes for other sheets. A base followed by '!' is now a sheet
name, and the text resolves as a plain reference.
This commit is contained in:
prxnvt
2026-10-22 02:26:32 -04:00
parent 7a0f591712
commit ac7c1ae639
2 changed files with 37 additions and 2 deletions
+35 -1
View File
@@ -110,7 +110,8 @@ pub fn is_ref_key(key: &str) -> bool {
/// The most nesting [`resolve_params`] walks into.
const MAX_JSON_DEPTH: usize = 64;
/// Whether `text` starts like a token (its base is one this environment knows).
/// Whether `text` starts like a token (its base is one this environment knows). A reference on a
/// sheet with such a name (`Output!A1`) is not a token.
pub fn is_token(text: &str, env: &Env) -> bool {
let t = text.trim();
if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
@@ -118,6 +119,9 @@ pub fn is_token(text: &str, env: &Env) -> bool {
}
let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum();
let base = t.get(..n).unwrap_or("");
if t.get(n..).unwrap_or("").trim_start().starts_with('!') {
return false;
}
is_builtin(base) || env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base))
}
@@ -641,6 +645,36 @@ mod tests {
assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err());
}
#[test]
fn references_on_sheets_named_like_tokens_stay_references() {
// Sheets named after the built-in bases and after an input: `Output!A1` is a cell on the
// sheet Output, in a reference key, in `$ref` and in `{{…}}`.
let mut wb = Workbook::new();
for name in ["Output", "Row", "Column", "Input", "Rates"] {
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new(name)));
}
let inputs = vec![("Input".to_string(), place("B2:D11"), true), ("Rates".to_string(), place("F2:F5"), false)];
for output in [None, Some(place("H2"))] {
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output, item: Some((RepeatOf::Rows, place("B5:D5"))) };
for (sheet, i) in [("Output", 1), ("Row", 2), ("Column", 3), ("Input", 4), ("Rates", 5)] {
for text in [format!("{sheet}!A1:B2"), format!("'{sheet}'!A1:B2")] {
let want = Place { sheet: i, range: RangeRef::parse("A1:B2").unwrap() };
assert_eq!(resolve_ref(&text, &env), Ok(want), "{text}");
let a = a1(want, &env);
let p = json!({"range": text, "target": {"$ref": text}, "formula": format!("=SUM({{{{{text}}}}})")});
let r = resolve_params(&p, &env).unwrap();
// A plain reference in a reference key is passed on as written.
assert_eq!(r, json!({"range": text, "target": a, "formula": format!("=SUM({a})")}), "{text}");
}
}
assert!(!is_token("Output !A1", &env));
// The bare names are still tokens.
check(&env, "Input.col(2)", "C2:C11");
check(&env, "Rates", "F2:F5");
check(&env, "Row", "B5:D5");
}
}
#[test]
fn sheet_names_are_quoted_when_needed() {
for (name, want) in [("Sheet1", "Sheet1"), ("My Data", "'My Data'"), ("O'Hara", "'O''Hara'"), ("2024", "'2024'"), ("", "''")] {
+2 -1
View File
@@ -60,7 +60,8 @@ Methods narrow or move a range and chain: `col(2)` or `col("Amount")`, `row(1)`,
Write a token as the whole value of a reference parameter (`cell`, `range`, `target`, keys ending
in `Cell` or `Range`…), as `{"$ref": "Input.col(2)"}` anywhere, or as `{{Input.col(2)}}` inside
any string, such as a formula. Plain A1 references stay absolute; a `Name:` anchor follows
inserted rows.
inserted rows. A reference with a sheet name is never a token, so `Output!A1` is a cell on a sheet
called Output, even when a token or input has that name.
## Storage