From fef1f4ddb1dd546f94de045318932d47fbc934d6 Mon Sep 17 00:00:00 2001
From: vangel76
Date: Mon, 12 Oct 2026 20:25:14 +0200
Subject: [PATCH 001/149] Security: the --control server requires an auth token
and closes bad connections (#777)
Co-Authored-By: Claude Sonnet 5.5
---
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
Cargo.lock | 2 +
ROADMAP.md | 2 +
apps/gridcraft/Cargo.toml | 3 +
apps/gridcraft/src/control_server.rs | 139 +++++++++++++++++++++++++--
apps/gridcraft/src/main.rs | 10 +-
crates/mcp/src/backend.rs | 35 ++++++-
crates/mcp/src/control_auth.rs | 135 ++++++++++++++++++++++++++
crates/mcp/src/lib.rs | 1 +
crates/mcp/src/tests.rs | 51 +++++++++-
docs/control-protocol.md | 26 ++++-
docs/mcp.md | 2 +-
13 files changed, 393 insertions(+), 17 deletions(-)
create mode 100644 crates/mcp/src/control_auth.rs
diff --git a/AGENTS.md b/AGENTS.md
index 963fc6d74..ccead14c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Running and looking at the app
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
-- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
+- Drive it: `gridcraft-cli send 7979 ''` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
diff --git a/CLAUDE.md b/CLAUDE.md
index 280fe8bcb..2a7ee363e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
## Running and looking at the app
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
-- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
+- Drive it: `gridcraft-cli send 7979 ''` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
diff --git a/Cargo.lock b/Cargo.lock
index ade3a5adb..c3fa06650 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1503,7 +1503,9 @@ dependencies = [
"eframe",
"egui",
"fmv-macos-events",
+ "getrandom 0.4.3",
"gridcraft-engine",
+ "gridcraft-mcp",
"gridcraft-ui-egui",
"image",
"log",
diff --git a/ROADMAP.md b/ROADMAP.md
index e8e1c06b3..930ded70d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
@@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
+| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml
index c867d24a2..14bc66b69 100644
--- a/apps/gridcraft/Cargo.toml
+++ b/apps/gridcraft/Cargo.toml
@@ -27,6 +27,9 @@ winresource = "0.1"
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
rfd = { workspace = true }
arboard = { workspace = true }
+gridcraft-mcp = { workspace = true }
+# The control channel's token (OS random numbers); already in the tree.
+getrandom = "0.4"
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
arboard = { workspace = true, features = ["wayland-data-control"] }
diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs
index e9e05a2e6..ab2046e3c 100644
--- a/apps/gridcraft/src/control_server.rs
+++ b/apps/gridcraft/src/control_server.rs
@@ -1,34 +1,82 @@
//! Localhost JSON-lines control server. Each connection may send many requests; each line
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
+//!
+//! The first line of a connection must authenticate with the app's token (#777; see
+//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
+//! JSON, is answered once and closed.
-use std::io::{BufRead, BufReader, Write};
+use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
+use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
+use gridcraft_mcp::control_auth;
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value as Json, json};
const MAX_LINE: usize = 64 * 1024 * 1024;
+/// How long a new connection may take to send its token.
+const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
-pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> {
+/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
+/// else 256 random bits from the operating system, written to a file only the user can read.
+fn make_token(port: u16, chosen: Option) -> std::io::Result {
+ if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
+ if t.len() < control_auth::MIN_TOKEN_LEN {
+ return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
+ }
+ eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
+ return Ok(t);
+ }
+ let mut bytes = [0u8; 32];
+ getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
+ let token = control_auth::hex(&bytes);
+ let path = control_auth::write_token_file(port, &token)?;
+ eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
+ Ok(token)
+}
+
+pub fn start(port: u16, chosen_token: Option, ctx: egui::Context) -> std::io::Result> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
+ let token: Arc = make_token(port, chosen_token)?.into();
let (tx, rx) = channel();
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
- let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
+ let token = Arc::clone(&token);
+ let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
}
})?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
-fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
+/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
+/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
+fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &str) -> bool {
+ let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
+ let mut first = String::new();
+ let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
+ let verdict = match read {
+ Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
+ _ => Err(json!({"ok": false, "error": "authentication required"})),
+ };
+ let allowed = verdict.is_ok();
+ let reply = verdict.unwrap_or_else(|e| e);
+ let _ = writeln!(out, "{reply}");
+ let _ = out.set_read_timeout(None);
+ allowed
+}
+
+fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
- let reader = BufReader::new(read);
+ let mut reader = BufReader::new(read);
+ if !authenticate(&mut reader, &mut out, token) {
+ return;
+ }
for line in reader.lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
@@ -42,7 +90,7 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
Ok(v) => v,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")}));
- continue;
+ break; // Not a client of ours; don't keep reading.
}
};
let id = msg.get("id").cloned().unwrap_or(Json::Null);
@@ -65,3 +113,82 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used)]
+ use super::*;
+
+ const TOKEN: &str = "0123456789abcdef0123456789abcdef";
+
+ /// A server on a loopback port with a stand-in UI that echoes each method.
+ fn spawn() -> std::net::SocketAddr {
+ let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
+ let addr = listener.local_addr().unwrap();
+ let (tx, rx) = channel::();
+ std::thread::spawn(move || {
+ for req in rx {
+ let _ = req.reply.send(json!({"ok": true, "result": req.method}));
+ }
+ });
+ std::thread::spawn(move || {
+ for stream in listener.incoming().flatten() {
+ let tx = tx.clone();
+ std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
+ }
+ });
+ addr
+ }
+
+ fn lines(addr: std::net::SocketAddr, send: &str) -> Vec {
+ let mut c = TcpStream::connect(addr).unwrap();
+ c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+ c.write_all(send.as_bytes()).unwrap();
+ // Replies up to the server closing the connection.
+ BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
+ }
+
+ #[test]
+ fn the_token_opens_the_connection() {
+ let addr = spawn();
+ let auth = control_auth::auth_line(1, TOKEN);
+ let mut c = TcpStream::connect(addr).unwrap();
+ c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
+ c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
+ let mut r = BufReader::new(c);
+ let mut line = String::new();
+ r.read_line(&mut line).unwrap();
+ assert_eq!(serde_json::from_str::(&line).unwrap()["ok"], json!(true));
+ line.clear();
+ r.read_line(&mut line).unwrap();
+ let v: Json = serde_json::from_str(&line).unwrap();
+ assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
+ }
+
+ #[test]
+ fn anything_but_the_token_first_closes_the_connection() {
+ let addr = spawn();
+ let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
+ for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
+ // The second request is never served: one refusal, then end of stream.
+ let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
+ assert_eq!(got.len(), 1, "{first}: {got:?}");
+ assert_eq!(got[0]["ok"], json!(false), "{first}");
+ }
+ }
+
+ #[test]
+ fn an_unparsable_line_after_auth_closes_the_connection() {
+ let addr = spawn();
+ let auth = control_auth::auth_line(1, TOKEN);
+ let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
+ assert_eq!(got.len(), 2, "{got:?}");
+ assert_eq!(got[1]["ok"], json!(false));
+ }
+
+ #[test]
+ fn a_chosen_token_must_be_long_enough() {
+ assert!(make_token(1, Some("short".into())).is_err());
+ assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
+ }
+}
diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs
index 8093205c4..18cff9844 100644
--- a/apps/gridcraft/src/main.rs
+++ b/apps/gridcraft/src/main.rs
@@ -1,9 +1,10 @@
//! GridCraft desktop app.
//!
-//! Usage: `gridcraft [--control ] [--sample ] [files…]`
+//! Usage: `gridcraft [--control [--control-token ]] [--sample ] [files…]`
//!
//! `--control ` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
+//! The first line of each connection must carry the token (`docs/control-protocol.md`).
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -242,6 +243,7 @@ fn main() -> eframe::Result<()> {
return Ok(());
}
let mut control_port: Option = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
+ let mut control_token: Option = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
let mut sample: Option = None;
let mut files: Vec = Vec::new();
let mut i = 0;
@@ -251,6 +253,10 @@ fn main() -> eframe::Result<()> {
i += 1;
control_port = args.get(i).and_then(|p| p.parse().ok());
}
+ "--control-token" => {
+ i += 1;
+ control_token = args.get(i).cloned();
+ }
"--sample" => {
i += 1;
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
@@ -338,7 +344,7 @@ fn main() -> eframe::Result<()> {
};
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
if let Some(port) = control_port {
- match control_server::start(port, cc.egui_ctx.clone()) {
+ match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => log::error!("control channel on port {port}: {e}"),
}
diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs
index adc866d2f..c77b612f7 100644
--- a/crates/mcp/src/backend.rs
+++ b/crates/mcp/src/backend.rs
@@ -25,14 +25,22 @@ const MAX_REPLY: u64 = 256 * 1024 * 1024;
/// A running GridCraft app, reached through its loopback control port.
pub struct Remote {
addr: String,
+ token: String,
conn: Option<(BufReader, TcpStream)>,
next_id: u64,
}
impl Remote {
- /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening.
+ /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. The app
+ /// requires a token (#777): `GRIDCRAFT_CONTROL_TOKEN`, else the file the app wrote for this port.
pub fn connect(addr: &str) -> std::io::Result {
- let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
+ let token = crate::control_auth::client_token(addr).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
+ Self::connect_with_token(addr, token)
+ }
+
+ /// Like [`Remote::connect`] with a token the caller already has.
+ pub fn connect_with_token(addr: &str, token: String) -> std::io::Result {
+ let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 };
r.reconnect()?;
Ok(r)
}
@@ -51,7 +59,9 @@ impl Remote {
s.set_read_timeout(Some(IO_TIMEOUT)).ok();
s.set_write_timeout(Some(IO_TIMEOUT)).ok();
let read = s.try_clone()?;
- self.conn = Some((BufReader::new(read), s));
+ let mut conn = (BufReader::new(read), s);
+ Self::authenticate(&mut conn, &self.token)?;
+ self.conn = Some(conn);
return Ok(());
}
Err(e) => last = e,
@@ -60,6 +70,25 @@ impl Remote {
Err(last)
}
+ /// The handshake every connection starts with: the app closes one that doesn't send the token first.
+ fn authenticate(conn: &mut (BufReader, TcpStream), token: &str) -> std::io::Result<()> {
+ let (reader, writer) = conn;
+ writer.write_all(crate::control_auth::auth_line(0, token).as_bytes())?;
+ writer.write_all(b"\n")?;
+ writer.flush()?;
+ let mut reply = String::new();
+ let n = std::io::Read::take(reader, 64 * 1024).read_line(&mut reply)?;
+ let ok = n > 0 && serde_json::from_str::(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true);
+ if ok {
+ Ok(())
+ } else {
+ Err(std::io::Error::new(
+ std::io::ErrorKind::PermissionDenied,
+ "the app refused the control token (is GRIDCRAFT_CONTROL_TOKEN or the app's token file for this port out of date?)",
+ ))
+ }
+ }
+
fn roundtrip(&mut self, line: &str) -> std::io::Result {
if self.conn.is_none() {
self.reconnect()?;
diff --git a/crates/mcp/src/control_auth.rs b/crates/mcp/src/control_auth.rs
new file mode 100644
index 000000000..7ff589353
--- /dev/null
+++ b/crates/mcp/src/control_auth.rs
@@ -0,0 +1,135 @@
+//! Authentication of the control channel (#777).
+//!
+//! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:`, can
+//! reach a loopback port. So the first line of every connection must be
+//! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The
+//! app keeps it in memory and writes it to a file only the user can read (or the user chooses it
+//! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places.
+
+use std::path::PathBuf;
+
+use serde_json::{Value, json};
+
+/// Environment variable holding a token chosen by the user. The app uses it instead of making
+/// one (and writes no file); clients use it instead of reading the file.
+pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN";
+/// The method of the handshake line.
+pub const AUTH_METHOD: &str = "auth";
+/// Shortest token the app accepts from the user (a made one has 256 bits).
+pub const MIN_TOKEN_LEN: usize = 16;
+/// Longest first line the app reads from a peer that hasn't authenticated yet.
+pub const MAX_AUTH_LINE: usize = 4096;
+
+/// Compare two secrets without stopping at the first difference. Unequal lengths are unequal
+/// at once (the length of a token isn't secret).
+pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
+ if a.len() != b.len() {
+ return false;
+ }
+ let mut diff = 0u8;
+ for (x, y) in a.iter().zip(b) {
+ diff |= x ^ y;
+ }
+ // Keep the optimiser from turning the loop into an early exit.
+ std::hint::black_box(diff) == 0
+}
+
+/// A made token as lowercase hex (two characters per byte).
+pub fn hex(bytes: &[u8]) -> String {
+ const DIGITS: &[u8; 16] = b"0123456789abcdef";
+ let mut s = String::with_capacity(bytes.len().saturating_mul(2));
+ for b in bytes {
+ s.push(char::from(DIGITS[usize::from(b >> 4)]));
+ s.push(char::from(DIGITS[usize::from(b & 15)]));
+ }
+ s
+}
+
+/// The handshake line a client sends first.
+pub fn auth_line(id: u64, token: &str) -> String {
+ json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
+}
+
+/// Check a connection's first line. `Ok` is the reply to send before serving further requests;
+/// `Err` is the reply for a refused connection, which the caller sends and then closes. The
+/// refusal is the same whatever was wrong, so it tells a stranger nothing.
+pub fn check_first_line(line: &str, token: &str) -> Result {
+ let refused = || json!({"ok": false, "error": "authentication required"});
+ let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?;
+ if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
+ return Err(refused());
+ }
+ let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or("");
+ // Always compare, so a missing token costs the same as a wrong one.
+ if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() {
+ Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}}))
+ } else {
+ Err(refused())
+ }
+}
+
+/// Where the app writes the token it made for `port`, and where clients look for it: a folder
+/// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows
+/// `%USERPROFILE%\.gridcraft`), file `control-.token`. `None` when no such folder is known.
+pub fn token_path(port: u16) -> Option {
+ let dir = if cfg!(windows) {
+ PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft")
+ } else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
+ PathBuf::from(run).join("gridcraft")
+ } else {
+ PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft")
+ };
+ Some(dir.join(format!("control-{port}.token")))
+}
+
+/// The port of a control address (`127.0.0.1:7979` → 7979).
+fn port_of(addr: &str) -> Option {
+ addr.rsplit(':').next()?.parse().ok()
+}
+
+/// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote.
+pub fn client_token(addr: &str) -> Result {
+ if let Ok(t) = std::env::var(TOKEN_ENV)
+ && !t.trim().is_empty()
+ {
+ return Ok(t.trim().to_string());
+ }
+ let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?;
+ match std::fs::read_to_string(&path) {
+ Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
+ _ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())),
+ }
+}
+
+/// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix),
+/// an older file is replaced, and the new one is created readable by the user only (mode 0600).
+/// Returns the path.
+pub fn write_token_file(port: u16, token: &str) -> std::io::Result {
+ use std::io::Write;
+ let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?;
+ let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?;
+ std::fs::create_dir_all(dir)?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ // Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is.
+ std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
+ }
+ // Replace rather than open: a leftover file may have looser permissions, or be a link.
+ match std::fs::remove_file(&path) {
+ Ok(()) => {}
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => return Err(e),
+ }
+ let mut opts = std::fs::OpenOptions::new();
+ opts.write(true).create_new(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ opts.mode(0o600);
+ }
+ let mut f = opts.open(&path)?;
+ f.write_all(token.as_bytes())?;
+ f.write_all(b"\n")?;
+ Ok(path)
+}
diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs
index de3e25044..7c66a26f9 100644
--- a/crates/mcp/src/lib.rs
+++ b/crates/mcp/src/lib.rs
@@ -15,6 +15,7 @@
#![forbid(unsafe_code)]
mod backend;
+pub mod control_auth;
mod headless;
mod server;
mod tools;
diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs
index eb1f20503..df6c954e7 100644
--- a/crates/mcp/src/tests.rs
+++ b/crates/mcp/src/tests.rs
@@ -363,6 +363,11 @@ fn remote_backend_over_tcp() {
let stream = stream.unwrap();
let mut w = stream.try_clone().unwrap();
let mut r = BufReader::new(stream);
+ // Every connection starts with the token, as the app requires.
+ let mut first = String::new();
+ r.read_line(&mut first).unwrap();
+ let hello = crate::control_auth::check_first_line(&first, "secret-token-0123456789").unwrap();
+ w.write_all(format!("{hello}\n").as_bytes()).unwrap();
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap_or(0) == 0 {
@@ -382,7 +387,7 @@ fn remote_backend_over_tcp() {
});
let addr = control_addr(&port.to_string());
assert_eq!(addr, format!("127.0.0.1:{port}"));
- let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
+ let mut s = Server::new(Box::new(Remote::connect_with_token(&addr, "secret-token-0123456789".into()).unwrap()));
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0));
// Second call reconnects transparently.
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"}));
@@ -391,7 +396,49 @@ fn remote_backend_over_tcp() {
assert!(msg.contains("unknown command"), "{msg}");
drop(s);
handle.join().unwrap();
- assert!(Remote::connect("127.0.0.1:1").is_err());
+ assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err());
+}
+
+#[test]
+fn control_auth_handshake_and_compare() {
+ use crate::control_auth::{auth_line, check_first_line, constant_time_eq, hex};
+ let token = "0123456789abcdef0123456789abcdef";
+ let ok = check_first_line(&auth_line(7, token), token).unwrap();
+ assert_eq!((ok["id"].clone(), ok["ok"].clone()), (json!(7), json!(true)));
+ // Wrong or missing token, another method, and non-JSON are all refused the same way.
+ let wrong = check_first_line(&auth_line(1, "0123456789abcdef0123456789abcdee"), token).unwrap_err();
+ for bad in [
+ auth_line(1, ""),
+ r#"{"id":1,"method":"auth","params":{}}"#.into(),
+ r#"{"id":1,"method":"app.save","params":{"path":"/tmp/x","token":"0123456789abcdef0123456789abcdef"}}"#.into(),
+ "POST / HTTP/1.1".into(),
+ String::new(),
+ ] {
+ assert_eq!(check_first_line(&bad, token).unwrap_err(), wrong, "{bad}");
+ }
+ // An empty configured token never matches an empty given one.
+ assert!(check_first_line(&auth_line(1, ""), "").is_err());
+ assert!(constant_time_eq(b"abc", b"abc"));
+ assert!(!constant_time_eq(b"abc", b"abd"));
+ assert!(!constant_time_eq(b"abc", b"abcd"));
+ assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff");
+}
+
+#[cfg(unix)]
+#[test]
+fn control_token_file_is_private() {
+ use std::os::unix::fs::PermissionsExt;
+ // Unique port per run so parallel tests don't share a file; the folder is the real one.
+ let port = 40_000 + (std::process::id() % 20_000) as u16;
+ let path = crate::control_auth::write_token_file(port, "tok-0123456789abcdef").unwrap();
+ assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
+ assert_eq!(std::fs::metadata(path.parent().unwrap()).unwrap().permissions().mode() & 0o777, 0o700);
+ // A looser leftover is replaced, not reused.
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap();
+ crate::control_auth::write_token_file(port, "tok-1123456789abcdef").unwrap();
+ assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
+ assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "tok-1123456789abcdef");
+ std::fs::remove_file(&path).unwrap();
}
#[test]
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 7cc6ec230..39a3e5d67 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -6,7 +6,31 @@ Start the desktop app with a control port:
gridcraft --control 7979 # or GRIDCRAFT_CONTROL_PORT=7979
```
-Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets one reply line:
+## Authentication
+
+The control port is reachable by every program on the machine, and by a web page that POSTs
+`text/plain` to `127.0.0.1:7979`, so every connection must prove it knows the app's token
+(#777). **There is no way to turn this off.**
+
+- The app makes a token at start (256 random bits from the operating system, kept in memory) and
+ writes it to a file only you can read: `$XDG_RUNTIME_DIR/gridcraft/control-.token` (else
+ `~/.cache/gridcraft/…`; Windows: `%USERPROFILE%\.gridcraft\…`; the folder is mode 0700 and the
+ file 0600 on Unix). The path is printed on stderr. The token itself is never printed or logged.
+- Or choose the token yourself: `GRIDCRAFT_CONTROL_TOKEN=…` (preferred) or `--control-token …`
+ (visible in the process list), at least 16 characters. The app then writes no file.
+- The first line of a connection must be
+ `{"id":0,"method":"auth","params":{"token":"…"}}`; the reply is
+ `{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing
+ token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or
+ one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the
+ connection is closed. After that, a line that isn't JSON also closes the connection.
+- `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use
+ `GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same
+ user on the same machine without setup). Your own client reads the file or the variable.
+
+## Requests
+
+Then send newline-delimited JSON to `127.0.0.1:7979` (after the `auth` line). Every request line gets one reply line:
```json
{"id": 1, "method": "engine.execute", "params": {"command": "cell.set", "params": {"cell": "B2", "input": "=SUM(A1:A9)"}}}
diff --git a/docs/mcp.md b/docs/mcp.md
index 3e6e7bc11..811fc9500 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -9,7 +9,7 @@ Two backends:
| Mode | Command | What it drives |
|---|---|---|
| Headless (default) | `gridcraft-cli mcp [--in book.xlsx \| --sample budget]` | An in-process engine session — no window. |
-| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`). |
+| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`, authenticated with the app's token: it is read from the app's token file or `GRIDCRAFT_CONTROL_TOKEN`, see [`control-protocol.md`](control-protocol.md)). |
## Claude Code
From 74de1b32ebc2097fb5545f3851c3db0ae6b136c7 Mon Sep 17 00:00:00 2001
From: Harrison Booth
Date: Wed, 14 Oct 2026 18:14:07 -0500
Subject: [PATCH 002/149] Keytips: Alt, H, B border letters apply their own
preset
Every Alt, H, B letter ran home.borders with no params, which defaults to
a bottom border, so H,B,N (No Border) and H,B,A (All Borders) both drew a
bottom border. keytips.json entries now take optional fixed `params`
(Action::CommandWith / DialogWith), and each Borders menu letter passes the
preset Excel's menu gives it. H,B,M opens Format Cells on the Border tab.
Erase Border, Line Color and Line Style are reserved (pending) instead of
drawing a bottom border.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 3 +-
crates/ui-egui/src/keytips.rs | 5 +
crates/ui-egui/src/keytips/keytip_table.rs | 4 +-
.../src/keytips/keytip_table/generated.rs | 64 ++++++-------
crates/ui-egui/src/keytips/keytips.json | 90 +++++++++++++-----
crates/ui-egui/src/lib.rs | 12 +++
crates/ui-egui/tests/keytips.rs | 93 +++++++++++++++++++
crates/ui-egui/tests/keytips_generated.rs | 17 +++-
docs/ui-parity.md | 5 +-
xtask/src/keytips.rs | 39 ++++++--
10 files changed, 260 insertions(+), 72 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 070552d07..708f80383 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Alt, H, B border keytips apply their own preset) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs
index e1163d9bb..07324dac7 100644
--- a/crates/ui-egui/src/keytips.rs
+++ b/crates/ui-egui/src/keytips.rs
@@ -10,7 +10,12 @@ pub mod keytip_table;
pub enum Action {
Tab(&'static str),
Command(&'static str),
+ /// A command run with fixed params (a JSON object literal from keytips.json `params`), for
+ /// menu items that share one command id, like the Borders presets (HBO bottom, HBN none).
+ CommandWith(&'static str, &'static str),
Dialog(&'static str),
+ /// A dialog opened with fixed params, like Format Cells on its Border tab (HBM).
+ DialogWith(&'static str, &'static str),
/// The target command does not exist yet: the sequence is reserved, its badge shows, and
/// invoking it is a silent no-op until the command lands (see keytips.json `pending`).
Pending(&'static str),
diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs
index 5415b9d2b..d14af141b 100644
--- a/crates/ui-egui/src/keytips/keytip_table.rs
+++ b/crates/ui-egui/src/keytips/keytip_table.rs
@@ -50,8 +50,8 @@ pub fn is_group_level(seq: &str) -> bool {
fn matches_target(action: &Action, target: &str) -> bool {
match action {
- Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target,
- Action::Dialog(name) => format!("dialog:{name}") == target,
+ Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target,
+ Action::Dialog(name) | Action::DialogWith(name, _) => format!("dialog:{name}") == target,
Action::Tab(_) => false,
}
}
diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs
index 79920b5b8..5f750e770 100644
--- a/crates/ui-egui/src/keytips/keytip_table/generated.rs
+++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs
@@ -37,40 +37,40 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable
("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins)
("HB", Entry::Continue), // Excel: Borders Menu; group level:
- ("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders)
- ("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD
- ("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD
- ("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD
- ("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD
- ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
- ("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD
- ("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD)
- ("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD
- ("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders)
- ("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD
- ("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD
- ("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD
- ("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD
- ("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders)
- ("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD
- ("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD
- ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
- ("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD)
- ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
- ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
- ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
- ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
- ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
- ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
- ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
- ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
+ ("HBA", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"all\"}"))), // Excel: All Borders; Borders menu item
+ ("HBB", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"doubleBottom\"}"))), // Excel: Bottom Double Border; Borders menu item
+ ("HBC", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topThickBottom\"}"))), // Excel: Top and Thick Bottom Border; Borders menu item
+ ("HBD", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topBottom\"}"))), // Excel: Top and Bottom Border; Borders menu item
+ ("HBE", Entry::Finish(Action::Pending("home.eraseBorder"))), // pending; Excel: Erase Border; pending: Excel arms an eraser pen; GridCraft has no border pen mode yet
+ ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border)
+ ("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item
+ ("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu
+ ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item
+ ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab
+ ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item
+ ("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item
+ ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item
+ ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item
+ ("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item
+ ("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item
+ ("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item
+ ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border
+ ("HBY", Entry::Finish(Action::Pending("home.borderLineStyle"))), // pending; Excel: Line Style; pending: Line Style submenu
+ ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable
+ ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns
+ ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells
+ ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows
+ ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet
+ ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All
+ ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents
+ ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats
("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks
- ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
- ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
+ ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes)
+ ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette
("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting)
- ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
- ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
- ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
+ ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find
+ ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To
+ ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find)
("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands
("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane
("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace
diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json
index 59aeadcfb..b24241c3c 100644
--- a/crates/ui-egui/src/keytips/keytips.json
+++ b/crates/ui-egui/src/keytips/keytips.json
@@ -8,6 +8,7 @@
" \"continue\" (an explicit group level with no deeper sequences yet).",
" - Intermediate levels need no entries: the matcher waits automatically when deeper sequences exist.",
" - pending: true = the command does not exist yet; the sequence stays reserved and silent until it lands.",
+ " - params: an optional JSON object passed to a command or dialog, for menu items that share one id (the Borders presets: HBO {\"preset\": \"bottom\"}).",
" - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)."
],
"sequences": [
@@ -196,36 +197,49 @@
"seq": "HBA",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "all"
+ },
"excel": "All Borders",
- "note": "containment(Borders)"
+ "note": "Borders menu item"
},
{
"seq": "HBB",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "doubleBottom"
+ },
"excel": "Bottom Double Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBC",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "topThickBottom"
+ },
"excel": "Top and Thick Bottom Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBD",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "topBottom"
+ },
"excel": "Top and Bottom Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBE",
"kind": "command",
- "target": "home.borders",
+ "target": "home.eraseBorder",
+ "pending": true,
"excel": "Erase Border",
- "note": "border-side TBD"
+ "note": "pending: Excel arms an eraser pen; GridCraft has no border pen mode yet"
},
{
"seq": "HBG",
@@ -238,78 +252,109 @@
"seq": "HBH",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "thickBottom"
+ },
"excel": "Thick Bottom Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBI",
"kind": "command",
- "target": "home.borders",
+ "target": "home.borderLineColor",
+ "pending": true,
"excel": "Line Color",
- "note": "Line Color (submenu TBD)"
+ "note": "pending: Line Color submenu"
},
{
"seq": "HBL",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "left"
+ },
"excel": "Left Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBM",
- "kind": "command",
- "target": "home.borders",
+ "kind": "dialog",
+ "target": "formatCells",
+ "params": {
+ "tab": "Border"
+ },
"excel": "More Borders",
- "note": "containment(Borders)"
+ "note": "Format Cells on its Border tab"
},
{
"seq": "HBN",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "none"
+ },
"excel": "No Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBO",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "bottom"
+ },
"excel": "Bottom Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBP",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "top"
+ },
"excel": "Top Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBR",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "right"
+ },
"excel": "Right Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBS",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "outside"
+ },
"excel": "Outside Borders",
- "note": "containment(Borders)"
+ "note": "Borders menu item"
},
{
"seq": "HBT",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "thickOutside"
+ },
"excel": "Thick Outside Borders",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBU",
"kind": "command",
"target": "home.borders",
+ "params": {
+ "preset": "topDoubleBottom"
+ },
"excel": "Top and Double Bottom Border",
- "note": "border-side TBD"
+ "note": "Borders menu item"
},
{
"seq": "HBW",
@@ -321,9 +366,10 @@
{
"seq": "HBY",
"kind": "command",
- "target": "home.borders",
+ "target": "home.borderLineStyle",
+ "pending": true,
"excel": "Line Style",
- "note": "Line Style (submenu TBD)"
+ "note": "pending: Line Style submenu"
},
{
"seq": "HC",
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index f96cdac3c..4e7de595a 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -1193,7 +1193,19 @@ impl SheetApp {
keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id),
keytips::Action::Command("edit.paste") => self.paste_from_clipboard(),
keytips::Action::Command(id) => self.run_or_alert(id, json!({})),
+ keytips::Action::CommandWith(id, params) => {
+ let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({}));
+ // A Borders preset picked by key tip becomes the split button's face, as a
+ // click on the same menu item does.
+ if id == "home.borders"
+ && let Some(preset) = params.get("preset").and_then(Json::as_str)
+ {
+ self.grid.last_border = preset.to_string();
+ }
+ self.run_or_alert(id, params);
+ }
keytips::Action::Dialog(name) => self.open_dialog(name, json!({})),
+ keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))),
// Reserved sequence: fires silently while its command is missing. When the
// command lands in the catalog it goes live with no table edit.
keytips::Action::Pending(id) => {
diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs
index 24be742a5..76ddd4467 100644
--- a/crates/ui-egui/tests/keytips.rs
+++ b/crates/ui-egui/tests/keytips.rs
@@ -550,3 +550,96 @@ fn every_sequence_terminates_without_panicking() {
}
assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran");
}
+
+fn borders(h: &Harness, cell: &str) -> gridcraft_engine::model::Borders {
+ let d = h.state().session.active().unwrap();
+ let sh = d.wb.active().unwrap();
+ d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).borders
+}
+
+/// [left, right, top, bottom] line styles of `cell`.
+fn sides(h: &Harness, cell: &str) -> [gridcraft_engine::model::BorderStyle; 4] {
+ let b = borders(h, cell);
+ [b.left.style, b.right.style, b.top.style, b.bottom.style]
+}
+
+fn selected(range: &str) -> Session {
+ let mut s = blank();
+ s.execute("selection.set", json!({"range": range})).unwrap();
+ s
+}
+
+#[test]
+fn border_keytips_apply_their_own_preset() {
+ use gridcraft_engine::model::BorderStyle::{Double, None as N, Thick, Thin};
+ // Alt, H, B, letter on A1 alone: each Borders menu letter draws its own preset (they all drew
+ // a bare bottom border before). Letters observed in Excel's Borders menu.
+ for (k, want) in [
+ (Key::O, [N, N, N, Thin]),
+ (Key::P, [N, N, Thin, N]),
+ (Key::L, [Thin, N, N, N]),
+ (Key::R, [N, Thin, N, N]),
+ (Key::A, [Thin, Thin, Thin, Thin]),
+ (Key::S, [Thin, Thin, Thin, Thin]),
+ (Key::T, [Thick, Thick, Thick, Thick]),
+ (Key::B, [N, N, N, Double]),
+ (Key::H, [N, N, N, Thick]),
+ (Key::D, [N, N, Thin, Thin]),
+ (Key::C, [N, N, Thin, Thick]),
+ (Key::U, [N, N, Thin, Double]),
+ ] {
+ let mut h = harness(selected("A1"));
+ sequence(&mut h, &[Key::H, Key::B, k], false);
+ assert!(h.state().message.is_none() && h.state().toast.is_none(), "H,B,{k:?} must run cleanly");
+ assert_eq!(sides(&h, "A1"), want, "H,B,{k:?}");
+ }
+}
+
+#[test]
+fn border_keytip_no_border_clears_a_bordered_cell() {
+ use gridcraft_engine::model::BorderStyle::None as N;
+ let mut s = selected("A1");
+ s.execute("home.borders", json!({"range": "A1", "preset": "all"})).unwrap();
+ let mut h = harness(s);
+ sequence(&mut h, &[Key::H, Key::B, Key::N], false);
+ assert_eq!(sides(&h, "A1"), [N, N, N, N], "H,B,N must clear every side, not add a bottom border");
+ assert_eq!(h.state().grid.last_border, "none", "the picked preset becomes the Borders button's face");
+}
+
+#[test]
+fn border_keytip_all_borders_covers_every_side_of_a_block() {
+ use gridcraft_engine::model::BorderStyle::Thin;
+ let mut h = harness(selected("A1:B2"));
+ sequence(&mut h, &[Key::H, Key::B, Key::A], false);
+ for cell in ["A1", "B1", "A2", "B2"] {
+ assert_eq!(sides(&h, cell), [Thin; 4], "H,B,A must border every side of {cell}");
+ }
+}
+
+#[test]
+fn border_keytip_outside_borders_skips_the_inside_edges() {
+ use gridcraft_engine::model::BorderStyle::{None as N, Thin};
+ let mut h = harness(selected("A1:B2"));
+ sequence(&mut h, &[Key::H, Key::B, Key::S], false);
+ assert_eq!(sides(&h, "A1"), [Thin, N, Thin, N]);
+ assert_eq!(sides(&h, "B2"), [N, Thin, N, Thin]);
+}
+
+#[test]
+fn border_keytip_more_borders_opens_format_cells_on_the_border_tab() {
+ let mut h = harness(blank());
+ sequence(&mut h, &[Key::H, Key::B, Key::M], false);
+ let d = h.state().dialog.as_ref().expect("H,B,M opens Format Cells");
+ assert_eq!(d.tab, "Border");
+}
+
+#[test]
+fn border_keytips_without_a_command_stay_silent() {
+ // Erase Border (a pen mode), Line Color and Line Style are reserved: no border is drawn.
+ for k in [Key::E, Key::I, Key::Y] {
+ let mut h = harness(selected("A1"));
+ sequence(&mut h, &[Key::H, Key::B, k], false);
+ assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.")), "H,B,{k:?} must not run a command");
+ assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border");
+ }
+}
diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs
index 653554857..dcf71cde1 100644
--- a/crates/ui-egui/tests/keytips_generated.rs
+++ b/crates/ui-egui/tests/keytips_generated.rs
@@ -308,7 +308,10 @@ fn keytip_hbd_() {
fn keytip_hbe_() {
let mut h = harness(blank());
sequence(&mut h, "HBE", false);
- assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBE must journal home.borders");
+ assert!(h.state().dialog.is_none(), "HBE must not open a dialog");
+ assert!(h.state().message.is_none(), "HBE must not open a message box");
+ assert!(h.state().toast.is_none(), "HBE must not raise a toast");
+ assert!(h.state().session.journal.is_empty(), "HBE must not journal");
}
#[test]
@@ -329,7 +332,10 @@ fn keytip_hbh_() {
fn keytip_hbi_() {
let mut h = harness(blank());
sequence(&mut h, "HBI", false);
- assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBI must journal home.borders");
+ assert!(h.state().dialog.is_none(), "HBI must not open a dialog");
+ assert!(h.state().message.is_none(), "HBI must not open a message box");
+ assert!(h.state().toast.is_none(), "HBI must not raise a toast");
+ assert!(h.state().session.journal.is_empty(), "HBI must not journal");
}
#[test]
@@ -343,7 +349,7 @@ fn keytip_hbl_() {
fn keytip_hbm_() {
let mut h = harness(blank());
sequence(&mut h, "HBM", false);
- assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBM must journal home.borders");
+ assert!(h.state().dialog.is_some(), "HBM must open the formatCells dialog");
}
#[test]
@@ -406,7 +412,10 @@ fn keytip_hbw_() {
fn keytip_hby_() {
let mut h = harness(blank());
sequence(&mut h, "HBY", false);
- assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBY must journal home.borders");
+ assert!(h.state().dialog.is_none(), "HBY must not open a dialog");
+ assert!(h.state().message.is_none(), "HBY must not open a message box");
+ assert!(h.state().toast.is_none(), "HBY must not raise a toast");
+ assert!(h.state().session.journal.is_empty(), "HBY must not journal");
}
#[test]
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 22e221e69..209c55d93 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Alt, H, B border keytips apply their own preset) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -77,7 +77,7 @@ this logic, with one undo step.
| Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done |
| F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing |
| Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) |
-| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded |
+| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded. Keytips can carry fixed params, so each Borders menu letter (Alt, H, B, O/P/L/R/N/A/S/T/B/H/D/C/U) draws its own preset and B, M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style are reserved |
| Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) |
### Shortcut audit (#110)
@@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | Alt, H, B border keytips apply the preset each letter names in Excel's Borders menu (they all drew a bottom border, so H,B,N No Border added one): keytips.json entries take optional `params`; H,B,M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style stay reserved |
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs
index 36847222f..11e7773aa 100644
--- a/xtask/src/keytips.rs
+++ b/xtask/src/keytips.rs
@@ -44,25 +44,35 @@ struct Entry {
kind: String,
target: String,
pending: bool,
+ /// Fixed params as a compact JSON object (`params` in keytips.json), empty for none.
+ params: String,
excel: String,
note: String,
}
+impl Entry {
+ /// The probe key: a command and the params its keytip passes.
+ fn probe_key(&self) -> String {
+ if self.params.is_empty() { self.target.clone() } else { format!("{} {}", self.target, self.params) }
+ }
+}
+
/// 0 = ran and journaled, 1 = ran without a journal step, 2 = errored (params
-/// missing, disabled, or environment-dependent).
+/// missing, disabled, or environment-dependent). Keyed by [`Entry::probe_key`].
type Probe = std::collections::HashMap;
#[cfg(feature = "engine")]
-fn probe_live_commands(ids: &[String]) -> Probe {
+fn probe_live_commands(calls: &[(String, String)]) -> Probe {
use gridcraft_engine::Session;
let mut map = Probe::new();
- for id in ids {
+ for (id, params) in calls {
if ENV_SENSITIVE.contains(&id.as_str()) {
continue;
}
+ let params = if params.is_empty() { serde_json::json!({}) } else { serde_json::from_str(params).unwrap_or_default() };
let mut s = Session::new();
s.new_workbook();
- let status = match s.execute(id, serde_json::json!({})) {
+ let status = match s.execute(id, params.clone()) {
Ok(_) => {
if s.journal.iter().any(|(jid, _)| jid == id) {
0
@@ -72,7 +82,8 @@ fn probe_live_commands(ids: &[String]) -> Probe {
}
Err(_) => 2,
};
- map.insert(id.clone(), status);
+ let key = if params.as_object().is_some_and(|o| !o.is_empty()) { format!("{id} {params}") } else { id.clone() };
+ map.insert(key, status);
}
map
}
@@ -132,6 +143,13 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> {
if pending && kind != "command" {
return Err(format!("{at}: `pending` is only valid with kind `command`"));
}
+ let params = match e.get("params") {
+ None => String::new(),
+ Some(v) if v.is_object() && matches!(kind.as_str(), "command" | "dialog") && !pending => {
+ serde_json::to_string(v).map_err(|e| format!("{at}: params: {e}"))?
+ }
+ Some(_) => return Err(format!("{at}: `params` must be a JSON object, on a non-pending command or dialog")),
+ };
match kind.as_str() {
"tab" => {
if target.is_empty() || !TABS.contains(&target.as_str()) {
@@ -163,6 +181,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> {
kind,
target,
pending,
+ params,
excel: e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(),
note: e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(),
});
@@ -170,10 +189,10 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> {
// JSON order is authoritative and curated: the first entry for a tab is its canonical
// badge (legacy aliases come later), and groups stay together. Do not sort.
- let live_ids: Vec = entries
+ let live_calls: Vec<(String, String)> = entries
.iter()
.filter(|e| e.kind == "command" && !e.pending)
- .map(|e| e.target.clone())
+ .map(|e| (e.target.clone(), e.params.clone()))
.collect::>()
.into_iter()
.collect();
@@ -187,7 +206,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> {
e.target.clone()
})
.collect();
- let mut probe = probe_live_commands(&live_ids);
+ let mut probe = probe_live_commands(&live_calls);
for id in landed {
let mut s = gridcraft_engine::Session::new();
s.new_workbook();
@@ -219,7 +238,9 @@ fn render_table(entries: &[Entry]) -> String {
let value = match e.kind.as_str() {
"tab" => format!("Entry::Tab({:?})", e.target),
"command" if e.pending => format!("Entry::Finish(Action::Pending({:?}))", e.target),
+ "command" if !e.params.is_empty() => format!("Entry::Finish(Action::CommandWith({:?}, {:?}))", e.target, e.params),
"command" => format!("Entry::Finish(Action::Command({:?}))", e.target),
+ "dialog" if !e.params.is_empty() => format!("Entry::Finish(Action::DialogWith({:?}, {:?}))", e.target, e.params),
"dialog" => format!("Entry::Finish(Action::Dialog({:?}))", e.target),
"control" => format!("Entry::Finish(Action::Control({:?}))", e.target),
"continue" => "Entry::Continue".to_string(),
@@ -269,7 +290,7 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String {
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_none(), \"{seq} must not open a dialog\");\n assert!(h.state().message.is_none(), \"{seq} must not open a message box\");\n assert!(h.state().toast.is_none(), \"{seq} must not raise a toast\");\n assert!(h.state().session.journal.is_empty(), \"{seq} must not journal\");",
seq = e.seq,
),
- "command" => match probe.get(&e.target).copied().unwrap_or(2) {
+ "command" => match probe.get(&e.probe_key()).copied().unwrap_or(2) {
0 => format!(
"sequence(&mut h, {seq:?}, false);\n assert!(h.state().session.journal.iter().any(|(id, _)| id == {id:?}), \"{seq} must journal {id}\");",
seq = e.seq,
From ab4d58ca6ffd65a8e9e71fe0470916343ea41133 Mon Sep 17 00:00:00 2001
From: Harrison Booth
Date: Wed, 14 Oct 2026 18:26:06 -0500
Subject: [PATCH 003/149] Lookups compare numbers exactly, like Excel
MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compared numbers at
15 significant digits (through compare / compare_numbers), so a computed
key such as =0.1+0.2 (0.30000000000000004) found 0.3. Excel 365 compares
lookup keys exactly in exact, approximate and binary search modes, while
=, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF,
MAXIFS) keep the 15-digit comparison. Observed in Excel via COM with
synthetic values.
lookup_equal now compares numbers with ==, and the lookup searches order
values with lookup_order (exact for numbers, compare otherwise).
compare_numbers and the criteria path are unchanged.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 4 +-
crates/calc/src/tests.rs | 143 +++++++++++++++++++++++++++++++
crates/functions/src/criteria.rs | 9 +-
crates/functions/src/lookup.rs | 63 ++++++++++++--
docs/function-parity.md | 3 +-
5 files changed, 211 insertions(+), 11 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 070552d07..fba2c6a35 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (lookups compare numbers exactly, like Excel) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 196c57de7..4a2eb701a 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -310,6 +310,149 @@ fn date_text_coerces_in_the_workbook_date_system() {
}
}
+/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15
+/// significant digits. Every expectation was observed in Excel 365 with these fixtures
+/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001).
+#[test]
+fn lookups_compare_numbers_exactly() {
+ let mut t = T::new();
+ for (at, input) in [
+ ("A1", "=0.1+0.2"),
+ ("A2", "0.3"),
+ ("A3", "=100+1E-14"),
+ ("A4", "100"),
+ ("A5", "=1E15+0.5"),
+ ("A6", "1E15"),
+ // Ascending 0.1, 0.3+e, 0.5 and the same descending.
+ ("B1", "0.1"),
+ ("B2", "=0.1+0.2"),
+ ("B3", "0.5"),
+ ("C1", "0.5"),
+ ("C2", "=0.1+0.2"),
+ ("C3", "0.1"),
+ // 0.3 next to 0.3+e, ascending and descending.
+ ("D1", "0.3"),
+ ("D2", "=0.1+0.2"),
+ ("D3", "0.7"),
+ ("E1", "0.7"),
+ ("E2", "=0.1+0.2"),
+ ("E3", "0.3"),
+ ("G1", "=0.1+0.2"),
+ ("H1", "x"),
+ ("G2", "row2"),
+ // The bisection probes 0.3 first and must not run on into 0.3+e.
+ ("J1", "0.1"),
+ ("J2", "0.2"),
+ ("J3", "0.3"),
+ ("J4", "=0.1+0.2"),
+ ("J5", "0.4"),
+ ("K1", "a"),
+ ("K2", "b"),
+ ("K3", "c"),
+ ("K4", "d"),
+ ("K5", "e"),
+ ] {
+ t.set(at, input);
+ }
+ let e = 0.1 + 0.2;
+ let n = Value::Number;
+ let s = |x: &str| Value::from(x);
+ let na = Value::Error(CellError::NA);
+ let cases: Vec<(&str, Value)> = vec![
+ // `=` and friends stay at 15 digits.
+ ("=A1=A2", Value::Bool(true)),
+ ("=A3=A4", Value::Bool(true)),
+ ("=A5=A6", Value::Bool(true)),
+ ("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")),
+ // Exact match.
+ ("=MATCH(A1,A2:A2,0)", na.clone()),
+ ("=MATCH(A2,A1:A1,0)", na.clone()),
+ ("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()),
+ ("=HLOOKUP(A1,A2,1,FALSE)", na.clone()),
+ ("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()),
+ ("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")),
+ ("=XMATCH(A1,A2:A2)", na.clone()),
+ ("=MATCH(0.1+0.2,{0.3},0)", na.clone()),
+ ("=MATCH(0.3,A1:A2,0)", n(2.0)),
+ ("=MATCH(A1,A1:A2,0)", n(1.0)),
+ ("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)),
+ ("=MATCH(100,A3:A3,0)", na.clone()),
+ ("=MATCH(1E15,A5:A5,0)", na.clone()),
+ ("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)),
+ ("=XMATCH(A1,A1:A2,0,-1)", n(1.0)),
+ // Approximate match against a single entry.
+ ("=MATCH(0.3,A1:A1,1)", na.clone()),
+ ("=MATCH(0.3,A1:A1,-1)", n(1.0)),
+ ("=LOOKUP(0.3,A1:A1)", na.clone()),
+ ("=LOOKUP(0.3,A1:A1,G2)", na.clone()),
+ ("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()),
+ ("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()),
+ ("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")),
+ ("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)),
+ ("=XMATCH(0.3,A1:A1,-1)", na.clone()),
+ ("=XMATCH(0.3,A1:A1,1)", n(1.0)),
+ ("=MATCH(100,A3:A3,1)", na.clone()),
+ ("=MATCH(A3,A4:A4,1)", n(1.0)),
+ // Approximate match in sorted lists.
+ ("=MATCH(0.3,B1:B3,1)", n(1.0)),
+ ("=MATCH(A1,B1:B3,1)", n(2.0)),
+ ("=MATCH(0.3,C1:C3,-1)", n(2.0)),
+ ("=MATCH(0.3,D1:D3,1)", n(1.0)),
+ ("=MATCH(A1,D1:D3,1)", n(2.0)),
+ ("=MATCH(0.3,E1:E3,-1)", n(3.0)),
+ ("=MATCH(A1,E1:E3,-1)", n(2.0)),
+ ("=MATCH(0.3,J1:J5,1)", n(3.0)),
+ ("=MATCH(A1,J1:J5,1)", n(4.0)),
+ ("=MATCH(0.3,J1:J5,0)", n(3.0)),
+ ("=MATCH(A1,J1:J5,0)", n(4.0)),
+ ("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")),
+ ("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")),
+ ("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")),
+ ("=LOOKUP(A1,J1:J5,K1:K5)", s("d")),
+ // XLOOKUP / XMATCH match and search modes.
+ ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")),
+ ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)),
+ ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)),
+ ("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)),
+ ("=XMATCH(0.3,B1:B3,1,2)", n(2.0)),
+ ("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)),
+ ("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)),
+ ("=XMATCH(0.3,C1:C3,0,-2)", na.clone()),
+ ("=XMATCH(0.3,J1:J5,0,2)", n(3.0)),
+ ("=XMATCH(A1,J1:J5,0,2)", n(4.0)),
+ ("=XMATCH(0.3,J1:J5,1,2)", n(3.0)),
+ ("=XMATCH(0.3,J1:J5,-1)", n(3.0)),
+ ("=XMATCH(0.3,J1:J5,1)", n(3.0)),
+ ("=XMATCH(A1,J1:J5,-1)", n(4.0)),
+ ("=XMATCH(0.3,B1:B3,1)", n(2.0)),
+ ("=XMATCH(0.3,B1:B3,-1)", n(1.0)),
+ ("=XMATCH(0.3,B1:B3,0,-1)", na.clone()),
+ ("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)),
+ // The criteria functions keep 15 digits.
+ ("=COUNTIF(A1:A1,0.3)", n(1.0)),
+ ("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)),
+ ("=COUNTIF(A1:A1,\">0.3\")", n(0.0)),
+ ("=COUNTIF(A2:A2,A1)", n(1.0)),
+ ("=COUNTIFS(A1:A1,0.3)", n(1.0)),
+ ("=SUMIF(A1:A1,0.3)", n(e)),
+ ("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)),
+ ("=AVERAGEIF(A1:A1,0.3)", n(e)),
+ ("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)),
+ ("=COUNTIF(A3:A3,\">100\")", n(0.0)),
+ ("=COUNTIF(J1:J5,0.3)", n(2.0)),
+ ("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)),
+ ("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)),
+ ];
+ let mut wrong = Vec::new();
+ for (formula, expected) in cases {
+ let got = crate::evaluate(&t.wb, 0, c("Z1"), formula);
+ if got != expected {
+ wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}"));
+ }
+ }
+ assert!(wrong.is_empty(), "{}", wrong.join("\n"));
+}
+
#[test]
fn operators() {
let mut t = T::new();
diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs
index e326267ce..58f0e6594 100644
--- a/crates/functions/src/criteria.rs
+++ b/crates/functions/src/criteria.rs
@@ -314,7 +314,9 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option bool {
match (needle, hay) {
(Value::Text(p), Value::Text(t)) => {
@@ -324,7 +326,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool {
compare_text(p, t) == Ordering::Equal
}
}
- (Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal,
+ (Value::Number(a), Value::Number(b)) => a == b,
(Value::Bool(a), Value::Bool(b)) => a == b,
(Value::Error(a), Value::Error(b)) => a == b,
(Value::Empty, Value::Empty) => true,
@@ -370,7 +372,8 @@ mod tests {
assert!(m("#N/A", Value::Error(CellError::NA)));
assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0)));
assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2)));
- assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
+ assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false));
+ assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false));
assert!(m("<>a*", Value::from("bcd")));
assert!(m(">1/1/2020", Value::Number(44000.0)));
// Text patterns never match blank cells, but an empty string is text.
diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs
index 56739c933..89ea86ad7 100644
--- a/crates/functions/src/lookup.rs
+++ b/crates/functions/src/lookup.rs
@@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R {
}
}
+/// Order of two values for the approximate and binary searches: [`compare`], except that numbers
+/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its
+/// lookups don't. Observed in Excel 365 with A1 holding
+/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because
+/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4.
+/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode.
+fn lookup_order(a: &Value, b: &Value) -> Ordering {
+ match (a, b) {
+ (Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal),
+ _ => compare(a, b),
+ }
+}
+
fn same_type(a: &Value, b: &Value) -> bool {
matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_)))
}
@@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option {
continue;
};
let v = hay.get(j)?;
- let o = compare(v, x);
+ let o = lookup_order(v, x);
if o == Ordering::Equal {
let mut k = j;
- while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) {
+ while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) {
k += 1;
}
return Some(k);
@@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option {
fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering {
match v {
Value::Empty => Ordering::Greater,
- _ if desc => compare(v, x).reverse(),
- _ => compare(v, x),
+ _ if desc => lookup_order(v, x).reverse(),
+ _ => lookup_order(v, x),
}
}
@@ -261,7 +274,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R R true,
Some(bv) => {
- let c = compare(v, bv);
+ let c = lookup_order(v, bv);
if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less }
}
};
@@ -747,6 +760,44 @@ mod tests {
assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]);
}
+ /// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365.
+ #[test]
+ fn lookups_compare_numbers_exactly() {
+ let e = 0.1 + 0.2;
+ let one = || rf(col(&[e]));
+ // Exact modes miss a key one bit away.
+ is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA);
+ is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA);
+ is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA);
+ close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0);
+ close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0);
+ is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA);
+ // 0 and -0 are still the same key.
+ close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0);
+ // Approximate modes: 0.3 sorts below 0.30000000000000004.
+ is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA);
+ close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0);
+ is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA);
+ is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA);
+ close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0);
+ // The bisection stops on 0.3 and doesn't run on into the next entry.
+ let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4]));
+ close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0);
+ close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0);
+ close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0);
+ close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0);
+ let asc = || rf(col(&[0.1, e, 0.5]));
+ let desc = || rf(col(&[0.5, e, 0.1]));
+ close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0);
+ close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0);
+ is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA);
+ close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0);
+ close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0);
+ close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0);
+ close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0);
+ close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0);
+ }
+
#[test]
fn index() {
let t3 = table_rows3();
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 760ffd6ac..6dad80804 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (lookups compare numbers exactly, like Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
From 5c20f164342f0b443fb7e6897f52f6e0b3934163 Mon Sep 17 00:00:00 2001
From: Harrison Booth
Date: Wed, 14 Oct 2026 18:30:16 -0500
Subject: [PATCH 004/149] core: read accounting negatives like $(1,234) in
number text
Excel 365 reads a dollar sign before accounting parentheses ($(1,234),
$ (5)) and a percent sign after them ((5)%) as negative numbers when the
text is typed, coerced by arithmetic or passed to VALUE. GridCraft gave
#VALUE! for these.
The parentheses branch no longer recurses: Excel reads only an unsigned
plain number or fraction inside, so (-5), ((5)), ($-5), and dates or
times in parentheses are now #VALUE! as in Excel (they used to give 5
or a negative serial). $5% is #VALUE! too: Excel never combines the
currency and percent signs.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 4 +-
crates/calc/src/tests.rs | 15 +++++
crates/core/src/parse.rs | 115 ++++++++++++++++++++++++++++++++++-
crates/functions/src/text.rs | 7 +++
docs/function-parity.md | 3 +-
5 files changed, 139 insertions(+), 5 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 070552d07..610011d87 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-14 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | Progress log: accounting negatives in number text |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 196c57de7..1d5a1a464 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -310,6 +310,21 @@ fn date_text_coerces_in_the_workbook_date_system() {
}
}
+#[test]
+fn accounting_negative_text_coerces() {
+ let mut t = T::new();
+ t.set("A1", "'$(1,234)");
+ t.set("B1", "=A1+0");
+ t.set("C1", "=VALUE(A1)");
+ t.set("D1", "=--\"$ (5)\"");
+ t.set("E1", "=\"(5)%\"*100");
+ t.set("F1", "=\"(-5)\"+0");
+ t.set("G1", "$(1,234)");
+ assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0));
+ assert_eq!(t.get("F1"), Value::Error(CellError::Value));
+ assert_eq!(t.num("G1"), -1234.0);
+}
+
#[test]
fn operators() {
let mut t = T::new();
diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs
index b465beaf9..bf8487622 100644
--- a/crates/core/src/parse.rs
+++ b/crates/core/src/parse.rs
@@ -54,8 +54,13 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)
return None;
}
// Parenthesised negatives: (100) = -100.
- if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
- return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f));
+ if let Some(inner) = parenthesized(t) {
+ // Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`.
+ if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) {
+ return None;
+ }
+ let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?"))));
+ return r.map(|(n, f)| (-n, f));
}
if let Some(r) = parse_plain(t) {
return Some(r);
@@ -69,6 +74,22 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)
None
}
+/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may
+/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as
+/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it.
+fn parenthesized(t: &str) -> Option {
+ let (currency, rest) = match t.strip_prefix('$') {
+ Some(r) => ("$", r.trim_start()),
+ None => ("", t),
+ };
+ let rest = rest.strip_prefix('(')?;
+ let (body, percent) = match rest.strip_suffix(')') {
+ Some(b) => (b, ""),
+ None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"),
+ };
+ Some(format!("{currency}{}{percent}", body.trim()))
+}
+
/// Signs, currency, thousands separators, percent, exponent.
fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
let mut s = t;
@@ -93,7 +114,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
percent = true;
s = r.trim_end();
}
- if s.is_empty() {
+ // Excel reads `$5` and `5%` but not `$5%`.
+ if s.is_empty() || currency && percent {
return None;
}
let has_sep = s.contains(',');
@@ -352,6 +374,93 @@ mod tests {
assert_eq!(p(".5").value, Value::Number(0.5));
}
+ /// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree.
+ #[test]
+ fn accounting_parentheses() {
+ let accepted: [(&str, f64, Option<&str>); 22] = [
+ ("$(5)", -5.0, Some("\"$\"#,##0")),
+ ("$ (5)", -5.0, Some("\"$\"#,##0")),
+ ("$( 5 )", -5.0, Some("\"$\"#,##0")),
+ ("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")),
+ ("$(1,234)", -1234.0, Some("\"$\"#,##0")),
+ ("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")),
+ ("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")),
+ ("$(.5)", -0.5, Some("\"$\"#,##0.00")),
+ ("$(0)", 0.0, Some("\"$\"#,##0")),
+ (" $(5) ", -5.0, Some("\"$\"#,##0")),
+ ("($5)", -5.0, Some("\"$\"#,##0")),
+ ("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")),
+ ("(5)", -5.0, None),
+ ("( 5 )", -5.0, None),
+ ("(1,234.5)", -1234.5, Some("#,##0.00")),
+ ("(1e-3)", -0.001, Some("0.00E+00")),
+ ("(5)%", -0.05, Some("0%")),
+ ("( 5 ) %", -0.05, Some("0%")),
+ ("(5%)", -0.05, Some("0%")),
+ ("(5.)%", -0.05, Some("0.00%")),
+ ("(1,234)%", -12.34, Some("0%")),
+ ("(1 1/2)", -1.5, Some("# ?/?")),
+ ];
+ for (s, n, f) in accepted {
+ assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}");
+ assert_eq!(parse_number_text(s), Some(n), "{s}");
+ }
+ for s in [
+ "$5%",
+ "$(5%)",
+ "$(5)%",
+ "($5%)",
+ "($5)%",
+ "$ ( 5 ) %",
+ "(5)%%",
+ "(5%)%",
+ "((5))",
+ "(-5)",
+ "(+5)",
+ "($-5)",
+ "($ -5)",
+ "($+5)",
+ "(-$5)",
+ "$(-5)",
+ "$ (-5)",
+ "$(+5)",
+ "-(5)",
+ "-$(5)",
+ "+$(5)",
+ "$+(5)",
+ "$$(5)",
+ "$ $(5)",
+ "$((5))",
+ "($(5))",
+ "(-1 1/2)",
+ "()",
+ "$()",
+ "(5)$",
+ "$(5)$",
+ "(5)(5)",
+ "$(5)0",
+ "(5)e3",
+ "(5)-",
+ "(5-)",
+ "$(5-)",
+ "$(5",
+ "$5)",
+ "(5",
+ "$(1,23)",
+ "$(,5)",
+ "(5e)",
+ "(1/2)",
+ "$(1/2)",
+ "(3/4/2026)",
+ "$(3/4/2026)",
+ "(10:30)",
+ "$(10:30)",
+ ] {
+ assert_eq!(p(s).value, Value::text(s), "{s}");
+ assert_eq!(parse_number_text(s), None, "{s}");
+ }
+ }
+
#[test]
fn texts_and_specials() {
assert_eq!(p("hello").value, Value::text("hello"));
diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs
index 91962d6e7..84fad9898 100644
--- a/crates/functions/src/text.rs
+++ b/crates/functions/src/text.rs
@@ -1092,6 +1092,13 @@ mod tests {
close(ev("VALUE", vec![t("12:00")]), 0.5);
close(ev("VALUE", vec![t("2020-01-01")]), 43831.0);
is_err(ev("VALUE", vec![t("abc")]), CellError::Value);
+ // Accounting negatives, as Excel 365 reads them.
+ close(ev("VALUE", vec![t("$(5)")]), -5.0);
+ close(ev("VALUE", vec![t("$ (1,000.50)")]), -1000.5);
+ close(ev("VALUE", vec![t("(5)%")]), -0.05);
+ for s in ["$(5)%", "$(5%)", "$5%", "(-5)", "((5))", "$(-5)", "(1/2)"] {
+ is_err(ev("VALUE", vec![t(s)]), CellError::Value);
+ }
close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27);
close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035);
close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0);
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 760ffd6ac..3b25b144f 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (number text with accounting parentheses coerces like Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
From ea190adad6a1edb792521a7818ef426570a8229c Mon Sep 17 00:00:00 2001
From: Harrison Booth
Date: Wed, 14 Oct 2026 19:55:46 -0500
Subject: [PATCH 005/149] functions: Analysis ToolPak value arguments reject
multi-cell references
EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the
coupon, bond and T-bill functions, DISC, BIN2DEC, IM* and the other Analysis
ToolPak functions give #VALUE! in Excel 365 for a reference to more than one
cell in a value argument, in dynamic-array and legacy formulas alike, where
GridCraft lifted the range and spilled. Array constants and computed arrays
still lift, and range arguments (holidays, cash flows, schedules) and the
.INTL weekend still take ranges.
FnSpec gains a value_args mask, filled at registration from one table in
lift.rs; call_lifted turns a multi-cell reference at a value position into a
scalar #VALUE! before lifting, so EOMONTH(A1:A3,{1;2}) is a 2x1 array of
#VALUE! as in Excel.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 4 +-
crates/calc/src/tests.rs | 85 +++++++++++++++++++++++++++++++++++
crates/functions/src/lib.rs | 18 ++++++++
crates/functions/src/lift.rs | 87 ++++++++++++++++++++++++++++++++++++
docs/function-parity.md | 4 +-
5 files changed, 196 insertions(+), 2 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 070552d07..2abac7262 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Analysis ToolPak functions reject multi-cell references in value arguments) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 196c57de7..a9317528b 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -1266,3 +1266,88 @@ fn offset_extreme_arguments_return_ref() {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}
+
+#[test]
+fn analysis_toolpak_value_params_reject_multi_cell_references() {
+ // Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
+ // than one cell, however the reference is formed, and lift array constants and computed arrays.
+ let mut t = T::new();
+ for (a, v) in [
+ ("A1", "45000"),
+ ("A2", "45031"),
+ ("A3", "45061"),
+ ("B1", "1"),
+ ("B2", "2"),
+ ("B3", "3"),
+ ("C2", "7"),
+ ("F1", "1"),
+ ("F2", "2"),
+ ("F3", "11"),
+ ] {
+ t.set(a, v);
+ }
+ t.wb.names.push(gridcraft_model::DefinedName {
+ name: "Dates".into(),
+ scope: None,
+ formula: "Sheet1!$A$1:$A$3".into(),
+ comment: String::new(),
+ hidden: false,
+ });
+ let value = Value::Error(CellError::Value);
+ for f in [
+ "=EOMONTH(A1:A3,1)",
+ "=EOMONTH(A1,B1:B3)",
+ "=EDATE(A1:A3,1)",
+ "=SUMPRODUCT(EOMONTH(A1:A3,0))",
+ "=EOMONTH(INDEX(A1:A3,0,1),0)",
+ "=EOMONTH(OFFSET(A1,0,0,3,1),0)",
+ "=EOMONTH(IF(TRUE,A1:A3),0)",
+ "=EOMONTH(CHOOSE(1,A1:A3),0)",
+ "=LET(x,A1:A3,EOMONTH(x,0))",
+ "=EOMONTH(Dates,0)",
+ "=EOMONTH((A1,A2),0)",
+ "=EOMONTH(Z:Z,0)",
+ "=EOMONTH(A1:INDEX(A1:A3,3),0)",
+ "=WORKDAY(A1:A3,1)",
+ "=NETWORKDAYS(A1,A3:A4)",
+ "=YEARFRAC(A1,A3,B1:B2)",
+ "=WEEKNUM(A1,B1:B3)",
+ "=COUPDAYS(A1:A3,46000,2,0)",
+ "=DISC(A1,46000,98,100,B1:B3)",
+ "=BIN2DEC(B1:B3)",
+ "=ISEVEN(B1:B3)",
+ ] {
+ t.set("H5", f);
+ assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}");
+ t.set("H5", "");
+ }
+ // One-cell references, intersections, @ and the range parameters are fine.
+ for (f, want) in [
+ ("=EOMONTH(A1:A1,0)", 45016.0),
+ ("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0),
+ ("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0),
+ ("=EOMONTH(A1:A3 A2:C2,0)", 45046.0),
+ ("=EOMONTH(@A1:A3,0)", 45046.0),
+ ("=WORKDAY(A1,1,A1:A3)", 45001.0),
+ ("=XNPV(0,B1:B3,A1:A3)", 6.0),
+ ] {
+ t.set("H2", f);
+ assert_eq!(t.num("H2"), want, "{f}");
+ t.set("H2", "");
+ }
+ // Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend.
+ for (f, below, right) in [
+ ("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty),
+ ("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)),
+ ("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)),
+ ("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty),
+ ("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty),
+ ("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty),
+ // The rejected reference adds no shape: a 2x1 array of #VALUE!.
+ ("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty),
+ ] {
+ t.set("H5", f);
+ assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}");
+ t.set("H5", "");
+ }
+}
diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs
index 148d68221..5f9dcb67d 100644
--- a/crates/functions/src/lib.rs
+++ b/crates/functions/src/lib.rs
@@ -30,6 +30,7 @@ macro_rules! f {
category: $crate::Category::$cat,
volatile: true,
scalar_args: $scal,
+ value_args: &[],
signature: $sig,
description: $desc,
imp: {
@@ -48,6 +49,7 @@ macro_rules! f {
category: $crate::Category::$cat,
volatile: false,
scalar_args: $scal,
+ value_args: &[],
signature: $sig,
description: $desc,
imp: {
@@ -147,6 +149,11 @@ pub struct FnSpec {
/// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an
/// empty slice means no lifting.
pub scalar_args: &'static [bool],
+ /// Parameter positions that take a single value and reject a reference to more than one
+ /// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…),
+ /// where other scalar parameters lift the range. Arrays that aren't references still lift.
+ /// Same layout as `scalar_args`; filled at registration from [`lift::value_args`].
+ pub value_args: &'static [bool],
pub signature: &'static str,
pub description: &'static str,
pub imp: FnImpl,
@@ -167,6 +174,14 @@ impl FnSpec {
}
}
+ /// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`.
+ pub fn is_value_arg(&self, i: usize) -> bool {
+ match self.value_args.get(i) {
+ Some(b) => *b,
+ None => self.value_args.last().copied().unwrap_or(false),
+ }
+ }
+
/// Whether a call with `n` arguments is valid (Excel allows at most 255).
pub fn accepts(&self, n: usize) -> bool {
(self.min_args..=self.max_args).contains(&n) && n <= 255
@@ -195,6 +210,9 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) {
] {
v.extend(list);
}
+ for s in &mut v {
+ s.value_args = lift::value_args(s.name);
+ }
let mut map = HashMap::with_capacity(v.len());
for (i, s) in v.iter().enumerate() {
map.entry(s.name).or_insert(i);
diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs
index cea0067ee..056cb6d31 100644
--- a/crates/functions/src/lift.rs
+++ b/crates/functions/src/lift.rs
@@ -6,7 +6,61 @@ use gridcraft_core::{Array, CellError, Value};
use crate::util::MAX_CELLS;
use crate::{Arg, Ctx, FnSpec};
+/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more
+/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no
+/// implicit intersection), while array constants and computed arrays lift. Range parameters
+/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend
+/// (which lifts a range) are `false`.
+pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[
+ // Date and time.
+ ("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]),
+ ("WORKDAY NETWORKDAYS", &[true, true, false]),
+ ("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]),
+ // Financial.
+ (
+ "ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \
+ DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \
+ PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT",
+ &[true],
+ ),
+ ("FVSCHEDULE XNPV", &[true, false]),
+ ("XIRR", &[false, false, true]),
+ // Math and information.
+ ("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]),
+ ("SERIESSUM", &[true, true, true, false]),
+ // Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here).
+ (
+ "BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \
+ ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \
+ IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \
+ IMTAN",
+ &[true],
+ ),
+];
+
+/// The value-parameter mask of `name` (empty for functions without value parameters).
+pub(crate) fn value_args(name: &str) -> &'static [bool] {
+ VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask)
+}
+
+/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds
+/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel.
+fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option> {
+ let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1);
+ if !args.iter().enumerate().any(|(i, a)| multi(i, a)) {
+ return None;
+ }
+ Some(
+ args.iter()
+ .enumerate()
+ .map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() })
+ .collect(),
+ )
+}
+
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
+ let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
+ let args = rejected.as_deref().unwrap_or(args);
let mut rows = 0usize;
let mut cols = 0usize;
let mut any = false;
@@ -94,4 +148,37 @@ mod tests {
);
is_err(huge, CellError::Num);
}
+
+ #[test]
+ fn value_params_reject_multi_cell_references() {
+ let s = FnSpec { value_args: &[true, false], ..spec() };
+ let mut ctx = TestCtx::default();
+ // A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine.
+ is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value);
+ assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0));
+ assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]);
+ // The rejected reference adds no shape: the other argument still lifts.
+ let v = Value::Error(CellError::Value);
+ assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]);
+ // A position that isn't a value parameter still lifts the reference.
+ assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]);
+ }
+
+ #[test]
+ fn value_arg_table_names_registered_scalar_positions() {
+ for (names, mask) in super::VALUE_ARGS {
+ assert!(!mask.is_empty());
+ for name in names.split_ascii_whitespace() {
+ let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") };
+ assert_eq!(s.value_args, *mask, "{name}");
+ for i in 0..s.max_args.min(12) {
+ assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift");
+ }
+ }
+ }
+ assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2)));
+ assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2)));
+ assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty()));
+ assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty()));
+ }
}
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 760ffd6ac..91283721d 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (Analysis ToolPak functions give #VALUE! for a multi-cell reference in a value argument, like Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -77,6 +77,7 @@ PIVOTBY, REGEXTEST…) are all present.
| Defined names (workbook and sheet scope), Create from Selection | done |
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
+| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` |
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
@@ -108,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
From 5bd6cc3d04701b1f70442f2a2eaae5d285a0f3e8 Mon Sep 17 00:00:00 2001
From: Harrison Booth
Date: Thu, 15 Oct 2026 00:57:17 -0500
Subject: [PATCH 006/149] Options: Maximum Iterations and Maximum Change in
File > Options > Formulas
The Formulas section had the calculation-mode radios and Enable iterative
calculation but not the two limits that formulas.calculationOptions already
takes. They now sit under the checkbox, disabled while iteration is off, and
commit when the field loses focus. Like Excel, out-of-range entries
(Maximum Iterations outside 1-32767, a negative or non-numeric Maximum
Change) are refused with a message instead of being clamped by the command;
the Enter that committed the field no longer also dismisses that message.
Labels and messages are translated for every interface language. A kittest
covers the disabled state, valid and invalid entries, and undo refreshing
the field. Drops the stale "no Options dialog yet" from gaps.md and
function-parity.md.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 3 +-
crates/ui-egui/src/backstage.rs | 53 +++++++++++++++++++
crates/ui-egui/src/i18n.rs | 4 ++
crates/ui-egui/src/i18n/cs.rs | 4 ++
crates/ui-egui/src/i18n/de.rs | 4 ++
crates/ui-egui/src/i18n/es.rs | 4 ++
crates/ui-egui/src/i18n/fr.rs | 6 +++
crates/ui-egui/src/i18n/ko.rs | 4 ++
crates/ui-egui/src/i18n/pt.rs | 4 ++
crates/ui-egui/src/i18n/ru.rs | 4 ++
crates/ui-egui/src/i18n/sr.rs | 4 ++
crates/ui-egui/src/i18n/zh.rs | 4 ++
crates/ui-egui/tests/file_menu.rs | 88 ++++++++++++++++++++++++++++++-
docs/function-parity.md | 5 +-
docs/gaps.md | 6 ++-
docs/ui-parity.md | 5 +-
16 files changed, 194 insertions(+), 8 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 070552d07..2c06f07d9 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Maximum Iterations and Maximum Change in File › Options) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs
index 922baabc5..bc41222e1 100644
--- a/crates/ui-egui/src/backstage.rs
+++ b/crates/ui-egui/src/backstage.rs
@@ -992,10 +992,63 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() {
app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative}));
}
+ // Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel.
+ ui.add_enabled_ui(calc.iterative, |ui| {
+ ui.horizontal(|ui| {
+ ui.add_space(24.0);
+ egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
+ ui.label(lang.tr("Maximum Iterations:"));
+ let id = ui.make_persistent_id("backstage_max_iterations");
+ if let Some(text) = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)) {
+ match parse_max_iterations(&text) {
+ Some(n) if n != calc.max_iterations => {
+ app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n}));
+ }
+ Some(_) => {}
+ None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."),
+ }
+ }
+ ui.end_row();
+ ui.label(lang.tr("Maximum Change:"));
+ let id = ui.make_persistent_id("backstage_max_change");
+ if let Some(text) = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)) {
+ match parse_max_change(&text) {
+ Some(d) if d != calc.max_change => {
+ app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d}));
+ }
+ Some(_) => {}
+ None => refuse(app, ui, "Maximum Change must be a number of 0 or more."),
+ }
+ }
+ ui.end_row();
+ });
+ });
+ });
+ // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes
+ // `precisionAsDisplayed` (PR #1366); the model already stores it.
ui.add_space(6.0);
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
}
+/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that
+/// committed the field is consumed so it doesn't also dismiss the message box in this frame.
+fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) {
+ ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
+ app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into()));
+}
+
+/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others).
+fn parse_max_iterations(text: &str) -> Option {
+ let n = text.trim().parse::().ok()?;
+ (1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n)
+}
+
+/// Maximum Change as typed: a finite number of 0 or more, or `None`.
+fn parse_max_change(text: &str) -> Option {
+ let d = text.trim().parse::().ok()?;
+ (d.is_finite() && d >= 0.0).then_some(d)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 061698df9..17b098de2 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -587,6 +587,10 @@ const JAPANESE: &[(&str, &str)] = &[
("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"),
("Workbook calculation", "ブックの計算"),
("Enable iterative calculation", "反復計算を行う"),
+ ("Maximum Iterations:", "最大反復回数:"),
+ ("Maximum Change:", "変化の最大値:"),
+ ("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"),
+ ("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"),
("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"),
];
diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs
index 7ed8372c7..deaff822c 100644
--- a/crates/ui-egui/src/i18n/cs.rs
+++ b/crates/ui-egui/src/i18n/cs.rs
@@ -323,6 +323,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
"Workbook calculation" => "Výpočet sešitu",
"Enable iterative calculation" => "Povolit iterativní výpočet",
+ "Maximum Iterations:" => "Maximální počet iterací:",
+ "Maximum Change:" => "Maximální změna:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "Maximální počet iterací musí být celé číslo od 1 do 32767.",
+ "Maximum Change must be a number of 0 or more." => "Maximální změna musí být číslo 0 nebo větší.",
"Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.",
"100%" => "100 %",
"Zoom to\nSelection" => "Přiblížit\nna výběr",
diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs
index c20c7d1b3..efa20cac7 100644
--- a/crates/ui-egui/src/i18n/de.rs
+++ b/crates/ui-egui/src/i18n/de.rs
@@ -323,6 +323,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
"Workbook calculation" => "Arbeitsmappenberechnung",
"Enable iterative calculation" => "Iterative Berechnung aktivieren",
+ "Maximum Iterations:" => "Maximale Iterationszahl:",
+ "Maximum Change:" => "Maximale Änderung:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "Die maximale Iterationszahl muss eine ganze Zahl von 1 bis 32767 sein.",
+ "Maximum Change must be a number of 0 or more." => "Die maximale Änderung muss eine Zahl größer oder gleich 0 sein.",
"Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.",
"Interface size" => "Größe der Benutzeroberfläche",
"Auto" => "Automatisch",
diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs
index 4ce231bca..508f04945 100644
--- a/crates/ui-egui/src/i18n/es.rs
+++ b/crates/ui-egui/src/i18n/es.rs
@@ -322,6 +322,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
"Workbook calculation" => "Cálculo del libro",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
+ "Maximum Iterations:" => "Iteraciones máximas:",
+ "Maximum Change:" => "Cambio máximo:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "Las iteraciones máximas deben ser un número entero de 1 a 32767.",
+ "Maximum Change must be a number of 0 or more." => "El cambio máximo debe ser un número igual o mayor que 0.",
"Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.",
"Interface size" => "Tamaño de la interfaz",
"Auto" => "Automático",
diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs
index 5d20ab0ca..dec45571b 100644
--- a/crates/ui-egui/src/i18n/fr.rs
+++ b/crates/ui-egui/src/i18n/fr.rs
@@ -409,6 +409,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
"Workbook calculation" => "Calcul du classeur",
"Enable iterative calculation" => "Activer le calcul itératif",
+ "Maximum Iterations:" => "Nombre maximal d'itérations :",
+ "Maximum Change:" => "Écart maximal :",
+ "Maximum Iterations must be a whole number from 1 to 32767." => {
+ "Le nombre maximal d'itérations doit être un entier compris entre 1 et 32767."
+ }
+ "Maximum Change must be a number of 0 or more." => "L'écart maximal doit être un nombre supérieur ou égal à 0.",
"Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.",
_ => return None,
})
diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs
index 1902ad301..566044d57 100644
--- a/crates/ui-egui/src/i18n/ko.rs
+++ b/crates/ui-egui/src/i18n/ko.rs
@@ -409,6 +409,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
"Workbook calculation" => "통합 문서 계산",
"Enable iterative calculation" => "반복 계산 사용",
+ "Maximum Iterations:" => "최대 반복 횟수:",
+ "Maximum Change:" => "변화 한도:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "최대 반복 횟수는 1에서 32767 사이의 정수여야 합니다.",
+ "Maximum Change must be a number of 0 or more." => "변화 한도는 0 이상의 숫자여야 합니다.",
"Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.",
_ => return None,
})
diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs
index fb9236cdb..74d4c6268 100644
--- a/crates/ui-egui/src/i18n/pt.rs
+++ b/crates/ui-egui/src/i18n/pt.rs
@@ -409,6 +409,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
"Workbook calculation" => "Cálculo da pasta de trabalho",
"Enable iterative calculation" => "Habilitar cálculo iterativo",
+ "Maximum Iterations:" => "Número Máximo de Iterações:",
+ "Maximum Change:" => "Alteração Máxima:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "O número máximo de iterações deve ser um número inteiro de 1 a 32767.",
+ "Maximum Change must be a number of 0 or more." => "A alteração máxima deve ser um número maior ou igual a 0.",
"Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.",
"OK" => "OK",
"Cancel" => "Cancelar",
diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs
index 765f6bb32..f53611517 100644
--- a/crates/ui-egui/src/i18n/ru.rs
+++ b/crates/ui-egui/src/i18n/ru.rs
@@ -409,6 +409,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
"Workbook calculation" => "Вычисления в книге",
"Enable iterative calculation" => "Включить итеративные вычисления",
+ "Maximum Iterations:" => "Предельное число итераций:",
+ "Maximum Change:" => "Относительная погрешность:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "Предельное число итераций должно быть целым числом от 1 до 32767.",
+ "Maximum Change must be a number of 0 or more." => "Относительная погрешность должна быть числом не меньше 0.",
"Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.",
_ => return None,
})
diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs
index cbf18d4c4..8f724c22b 100644
--- a/crates/ui-egui/src/i18n/sr.rs
+++ b/crates/ui-egui/src/i18n/sr.rs
@@ -401,6 +401,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene",
"Workbook calculation" => "Izračunavanje radne sveske",
"Enable iterative calculation" => "Omogući iterativno izračunavanje",
+ "Maximum Iterations:" => "Maksimalan broj iteracija:",
+ "Maximum Change:" => "Maksimalna promena:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "Maksimalan broj iteracija mora biti ceo broj od 1 do 32767.",
+ "Maximum Change must be a number of 0 or more." => "Maksimalna promena mora biti broj veći ili jednak 0.",
"Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.",
"Left Margin" => "Leva margina",
"Right Margin" => "Desna margina",
diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs
index 00175925e..8f77aeb6d 100644
--- a/crates/ui-egui/src/i18n/zh.rs
+++ b/crates/ui-egui/src/i18n/zh.rs
@@ -401,6 +401,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
"Workbook calculation" => "工作簿计算",
"Enable iterative calculation" => "启用迭代计算",
+ "Maximum Iterations:" => "最多迭代次数:",
+ "Maximum Change:" => "最大误差:",
+ "Maximum Iterations must be a whole number from 1 to 32767." => "最多迭代次数必须是 1 到 32767 之间的整数。",
+ "Maximum Change must be a number of 0 or more." => "最大误差必须是大于或等于 0 的数字。",
"Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。",
_ => return None,
})
diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs
index 691eef367..33059c14e 100644
--- a/crates/ui-egui/tests/file_menu.rs
+++ b/crates/ui-egui/tests/file_menu.rs
@@ -2,7 +2,7 @@
use std::sync::{Arc, Mutex};
-use egui_kittest::kittest::Queryable as _;
+use egui_kittest::kittest::{NodeT as _, Queryable as _};
use gridcraft_engine::{Session, core::CellRef, core::Value};
use gridcraft_ui_egui::{Services, SheetApp};
@@ -217,3 +217,89 @@ fn a_cancelled_export_pdf_picker_writes_nothing() {
assert!(h.state().message.is_none());
assert!(h.state_mut().session.take_ui_requests().is_empty());
}
+
+/// Options › Formulas: Maximum Iterations and Maximum Change, with the checkbox that enables them.
+#[test]
+fn options_sets_maximum_iterations_and_change_only_while_iteration_is_on() {
+ use egui::accesskit::Role;
+ let mut session = Session::new();
+ session.new_workbook();
+ // Tall enough that the Formulas section is on screen without scrolling.
+ let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1400.0)).build_ui_state(
+ |ui, app: &mut SheetApp| {
+ let ctx = ui.ctx().clone();
+ app.logic(&ctx);
+ app.ui(ui);
+ },
+ SheetApp::new(session, Services::default()),
+ );
+ h.run_steps(4);
+ h.state_mut().run("file.options", serde_json::json!({})).unwrap();
+ h.run_steps(3);
+ let calc = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().session.active().unwrap().wb.calc.clone();
+ // The text field on the same row as `label`.
+ let field = |h: &egui_kittest::Harness<'static, SheetApp>, label: &str| {
+ let l = h.get_by_label(label).rect();
+ h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > l.left() && (r.center().y - l.center().y).abs() < 8.0).unwrap()
+ };
+ let disabled = |h: &egui_kittest::Harness<'static, SheetApp>, rect: egui::Rect| {
+ h.get_all_by_role(Role::TextInput).find(|n| n.rect() == rect).unwrap().accesskit_node().is_disabled()
+ };
+ let retype = |h: &mut egui_kittest::Harness<'static, SheetApp>, rect: egui::Rect, text: &str| {
+ h.input_mut().events.push(egui::Event::PointerMoved(rect.center()));
+ h.run_steps(1);
+ h.get_all_by_role(Role::TextInput).find(|n| n.rect() == rect).unwrap().click();
+ h.run_steps(2);
+ h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
+ h.run_steps(1);
+ for c in text.chars() {
+ h.input_mut().events.push(egui::Event::Text(c.to_string()));
+ h.run_steps(2);
+ }
+ h.key_press(egui::Key::Enter);
+ h.run_steps(2);
+ };
+
+ // Iteration is off: the two fields show the defaults, greyed out.
+ let (iterations, change) = (field(&h, "Maximum Iterations:"), field(&h, "Maximum Change:"));
+ assert!(disabled(&h, iterations) && disabled(&h, change), "the fields are disabled while iteration is off");
+ assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == iterations).unwrap().value().as_deref(), Some("100"));
+ assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == change).unwrap().value().as_deref(), Some("0.001"));
+
+ h.get_by_role_and_label(Role::CheckBox, "Enable iterative calculation").click();
+ h.run_steps(3);
+ assert!(calc(&h).iterative);
+ let (iterations, change) = (field(&h, "Maximum Iterations:"), field(&h, "Maximum Change:"));
+ assert!(!disabled(&h, iterations) && !disabled(&h, change));
+
+ retype(&mut h, iterations, "500");
+ assert_eq!(calc(&h).max_iterations, 500);
+ retype(&mut h, change, "0.01");
+ assert_eq!(calc(&h).max_change, 0.01);
+ assert!(h.state().message.is_none());
+
+ // Out-of-range entries are refused with a message and change nothing (the command alone would clamp them).
+ for bad in ["0", "32768", "12.5", "lots"] {
+ retype(&mut h, iterations, bad);
+ assert_eq!(calc(&h).max_iterations, 500, "{bad}");
+ assert!(h.state_mut().message.take().is_some(), "{bad} is reported");
+ h.run_steps(2);
+ }
+ retype(&mut h, iterations, "32767");
+ assert_eq!(calc(&h).max_iterations, 32767);
+ for bad in ["-1", "x"] {
+ retype(&mut h, change, bad);
+ assert_eq!(calc(&h).max_change, 0.01, "{bad}");
+ assert!(h.state_mut().message.take().is_some(), "{bad} is reported");
+ h.run_steps(2);
+ }
+ retype(&mut h, change, "0");
+ assert_eq!(calc(&h).max_change, 0.0);
+
+ // Undo puts the old value back in the field.
+ h.state_mut().run("edit.undo", serde_json::json!({})).unwrap();
+ h.run_steps(3);
+ assert_eq!(calc(&h).max_change, 0.01);
+ let change = field(&h, "Maximum Change:");
+ assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == change).unwrap().value().as_deref(), Some("0.01"));
+}
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 760ffd6ac..7b2f5f03e 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (iterative calculation settings in File › Options) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -79,7 +79,7 @@ PIVOTBY, REGEXTEST…) are all present.
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
-| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
+| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened |
| 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) |
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
| Localized function names and `;` separators | **missing** (#45); en-US only |
@@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Iterative calculation: Maximum Iterations and Maximum Change are set in File › Options › Formulas (dropped "no Options dialog yet") |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
diff --git a/docs/gaps.md b/docs/gaps.md
index 03e8672c5..61b5f7422 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Gaps
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (§6: Maximum Iterations and Maximum Change in File › Options) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -62,7 +62,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- **Missing:** 15-significant-digit equality; spill updates after structural edits and cleared
blockers; 3-D span deletion; qualified names after sheet rename.
- **Evidence:** #57, #133, #139, #140, #134; PRs #148, #155, #156, #157. Iterative calculation
- for circular references landed (no Options dialog yet).
+ for circular references landed, set in File › Options › Formulas (Maximum Iterations, Maximum
+ Change).
- **Impact:** wrong numbers are worse than missing features.
- **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language)
@@ -168,6 +169,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | §6: Maximum Iterations and Maximum Change in File › Options › Formulas; dropped the stale "no Options dialog yet" |
| 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) |
| 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder |
| 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 22e221e69..34b6ed1e4 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (File › Options › Formulas: Maximum Iterations and Maximum Change) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
@@ -26,7 +26,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing |
| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing |
| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) |
-| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has |
+| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has |
| Dark mode, system appearance | yes | yes | done (10-09) |
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) |
| Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) |
@@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | File › Options › Formulas: Maximum Iterations (1 to 32767) and Maximum Change (0 or more) under Enable iterative calculation, disabled while it is off; invalid entries are refused with a message |
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |
From 6db94d154a14697cb8a0d3b85c77f8a5c4c33efb Mon Sep 17 00:00:00 2001
From: Harrison Booth
Date: Thu, 15 Oct 2026 05:19:32 -0500
Subject: [PATCH 007/149] Calc: INDEX with one index on a 2-D reference is
#REF!, as in Excel
INDEX(A1:C3,2) returned the whole second row. Excel gives #REF! when a
reference has several rows and columns and the column argument is
omitted, so the error also carries into ranges built from it
(A1:INDEX(A1:C3,2)), position arrays (INDEX(A1:C3,{1;2})) and outer
functions. An empty column argument (INDEX(A1:C3,2,)) still gives the
row, and arrays are unchanged: INDEX({1,2;3,4},2) is the row.
Observed in Excel that a column array with one index,
INDEX({10;20;30},2), is a 1x1 array (TYPE 64), which is what calc
already returned; the new test pins it.
Co-Authored-By: Claude Opus 5.5
---
ROADMAP.md | 4 ++-
crates/calc/src/eval.rs | 8 ++++-
crates/calc/src/tests.rs | 75 ++++++++++++++++++++++++++++++++++++++++
docs/function-parity.md | 3 +-
4 files changed, 87 insertions(+), 3 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 070552d07..3292b595f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (INDEX with one index on a 2-D reference is #REF! as in Excel) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-15 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
@@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs
index 0f9cfb03d..1f58c50be 100644
--- a/crates/calc/src/eval.rs
+++ b/crates/calc/src/eval.rs
@@ -1251,7 +1251,13 @@ impl<'h> Evaluator<'h> {
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
let r = a.range;
- // A single row or column with one index picks along it.
+ // A single row or column with one index picks along it. An area with several rows
+ // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third
+ // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ:
+ // `INDEX({1,2;3,4},2)` is the row.
+ if two_args && r.height() > 1 && r.width() > 1 {
+ return err(CellError::Ref);
+ }
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 {
return err(CellError::Ref);
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 196c57de7..5abedec60 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -172,6 +172,81 @@ fn index_area_number_overflow() {
}
}
+/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a
+/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value.
+#[test]
+fn index_with_one_index() {
+ let mut t = T::new();
+ for (i, v) in [10, 20, 30].iter().enumerate() {
+ t.set(&format!("A{}", i + 1), &v.to_string());
+ }
+ let mut k = 1;
+ for r in 5..=7 {
+ for col in ["A", "B", "C"] {
+ t.set(&format!("{col}{r}"), &k.to_string());
+ k += 1;
+ }
+ }
+ for (row, (formula, expected)) in [
+ // Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value.
+ ("=INDEX({10;20;30},2)", 20.0),
+ ("=TYPE(INDEX({10;20;30},2))", 64.0),
+ ("=TYPE(INDEX(SEQUENCE(3),2))", 64.0),
+ ("=TYPE(INDEX({10;20;30},2,1))", 1.0),
+ ("=TYPE(INDEX({10,20,30},2))", 1.0),
+ ("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0),
+ ("=SUM(INDEX({10;20;30},2))", 20.0),
+ // 1-D references with one index are single-cell references.
+ ("=TYPE(INDEX(A1:A3,2))", 1.0),
+ ("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0),
+ ("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0),
+ // A 2-D array with one index is the row.
+ ("=SUM(INDEX({1,2;3,4},2))", 7.0),
+ ("=SUM(INDEX({1,2;3,4},0))", 10.0),
+ // A 2-D reference with an empty (not omitted) column argument is the row.
+ ("=SUM(INDEX(A5:C7,2,))", 15.0),
+ ("=SUM(INDEX(A5:C7,2,,1))", 15.0),
+ ("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0),
+ ("=INDEX(A5:C7,2,2)", 5.0),
+ // A 2-D reference with one index is #REF!, and the error carries through.
+ ("=TYPE(INDEX(A5:C7,2))", 16.0),
+ ("=ISREF(INDEX(A5:C7,2))", 0.0),
+ ]
+ .iter()
+ .enumerate()
+ {
+ let at = format!("J{}", row * 4 + 1);
+ t.set(&at, formula);
+ let got = match t.get(&at) {
+ Value::Bool(b) => f64::from(u8::from(b)),
+ v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")),
+ };
+ assert_eq!(got, *expected, "{formula}");
+ }
+ for formula in [
+ "=INDEX(A5:C7,2)",
+ "=INDEX(A5:C7,0)",
+ "=INDEX(A5:C7,1.9)",
+ "=INDEX(A5:C7,)",
+ "=INDEX(A5:B6,1)",
+ "=INDEX(A5:C7,2)+1",
+ "=SUM(INDEX(A5:C7,{1;2}))",
+ "=ROWS(INDEX(INDEX(A5:C7,2),3))",
+ "=SUM(A5:INDEX(A5:C7,2))",
+ "=SUM(INDEX(A5:C7,2):C7)",
+ ] {
+ t.set("L1", formula);
+ assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}");
+ }
+ t.set("L1", "=INDEX(A5:C7,{1;2})");
+ assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]);
+ t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")");
+ assert_eq!(t.get("L1"), Value::from("e"));
+ // A bad position is still #VALUE!.
+ t.set("L1", "=INDEX(A5:C7,\"x\")");
+ assert_eq!(t.get("L1"), Value::Error(CellError::Value));
+}
+
#[test]
fn errors_and_cycles() {
let mut t = T::new();
diff --git a/docs/function-parity.md b/docs/function-parity.md
index 760ffd6ac..12bef8ee9 100644
--- a/docs/function-parity.md
+++ b/docs/function-parity.md
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (INDEX with one index on a reference of several rows and columns is #REF! as in Excel) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's worksheet functions, category by category, against GridCraft's function library. Part of
[target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md).
@@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
From 672c102eb2eb375b3b2752f1e446147aebd522dc Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Sat, 17 Oct 2026 15:12:02 -0700
Subject: [PATCH 008/149] Calc: a LAMBDA that calls itself without end answers
#NUM! instead of overflowing the stack
The evaluator stopped at MAX_DEPTH (400) nested eval calls. A LAMBDA
call takes one of those levels but several stack frames (eval,
eval_inner, call, builtin, apply_lambda, call_lambda_values), so on the
1 MiB main thread of the Windows build
`=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))` overflowed the stack before it
reached the limit, and the process ended.
eval now also stops once a formula has used 512 KiB of stack below its
first eval. Both limits answer #NUM!, the error Excel documents for too
many recursive calls, and a defined name that holds the recursion passes
that error on instead of answering #NAME?.
Fixes #1662
---
crates/calc/src/eval.rs | 37 +++++++++++++++++++++++++++++--------
crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+), 8 deletions(-)
diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs
index 0f9cfb03d..2bbedd551 100644
--- a/crates/calc/src/eval.rs
+++ b/crates/calc/src/eval.rs
@@ -49,6 +49,18 @@ fn err(e: CellError) -> Ev {
Ev::V(Value::Error(e))
}
+/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it,
+/// `eval` answers `#NUM!`. One level of `MAX_DEPTH` can take several stack frames. The Windows main
+/// thread and the web build have 1 MiB of stack.
+const STACK_BUDGET: usize = 512 * 1024;
+
+/// The address of a local variable, which shows how far down the native stack this call is.
+#[inline(always)]
+fn stack_position() -> usize {
+ let marker = 0u8;
+ std::hint::black_box(&marker) as *const u8 as usize
+}
+
/// What the evaluator needs from its surroundings.
pub trait Host {
fn workbook(&self) -> &Workbook;
@@ -84,6 +96,8 @@ pub struct Evaluator<'h> {
pub at: CellRef,
env: Vec<(String, Ev)>,
depth: usize,
+ /// `stack_position()` at the outermost `eval`.
+ stack_top: usize,
}
struct FnCtx<'a, 'h> {
@@ -121,14 +135,14 @@ impl Ctx for FnCtx<'_, '_> {
_ => None,
})
.collect();
- let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
+ let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, stack_top: self.ev.stack_top };
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
}
}
impl<'h> Evaluator<'h> {
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
- Evaluator { host, sheet, at, env: Vec::new(), depth: 0 }
+ Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 }
}
/// Evaluates to a value (references are dereferenced; multi-cell → array).
@@ -205,10 +219,16 @@ impl<'h> Evaluator<'h> {
}
pub fn eval(&mut self, e: &Expr) -> Ev {
+ let here = stack_position();
+ if self.depth == 0 {
+ self.stack_top = here;
+ } else if self.stack_top.abs_diff(here) > STACK_BUDGET {
+ return err(CellError::Num);
+ }
self.depth += 1;
if self.depth > MAX_DEPTH {
self.depth -= 1;
- return err(CellError::Calc);
+ return err(CellError::Num);
}
let r = self.eval_inner(e);
self.depth -= 1;
@@ -492,7 +512,7 @@ impl<'h> Evaluator<'h> {
}
_ => {
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
- // of one level of recursion per operator (past the depth limit it was #CALC!).
+ // of one level of recursion per operator (past the depth limit it was an error).
let mut rest = vec![(op, b)];
let mut left = a;
while let Expr::Binary(o, l, r) = left
@@ -801,10 +821,11 @@ impl<'h> Evaluator<'h> {
fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
let Some(spec) = gridcraft_functions::lookup(name) else {
// A defined name holding a LAMBDA, or a LET-bound lambda.
- if let Ev::L(l) = self.name(name) {
- return self.apply_lambda(&l, args);
- }
- return err(CellError::Name);
+ return match self.name(name) {
+ Ev::L(l) => self.apply_lambda(&l, args),
+ Ev::V(Value::Error(x)) => err(x),
+ _ => err(CellError::Name),
+ };
};
if args.len() < spec.min_args || args.len() > spec.max_args {
return err(CellError::Value);
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 196c57de7..5cbbd5509 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -643,6 +643,38 @@ fn lambda_argument_count() {
assert_eq!(t.get("A9"), Value::Bool(true));
}
+#[test]
+fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
+ // 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run.
+ let handle = std::thread::Builder::new()
+ .stack_size(1 << 20)
+ .spawn(|| {
+ let mut t = T::new();
+ t.wb.names.push(gridcraft_model::DefinedName {
+ name: "LOOP".into(),
+ scope: None,
+ formula: "LAMBDA(n,LOOP(n))".into(),
+ comment: String::new(),
+ hidden: false,
+ });
+ t.calc.rebuild(&t.wb);
+ let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))";
+ t.set("A1", endless);
+ t.set("A2", "=LOOP(1)");
+ t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
+ t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,50))");
+ let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
+ [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
+ })
+ .expect("spawn");
+ let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack");
+ assert_eq!(endless, Value::Error(CellError::Num));
+ assert_eq!(endless_name, Value::Error(CellError::Num));
+ assert_eq!(through_map, Value::Error(CellError::Num));
+ assert_eq!(evaluated, Value::Error(CellError::Num));
+ assert_eq!(countdown, Value::Number(50.0));
+}
+
#[test]
fn names_and_sheets() {
let mut t = T::new();
From 09973e7831ecdb03f8ee71ef9a3841cd26c6547f Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Sat, 17 Oct 2026 15:12:19 -0700
Subject: [PATCH 009/149] Contributors: add dotCooCoo (Robert Lee)
---
contributors/people.toml | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/contributors/people.toml b/contributors/people.toml
index d522b84e0..96bead93e 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -65,3 +65,7 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.dotCooCoo]
+real_name = "Robert Lee"
+sync_github_name = true
From d0fdf948db4ad425b3fe19ec8ad8ee035d792059 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Sun, 18 Oct 2026 13:09:44 -0700
Subject: [PATCH 010/149] Calc: a recursive LAMBDA stops at a fixed count of
140 calls
A LAMBDA recursion answered #NUM! once the formula had used 512 KiB of
native stack, so the depth it reached depended on frame sizes. On a
Windows release build the deepest countdown was F(F,94), where 0.7.0
reached F(F,131).
The evaluator now counts the LAMBDA calls a formula has in progress, in
call_lambda_values, which direct calls, defined names, MAP, REDUCE,
SCAN, BYROW, BYCOL and MAKEARRAY all go through. The 141st call answers
#NUM!. The countdown F(F,131), the deepest that the 400-level MAX_DEPTH
allows, has 132 calls in progress and still runs.
The stack check stays as a guard. Its budget rises to 896 KiB, which
leaves 128 KiB of a 1 MiB thread for the frames above the formula.
a_recursive_lambda_stops_at_the_same_call_on_any_stack_size runs
recursions on each side of the limit on a 1 MiB and a 64 MiB thread and
expects the same results. The 1 MiB test now runs the countdown
F(F,131).
---
crates/calc/src/eval.rs | 31 ++++++++++++++++++++++++++-----
crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++--
2 files changed, 56 insertions(+), 7 deletions(-)
diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs
index 2bbedd551..bc60c5b7a 100644
--- a/crates/calc/src/eval.rs
+++ b/crates/calc/src/eval.rs
@@ -49,10 +49,16 @@ fn err(e: CellError) -> Ev {
Ev::V(Value::Error(e))
}
+/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build.
+/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132
+/// calls in progress, so it still runs.
+const MAX_LAMBDA_CALLS: usize = 140;
+
/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it,
-/// `eval` answers `#NUM!`. One level of `MAX_DEPTH` can take several stack frames. The Windows main
-/// thread and the web build have 1 MiB of stack.
-const STACK_BUDGET: usize = 512 * 1024;
+/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames
+/// above the formula. A cell that another cell's formula computes on demand gets its own `Evaluator`,
+/// which starts its own budget.
+const STACK_BUDGET: usize = 896 * 1024;
/// The address of a local variable, which shows how far down the native stack this call is.
#[inline(always)]
@@ -96,6 +102,8 @@ pub struct Evaluator<'h> {
pub at: CellRef,
env: Vec<(String, Ev)>,
depth: usize,
+ /// LAMBDA calls in progress.
+ calls: usize,
/// `stack_position()` at the outermost `eval`.
stack_top: usize,
}
@@ -135,14 +143,22 @@ impl Ctx for FnCtx<'_, '_> {
_ => None,
})
.collect();
- let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, stack_top: self.ev.stack_top };
+ let mut ev = Evaluator {
+ host: &mut *self.ev.host,
+ sheet: area.sheet,
+ at,
+ env,
+ depth: self.ev.depth,
+ calls: self.ev.calls,
+ stack_top: self.ev.stack_top,
+ };
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
}
}
impl<'h> Evaluator<'h> {
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
- Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 }
+ Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0 }
}
/// Evaluates to a value (references are dereferenced; multi-cell → array).
@@ -963,10 +979,15 @@ impl<'h> Evaluator<'h> {
}
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
+ if self.calls >= MAX_LAMBDA_CALLS {
+ return err(CellError::Num);
+ }
+ self.calls += 1;
let saved = std::mem::replace(&mut self.env, l.env.clone());
self.env.extend(bound);
let r = self.eval(&l.body);
self.env = saved;
+ self.calls -= 1;
r
}
diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs
index 99ef117ce..d56bf0c13 100644
--- a/crates/calc/src/tests.rs
+++ b/crates/calc/src/tests.rs
@@ -662,7 +662,7 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
t.set("A1", endless);
t.set("A2", "=LOOP(1)");
t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
- t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,50))");
+ t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))");
let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
[t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
})
@@ -672,7 +672,35 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
assert_eq!(endless_name, Value::Error(CellError::Num));
assert_eq!(through_map, Value::Error(CellError::Num));
assert_eq!(evaluated, Value::Error(CellError::Num));
- assert_eq!(countdown, Value::Number(50.0));
+ assert_eq!(countdown, Value::Number(131.0));
+}
+
+#[test]
+fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {
+ let run = |stack: usize| {
+ std::thread::Builder::new()
+ .stack_size(stack)
+ .spawn(|| {
+ let mut wb = Workbook::new();
+ wb.names.push(gridcraft_model::DefinedName {
+ name: "CNT".into(),
+ scope: None,
+ formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(),
+ comment: String::new(),
+ hidden: false,
+ });
+ // F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.
+ let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");
+ let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");
+ [tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))
+ })
+ .expect("spawn")
+ .join()
+ .expect("recursion must not overflow the stack")
+ };
+ let small = run(1 << 20);
+ assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);
+ assert_eq!(run(64 << 20), small);
}
#[test]
From 5392d95bf85d53eab9abedf78a8757e7d292d9ad Mon Sep 17 00:00:00 2001
From: Higgs32584
Date: Wed, 21 Oct 2026 21:13:13 -0400
Subject: [PATCH 011/149] scripts: require workbook trust before running
embedded scripts (#2178)
A workbook opened with GridCraft scripts (`_sc_scripts`) starts untrusted:
automate.runScript refuses them until the user trusts the workbook for the
session (Macros dialog > Trust Scripts, i.e. automate.trustScripts) or the
caller passes `trusted: true` for that one run. Workbooks without scripts,
and scripts written in the session, are unaffected.
Whatever the trust, a script whose steps reach outside the workbook
(file.*, link.*, or any `path` param: Get Data, From Text/CSV, Insert
Picture) is refused before any step runs.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/cmd/extra.rs | 75 ++++++++++++++++++++++++++---
crates/engine/src/lib.rs | 7 +++
crates/engine/src/tests.rs | 87 ++++++++++++++++++++++++++++++++++
crates/ui-egui/src/dialogs.rs | 15 +++++-
4 files changed, 176 insertions(+), 8 deletions(-)
diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs
index 01e2ac6e6..b88ce5592 100644
--- a/crates/engine/src/cmd/extra.rs
+++ b/crates/engine/src/cmd/extra.rs
@@ -193,7 +193,24 @@ pub fn specs() -> Vec {
new_script
),
cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts),
- cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script),
+ cmd!(
+ "automate.runScript",
+ "Run Script",
+ [],
+ None,
+ "{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)",
+ has_doc,
+ run_script
+ ),
+ cmd!(
+ noundo "automate.trustScripts",
+ "Trust Scripts",
+ ["Automate", "Scripting Tools"],
+ None,
+ "{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session",
+ has_doc,
+ trust_scripts
+ ),
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
// Cell controls, themes, borders, styles
cmd!(
@@ -782,6 +799,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result {
let d = s.doc()?.clone();
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
copy.selection = d.selection.clone();
+ copy.scripts_trusted = d.scripts_trusted;
let i = s.add_document(copy);
Ok(json!({"index": i}))
}
@@ -970,10 +988,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result {
}
fn all_scripts(s: &mut Session, _: &Json) -> Result {
- let wb = &s.doc()?.wb;
+ let d = s.doc()?;
+ let (wb, trusted) = (&d.wb, d.scripts_trusted);
let all = stash_get(wb, SCRIPTS_NAME);
- let mut list: Vec =
- all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
+ let mut list: Vec = all
+ .as_object()
+ .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
+ .unwrap_or_default();
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
// save but never executed here, so it has no step count.
if let Some(bytes) = &wb.vba_project {
@@ -982,12 +1003,54 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result {
Ok(Json::Array(list))
}
+/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
+pub(crate) fn has_scripts(wb: &Workbook) -> bool {
+ stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
+}
+
+/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
+/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
+/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
+/// directly, not a stored script's. (Nested `automate.*` steps are skipped.)
+fn denied_script_step(id: &str, params: &Json) -> Option {
+ if id.starts_with("file.") || id.starts_with("link.") {
+ return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
+ }
+ if params.get("path").is_some() {
+ return Some(format!("scripts can't pass a file `path` to `{id}`"));
+ }
+ None
+}
+
+fn trust_scripts(s: &mut Session, p: &Json) -> Result {
+ let trusted = bool_param(p, "trusted").unwrap_or(true);
+ s.doc_mut()?.scripts_trusted = trusted;
+ Ok(json!({"trusted": trusted}))
+}
+
+/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
+/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
+/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
fn run_script(s: &mut Session, p: &Json) -> Result {
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
- let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
+ let d = s.doc()?;
+ let all = stash_get(&d.wb, SCRIPTS_NAME);
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
+ if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
+ return Err(bad(
+ "automate.runScript",
+ "scripts in this workbook came from a file and are disabled; trust the workbook first (Automate › Macros › Trust Scripts, or `trusted: true`)",
+ ));
+ }
+ let steps: Vec = steps.into_iter().take(100_000).collect();
+ for st in &steps {
+ let id = st["command"].as_str().unwrap_or("");
+ if let Some(why) = denied_script_step(id, &st["params"]) {
+ return Err(bad("automate.runScript", why));
+ }
+ }
let mut n = 0;
- for st in steps.iter().take(100_000) {
+ for st in &steps {
let id = st["command"].as_str().unwrap_or("");
if id.starts_with("automate.") {
continue;
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 258a1cb72..bf7dd633d 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -112,6 +112,11 @@ pub struct DocState {
/// The format `path`'s content was recognised as when its extension doesn't name one (an
/// XLSX called `budget.dat`): Save writes it back in that format.
pub content_kind: Option,
+ /// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
+ /// that arrives already carrying scripts (opened from a file) starts untrusted until the
+ /// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
+ /// trusted, since any script it gains later was written here.
+ pub scripts_trusted: bool,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -121,6 +126,7 @@ impl DocState {
pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState {
let mut calc = Calc::new();
calc.recalc_all(&mut wb);
+ let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
DocState {
@@ -137,6 +143,7 @@ impl DocState {
sheet_selections: vec![],
unlocked_ranges: vec![],
content_kind: None,
+ scripts_trusted,
}
}
pub fn is_dirty(&self) -> bool {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 2ac290654..357b925c1 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -940,6 +940,93 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() {
s.execute("automate.runScript", json!({"name": "Lock"})).unwrap();
}
+/// A session holding a workbook opened from file bytes whose `Fill` script sets B1 (#2178).
+fn opened_with_script() -> Session {
+ let mut author = s();
+ author
+ .execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
+ .unwrap();
+ let bytes = crate::io::save_bytes(&author.doc().unwrap().wb, "book.xlsx").unwrap();
+ let mut s = Session::new();
+ s.execute("file.open", json!({"name": "downloaded.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap();
+ s
+}
+
+#[test]
+fn scripts_written_in_this_session_run_without_a_trust_step() {
+ let mut s = s();
+ assert!(s.doc().unwrap().scripts_trusted);
+ s.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]}))
+ .unwrap();
+ assert_eq!(s.execute("automate.runScript", json!({"name": "Fill"})).unwrap()["ran"], 1);
+ assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
+}
+
+#[test]
+fn untrusted_workbook_scripts_are_refused_until_trusted() {
+ let mut s = opened_with_script();
+ s.ui_requests.clear();
+ assert!(!s.doc().unwrap().scripts_trusted, "a workbook opened with scripts starts untrusted");
+ let listed = s.execute("automate.allScripts", json!({})).unwrap();
+ assert_eq!(listed[0]["trusted"], false, "{listed}");
+ let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string();
+ assert!(err.contains("trust"), "{err}");
+ assert_eq!(v(&s, "B1"), Value::Empty, "no step of an untrusted script runs");
+ // Trust can't be smuggled in as a string, and nothing opens a dialog.
+ assert!(s.execute("automate.runScript", json!({"name": "Fill", "trusted": "yes"})).is_err());
+ assert!(s.ui_requests.is_empty(), "{:?}", s.ui_requests);
+
+ // An explicit `trusted: true` runs it this once without trusting the workbook.
+ s.execute("automate.runScript", json!({"name": "Fill", "trusted": true})).unwrap();
+ assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
+ assert!(!s.doc().unwrap().scripts_trusted);
+ s.execute("cell.set", json!({"cell": "B1", "input": ""})).unwrap();
+ assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
+
+ // Trusting the workbook (the UI's Trust Scripts action) lets its scripts run this session.
+ assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true);
+ s.execute("automate.runScript", json!({"name": "Fill"})).unwrap();
+ assert_eq!(v(&s, "B1"), Value::Text("ran".into()));
+ // A second window on the same workbook keeps the decision; withdrawing it disables them again.
+ s.execute("view.newWindow", json!({})).unwrap();
+ assert!(s.doc().unwrap().scripts_trusted);
+ s.execute("automate.trustScripts", json!({"trusted": false})).unwrap();
+ assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err());
+}
+
+#[test]
+fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() {
+ let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id()));
+ let leak = dir.join("leak.xlsx");
+ let leak = leak.to_string_lossy().to_string();
+ let mut s = opened_with_script();
+ s.ui_requests.clear();
+ s.execute("automate.trustScripts", json!({})).unwrap();
+ let denied = [
+ json!({"command": "link.open", "params": {"url": "https://example.com/?d=secret"}}),
+ json!({"command": "file.saveAs", "params": {"path": leak}}),
+ json!({"command": "file.save", "params": {}}),
+ json!({"command": "file.exportPdf", "params": {"path": leak}}),
+ json!({"command": "file.open", "params": {"path": leak}}),
+ json!({"command": "data.getData", "params": {"path": leak}}),
+ json!({"command": "data.fromTextCsv", "params": {"path": leak}}),
+ json!({"command": "insert.picture", "params": {"path": leak}}),
+ ];
+ for step in denied {
+ // The harmless first step proves the whole script is refused before anything runs.
+ let first = json!({"command": "cell.set", "params": {"cell": "C1", "input": "partial"}});
+ s.execute("automate.newScript", json!({"name": "Leak", "commands": [first, step]})).unwrap();
+ let r = s.execute("automate.runScript", json!({"name": "Leak", "trusted": true}));
+ assert!(r.is_err(), "{step} must be refused");
+ assert_eq!(v(&s, "C1"), Value::Empty, "{step}: no step runs");
+ }
+ assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "{:?}", s.ui_requests);
+ assert!(!std::path::Path::new(&leak).exists());
+ // Data a script supplies itself is still fine.
+ s.execute("automate.newScript", json!({"name": "Csv", "commands": [{"command": "data.fromTextCsv", "params": {"text": "a,b\n1,2"}}]})).unwrap();
+ s.execute("automate.runScript", json!({"name": "Csv"})).unwrap();
+}
+
/// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected.
fn protected(extra: serde_json::Value) -> Session {
let mut s = s();
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index fb899c702..10320476c 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -1077,7 +1077,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
}
});
}
- "macros" => macros(ui, &mut d),
+ "macros" => macros(app, ui, &mut d),
"statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => {
let r = if d.name == "comments" {
app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>())))
@@ -1496,7 +1496,7 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) {
/// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the
/// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is
/// preserved and not executed.
-fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
+fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed.");
ui.add_space(6.0);
let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default();
@@ -1504,6 +1504,17 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) {
ui.weak("This workbook has no scripts or macros.");
return;
}
+ // Scripts that came with a file stay disabled until the user trusts the workbook (#2178).
+ if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) {
+ ui.horizontal(|ui| {
+ ui.weak("Scripts in this workbook came from a file and are disabled.");
+ if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() {
+ let _ = app.run("automate.trustScripts", json!({"trusted": true}));
+ d.result = app.session.run("view.macros", json!({})).ok();
+ }
+ });
+ ui.add_space(6.0);
+ }
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
for it in &items {
let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)");
From b9801f3f810927d4dd597c0d38b5ba13729221eb Mon Sep 17 00:00:00 2001
From: Viorel Toader
Date: Thu, 22 Oct 2026 07:55:04 +0300
Subject: [PATCH 012/149] Romanian: translate Options|Editing; 22 strings main
added since (sheet tab menu, account, chart and error-checking labels)
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
---
crates/ui-egui/src/i18n/ro.rs | 26 +++++++++++++++++++++++++-
1 file changed, 25 insertions(+), 1 deletion(-)
diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs
index 699e30ff3..9e0d92516 100644
--- a/crates/ui-egui/src/i18n/ro.rs
+++ b/crates/ui-egui/src/i18n/ro.rs
@@ -1005,7 +1005,31 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Print job submitted: {receipt}" => "Lucrarea de imprimare a fost trimisă: {receipt}",
"Print failed: {error}" => "Imprimarea a eșuat: {error}",
"Print failed: the printing worker stopped." => "Imprimarea a eșuat: procesul de imprimare s-a oprit.",
- "Options|Editing" => "Editing",
+ "Options|Editing" => "Editare",
+ "Duplicate" => "Duplicare",
+ "Rename" => "Redenumire",
+ "Product Information" => "Informații despre produs",
+ "Tab Color" => "Culoare filă",
+ "Select All Sheets" => "Selectare toate foile",
+ "Fit selection" => "Încadrare selecție",
+ "All sheets" => "Toate foile",
+ "Version" => "Versiune",
+ "Unhide…" => "Reafișare…",
+ "Platform" => "Platformă",
+ "Unprotect Sheet…" => "Deprotejare foaie…",
+ "AutoSave: save after every change (workbooks saved to a file)" => {
+ "Salvare automată: salvează după fiecare modificare (registrele salvate într-un fișier)"
+ }
+ "Licence" => "Licență",
+ "Account" => "Cont",
+ "No Color" => "Fără culoare",
+ "Move or Copy…" => "Mutare sau copiere…",
+ "Number Stored as Text" => "Număr stocat ca text",
+ "Select Data…" => "Selectare date…",
+ "Convert to Number" => "Conversie în număr",
+ "Format Chart Area…" => "Formatare zonă diagramă…",
+ "Error checking options" => "Opțiuni de verificare a erorilor",
+ "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate",
_ => return None,
})
}
From e177bf1c44581da8bf29ebf7e261b8a988449c05 Mon Sep 17 00:00:00 2001
From: Tyrel Haveman <1434399+tyrel@users.noreply.github.com>
Date: Wed, 21 Oct 2026 23:41:28 -0700
Subject: [PATCH 013/149] calc: hardening: overlong digits, NaN indexes,
REGEXREPLACE output (#2163, #2162)
- Text coercion: digits too many for a finite number are not a number,
and a fraction must come out finite, so "1 <400 digits>/<400 digits>"
(inf/inf) no longer coerces to NaN (test overlong_digits_stay_text).
- VLOOKUP/HLOOKUP and the D-functions' field index refuse NaN, which
passed their "< 1" checks and became index 0, then underflowed
(test nan_index_is_a_value_error; hostile_inputs_never_panic now also
passes NaN and infinity to every function).
- REGEXREPLACE replaces match by match and gives up once the result can
only be too long, instead of building every replacement first: the
32,767 x 32,767 worst case went from 1.05 GB peak to 7 MB
(test regexreplace_bounds_its_result).
#2165 (MAP's size check) is already fixed on main by the broadcast cap
(836b2698, #2182), whose test covers MAP.
Also adds my contributors/people.toml entry.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
---
ROADMAP.md | 4 +++-
contributors/people.toml | 3 +++
crates/core/src/parse.rs | 19 +++++++++++++++++--
crates/functions/src/database.rs | 5 +++--
crates/functions/src/lib.rs | 31 +++++++++++++++++++++++++++++++
crates/functions/src/lookup.rs | 5 +++--
crates/functions/src/text.rs | 16 +++++++++++++++-
7 files changed, 75 insertions(+), 8 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 2e04e9123..1faf680ca 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
+| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
@@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
+| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
diff --git a/contributors/people.toml b/contributors/people.toml
index 62635c8ae..88e496fc7 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -93,3 +93,6 @@ sync_github_name = false
[people.bilalmughal]
sync_github_name = true
+
+[people.tyrel]
+real_name = "Tyrel Haveman"
diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs
index b58586aef..3f2b97a8a 100644
--- a/crates/core/src/parse.rs
+++ b/crates/core/src/parse.rs
@@ -167,7 +167,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
}
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
-/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept.
+/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
+/// finite number (#2163).
fn plain_number(s: &str, frac: bool) -> Option {
let (int, dec) = match s.split_once('.') {
Some((i, d)) if frac => (i, Some(d)),
@@ -177,7 +178,7 @@ fn plain_number(s: &str, frac: bool) -> Option {
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
return None;
}
- s.parse().ok()
+ s.parse().ok().filter(|x: &f64| x.is_finite())
}
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
@@ -192,6 +193,9 @@ fn parse_fraction(t: &str) -> Option {
return None;
}
let v = w + a / b;
+ if !v.is_finite() {
+ return None;
+ }
Some(if neg { -v } else { v })
}
@@ -366,6 +370,17 @@ mod tests {
parse_input(s, DateSystem::D1900)
}
+ /// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
+ #[test]
+ fn overlong_digits_stay_text() {
+ let big = "9".repeat(400);
+ for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
+ assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
+ assert_eq!(parse_number_text(&s), None, "{s}");
+ }
+ assert_eq!(parse_number_text("1 1/2"), Some(1.5));
+ }
+
#[test]
fn non_digit_time_and_fraction_parts_stay_text() {
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs
index 67132e98f..455b8a1d6 100644
--- a/crates/functions/src/database.rs
+++ b/crates/functions/src/database.rs
@@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R
+
+
+
+
GridCraft
From 56207f2d3cc55f5f0574415998ebcbaf61c90216 Mon Sep 17 00:00:00 2001
From: Hamza
Date: Thu, 22 Oct 2026 17:50:09 +0500
Subject: [PATCH 018/149] run --script: a line's unknown key stops the run
(#2256)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A script line whose parameters sat under a key the runner does not read ran the
command with no parameters at all, and the run still exited 0:
{"command":"home.insertSheet","args":{"name":"Report"}}
{"command":"home.insertSheet","name":"Report2"}
made Sheet2 and Sheet3 instead of Report and Report2, and said nothing. A typo
in a script produced a valid, wrong workbook.
A line is read for `command` (or `id`) and `params`; any other key is a usage
error now, naming the keys and the line, and the run stops before the workbook
is written:
gridcraft-cli: s.jsonl:2: unknown key `args`: a line is {"command": "id", "params": {…}}
The usage text says what a line looks like, which is the other half of the
report — the format was not written down anywhere, so the reporter found it by
trying `args`, a flat object and `id` in turn.
Tested at both levels: `parse_command` for the keys, their sorted list when
there are several, and the forms that are read (`id`, `params`, `id={json}`,
comments); and a run of a four-line script that stops at line 2 without writing
the file, then the same script corrected, whose sheets come out named. Without
the check the mistaken line parses again.
An unknown key *inside* `params` is still ignored — the rest of #2256 — and
needs the command's parameter list, which the engine does not expose yet. Noted
on the issue.
---
apps/gridcraft-cli/src/main.rs | 33 ++++++++++++++++++++++++++++
apps/gridcraft-cli/tests/cli.rs | 38 +++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+)
diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs
index 0b763e378..972c023fa 100644
--- a/apps/gridcraft-cli/src/main.rs
+++ b/apps/gridcraft-cli/src/main.rs
@@ -43,6 +43,8 @@ const USAGE: &str = "usage:
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
[--out FILE] [--print RANGE] [--quiet]
+ a script line is id={json} or {\"command\": \"id\", \"params\": {…}};
+ blank lines and lines starting with # or // are skipped
gridcraft-cli commands [--json] [--search X]
gridcraft-cli functions [--json] [--search X] [--category C]
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
@@ -375,6 +377,17 @@ fn parse_command(text: &str) -> Result