Commit Graph
633 Commits
Author SHA1 Message Date
prxnvt 74ff8ac625 Engine: macro runs refuse a shift that moves an input, stay clean when nothing changed, take the answer however ask was given, and share one step budget
Four fixes from a review of the runner, each with a regression test that
fails without it:

- A shift inserts cells over the block of every written cell, which moves
  that block's columns (or rows) from there on. When a bound input lay in
  the way, its data moved and sheared while the run kept the old coordinates
  and reported success. The shift is now refused, naming the input and its
  place. The output is not counted: it is where the macro writes, and the
  second footprint catches data a shift moved into it.
- A dry run or a refused run left a clean workbook dirty: view.zoom,
  sheet.activate and the view modes move the saved mark to the current
  workbook on a clean document, and the rollback put the workbook back but
  not the mark. Saved keeps and restores it.
- overwrite: true answers the question however ask was given; with an
  explicit onConflict: ask the answer was ignored and the question came back
  forever.
- The dry runs and the run share one step budget as they share one clock;
  each phase counted its own 100,000 steps before. ran reports the run's
  own steps.
2026-10-23 23:13:18 -04:00
prxnvt 0df540f166 Merge #2151 (44a2f4e2, with #465 at 673af7ec, #462 at 712bb766 and main at 530a8cc8) into #2144
# Conflicts:
#	docs/macros.md
2026-10-23 22:36:10 -04:00
prxnvt 44a2f4e2f1 Merge #465 (673af7ec, with #462 at 712bb766 and main at 530a8cc8) into #2151
# Conflicts:
#	docs/macros.md
2026-10-23 22:08:05 -04:00
prxnvt 00b63468e6 Engine: macro tokens resolve only where they mean a reference, and refuse instead of guessing
Six fixes from a review of the resolver, each with a regression test that
fails without it:

- A bare Row or Column under a general reference key (to, from, at, area,
  source, target, destination, location) stays text: Change Spelling's
  replacement word and a pivot field's area were being rewritten into the
  current row's A1 range. Row.col(2), {{Row}} and {"$ref": "Row"} still
  resolve there, and the bare word still resolves under cell and range keys.
- {{{{ in a string is a literal {{, so text that needs the braces can be
  written; an unknown token is still an error.
- Input.data on a range that is only a header row, and Name: of a table
  with no data rows, are refused instead of reading the header.
- below(0), below(-3) and right(0) are refused like resize(0, 1) is, instead
  of becoming 1.
- A defined name on a sheet whose name holds a dollar sign resolves: the
  dollar anchors are stripped from the reference part only.
- quote_sheet is the engine's one rule (gridcraft_core::quote_sheet), which
  quotes a sheet named like a cell reference, an R1C1 token or a boolean and
  leaves a period unquoted.

docs/macros.md says which keys take a bare token and how to write a literal
brace.
2026-10-23 22:02:45 -04:00
prxnvt 673af7ecdc Merge #462 (712bb766) into #465 2026-10-23 22:00:39 -04:00
prxnvt 4672b6da51 Model: an input name that reads as a cell reference is refused
A macro could declare an input called Q1, AB12 or Sum1, and a step written
with plain A1 text, as a recorded one is, would then resolve to the input's
range instead of the cell. check_limits refuses such names; the docs say so.
Regression test.
2026-10-23 21:48:53 -04:00
prxnvt 712bb766ec Engine: a grouped action that panics is rolled back before the panic goes on
Session::grouped ran its action without a panic guard, so a caller outside
a command that caught the panic found the group still open: Undo and Redo
disabled, the partial edits kept, and the next grouped action nested in the
abandoned group. The group is now rolled back and the panic resumed.
Regression test.
2026-10-23 21:46:25 -04:00
prxnvt 8219c18b92 Engine: macro storage shares the list, saves in one pass, keeps unknown names, checks once
- Workbook::macros is a shared list (Macros, a copy-on-write newtype): a
  workbook copy made for a cell edit or an undo snapshot holds the same list,
  and the undo budget charges a list once a snapshot owns its own copy.
  Before, every mutating command copied every step of every macro, and a
  hundred undo entries kept a hundred copies the budget never saw.
- Saving to xlsx drops the last save's items and writes the macros in one
  pass each (stash::set_many), instead of a scan of every name per macro.
- A hidden _gc_macro_<x>_<k> name with a key this build did not write is
  kept on open and on save, as a visible one is, instead of being dropped
  without a warning.
- macros::check is the one check for every path that adds a macro (JSON, a
  file, built in code): the model's limits and the xlsx size limit.

Regression tests for each.
2026-10-23 21:45:54 -04:00
prxnvt 259b6e67e8 Model: Repeat and If nest four deep, as the limit says
The depth check refused any step list at the fifth level, so a fourth Repeat
or If could not even hold an empty body: three levels nested, not the four
MAX_MACRO_DEPTH and the docs state. The check now counts the Repeat or If
steps themselves, in the model's limits and in the parser's pass over the
JSON. The test asks for four and refuses a fifth.
2026-10-23 21:45:54 -04:00
prxnvt 3ca1252571 Engine: a group a panicking command ended keeps its step
A group open before a command and ended or rolled back inside it lost its
undo step when the command then panicked: the restored history held no entry
for the group's changes, so Undo skipped the state before the group. The
panic path now pushes the outermost such group's step from its start to the
restored workbook. The group stays ended, because its token went with the
command. Regression test for a group ended and one rolled back.
2026-10-23 21:36:09 -04:00
prxnvt cef58da39e Merge #462 (d7c3c183, with main at 530a8cc8) into #465 2026-10-23 21:25:35 -04:00
prxnvt d7c3c18345 Engine: the panic guard restores entries a nested Undo removed
Truncating the undo history to its length at the command's start cannot
bring back an entry that a nested edit.undo popped. The guard now keeps the
undo and redo history as they were and puts both back. The regression test
gains the undo-then-panic case.
2026-10-23 20:47:40 -04:00
prxnvt 8440427c53 Tests: a linked paste of column widths or notes copies its metadata and no cell
The source now carries a note and a column width, and the test checks that
the destination received them while its trust stays Local and no cell is
written.
2026-10-23 20:15:05 -04:00
prxnvt d1e51a4af4 Engine: a later stash chunk on its own is not a listed item
stash::keys reported a key that get read as missing, because only a later
chunk of it existed. keys now counts chunk 0 and legacy names only, as get
does. Regression assertions in the hostile-names test.
2026-10-23 20:08:40 -04:00
prxnvt 456cf73886 Engine: a headless call that fails for want of a file is not recorded
file.open with no file succeeds by queuing a dialog, which execute_headless
turns into an error for callers with no UI; the recording kept the step.
Recorder::truncate forgets it and keeps the byte count right. Regression test.
2026-10-23 20:08:40 -04:00
prxnvt a3b5f8e45c Engine: a command that panics leaves the undo history as it found it
The panic guard restored the workbook and selection but not the history, so
a nested edit made before the panic left a no-op undo step and the redo
history it had cleared stayed lost. The guard now truncates the steps made
during the command and puts the redo history back. Regression test, and the
architecture note says what the guard restores.
2026-10-23 20:08:40 -04:00
prxnvt 6b7c48f119 Engine: a stash chunk number with leading zeros names someone else's name
`_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first
chunk and a visible one stopped `_gc_watch_0` from being written. Only the
chunk number as `set` writes it counts now. Regression test.
2026-10-23 20:01:21 -04:00
prxnvt 66e69cb946 Engine: a linked paste carries the file's trust whatever it pastes
Paste Link writes a formula into every cell whatever `what` says, so a linked
paste of formats from a file now makes the destination FromFile as a paste of
formulas does. Column widths and notes write no cells, linked or not, and
still carry nothing. Regression test for the three kinds.
2026-10-23 20:01:21 -04:00
prxnvt 080b6d993b Engine: a grouped action that closes its document succeeds
Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error.
2026-10-23 16:36:34 -04:00
prxnvt 6ab8d97118 Engine: the undo steps made inside a command are bounded while it runs
The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once.

Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop.
2026-10-23 16:35:45 -04:00
prxnvt 114088d589 Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own
Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at.

A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits.

The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it.
2026-10-23 16:33:42 -04:00
prxnvt 5ca886507c Engine: writing a stash item checks for a clashing visible name in one pass over the names
The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged.
2026-10-23 16:21:48 -04:00
prxnvt a8e8bf851e Engine: the recording buffer is bounded in bytes and keeps the start of a recording
The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps.
2026-10-23 16:17:57 -04:00
prxnvt 18ec913c4e Engine: a command keeps its group's name only for a step that is exactly the group's
The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone.

The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record.
2026-10-23 16:17:57 -04:00
prxnvt 602d4450a2 Merge main into #462 2026-10-23 16:10:43 -04:00
Brandon ThomasandClaude Opus 5.5 f1e29fa828 Review fixes for #2267 and #2292
- range.setValues with parse: false still turns on Wrap Text for a line break (CodeRabbit).
- Japanese: the fill-handle hint says AutoFill (オートフィル), not paint (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:15:31 -04:00
Brandon Thomas bcf55f0f00 Merge PR #2274
# Conflicts:
#	docs/ui-parity.md
2026-10-23 10:11:33 -04:00
Brandon Thomas e92d8b4e48 Merge PR #2295 2026-10-23 10:11:12 -04:00
Brandon Thomas 50e5a3e928 Merge PR #2267 2026-10-23 10:11:11 -04:00
D. Fender 09f78cb3a8 Tables: Format as Table inside a table restyles it
Home > Format as Table on a cell or range inside an existing table
failed with "A table can't overlap another table." Excel applies the
picked style to that table instead. The command now returns that table
and its range, as it does for a new table. A range crossing a table's
edge is still rejected.

Without a range, the active area of the selection decides, as it does
for Insert > Table. The native Format menu's Format as Table now runs
home.formatAsTable instead of insert.table.
2026-10-22 11:43:58 -07:00
Higgs32584andClaude Opus 5.5 b5b3969377 scripts: trust gate review fixes (#2178)
- automate.trustScripts: only a missing `trusted` defaults to true; a
  non-boolean value is an error and leaves the workbook untrusted.
- The refusal message points at Trust Scripts in View > Macros (Alt+F8)
  and at automate.trustScripts.
- Catalog row for automate.trustScripts; parity checklist regenerated.
- Script steps run inside an io::ScriptSandbox (thread-local) so
  read_file/write_file refuse whatever the parameter is called, and a
  step that raises OpenUrl is dropped and stops the script. The id/param
  deny list stays as the up-front refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:43:48 -04:00
Higgs32584 6dedb9310b Merge remote-tracking branch 'origin/main' into fix/2178-script-trust-gate 2026-10-22 12:32:28 -04:00
Dominik StrebelandClaude Opus 5.5 840ba5c3a5 view: Show Outline Symbols (Ctrl+8), read and written
A sheet can hide its outline's level buttons and brackets while keeping
the outline (Excel's Ctrl+8, saved as showOutlineSymbols="0"). It wasn't
modelled, so such sheets showed a wide outline band Excel doesn't.

- model: Sheet::show_outline_symbols (default on, also for older JSON).
- xlsx: read and written on the sheet view.
- engine: view.showOutlineSymbols {on?} (Ctrl+8), undoable.
- ui: no outline bands while it's off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:54:06 +02:00
Hamza 99f92f4a28 range.setValues: parse: false stores a string as the text it is (#2255)
Every string in `values` went through typed input, which reads it with en-US
rules, and nothing said so:

    "05.04.2026"  (dd.mm.yyyy)  → the date 4 May 2026
    "02/11/2026"  (dd/mm/yyyy)  → the date 11 February 2026
    "2.500"       (thousands)   → the number 2.5
    "4.25 %"      (text)        → 0.0425, formatted 0.00%

A workbook built from Turkish-format exports therefore held different values
than the script put in it, and the only way to notice was to read the saved
file back cell by cell.

`parse: false` stores every string as the text it is. A formula is still a
formula — `=` means a formula in a cell, and the ticket asks for that — and
numbers, booleans and null are unaffected either way. A leading apostrophe is
part of the text rather than the marker that makes it text, since nothing is
being marked.

The default is unchanged: `parse` is absent in every existing call, and those
calls behave exactly as before. A `parse` that is neither true nor false is a
usage error rather than a quiet default.

The test pins both sides — the swapped date, the decimal point and the
percentage under today's default; each of the six strings stored verbatim with
`parse: false`, with `=1+1` still calculating 2; numbers and booleans either
way; and the refused `parse` values. Without the change the first string comes
back as a date.
2026-10-22 18:05:53 +05:00
prxnvt 38ad2dba83 Merge main into #2144
# Conflicts:
#	crates/engine/src/tests.rs
2026-10-22 02:37:04 -04:00
prxnvt 3eed81318f Merge main into #2151 2026-10-22 02:36:09 -04:00
prxnvt faff24f3aa Merge main into #465
# Conflicts:
#	crates/engine/src/io.rs
2026-10-22 02:36:09 -04:00
prxnvt 66f7993ba5 Merge main into #462
# Conflicts:
#	crates/engine/src/lib.rs
#	crates/engine/src/tests.rs
#	docs/architecture.md
2026-10-22 02:30:25 -04:00
prxnvt 045cf428f5 Engine: a column header made by a formula matches its token while formulas are shown
`col("Amount")` read the header through the cell's display text, which is the formula while
Show Formulas is on, so a header such as ="Amo"&"unt" matched nothing. The header is now the
cell's value as formatted, whatever the sheet shows.
2026-10-22 02:26:32 -04:00
prxnvt 6312c7cb40 Engine: only hidden names are read as macro storage
A visible workbook-scope name such as `_gc_macro_0_0` matched the storage key and was read as a
macro and removed when the workbook opened. Macro storage is written hidden, so only hidden names
are read back; a person's own name of that shape stays a name.
2026-10-22 02:26:32 -04:00
prxnvt 0ea5d3ee33 Engine: a Name: token reaches a defined name that holds periods
`Name:` read a name only up to the first period, so `Name:Sales.Q1` looked up `Sales` and
treated `Q1` as a method, and a workbook name called Sales.Q1 could not be used in a macro,
alone or as `Name:Sales.Q1.cell(1,1)`.

The name now runs through periods, and the longest defined name or table that exists and ends
at a period (or the end) is used; what follows is read as methods. So `Name:Data.row` is a
name called Data.row, and `Name:Sales.offset(1,0)` still moves Sales. When no name matches,
the error names the text before the first method, and a bad method after a dotted name is
still refused. Tests cover direct resolution, reference parameters, `$ref` and `{{...}}`,
with and without a chained method.
2026-10-22 02:26:32 -04:00
prxnvt 3c74dfcbb0 Engine: renaming macros with clashing names on open takes near-linear work
Opening a workbook gave each clashing macro name a suffix by trying " (2)", " (3)"... in turn,
and each try scanned every macro accepted so far. A file with 1,000 macros of the same
255-character name made about 300 million name comparisons.

Load now keeps a set of the names in use (ASCII case-insensitive, as find compares them) and,
for each shortened name and suffix width, the next suffix to try, so each suffix is looked up
about once. The result is unchanged: the smallest free suffix, within the name length limit,
and the renamed macro still passes the final checks. unique_name keeps its signature. A test
opens, saves and reopens 1,000 same-name macros in JSON and xlsx and keeps them all, and
another counts lookups instead of timing them.
2026-10-22 02:26:32 -04:00
prxnvt 06bbcb504a Engine: macro approvals are keyed on the macro's canonical JSON, not a 64-bit hash
Approvals were stored as (document, FNV-1a hash of the macro's JSON).
FNV-1a is not collision resistant, so a crafted workbook could hold a
harmless macro and a different one with the same hash, and approving
the first would let the second run without asking.

The key is now the macro's canonical JSON text itself (object keys
sorted), so two different macros never share an approval.
macros::hash is replaced by macros::fingerprint.
2026-10-22 02:26:32 -04:00
prxnvt 77150be455 Engine: col("Header") is refused on a range without a header row
col("...") always matched against the first row of the range, whatever
its header flag. On an input declared without a header row,
Input.col("Amount") silently picked the first column whose first data
cell read "Amount". After .data, the natural Input.data.col("Amount")
failed, because the first row was then a data row. The header row is
now carried through data, and col("...") is refused with a message when
there is none (an input without a header, a Name: base, or after any
other method).
2026-10-22 02:26:32 -04:00
prxnvt 7d31c5a4ed Engine: a macro in a newer format is dropped with a warning, not saved back as version 1
Opening a workbook accepted a macro whose format version `v` was above MACRO_VERSION. Serde
dropped the fields this build doesn't know, and the next save wrote the reduced macro back
with `v: 2`, so a newer build would read it as a complete version 2 macro.

A version above MACRO_VERSION is now refused by Macro::check_limits, and parse_macro checks
the raw `v` before reading the steps. Such a macro takes the existing per-macro path: it is
left out with the usual single warning (which now names a newer format), and the rest of the
workbook opens. Tests cover JSON and xlsx hidden names, with current and missing versions as
controls.
2026-10-22 02:26:32 -04:00
prxnvt b51d588cac Engine: macros can't open, hide or show windows
The policy denies window.* commands, but the View tab's window commands
are view.*: a macro could run view.newWindow, which adds a window that
the run's rollback doesn't remove, or view.hideWindow and
view.unhideWindow, which change the session's hidden windows.

The policy now denies those three. Arrange All changes nothing in the
engine and Switch Windows only lists windows, so both stay allowed.
2026-10-22 02:26:32 -04:00
prxnvt ac7c1ae639 Engine: a reference on a sheet named like a token stays a reference
is_token looked only at the leading identifier, so on a workbook with a
sheet called Output (or Row, Column, or the name of a declared input),
"Output!A1:B2" was read as the Output token, and the step failed with
"the macro has no output anchor" or "unexpected '!A1:B2'". This hit
reference keys, $ref and {{...}} alike, including references the
recorder writes for other sheets. A base followed by '!' is now a sheet
name, and the text resolves as a plain reference.
2026-10-22 02:26:32 -04:00
prxnvt bf456c9ff8 Engine: a macro's footprint dry runs keep to the run's timeLimit
footprint always ran its dry run with the default 60 second limit, so
macro.run with timeLimit 5 could run for over two minutes (a dry run,
then another after a shift) and fail with "ran longer than 60 seconds".

footprint now takes the run's clock and limit. make_way starts the
run's clock before the dry runs, so the checks and the run share one
limit, and macro.check takes timeLimit too.
2026-10-22 02:26:32 -04:00
prxnvt 709a995290 Model: one damaged macro no longer stops a JSON workbook opening
Workbook::macros was read straight into typed macros, so one entry this
build couldn't read (a step type from a later build, an unknown
onConflict value, an entry that isn't an object) failed the whole file
with "not a GridCraft JSON workbook". The field is now read as raw
entries into Workbook::macros_read, and macros::load reads each one
with parse_macro, as for xlsx: short-form steps are accepted, the depth
limit is checked before deserializing, and a bad entry is dropped with
the same warning. Only the first 1,000 entries are read, as in xlsx.
2026-10-22 02:26:32 -04:00
prxnvt 4c6962a97c Engine: a partial macro footprint no longer clears later writes; the run checks each step
The footprint dry run stops at an approved file step and at Solver, but
make_way took a partial footprint with no conflicts as proof that the
run was safe, also after a shift. A macro that exported a CSV and then
set B1 ran under onConflict fail or ask and overwrote B1 without asking.

When the footprint is partial and the rule is not overwrite, the run's
machine now checks every step as it runs: a step that writes over
cells, sheets or objects that held data when the run started stops the
run and rolls it back, with a confirmation under ask and an error
otherwise. make_way now returns the machine for the run along with
whether it shifted.
2026-10-22 02:26:32 -04:00