Files
improvise/context/code/cli.md
T
Claude (cmdlang) 8fa504fbdc Merge gh/main into fix/31oe-cmdlang; renumber cli.md §13/§14 to §14/§15
#321 added its own cli.md section 13, so this PR's sections become 14 (pipe
quoting, surplus arguments) and 15 (names the document lacks), with every
citation renumbered. The stale descriptions of the deleted unknown_* helpers in
cli.md, command.md and the test evidence now describe RequireKnown, and section
15 says a refused name stops the rest of a `:` line but not a headless one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-10-02 22:00:38 -07:00

541 lines
32 KiB
Markdown

# The headless CLI
`improvise cmd` and `improvise script` are one function — `run_headless_commands`
in `src/main.rs`; `run_headless_script` reads the file and delegates. Every claim
below therefore holds for both, and a fix to one is a fix to both by
construction rather than by remembering.
## 1 — the status line is the only channel, so it must be printed
`App` has no return channel for "what happened". A command's outcome — a
refusal, a warning, a `fit-measure`'s reproducing seed — is written to
`ViewState::status_msg` and rendered by the TUI's bottom bar. Headless, nothing
read it, so **success and refusal were byte-identical: silence and exit 0**
(improvise-q914, improvise-j6x8). Two things followed that are worth keeping
in mind, because both were mistaken for correct behaviour by agents working
above this layer:
- A P1's carefully-worded refusal can be written, tested at the effect, and
still reach nobody, because the test asserts the *effect's* status and the
surface that would show it drops it. That is this repo's characteristic
defect one layer out, and the only cure is a test at the process boundary —
`tests/headless_cli.rs`, which spawns the real binary.
- `improvise cmd 'fit-measure …'` looked like it did nothing whether it worked
or not, which made the whole GP feature read as broken from a script.
The loop takes the message with `std::mem::take` and clears it per command, so
one command's status cannot be reported twice or attributed to its successor.
## 2 — stderr, all of it, and why no split was possible
The tempting design is stdout for a report and stderr for a refusal. **It
cannot be built honestly**, because `status_msg` is a bare `String` with no
severity, and both candidate classifiers are disproved by code in the tree:
- *"A refusal is a batch that aborted."* Only three effects set
`abort_effects` (gotcha 21). `AddItem` writes `workbook.add_item`'s `Err`
straight to the status and does not abort — and that is q914's own worked
example of a refusal. So this classifier puts a refusal on stdout labelled a
success.
- *"A refusal is anything that spoke."* `cmd/fit.rs` sets a status on
**success**, carrying the seed that reproduces the fit. So this one labels a
deliverable a failure.
Guessing between them would be precisely the adjacent-check defect
`context/design-principles.md` §7 names. So everything goes to stderr: it is
the diagnostics stream, it leaves stdout free for a future data output, and it
matches every other diagnostic `main.rs` already emits (`Saved to …`,
`warning: …`, `Parse error: …`).
**No `--quiet` flag.** Silence is never the default and `2>/dev/null` is the
standard suppression; a flag would be a second knob with nothing asking for it.
## 3 — the exit code (superseded by §10)
This section used to say: exit 1 on a parse error or on
`App::last_batch_aborted()`, and a refusal that only writes a status exits 0,
because closing that needed a severity beside `status_msg` — a `ViewState`
change, and so a wire change (gotcha 25). Both halves were wrong in a way worth
keeping. The abort half made a *success* exit 1 (improvise-c5ip), and the
severity half named the wrong home: the question an exit code asks is about one
batch, not about a message, so it belongs beside `batch_aborted` on `App` and
never reaches the wire. §10 is what replaced it.
## 4 — a run that changed nothing does not write
`persistence::save` used to run unconditionally, so `improvise cmd` normalised
the document on every invocation — `1e20` expanded to twenty digits. The cost
was not cosmetic: an agent editing a fixture by string-replacing a value after
one run got a silent no-op, and two misleading test results out of it.
The gate is `changed`, accumulated from `model_state.dirty` **after each
command**, not read once at the end. Reading it at the end is wrong because
`effect::Save` clears the flag, so `[add-item …, w other.improv]` would look
unchanged and drop the edit from the `-f` document. `!path.exists()` is or-ed
in so that naming a new file still creates it.
Over-approximation is the safe direction here, the same one `Dirty::of` takes
(gotcha 35): a `Model(_)` effect that happens to write the value already there
still costs one redundant save.
## 5 — the refusal names the word the user typed
`CmdRegistry::resolve` maps `formula` → `add-formula` *before* the arity check
runs, so every validator that spelled its own name reported a command nobody
typed (improvise-z05h). The fix moves the name to the one place that knows
which of the two the user wrote: `require_args` no longer takes a `word`, and
`CmdRegistry::parse` prefixes `"{typed}: "` onto any `Err`.
That removed 41 name literals from `cmd/`, which is the same argument gotcha 15
makes about `registry.rs` having no name literals — a name spelled twice can be
spelled wrong once. `interactive` deliberately does **not** prefix: `dispatch`
in `command/keymap.rs` already formats `"{name}: {e}"` with the *bound* name
(improvise-55l5, PR #126), and prefixing in both would double it.
The message states the **shape** rather than the arity, because
"requires 1-2 argument(s), got 6" does not tell anyone to quote anything.
## 6 — the help's ex-commands were the unguarded surface
`help.rs` derives `HelpLine::Keys` rows from the keymap and drops any row whose
command does not resolve, so a key row cannot go stale. `HelpLine::Literal` (an
ex-command only, since improvise-wk18: built by `ex!`) and
`HelpLine::Accent` rows are hand-written prose and had nothing checking them —
which is how all three documented spellings of `:formula` came to be ones the
parser refuses.
`every_ex_command_the_help_pages_show_is_one_the_parser_accepts` closes that
surface rather than the one instance: every help line whose trimmed text opens
with `:` is split on its colons and each piece handed to `parse_line`. The
split exists because one row shows two examples side by side.
What it still cannot see, and should be closed if the welcome text moves again:
`draw.rs::draw_welcome` builds its lines as a local array inside a ratatui
render function, so nothing can walk them. Its `:formula` line was corrected by
hand.
## 7 — a script's command is built against the cursor it will run against
`improvise cmd` and `improvise script` used to resolve every command through
`CmdRegistry::parse` — the **pure** arm, which builds `CursorState::default()`:
row 0, col 0, and a grid of zero rows and zero columns. The interactive arm,
which builds `CursorState::from_ctx(ctx)`, was never reached from a script.
So every `Move` (`move-selection`, `scroll-rows`, `page-scroll`, the four
`jump-*`) and `enter-advance` snapshotted a zeroed cursor at **parse** time,
clamped to `(0, 0)`, and emitted `SetSelected(0, 0)`. That is worse than a
no-op: it *resets* the cursor. Nothing could position it, so every
cursor-reading command — `clear-cell`, `yank`, `paste`, `cycle-axis-at-cursor`,
`hide-selected-row-item`, `edit-or-drill`, `drill-into-cell`,
`toggle-group-under-cursor`, the three `delete-*-at-cursor`,
`commit-and-advance` — was pinned to the origin (improvise-6uva). It was
introduced with `CursorState` and the two-arm registry in April and predates the
browser work by five months.
**A registry with two construction arms silently demotes every caller of the
weaker arm, and nothing type-checks which arm a caller deserves.** That is the
shape to remember, and it has a second instance in the same fix: the
`register_pure` entries had an interactive arm that refused **empty** args while
their pure arm accepted them, so `:w` (save to the current path) parsed one way
and refused the other. Both arms are now `parse`, which is what "needs no
interactive context" was supposed to mean;
`a_pure_commands_two_arms_agree_on_no_arguments` is the guard.
The repair is `parse::segments` plus `parse::parse_segment_interactive`, and the
loop re-parses **per segment**, because command *n* must see command *n-1*'s
cursor — which means construction cannot be hoisted out of the loop. Line
atomicity is preserved by keeping the existing whole-line `parse_line` as a
**syntax gate** before anything in the line runs: a line that parsed before
still parses, and a line that did not still does not, so the only behavioural
change is which cursor the commands see.
**That atomicity claim was false as written** (improvise-2bac), and §8 is what
makes it true. The gate runs the pure arm; the loop runs the interactive arm. A
gate is only a gate if *every argument list the pure arm accepts, the
interactive arm accepts too* — and `commit-cell-edit` broke it:
`add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document
passed the gate, applied the first segment, and then refused the second with
`no cell at cursor`. Half the line landed.
The TUI's `:` line had the same defect and is fixed the same way, with one
limitation that is not new: `ExecuteCommand` returns one effect batch, so the
segments of `:a . b` all see the pre-batch cursor, exactly as a
`Binding::Sequence` does (gotchas 21 and 23).
## 8 — the interactive arm is `parse`, then the cursor; it is never a second parser
#151 fixed three instances of *a registry with two construction arms demotes
whichever caller reaches the weaker one*, one patch each. A post-merge review
found the fourth (improvise-2bac): `commit-cell-edit <mode> <value> <coords>`,
whose interactive arm read only `<mode>`, took the value from the `edit` buffer
and the key from the cursor. So a script that wrote 5 to `Region/East`
**deleted the cell under the cursor** — the empty buffer, committed — with exit
0 and nothing on stderr. The fifth was already in the tree:
`toggle-panel-and-focus <panel> false false` ignored both flags and toggled.
The shape rather than the instances is what changed. `CmdRegistry::register`
used to take two free functions, `parse(args)` and `interactive(args, ctx)`,
and nothing related them; each command re-derived its arguments twice and every
disagreement was silent. The interactive arm is now **built from `parse`**, not
beside it:
- `parse(args) -> C` — the only function that reads arguments a caller supplied.
- `fallback: Option<(args, ctx) -> C>` — reached **only** when `parse` refused.
This is where "no coordinates means the cursor" lives: `clear-cell` bare, and
`commit-cell-edit <mode>` bare, which is what the keymap binds.
- For `register_at_cursor` only, the registry itself then sets the command's
**cursor** — its `CursorState`, reached through `AtCursor::cursor_mut` — to the
live one. That is the whole of what the interactive arm adds to a parsed
command, and no registration supplies it.
Three consequences hold by construction rather than by test. **Whatever `parse`
accepts, the interactive arm accepts** — nothing after `parse` can fail — so the
§7 gate cannot pass a segment the loop then refuses. **An accepted argument list
cannot be overridden by the cursor**, because the only function that sees both
args and context never runs on an accepted list. And **no field `parse` set can
be overwritten from context**, because nothing a registration writes can name
one: the cursor slot is typed `&mut CursorState`, and no command parses a
`CursorState` out of its arguments. `register_pure` and `register_nullary`
already had one arm; they are `register` with no fallback.
That last one is improvise-yf7p, and it is why the registry lost a parameter.
#171 took a third function, `contextualize(C, &CmdContext) -> C`, handed the
built command and not the args, and stated honestly that it could still
**overwrite** a parsed field — naming the field was "a visible line in review".
Review then injected five such overwrites on commands other than the one with a
test (yank, paste and drill-into-cell taking their key from the cursor over the
parsed one; toggle-panel-visibility ignoring its parsed panel; move-panel-cursor
clamping its parsed delta to ±1) and the whole workspace stayed green. A hole
guarded by review discipline plus one command's test is not guarded. Every
contextualizer in the tree fell into one of two shapes, and each had an answer
that removes the function rather than checking it:
- **The cursor** (`Move`, `EnterAdvance`, `CommitAndAdvance`). Always one
`CursorState` field, always `CursorState::from_ctx`. So the registry does it,
through a slot whose *type* is the guard: an `AtCursor` impl pointing at a
`CellKey` or a delta is a compile error, which was tried.
- **A read of context that belonged in `execute`** (`toggle-panel-visibility`'s
`currently_open`, `cycle-panel-focus`'s three flags, `move-panel-cursor`'s
cursor and bound). These copied context into the command only to read it back;
`execute` now reads `ctx` directly, the same answer `toggle-panel-and-focus`
got below. Those three are `register`/`register_nullary` now, so their
interactive arm is exactly `parse`.
What `execute` does with a parsed field is still the command's own logic and is
tested as such — `toggle_panel_visibility_reads_the_panel_it_names` and
`move_panel_cursor_moves_from_the_named_panels_own_cursor` fail if the panel or
the delta is ignored there. The difference is that the registry is no longer a
second place such a bug can be written.
What is still representable, stated rather than hidden:
- A `fallback` sees the args `parse` refused and could ignore some of them.
`commit-cell-edit`'s refuses anything but a lone mode, so the malformed
`commit-cell-edit editing 5` is an error on the `:` line — which has no pure
gate — rather than a commit of the edit buffer to the cursor cell.
- The gate is **stricter** than the loop wherever a fallback exists: a bare
`clear-cell` is refused in a script. That is the safe direction — the whole
line is refused — and it is unchanged from before #151.
- A new command could declare a *second* `CursorState` field and parse it from
args; `cursor_mut` could then point at the wrong one. No command has one, and
a parsed cursor would be a new idea deserving its own argument.
`toggle-panel-and-focus` did not fit #171's `contextualize` either, because its
pure arm defaulted the omitted flags to `true` and a contextualizer cannot tell an
omitted flag from an explicit one. So the omission is now in the type —
`target: Option<(bool, bool)>`, `None` meaning *toggle* — and the toggle is
resolved in `execute` against the same context. That is the honest home: the
decision depends on context, and `execute` is where context is.
Guards: `every_argument_list_the_gate_accepts_the_run_accepts_too` enumerates
**every registration** against every candidate argument list, in a context whose
cursor sits on no cell — the context in which the old `commit-cell-edit` arm
refused. It lists no commands, so a new registration is covered the day it
lands. It is backed by the process-level tests in `tests/headless_cli.rs`.
## 9 — the extent is fixed, because it reaches the document
A headless run used to read `crossterm::terminal::size()`, and the page size
derived from it decides where `page-scroll` lands and therefore which record the
next cursor-reading command writes (improvise-up8g). So one script wrote a
different document in a different window, and with no tty — CI, cron — the read
failed and left `App`'s default, so CI and a shell disagreed too. It was
invisible until §7 let a script see its cursor.
`HEADLESS_EXTENT` in `main.rs` pins it to 80x24 — the size CI already had, so
nothing that passed there moves. There is **no flag** to change it, for §2's
reason: a second knob with nothing asking for it. A script that wants a
particular row says so with `move-selection` or `jump-*`, which do not depend on
any window.
What this does not close: the **TUI** still takes its extent from the terminal,
and that is correct there, because the window really is an input to an
interactive session. The ticket's wider claim — that a replayable log needs the
extent in it (improvise-ilvl) — is about that log, which does not exist yet; a
headless run has nothing to replay *except* its script, so pinning is the whole
fix for this surface.
## 10 — the exit code asks whether the change happened, never whether a chain stopped
`App::last_batch_aborted()` answered the wrong question for an exit code, in
both directions at once.
- **A success that exited 1** (improvise-c5ip). `commit-cell-edit` ends in
`EnterAdvance`, which emits `AbortChain` when the cursor is already
bottom-right or the grid is empty: *the advance's premise no longer holds*.
The write had already landed and was saved, and the run exited 1 with
nothing on stderr. Before §7 that was every scripted commit.
- **A refusal that exited 0** (improvise-udt2). `AddItem` into a missing
category writes the error and does not abort, because nothing after it
depends on the item. So the flag never saw it.
An abort is a statement about the **chain**; a refusal is a statement about the
**request**. `AbortChain` stops a chain without refusing anything, and
`AddItem` refuses without stopping anything. The two coincide only for
`AddCategory` and the file writes, which is why reading one for the other
looked right for so long.
So `App` carries a second per-batch outcome, `batch_refused`, reset where
`batch_aborted` is and written by exactly one method, `App::refuse(msg)`, which
sets the status and the flag together — a refusal cannot write its message and
forget the flag. Commands reach it through `effect::refuse`, the `Refuse`
effect, beside `effect::set_status` for reports. `main.rs` exits 1 on
`last_batch_refused()` and no longer reads `last_batch_aborted()` at all.
**Why not a severity on `status_msg`**, which udt2 proposed. A severity on the
message would live in `ViewState`, so it would be portable state sent to every
client in a `Snapshot`, and it would go **stale**: every one of the ~60 writers
of `status_msg` would have to reset it or leave the previous message's severity
in place. A per-batch flag cannot go stale, because `apply_effects` clears it
before every batch, and it is not in `ViewState`, so nothing on the wire
carries it or has to agree with it. The TUI colouring the ticket hoped for is
still possible later, and would want the severity *on the message*; that is a
separate change with a separate staleness problem.
**The classification is per writer.** Every refusal in `ui/effect.rs`, in
`cmd/{cell,commit,fit,grid,mode,panel,search,text_buffer}.rs` and in
`command/keymap.rs::dispatch` goes through `refuse`. The last of them were
converted in two steps:
- **improvise-i3o5:** `EditOrDrill`'s collapsed-group arm, and the three
spelled-paste declines in `cell.rs` (aggregate, coordinate column, inside a
drill).
- **improvise-cwfj:** the three `cmd/panel.rs` declines, and `dispatch`'s
arg-error arm.
The sweeps that pin refusals revert `refuse(` calls, so they **cannot see a
refusal written as `set_status`**. Finding one takes a read of every
`set_status` on a decline path, and that is how both batches were found.
**A bound key whose command will not build is a refusal** (the orchestrator's
ruling, improvise-cwfj). The key asked for something that did not happen,
which is this section's definition. So `dispatch` answers a constructor's
`Err` with `refuse(e)` rather than `set_status(e)`. That arm is where every
key-path decline lands, including `p`, `x` and `i` onto an aggregate, a fold,
a coordinate column or inside a drill.
Before the conversion, the key path was checked for any reader of the flag:
- `last_batch_refused` is read in production only by `Host::mutating_frame`,
which handles frames and not keys, and by `main.rs`'s headless loop, which
handles `:` lines and not keys.
- `Refuse` is `ViewOnly`, like `SetStatus`, so the `Dirty` a key returns and
the projections built from it are unchanged.
- The browser lockstep suite compares `ViewState`, including `status_msg`,
never the flag, and `Refuse` writes the same message.
The whole workspace stayed green with the change. `q` on a dirty document is deliberately left a
report: headless, its text is false — the CLI saves anyway (improvise-ep5o).
The two rivals udt2 named both stay green: a successful `:w` and a successful
`fit-measure` report through `set_status` and exit 0.
**`remove-formula` of a formula that is not there now exits 1** (improvise-tej1,
improvise-9trl). It used to exit 0: the effect was keyed by names and removing
nothing was a silent no-op. Since tej1, `RemoveFormula` names its formula by
`FormulaId`, and `remove-formula <category> <target>` resolves the two names
to an id in `execute`. When nothing matches it `refuse`s with `No formula for
<target> in <category>`, so a script that removes a formula it never added now
fails where it used to pass. That is the §10 rule applied: nothing changed, so
the request was not met. `remove_formula_of_a_missing_formula_is_a_refusal`
pins it, with a present formula as the control.
## 11 — a headless import refuses colliding records unless told otherwise (improvise-cgqt)
`import --no-wizard` takes `--on-duplicate refuse|sum|separate`, and the
default is `refuse`. The argument is in `context/code/formula-and-io.md`,
"Colliding records are a question, not a default". In short, a default of sum
is the silent outcome the user called critical, and a default of separate
reshapes a scripted document. The refusal prints every colliding tuple through
`WizardCollision::describe`, the wizard's own wording, then the flag. It exits
non-zero and writes nothing. It is checked before the build rather than read
off the build's error, so the listing is structured data and not a parsed
sentence. `separate` uses `ImportPipeline::suggested_duplicate_category`, and
there is deliberately no flag to name the category yet.
## 12 — `import` takes folders, and the CLI only says which arguments are folders (improvise-lpzu)
`improvise import` takes any mix of files and folders. A folder stands for its
`*.csv` files, in name order. A folder always merges, even when it holds a
single CSV, so that file keeps its `File` item and view. A single file named
directly still imports alone (the user's ruling on the PR). The rules
and their reasons are in `context/code/formula-and-io.md`, "A folder of CSVs is
one sparse model, with a view per file". The CLI's part is the one question
`Storage` cannot answer: whether a path is a folder. `get_import_data` asks
`Path::is_dir` for each argument, hands the resulting `ImportPath`s to
`expand_import_paths` over `PhysicalFs`, and from there takes the path a list
of files always took. A folder with no CSV in it is refused before anything is
read. The message names the folder, and the run exits non-zero and writes
nothing.
A merge now reaches the build as an `ImportPipeline` carrying the merged files'
`sources`, rather than as a bare JSON value. Both `--no-wizard` and the TUI
wizard (`run_tui` → `App::start_import`) therefore build the per-file views.
The headless path matters most, because the code-maat pipeline
(improvise-1xfb.5.1) runs there. `a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte`
pins the views, the sparse Data lines and the byte-identical second run, with
the first run's output sitting in the folder it reads.
## 13 — a headless import refuses rather than saving less than was asked (improvise-8jk6.4, 8jk6.5, 8jk6.6)
The user treats the headless command and model layer as a product of its own,
and the scripted `import --no-wizard` is where it meets a pipeline. The
repo-stats pipeline (improvise-1xfb.5.1) found three ways that import saved
something other than what the flags asked for, and still exited 0. The
reasoning for each is in `context/code/formula-and-io.md`, "A CSV field is
text, and the field's role reads it" and "An import refuses what it cannot
keep". The CLI's contract:
- **`--category` keeps the CSV text.** A forced category's items are the
file's text, verbatim: `00565796` stays `00565796`, and `220944e2` stays
`220944e2`. Measures still read as numbers. A JSON number has no source
text, so it is named as JSON writes it (`1e3` is `1000.0`).
- **An unparseable `--formula` is a refusal.** It exits non-zero and writes
nothing. Every bad formula is named on stderr, each with its parse error.
This is §10's rule: the request was not met, so the exit code says so. The
wizard refuses the same text at the formula step, with the same wording.
- **An import that kept no record is a refusal.** It exits non-zero, writes
nothing, and says why: every record was skipped as not a JSON object, or
the source had none.
- **An import that skipped *some* records exits 0 by default.** It writes the
model and warns with a count. With `--strict` (the user's ruling on PR #321)
any skipped record is a refusal instead: exit 1, the same count named, and
nothing written. The argument is in `formula-and-io.md`.
Two refusals bail in `run_headless_import` before the build: the `--strict`
skip check (`src/main.rs:305`) and the `--on-duplicate refuse` collision
check. The rest come from `build_model_with_warnings` as an `Err`. All of
them reach `main` as exit 1 before `persistence::save`. So "writes nothing"
holds by statement order, not by where the refusal is raised. The headless
tests check it by asserting that the output path does not exist.
## 14 — the command language reads `|pipe quoting|`, and a surplus argument is refused (improvise-31oe.3)
`create-view |By area|` used to make a view named `|By` and drop `area|` without
a word. Two separate defects, fixed separately.
**The quoting.** `parse.rs` now has one lexer, `lex`, and everything else is
built on it: `parse_line_with`, `segments` and `parse_segment_interactive`. The
rules are:
- A `|…|` piece is read by `improvise_formula::quote::unescape_pipe_body`, the
one home of pipe quoting (gotcha 34). So `\|`, `\\` and `\n` mean exactly what
they mean in `.improv` and in formulas. The lexer only finds the closing pipe:
a backslash takes the next character with it, so `\|` does not close.
- A piece may sit **anywhere in a word**, and the pieces and bare text are
joined. So a coordinate quotes each half, `|Sales Region|/|East Coast|`, the
way a `.improv` data line quotes each name. The alternative, a pipe only at
the start of a word, would have left no way to spell a coordinate with a
space in it.
- `"…"` is **kept**, verbatim, with no escapes, and a `|` inside it stays a
`|`. It is not a workaround to retire. Formula text carries its own pipes:
`add-formula "Net = |Gross Rev| - Cost"` must reach `parse_formula` with the
pipes on. If the line had pipes only, every pipe in a formula would need
escaping. Its old leniency is kept too: a `"` that is never closed runs to
the end of the line. A `|` that is never closed is refused, naming the text
from the open pipe on, because a name is never meant to run to the end.
- The separator is a **bare** `.` word. A quoted `|.|` or `"."` is an argument,
and a `.` inside `|a . b|` does not split the line. The old `split_on_dot`
was a second scanner that knew double quotes and not pipes. With one lexer
there is no second scanner to disagree.
- A comment line (`#`, `//`) is not lexed, so an unmatched pipe in a comment is
not an error.
`segments` now returns `Result`, because an unterminated pipe is a fact about
the whole line. `main.rs` refuses the line before running any of it, as §7 does
for a parse error, and `ExecuteCommand` refuses it on the status line.
What is still not spellable: an item whose name contains `/`. The coordinate is
split on the first `/` **after** unquoting, so `|a/b|/x` is still read as
category `a` and item `b/x`. Closing that needs the lexer to hand structured
coordinates to the commands, which is a larger change than this ticket.
**The arity.** Every registration now declares `MaxArgs::AtMost(n)` or
`MaxArgs::Any` beside its prototype. `CmdRegistry::parse` and
`CmdRegistry::interactive` apply it after construction succeeds. So the rule is
written once and every command gets it, and a new registration cannot compile
without a declaration. The refusal names the command, the limit and every
surplus argument (`create-view: takes at most 1 argument(s), got 2; unexpected
"area"`). A headless run exits non-zero through the §7 parse gate, and the `:`
line refuses on the status line.
It runs **after** the command's own parse, deliberately. A command whose own
refusal states its shape keeps it: `add-formula`'s "quote the formula"
(improvise-z05h) is more use than an arity, and it is what `formula Product
Total = Widget + Gadget` still says.
`MaxArgs::Any` is a claim, and the list of commands making it is short:
`add-items`, `set-cell`, `set-format` (it joins its words), `clear-cell`,
`yank`, `paste`, `drill-into-cell` and `commit-cell-edit`. Each takes a list of
items or coordinates. `no_command_silently_drops_an_argument_it_was_given` names
that list, so widening another command to `Any` has to add it there.
## 15 — a command that names something the document lacks is refused (improvise-31oe.2)
`set-axis Regoin row`, `set-page Region Nowhere` and `set-axis Region sideways`
all exited 0 and said nothing. §10's rule applies: nothing changed, so the
request was not met.
- **An unreadable axis is an argument error.** `set-axis` now checks its axis
with `parse_axis` when it is parsed, so a script is refused before any of it
runs. `execute` still refuses an axis it cannot read rather than returning
nothing. That arm cannot be reached through the registry.
- **An unknown category, item, group or view is a refusal**, from `RequireKnown`
(below), because only the document knows what it holds. `effect::unknown_name`
asks the active view (`try_axis_of`) for a category. That is exactly the
condition under which `View::set_axis` was a silent no-op. It asks
`effective_item_names` for an item, so a formula target is a known `_Measure`
item. It asks both an item's own group and the category's `groups` for a
group. `add_item_in_group` registers no `Group`, and a parent group has no
items, so either one alone misses a real group. A view is asked of
`workbook.views`.
The siblings fixed with it: `hide-item` and `show-item` (hiding an unknown item
wrote it into `hidden_items`), `toggle-group`, and `switch-view` (the effect
ignores a view it lacks, and says nothing). `set-page` with an unknown item was
worse than a no-op: it wrote the name into `page_selections`.
The check runs at **apply** time, as an effect of its own. `execute` runs
against one context built before the line ran, and a `:` line builds every
segment up front, so a check in `execute` refused
`create-view Second . switch-view Second` and
`add-item Type Tools . set-page Type Tools` after their first halves had landed,
while `improvise cmd` (which rebuilds the context per segment) accepted them.
Each of these commands therefore returns `RequireKnown(needs)` followed by the
real effect. `RequireKnown` refuses and aborts the batch (gotcha 21), so the
real effect never applies. It is a `ViewOnly` effect that writes nothing and is
off the wire on purpose: the wire's own effects (`model_effects`, gotcha 38)
stay non-refusing, since a refusing effect there becomes a frame rejection
(`MutationRefused`). That is arguably right, but it changes the protocol, and
a browser composes these only from names it was sent. It is left for a ticket
of its own.
A refused name stops the rest of a `:` line, because the abort ends the one batch
the whole line runs as. Headless runs each segment as its own batch, so an abort
never crosses a `.` there: `:set-page Type Nowhere . add-item Type X` adds
nothing, while `improvise cmd` on the same line exits 1 and still adds `X`.
`load`/`Save` already behaved this way. The two front ends' chain semantics are
left for a ticket of their own.
The interactive cursor fallbacks of `clear-cell`, `yank`, `paste` and
`drill-into-cell` answer only for coordinates that were OMITTED
(`only_when_omitted`). Coordinates that were given and are malformed are
refused, never replaced by the cursor's cell.