Files
gridcraft/crates/engine/src/cmd/formulas.rs
T

1037 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
use serde_json::{Value as Json, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"formulas.autoSum",
"AutoSum",
["Formulas", "Function Library"],
Some("Cmd+Shift+T"),
"{function?: SUM|AVERAGE|COUNT|MAX|MIN, range?}",
has_doc,
auto_sum
),
cmd!(
"formulas.insertFunction",
"Insert Function…",
["Formulas", "Function Library"],
Some("Shift+F3"),
"{name?: \"VLOOKUP\"} (UI opens the dialog; with a name, starts the formula in the active cell)",
has_doc,
insert_function
),
cmd!(query "formulas.functions", "List Functions", [], None, "{category?, search?} → [{name, category, signature, description}]", always, list_functions),
cmd!(
"formulas.defineName",
"Define Name…",
["Formulas", "Defined Names"],
None,
"{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
has_doc,
define_name
),
cmd!(
"formulas.deleteName",
"Delete Name",
[],
None,
"{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc_<key>_<n>` or `_sc_<key>`, is refused unless force is true)",
has_doc,
delete_name
),
cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager),
cmd!(
"formulas.createFromSelection",
"Create from Selection",
["Formulas", "Defined Names"],
Some("Cmd+Shift+F3"),
"{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc_<key>_<n>` and `_sc_<key>`, are listed in `skipped` unless force is true)}",
has_doc,
create_from_selection
),
cmd!(
"formulas.pasteName",
"Paste Names…",
["Formulas", "Defined Names"],
Some("F3"),
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
has_doc,
paste_name
),
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas),
cmd!(
"formulas.convertToNumber",
"Convert to Number",
[],
None,
"{range?} → {converted} (numbers stored as text in the range or selection become numbers)",
has_doc,
convert_to_number
),
cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking),
cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula),
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate),
cmd!(noundo "formulas.calculateNow", "Calculate Now", ["Formulas", "Calculation"], Some("F9"), "{}", has_doc, calc_now),
cmd!(noundo "formulas.calculateSheet", "Calculate Sheet", ["Formulas", "Calculation"], Some("Shift+F9"), "{}", has_doc, calc_now),
cmd!(
"formulas.calculationOptions",
"Calculation Options",
["Formulas", "Calculation"],
None,
"{mode?: automatic|automaticExceptTables|manual, iterative?: bool (Enable iterative calculation), maxIterations?: 1–32767 (100), maxChange?: number (0.001)} → the settings. File › Options › Formulas › Calculation options; stored in the workbook.",
has_doc,
calc_options
),
]
}
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let sel = match str_param(p, "range") {
Some(_) => target_range(s, p)?,
None => d.selection.current(),
};
if !sel.is_single() {
// A block: totals below each column (and to the right when a blank column is included).
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
// formulas), and all totals are one edit (one undo step).
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
if out_row >= gridcraft_core::MAX_ROWS {
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
}
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
.map(|c| {
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
})
.collect();
let sheet = d.wb.active_sheet;
edit(s, |cx| {
for (c, f) in &writes {
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
cx.sheet_mut(sheet)?.set_cell(*c, cell);
cx.touch(sheet, *c);
}
Ok(())
})?;
return ok();
}
// Single cell: guess the range above, else to the left.
let at = sel.start;
let numeric = |c: CellRef| sh.value(c).is_number();
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
let mut top = at.row - 1;
while top > 0
&& (numeric(CellRef::new(top - 1, at.col))
|| sh.value(CellRef::new(top - 1, at.col)).is_empty() && top > 1 && numeric(CellRef::new(top - 2, at.col)))
{
top -= 1;
}
if numeric(CellRef::new(at.row - 1, at.col)) {
range = Some(RangeRef::new(CellRef::new(top, at.col), CellRef::new(at.row - 1, at.col)));
}
}
if range.is_none() && at.col > 0 && numeric(CellRef::new(at.row, at.col - 1)) {
let mut left = at.col - 1;
while left > 0 && numeric(CellRef::new(at.row, left - 1)) {
left -= 1;
}
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
}
let text = match range {
Some(r) => format!("={func}({})", r.a1()),
None => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
return ok();
}
};
if p.get("enter").and_then(Json::as_bool) == Some(false) {
s.ui_requests.push(crate::UiRequest::EditCell(Some(text.clone())));
return Ok(json!({"formula": text}));
}
s.execute("cell.set", json!({"cell": at.a1(), "input": text}))?;
Ok(json!({"formula": text}))
}
fn insert_function(s: &mut Session, p: &Json) -> Result<Json> {
match str_param(p, "name") {
Some(n) => {
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={}(", n.to_ascii_uppercase()))));
ok()
}
None => {
s.ui_requests.push(crate::UiRequest::Dialog("insertFunction".into(), json!({})));
ok()
}
}
}
pub fn function_list() -> Vec<Json> {
let mut v: Vec<Json> = gridcraft_functions::all()
.iter()
.map(|f| json!({"name": f.name, "category": format!("{:?}", f.category), "signature": f.signature, "description": f.description}))
.collect();
let specials: &[(&str, &str, &str, &str)] = &[
(
"IF",
"Logical",
"IF(logical_test, [value_if_true], [value_if_false])",
"Returns one value if a condition is true and another if it's false.",
),
("IFS", "Logical", "IFS(test1, value1, ...)", "Returns the value of the first true condition."),
("IFERROR", "Logical", "IFERROR(value, value_if_error)", "Returns a fallback when a value is an error."),
("IFNA", "Logical", "IFNA(value, value_if_na)", "Returns a fallback when a value is #N/A."),
("CHOOSE", "Lookup", "CHOOSE(index_num, value1, [value2], ...)", "Picks a value from a list by position."),
("SWITCH", "Logical", "SWITCH(expression, value1, result1, ..., [default])", "Matches a value against a list of cases."),
("ROW", "Lookup", "ROW([reference])", "Row number of a reference."),
("COLUMN", "Lookup", "COLUMN([reference])", "Column number of a reference."),
("ROWS", "Lookup", "ROWS(array)", "Number of rows in a reference or array."),
("COLUMNS", "Lookup", "COLUMNS(array)", "Number of columns in a reference or array."),
("OFFSET", "Lookup", "OFFSET(reference, rows, cols, [height], [width])", "A reference shifted from a starting point."),
("INDIRECT", "Lookup", "INDIRECT(ref_text, [a1])", "The reference named by a text string."),
("INDEX", "Lookup", "INDEX(array, row_num, [column_num], [area_num])", "The value or reference at a position."),
("SUBTOTAL", "MathTrig", "SUBTOTAL(function_num, ref1, ...)", "A subtotal that can skip hidden rows."),
("AGGREGATE", "MathTrig", "AGGREGATE(function_num, options, ref1, ...)", "An aggregate that can skip hidden rows and errors."),
("TEXT", "Text", "TEXT(value, format_text)", "Formats a number as text."),
("LET", "Logical", "LET(name1, value1, ..., calculation)", "Names intermediate results."),
("LAMBDA", "Logical", "LAMBDA([parameter1, ...], calculation)", "Creates a reusable custom function."),
("MAP", "Logical", "MAP(array1, ..., lambda)", "Applies a LAMBDA to each value."),
("REDUCE", "Logical", "REDUCE(initial_value, array, lambda)", "Accumulates an array into one value."),
("SCAN", "Logical", "SCAN(initial_value, array, lambda)", "Running accumulation of an array."),
("BYROW", "Logical", "BYROW(array, lambda)", "Applies a LAMBDA to each row."),
("BYCOL", "Logical", "BYCOL(array, lambda)", "Applies a LAMBDA to each column."),
("MAKEARRAY", "Logical", "MAKEARRAY(rows, cols, lambda)", "Builds an array from a LAMBDA."),
("CELL", "Information", "CELL(info_type, [reference])", "Information about a cell."),
("ISREF", "Information", "ISREF(value)", "TRUE for references."),
("ISFORMULA", "Information", "ISFORMULA(reference)", "TRUE when the cell has a formula."),
("FORMULATEXT", "Lookup", "FORMULATEXT(reference)", "The formula of a cell as text."),
("SHEET", "Information", "SHEET([value])", "Sheet number."),
("SHEETS", "Information", "SHEETS([reference])", "Number of sheets."),
("AREAS", "Lookup", "AREAS(reference)", "Number of areas in a reference."),
("TRIMRANGE", "Lookup", "TRIMRANGE(range, [trim_rows], [trim_cols])", "A range or array without its blank outer rows and columns."),
("ISOMITTED", "Information", "ISOMITTED(argument)", "TRUE when a LAMBDA argument was left out."),
];
for (n, c, sig, d) in specials {
if !v.iter().any(|x| x["name"] == *n) {
v.push(json!({"name": n, "category": c, "signature": sig, "description": d}));
}
}
v.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
v
}
fn list_functions(_: &mut Session, p: &Json) -> Result<Json> {
let cat = str_param(p, "category").map(str::to_ascii_lowercase);
let q = str_param(p, "search").map(str::to_ascii_lowercase);
let v: Vec<Json> = function_list()
.into_iter()
.filter(|f| cat.as_ref().is_none_or(|c| f["category"].as_str().is_some_and(|x| x.to_ascii_lowercase() == *c)))
.filter(|f| {
q.as_ref().is_none_or(|q| {
f["name"].as_str().is_some_and(|x| x.to_ascii_lowercase().contains(q.as_str()))
|| f["description"].as_str().is_some_and(|x| x.to_ascii_lowercase().contains(q.as_str()))
})
})
.collect();
Ok(Json::Array(v))
}
fn valid_name(n: &str) -> bool {
let mut chars = n.chars();
let Some(first) = chars.next() else { return false };
if !(first.is_alphabetic() || first == '_' || first == '\\') {
return false;
}
if n.chars().count() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
return false;
}
n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?'))
}
/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't
/// accept as a name.
pub(crate) fn is_r1c1(n: &str) -> bool {
let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count();
let mut rest = n;
if let Some(r) = rest.strip_prefix(['R', 'r']) {
rest = r.get(digits(r)..).unwrap_or("");
}
if let Some(c) = rest.strip_prefix(['C', 'c']) {
rest = c.get(digits(c)..).unwrap_or("");
}
rest.is_empty() && !n.is_empty()
}
fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
let Some(name) = str_param(p, "name").map(str::to_string) else {
s.ui_requests.push(crate::UiRequest::Dialog("defineName".into(), json!({})));
return ok();
};
if !valid_name(&name) {
return Err(EngineError::Other("The name that you entered is not valid.".into()));
}
for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() {
refuse_stash_name(&s.doc()?.wb, p, n)?;
}
let d = s.doc()?;
let scope = match str_param(p, "scope") {
None | Some("Workbook") => None,
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
};
let refers = match str_param(p, "refersTo") {
// As in Excel, relative references are relative to the active cell; names keep them
// relative to A1, as files do.
Some(r) => match gridcraft_formula::parse_name(r.trim_start_matches('=')) {
Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => {
let at = d.selection.active;
gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64)))
}
_ => r.trim_start_matches('=').to_string(),
},
None => {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = d.selection.current();
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row + 1);
let body = if r.is_single() { abs(r.start) } else { format!("{}:{}", abs(r.start), abs(r.end)) };
format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), body)
}
};
if gridcraft_formula::parse_name(&refers).is_err() {
return Err(EngineError::Other("There's a problem with this formula.".into()));
}
let comment = str_param(p, "comment").unwrap_or("").to_string();
// Editing an existing name (Name Manager › Edit…): it must exist, and its new name must not
// collide with another name in the target scope.
let old = match str_param(p, "oldName") {
Some(old_name) => {
let old_scope = match str_param(p, "oldScope") {
None | Some("Workbook") => None,
Some(sh) => Some(d.wb.sheet_index(sh).ok_or_else(|| bad("formulas.defineName", "no such sheet"))?),
};
if !d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(old_name) && n.scope == old_scope) {
return Err(bad("formulas.defineName", "no such name to edit"));
}
let same = old_name.eq_ignore_ascii_case(&name) && old_scope == scope;
if !same && d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name) && n.scope == scope) {
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
}
Some((old_name.to_string(), old_scope))
}
None => None,
};
edit(s, |cx| {
if let Some((old_name, old_scope)) = &old {
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(old_name) && n.scope == *old_scope));
if !old_name.eq_ignore_ascii_case(&name) {
rename_name_in_formulas(&mut cx.wb, old_name, *old_scope, &name);
}
}
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && n.scope == scope));
cx.wb.names.push(DefinedName { name: name.clone(), scope, formula: refers.clone(), comment: comment.clone(), hidden: false });
cx.structural = true;
Ok(json!({"name": name, "refersTo": format!("={refers}")}))
})
}
/// Formulas that use a renamed name follow it: cell formulas and other names' definitions, on
/// the sheets where `old` resolves to the renamed name (its own sheet for a sheet-scoped name;
/// every sheet without a same-named sheet-level name for a workbook-level one).
fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_scope: Option<usize>, new: &str) {
let shadowed: Vec<usize> = wb.names.iter().filter(|n| n.name.eq_ignore_ascii_case(old)).filter_map(|n| n.scope).collect();
let applies = |sheet: usize| match old_scope {
Some(s) => s == sheet,
None => !shadowed.contains(&sheet),
};
// The renamed expression, or `None` when the name doesn't occur in it.
let rename = |e: gridcraft_formula::Expr| {
let mut hit = false;
let out = e.map(&mut |x| match x {
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(old) => {
hit = true;
gridcraft_formula::Expr::Name(new.to_string())
}
o => o,
});
hit.then_some(out)
};
for i in 0..wb.sheets.len() {
if !applies(i) {
continue;
}
let Some(sh) = wb.sheet_mut(i) else { continue };
let formulas: Vec<(CellRef, std::sync::Arc<Formula>)> = sh.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect();
for (c, f) in formulas {
let Some(e) = f.expr().and_then(rename) else { continue };
let mut nf = Formula::from_expr(e);
nf.array = f.array;
nf.dynamic = f.dynamic;
if let Some(cell) = sh.cells.get_mut(c) {
cell.formula = Some(std::sync::Arc::new(nf));
}
}
}
for n in wb.names.iter_mut() {
if n.scope.is_none_or(applies)
&& let Some(e) = gridcraft_formula::parse_name(&n.formula).ok().and_then(rename)
{
n.formula = gridcraft_formula::print(&e);
}
}
}
/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]):
/// defining, renaming or deleting one would replace or remove part of an item (a stored macro,
/// script or view) and leave the rest behind. `force: true` lets the call go ahead.
/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope
/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and
/// the name commands may change it.
fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool {
// The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not.
stash::holds_item(wb, name)
}
fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> {
if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) {
return Err(EngineError::Other(format!(
"'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway."
)));
}
Ok(())
}
fn delete_name(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string();
refuse_stash_name(&s.doc()?.wb, p, &name)?;
let scope = match str_param(p, "scope") {
None => None,
Some("Workbook") => Some(None),
Some(sh) => Some(Some(s.doc()?.wb.sheet_index(sh).ok_or_else(|| bad("formulas.deleteName", "no such sheet"))?)),
};
edit(s, |cx| {
let before = cx.wb.names.len();
cx.wb.names.retain(|n| !(n.name.eq_ignore_ascii_case(&name) && scope.is_none_or(|sc| n.scope == sc)));
if cx.wb.names.len() == before {
return Err(bad("formulas.deleteName", "no such name"));
}
cx.structural = true;
Ok(Json::Null)
})
}
fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let list: Vec<Json> = d
.wb
.names
.iter()
.filter(|n| !n.hidden)
.map(|n| {
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
// parse_name, not evaluate's parse: a name's top-level commas are unions.
let v = gridcraft_formula::parse_name(&n.formula)
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
json!({"name": n.name, "refersTo": format!("={}", n.formula), "scope": n.scope.and_then(|i| d.wb.sheet(i)).map(|s| s.name.clone()).unwrap_or_else(|| "Workbook".into()), "value": crate::cmd::inspect::value_json(&v), "comment": n.comment})
})
.collect();
Ok(Json::Array(list))
}
fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
let r = target_range(s, p)?;
let top = bool_param(p, "top").unwrap_or(true);
let left = bool_param(p, "left").unwrap_or(false);
let bottom = bool_param(p, "bottom").unwrap_or(false);
let right = bool_param(p, "right").unwrap_or(false);
let replace = bool_param(p, "replace").unwrap_or(false);
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let q = gridcraft_formula::quote_sheet(&sh.name);
let abs = |r: RangeRef| {
format!(
"{q}!${}${}:${}${}",
gridcraft_core::col_to_letters(r.start.col),
r.start.row + 1,
gridcraft_core::col_to_letters(r.end.col),
r.end.row + 1
)
};
let clean = |t: String| -> String {
let mut n: String = t.trim().chars().map(|c| if c.is_alphanumeric() || c == '.' { c } else { '_' }).collect();
if !n.is_empty() && !valid_name(&n) {
n.insert(0, '_');
}
if valid_name(&n) { n } else { String::new() }
};
// The labels in the chosen edge rows/columns name the cells between them.
let (r0, r1) = (r.start.row + top as u32, r.end.row.saturating_sub(bottom as u32));
let (c0, c1) = (r.start.col + left as u32, r.end.col.saturating_sub(right as u32));
let mut made = Vec::new();
if r0 <= r1 && c0 <= c1 {
for (on, row) in [(top, r.start.row), (bottom, r.end.row)] {
for c in (c0..=c1).filter(|_| on) {
let name = clean(sh.value(CellRef::new(row, c)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(r0, c), CellRef::new(r1, c)))));
}
}
}
for (on, col) in [(left, r.start.col), (right, r.end.col)] {
for row in (r0..=r1).filter(|_| on) {
let name = clean(sh.value(CellRef::new(row, col)).display());
if !name.is_empty() {
made.push((name, abs(RangeRef::new(CellRef::new(row, c0), CellRef::new(row, c1)))));
}
}
}
}
// Excel asks before replacing an existing name; here it's kept unless `replace` is set, and
// reported.
let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none());
// A label that names one of GridCraft's own hidden data names would replace part of an item
// (see `refuse_stash_name`).
let force = bool_param(p, "force").unwrap_or(false);
let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n);
let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n)));
let skipped: Vec<String> = skipped.into_iter().map(|(n, _)| n).collect();
let n = made.len();
edit(s, |cx| {
for (name, f) in made {
cx.wb.names.retain(|x| !(x.name.eq_ignore_ascii_case(&name) && x.scope.is_none()));
cx.wb.names.push(DefinedName { name, scope: None, formula: f, comment: String::new(), hidden: false });
}
cx.structural = true;
Ok(json!({"created": n, "skipped": skipped}))
})
}
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
/// workbook name it shares.
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
let sheet = wb.active_sheet;
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
for n in shown(false) {
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
out.push((n.name.clone(), format!("={}", n.formula)));
}
}
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
out
}
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let names = pasteable_names(&d.wb);
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
if !bool_param(p, "list").unwrap_or(false) {
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
};
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
}
return Ok(json!({"names": listed}));
}
if names.is_empty() {
return Err(EngineError::Other("This workbook has no names to paste.".into()));
}
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
}
let sheet = d.wb.active_sheet;
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
edit(s, |cx| {
for (row, (name, refers)) in (at.row..).zip(&names) {
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
let c = CellRef::new(row, col);
let sh = cx.sheet_mut(sheet)?;
let style = sh.style_id(c);
// The second column holds the refers-to text as a text value, with no formula.
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
cx.touch(sheet, c);
}
}
Ok(json!({"names": listed, "range": range.a1()}))
})
}
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
Ok(Json::Array(v))
}
fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let mut deps = Vec::new();
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
deps.sort();
deps.dedup();
let v: Vec<Json> = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect();
Ok(Json::Array(v))
}
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
use std::collections::BTreeSet;
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let all = bool_param(p, "all").unwrap_or(false);
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new();
let mut frontier = vec![(sheet, at)];
seen.insert((sheet, at));
while let Some((ks, kc)) = frontier.pop() {
if precedents {
for a in d.calc.graph.precedents_of((ks, kc)) {
if all {
// Walk only the stored cells inside the precedent area (sparse
// range iterator, not the whole sheet): blanks are never selected
// and only formula cells extend the frontier, so a formula late in
// a large range is not skipped.
let Some(sh) = d.wb.sheet(a.sheet) else { continue };
for (c, cell) in sh.cells.iter_range(a.range) {
if matches!(cell.value, Value::Empty) {
continue;
}
if seen.insert((a.sheet, c)) {
found.insert((a.sheet, c.a1()));
if cell.formula.is_some() {
frontier.push((a.sheet, c));
}
}
}
} else {
found.insert((a.sheet, a.range.a1()));
}
}
} else {
let mut deps = Vec::new();
d.calc.graph.dependents(ks, kc, &mut deps);
for k2 in deps {
if all {
if seen.insert(k2) {
found.insert((k2.0, k2.1.a1()));
frontier.push(k2);
}
} else {
found.insert((k2.0, k2.1.a1()));
}
}
}
if !all {
break;
}
}
found.remove(&(sheet, at.a1()));
let name = if precedents { "precedent" } else { "dependent" };
let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else {
return Err(bad("cell", format!("No {name} cells.")));
};
let list: Vec<String> = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect();
let mut params = json!({ "range": list.join(",") });
if si != sheet {
params["sheet"] = json!(si);
}
s.execute("selection.set", params)
}
fn select_precedents(s: &mut Session, p: &Json) -> Result<Json> {
select_trace_cells(s, p, true)
}
fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
select_trace_cells(s, p, false)
}
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = s.doc()?.wb.active_sheet;
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
let on = bool_param(p, "on").unwrap_or(!cur);
edit(s, |cx| {
cx.sheet_mut(sheet)?.show_formulas = on;
Ok(json!({"on": on}))
})
}
/// The number a cell's text stands for when Excel's background check flags it as a number stored
/// as text: a constant (not a formula's result) that would be a number if typed.
pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option<f64> {
match &cell.value {
Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t),
_ => None,
}
}
/// Convert to Number: each number stored as text in the range becomes that number, keeping the
/// cell's format.
fn convert_to_number(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let ranges = target_ranges(s, p)?;
edit(s, |cx| {
let mut converted = 0;
for r in &ranges {
let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue };
let found: Vec<(CellRef, f64)> = cx
.wb
.sheet(sheet)
.map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect())
.unwrap_or_default();
let sh = cx.sheet_mut(sheet)?;
for (c, n) in &found {
sh.set_value(*c, Value::number(*n));
}
cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c)));
converted += found.len();
}
Ok(json!({"converted": converted}))
})
}
fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc()?;
let mut out = Vec::new();
for sh in &d.wb.sheets {
for (c, cell) in sh.cells.iter() {
if let Some(e) = cell.value.as_error() {
out.push(
json!({"sheet": sh.name, "cell": c.a1(), "error": e.as_str(), "formula": cell.formula.as_ref().map(|f| format!("={}", f.text))}),
);
}
if out.len() >= 10_000 {
break;
}
}
}
let circ: Vec<String> = d
.calc
.circular
.iter()
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
.collect();
Ok(json!({"errors": out, "circular": circ}))
}
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let sheet = d.wb.active_sheet;
let text = match str_param(p, "formula") {
Some(f) => f.trim_start_matches('=').to_string(),
None => {
d.wb.sheet(sheet)
.and_then(|sh| sh.cell(at))
.and_then(|c| c.formula.as_ref())
.map(|f| f.text.clone())
.ok_or_else(|| EngineError::Other("The cell has no formula.".into()))?
}
};
let expr = gridcraft_formula::parse(&text).map_err(|e| EngineError::Other(e.to_string()))?;
// Steps: each sub-expression (innermost first) with its value.
let mut steps = Vec::new();
let mut nodes = Vec::new();
expr.walk(&mut |e| {
// Borrowed, not cloned: copying every operator of a long chain A1+A2+… copies the
// rest of the chain each time (quadratic memory).
if matches!(e, gridcraft_formula::Expr::Call(..) | gridcraft_formula::Expr::Binary(..) | gridcraft_formula::Expr::Ref(_)) {
nodes.push(e);
}
});
for e in nodes.into_iter().rev().take(200) {
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, e);
steps.push(json!({"expression": gridcraft_formula::print(e), "value": crate::cmd::inspect::value_json(&v)}));
}
let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr);
Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)}))
}
/// Evaluates what typing `formula` into a cell would show. A formula `cell.set` would refuse (it
/// doesn't parse, or calls a function with the wrong number of arguments) is an error that says
/// why, not `#NAME?`; an unknown function name still evaluates to `#NAME?`, as in Excel.
fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?;
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
crate::cmd::edit::check_arg_counts(&expr)?;
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
Ok(crate::cmd::inspect::value_json(&v))
}
fn calc_now(s: &mut Session, _: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let mut wb = (*d.wb).clone();
d.calc.recalc_all(&mut wb);
d.wb = std::sync::Arc::new(wb);
Ok(json!({"cells": d.calc.last_recalc_cells}))
}
fn calc_options(s: &mut Session, p: &Json) -> Result<Json> {
let mode = match str_param(p, "mode") {
Some("manual") => Some(CalcMode::Manual),
Some("automaticExceptTables") => Some(CalcMode::AutomaticExceptTables),
Some("automatic") => Some(CalcMode::Automatic),
_ => None,
};
edit(s, |cx| {
let before = cx.wb.calc.clone();
if let Some(m) = mode {
cx.wb.calc.mode = m;
}
if let Some(v) = bool_param(p, "iterative") {
cx.wb.calc.iterative = v;
}
if let Some(v) = u32_param(p, "maxIterations") {
cx.wb.calc.set_max_iterations(v);
}
if let Some(v) = f64_param(p, "maxChange") {
cx.wb.calc.set_max_change(v);
}
let c = &cx.wb.calc;
// Switching to automatic, or changing how circular references are calculated outside
// manual mode, recalculates the workbook.
let iteration_changed = (c.iterative, c.max_iterations, c.max_change) != (before.iterative, before.max_iterations, before.max_change);
if mode == Some(CalcMode::Automatic) || (iteration_changed && c.mode != CalcMode::Manual) {
cx.structural = true;
}
Ok(json!({"mode": format!("{:?}", c.mode), "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_with_relative_references() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
// Defined from B2, `=Sheet1!A2` is "the cell to the left".
s.execute("selection.set", json!({"range": "B2"})).unwrap();
let r = s.execute("formulas.defineName", json!({"name": "LeftCell", "refersTo": "=Sheet1!A2"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!XFD1");
s.execute("cell.set", json!({"cell": "B1", "input": "=LeftCell"})).unwrap();
s.execute("cell.set", json!({"cell": "B3", "input": "=LeftCell*10"})).unwrap();
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "B1"), json!(1.0));
assert_eq!(v(&mut s, "B3"), json!(30.0));
// Absolute references are kept as they are.
let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
}
#[test]
fn the_name_commands_leave_gridcraft_data_alone() {
// Defining, renaming or deleting a name of a stored item's shape would replace or remove
// one of its chunks and leave the rest behind: refused unless `force` is true.
let mut s = Session::new();
s.new_workbook();
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
let text = "m".repeat(9_000);
stash::set(wb, "macro_1", &text).unwrap();
wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true });
// A legacy item is read whether or not it is hidden, so a visible one is data too.
wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false });
let names = |s: &Session| s.doc().unwrap().wb.names.clone();
let before = names(&s);
assert!(before.len() >= 3, "the item takes two chunks");
for params in [
json!({"name": "_gc_macro_1_0", "refersTo": "=1"}),
json!({"name": "_SC_Watch", "refersTo": "=1"}),
json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}),
json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}),
json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}),
] {
let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string();
assert!(err.contains("force"), "{params}: {err}");
}
for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] {
let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string();
assert!(err.contains("force"), "{name}: {err}");
}
s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap();
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap();
assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"])));
let after = names(&s);
assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::<Vec<_>>(), before);
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()));
// A visible name of the shape is the person's own (the stash never reads it): the name
// commands change and remove it without `force`, as Name Manager cannot pass one.
let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false };
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone());
s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap();
s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own);
s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap();
assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched");
// Ordinary names and forced calls go ahead.
s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap();
s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap();
s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap();
assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1"));
}
#[test]
fn arabic_defined_names_count_characters_not_bytes() {
let mut s = Session::new();
s.new_workbook();
let arabic = |n: usize| "س".repeat(n);
for ok in [200, 255] {
s.execute("formulas.defineName", json!({"name": arabic(ok), "refersTo": "=1"})).unwrap();
}
assert!(s.execute("formulas.defineName", json!({"name": arabic(256), "refersTo": "=1"})).is_err());
s.execute("range.setValues", json!({"range": "A1", "values": [[format!("ع{}", arabic(199))], [1]]})).unwrap();
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:A2", "top": true})).unwrap();
assert_eq!(r["created"], 1);
}
#[test]
fn names_referring_to_several_ranges() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
// Excel writes and accepts a union in a name without parentheses.
let r = s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
assert_eq!(r["refersTo"], "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2");
for (cell, input) in [("E1", "=SUM(u)"), ("E2", "=AREAS(u)"), ("E3", "=MIN(u)"), ("E4", "=INDEX(u,2,1,2)")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
assert_eq!(v(&mut s, "E1"), json!(15.0));
assert_eq!(v(&mut s, "E2"), json!(2.0));
assert_eq!(v(&mut s, "E3"), json!(1.0));
assert_eq!(v(&mut s, "E4"), json!(7.0));
// Renaming the sheet follows every area, and the definition keeps Excel's form.
s.execute("sheet.rename", json!({"name": "Data"})).unwrap();
let name = s.doc().unwrap().wb.names.iter().find(|n| n.name == "u").map(|n| n.formula.clone());
assert_eq!(name.as_deref(), Some("Data!$A$1:$A$2,Data!$C$1:$C$2"));
assert_eq!(v(&mut s, "E1"), json!(15.0));
}
#[test]
fn name_manager_previews_a_name_over_several_ranges() {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
let list = s.execute("formulas.nameManager", json!({})).unwrap();
let u = list.as_array().and_then(|l| l.iter().find(|n| n["name"] == "u")).cloned().unwrap();
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
}
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
/// `Tax`, and a name of another sheet.
fn named_workbook() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
let d = s.doc_mut().unwrap();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden,
};
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
wb.names.push(name("TAX", Some(0), "0.25", false));
wb.names.push(name("Other", Some(1), "1", false));
s
}
fn text_at(s: &Session, cell: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
}
#[test]
fn paste_names_lists_the_names_the_active_sheet_can_use() {
let mut s = named_workbook();
let r = s.execute("formulas.pasteName", json!({})).unwrap();
assert_eq!(
r["names"],
json!([
{"name": "rate", "refersTo": "=0.07"},
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
{"name": "TAX", "refersTo": "=0.25"},
])
);
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
}
#[test]
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
let mut s = named_workbook();
s.take_ui_requests();
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
}
#[test]
fn paste_list_writes_each_name_and_what_it_refers_to() {
let mut s = named_workbook();
s.execute("selection.set", json!({"range": "D2"})).unwrap();
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
assert_eq!(r["range"], json!("D2:E4"));
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
for (cell, text) in rows {
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
}
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
s.execute("edit.undo", json!({})).unwrap();
for (cell, _) in rows {
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
}
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
}
#[test]
fn paste_list_needs_names_and_room() {
let mut s = named_workbook();
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
let mut empty = Session::new();
empty.new_workbook();
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
}
}