Commit Graph

53 Commits

Author SHA1 Message Date
claude@clouddev1 6b8888f105 grammar+db: 3h — UPSERT ON CONFLICT DO NOTHING / DO UPDATE (ADR-0033 §9)
on_conflict_clause on SQL_INSERT_SHAPE: optional (col,…) conflict
target (distinct conflict_target_column role so it never enters
listed_columns), DO NOTHING / DO UPDATE SET … [WHERE …]. `do` is
factored out of the action Choice so nothing/update disambiguate
without tripping the walk_seq/walk_choice shared-prefix trap
(ADR-0033 Amendment 1). Worker runs the UPSERT verbatim (SQLite
native); no new execution path.

build_sql_insert: row_source now stops before the FIRST trailing
clause — ON CONFLICT (3h) or RETURNING (3g) — and do_sql_insert's
shortid auto-fill rewrite re-appends the whole trailing tail, so an
auto-filled INSERT keeps its ON CONFLICT / RETURNING.

excluded pseudo-table (§9): resolves to the target's columns inside
the DO UPDATE action and completes at `excluded.|`, but stays flagged
as unknown_qualifier in VALUES / RETURNING / non-upsert statements.
Diagnostic pass scopes it by the DO UPDATE byte-range (update token →
RETURNING/end); completion resolves it against the INSERT target's
current_table_columns. NOTE: scoping uses byte-range rather than the
plan's prescribed from_scope TableBinding push — same behaviour, no
walker scope-frame change.

Tests (+13): grammar accept/reject; DO NOTHING / DO UPDATE-excluded /
no-target execution + persistence; auto-fill × ON CONFLICT with a
REAL unique conflict (proves the clause survives the rewrite, not a
no-op); excluded resolves in DO UPDATE SET + WHERE, flagged in VALUES
(incl. same statement), unknown column under excluded; excluded.|
completion; conflict-target not in listed_columns. 1576 pass / 0 fail
/ 1 ignored. Clippy clean. Dev sql_insert entry word still removed in
3j.

Known follow-up (tracked for 3i): UPSERT DO UPDATE bare column refs
(SET LHS / WHERE) are not schema-validated, unlike regular UPDATE —
the INSERT target isn't a diagnostic binding. Fits 3i's cross-cut
SET/WHERE validation scope.
2026-05-22 21:28:24 +00:00
claude@clouddev1 fd8b74ba5e grammar+db: 3g — RETURNING on INSERT/UPDATE/DELETE (ADR-0033 §5)
Shared RETURNING_CLAUSE (reuses Phase-2 PROJECTION_LIST, now
pub(crate)) as an optional tail on all three SQL DML shapes.
`returning: bool` on the Command variants, set by the ast-builders
and threaded to the worker. run_returning collects the returned rows
as a DataResult (RETURNING mutates + yields in one pass), reusing
resolve_select_column_types for bare-column type recovery; computed
projections stay typeless. DeleteResult gains a `data` field rendered
alongside the cascade summary.

Follow-set fix: `returning` is added to the table-source and
projection bare-alias follow-sets so an INSERT … SELECT row source
stops before RETURNING instead of reading it as a table alias.

Auto-fill × RETURNING: build_sql_insert stops row_source before the
RETURNING token (keeping it preparable for shortid materialisation),
and plan_shortid_autofill re-appends the RETURNING tail so generated
shortids surface in RETURNING *.

Tests (+17): grammar accept on all three; INSERT/UPDATE/DELETE
RETURNING incl. *, aliases, multi-row, type recovery + computed-
typeless; auto-fill × RETURNING (single + multi-row distinct ids);
INSERT…SELECT…RETURNING execution; UPDATE…RETURNING zero-match;
DELETE…RETURNING cascade+rows; app-level render of both. Dev
sql_insert/sql_update/sql_delete entry words still removed in 3j.
1562 pass / 0 fail / 1 ignored. Clippy clean.
2026-05-22 20:44:55 +00:00
claude@clouddev1 2c86a1313e grammar+db: 3f — SQL DELETE + cascade summary (ADR-0033 §1/§7)
New src/dsl/grammar/sql_delete.rs (FROM <table> [WHERE] [;]),
Command::SqlDelete, Request::RunSqlDelete, do_sql_delete worker.

do_sql_delete mirrors the DSL do_delete: detect FK cascade by
before/after child row-count diffing, re-persist target + every
cascade-affected child, history-on-success inside the tx. Reuses
CommandOutcome::Delete -> handle_dsl_delete_success, so the
per-relationship cascade summary formatter is shared, not duplicated.

ADR-0033 Amendment 2: supersedes §7's WHERE-injected pre-count. Its
premise (DSL handler builds pre-counts from the typed Expr) was wrong
— do_delete uses count-diff. The pre-count would also have broken the
§2 parity promise by reporting SET NULL the DSL path doesn't. Count-
diff gives exact parity, no WHERE-byte extraction, and withdraws R2.
SET NULL reporting deferred for both paths (user-confirmed).

Tests: +6 grammar unit, +12 integration (cascade parity with DSL,
both R2 subquery cases, before-execute order, no-WHERE, FK-rejection
rollback, childless-parent, two-child cascade). 1542 pass / 0 fail /
1 ignored. Clippy clean. Dev sql_delete entry word removed in 3j.
2026-05-22 14:59:01 +00:00
claude@clouddev1 53808ed9d7 grammar+db: 3e — SQL UPDATE grammar + execution (ADR-0033 §2)
New src/dsl/grammar/sql_update.rs: SQL_UPDATE_SHAPE =
<table> SET col = sql_expr (',' …)* [WHERE sql_expr] [';'], the
__rdbms_* target rejection, and the shared sql_expr on both the
assignment RHS and the predicate. No --all-rows rail — a SQL
UPDATE without WHERE runs as written (ADR-0030 §12). Reuses
sql_select::WHERE_CLAUSE (now pub(crate)) so the predicate
diagnostics are identical. The target uses the shared `table_name`
ident role (not a bespoke one) so the Phase-2 schema-existence and
predicate-warning passes collect it as a scope binding and check
the SET / WHERE columns for free — a bespoke role left them
unchecked (the cross-cut tests caught this).

Command::SqlUpdate { sql, target_table }; Request::RunSqlUpdate +
do_sql_update (execute validated SQL via execute_with_fk_enrichment,
re-persist the target CSV, append history.log). 3e surfaces the
affected-row count only; precise row output is RETURNING (3g), so
the update-success render skips a column-less data set rather than
showing a misleading "(no rows)" band. Behind the dev `sql_update`
entry word until 3j.

Tests: grammar accept/reject; integration (single/multi-col,
no-WHERE all-rows, sql_expr in SET, scalar subquery in SET,
zero-match success, history); walker cross-cut (unknown SET column
→ unknown_column, `= NULL` in WHERE → eq_null warning); app-level
render-guard both ways (column-less → count only; with columns →
table renders). 1524 green, clippy clean.
2026-05-22 13:57:21 +00:00
claude@clouddev1 78ad476d24 db+grammar: 3d — shortid auto-fill for SQL INSERT (ADR-0033 §6)
When an INSERT's column list omits one or more shortid columns,
the worker now fills them. Command::SqlInsert gains listed_columns
and row_source, captured in build_sql_insert from the matched path
(the row source is located by the first values/select/with Word
token, so a string literal like 'select' can't be mistaken for the
keyword). do_sql_insert calls plan_shortid_autofill, which — per
the user-confirmed Option B — materialises the row source by
running it as a query, generates a distinct shortid per row via the
existing generate_shortid_batch (deduped against stored values),
and reconstructs a parameterised multi-row INSERT over the listed
columns plus the omitted shortid columns. Uniform for VALUES and
INSERT…SELECT, and handles multiple omitted shortids in one row
(each gets its own batch). No explicit list, no omitted shortid, or
a zero-row source → execute verbatim (the 3b path). serial stays
engine-filled via rowid. history.log keeps the original line, never
the rewrite (§11).

Tests: VALUES single/multi-row distinct; explicit override
honoured; INSERT…SELECT distinct fills; combined serial(engine) +
shortid(worker); two shortids (PK + non-PK) both fill; one provided
+ one omitted; compound-PK shortid member; mixed-case column name
(ADR-0009 DA gate); original-source-in-history on the rewrite path.
Still behind the dev `sqlinsert` entry word (3j). 1503 green,
clippy clean.
2026-05-22 07:26:54 +00:00
claude@clouddev1 6ff9144c7a grammar: 3c — INSERT … SELECT row source (ADR-0033 §4)
Make the INSERT row source a Choice between the VALUES clause and
Subgrammar(&sql_select::SQL_SELECT_COMPOUND). SQL_SELECT_COMPOUND
is itself a Choice that admits a leading WITH, so a WITH-prefixed
SELECT row source (R4) parses through it for free; the two
branches start on disjoint keywords (values vs select/with) so the
Choice never ambiguously commits. No worker change — do_sql_insert
already executes the validated SQL and re-persists, and the engine
handles insert-from-query.

Tests: grammar accept (plain / column-list+projection / WITH-
prefixed / trailing-semi) and reject (__rdbms_* on the SELECT's
FROM slot, incomplete select); integration parse-path lowering +
worker round-trip (rows land, CSV re-persisted) incl. R4 WITH end-
to-end; walker cross-cut that the Phase-2 unknown_column diagnostic
fires on the INSERT…SELECT projection; DA-gate test that a self-
sourced INSERT…SELECT runs as a plain insert (no cascade summary —
that is DELETE-only). Still behind the dev `sqlinsert` entry word
(shared `insert` is 3j). 1493 tests green, clippy clean.
2026-05-21 22:08:25 +00:00
claude@clouddev1 c87363168f grammar+db: 3b — SQL INSERT grammar + minimal execution (ADR-0033 §1)
SQL_INSERT_SHAPE (INTO <table> [(cols)] VALUES tuple(s)) with __rdbms_*
target rejection; Command::SqlInsert{sql,target_table}; Request::RunSqlInsert
+ do_sql_insert worker (tx-guarded: execute, then finalize_persistence for
CSV + history before commit, so failures roll back and don't re-persist).
Auto-show is best-effort via last_insert_rowid range.

Isolated behind a dev `sqlinsert` entry word (Advanced) so the SQL path is
testable without making `insert` a shared word yet (that's 3j, after 3d
auto-fill parity). Command::SqlInsert carries only sql+target_table; the
plan's listed_columns/returning land in 3d/3g where they're read.

6 grammar accept/reject tests + 8 integration tests (single/multi-row,
column-list, full-arity, history, rollback-on-failure, multi-row atomicity,
parse-path reconstruction, internal-table rejection). 1452 baseline green.
2026-05-21 18:51:21 +00:00
claude@clouddev1 4e16d97fe0 walker: 3a — category-grouped mode-aware dispatch (ADR-0033 Amendment 1)
Replaces ADR-0033 §2's original Node::Guard + Choice(SQL,DSL) mechanism,
which was found during 3a to be unworkable: any guard-in-Choice approach
forces a walk_choice change (walk_choice falls through only on NoMatch, so
simple-mode valid-DSL would wrongly surface "this is SQL"), and walk_seq
treats a NoMatch past idx 0 as a hard Failed, breaking advanced-mode DSL
fall-through.

Mechanism (Amendment 1): each REGISTRY entry is tagged
CommandCategory::{Simple, Advanced}, generalising the whole-command
is_advanced_only gate. walk() becomes a thin dispatcher over decide()
(mode-aware candidate selection: simple commits the DSL node or emits the
"this is SQL" hint; advanced tries SQL first, DSL as a full-line fallback)
and an extracted walk_one_command(); speculative match-testing runs on a
scratch WalkContext so the caller's context is only touched by the
committed walk. No Node::Guard, no walk_choice/walk_seq change.

6 dispatch smoke tests on a shared-entry-word smoke registry; 1446 baseline
green; clippy clean.
2026-05-21 18:18:50 +00:00
claude@clouddev1 fd259048da grammar: admit WITH inside subqueries / CTE bodies (ADR-0032 §10.3)
ADR-0032 §10.3 says cte_bindings lives on the scope frame, with
inner subqueries free to declare their own CTEs that shadow outer
ones. The grammar didn't actually admit nested WITH inside
SQL_SELECT_COMPOUND — a real ADR-vs-implementation gap.

Closes the gap by making SQL_SELECT_COMPOUND a Choice between a
WITH-prefixed form and a plain form. The naive Optional-prefix
approach silently broke the paren-vs-subquery dispatch in
sql_expr.rs's PAREN_GROUP: Optional matches 0 bytes, committing
the Seq, so SELECT_CORE's NoMatch on `(a + b)` became Failed and
the Choice couldn't fall through to or_expr. The Choice-fronted
form keeps the fast NoMatch on non-WITH non-SELECT first tokens.

Side effect: scalar subquery / IN / EXISTS / derived-table
bodies now admit a leading WITH too, which matches standard SQL.

Updated two tests that were guarding the old `(WITH …)` rejection
behavior. Added one new harvest test exercising nested-WITH inside
a CTE body — the harvest's `expand_binding` mechanism already
handled the data correctly; the grammar gap was the sole blocker.

Test totals: 1414 → 1415 passing (+1 nested-with-in-cte test).
Clippy clean.
2026-05-20 20:34:42 +00:00
claude@clouddev1 c5cf03b152 walker: SQL diagnostics — multi-binding scope, qualified refs, Phase-1 gap closure (sub-phase 2d)
Implements the bulk of ADR-0032 §11 diagnostics. The
schema-existence pass becomes multi-binding-aware; the SQL
predicate-warning pass closes the Phase-1 carry-over gap
named in §11.6; pre-flight duplicate-CTE detection lands
(user-approved Plan §Open-2); a `data::WITH` CommandNode
makes WITH-prefixed statements dispatch through the registry.

Catalog (`src/friendly/strings/en-US.yaml`, `src/friendly/keys.rs`):

- Six new `diagnostic.*` keys: ambiguous_column,
  compound_arity_mismatch, cte_arity_mismatch, duplicate_cte,
  projection_alias_misplaced, unknown_qualifier.
- Eight new `engine.*` translation keys (ADR-0032 §11.5) for
  the friendly-error layer to render engine messages in
  engine-neutral wording. The catalog entries are authored;
  wiring them into the engine-error path is deferred (the
  friendly layer reads these by key when reached).

Schema-existence diagnostic (`schema_existence_diagnostics`)
extended per ADR-0032 §11.2:

- A pre-pass collects all `table_name` / `cte_name` / table-
  alias idents into a `PassBinding` vec + a CTE name list,
  sidestepping the projection-before-FROM ordering problem
  (§10.6). The main pass then resolves identifiers against the
  complete scope.
- Bare column references resolve against any binding's
  columns. Zero matches → `diagnostic.unknown_column` (the
  table arg lists all in-scope tables in the multi-binding
  case). Two-or-more matches → `diagnostic.ambiguous_column`.
- Qualified `t.c` refs detect their qualifier via a look-ahead
  on the matched path (Punct '.' + Ident{role:
  sql_expr_qualified_ref} after the leading Ident). Unknown
  qualifier → `diagnostic.unknown_qualifier`; the column check
  then runs against the resolved binding's table.
- The `t.*` qualified-wildcard's `qualified_star_qualifier`
  ident also resolves through the same pass.
- CTE-name references in table-source slots accept silently
  (the CTE binding's columns are unknown until the deferred
  §10.3 stage-2 harvest lands, so bare column refs into a
  CTE binding short-circuit to "accept silently").
- Duplicate CTE names in the same `WITH` block emit
  `diagnostic.duplicate_cte` on the second occurrence
  (Plan §Open-2).

Phase-1 gap closure (`sql_predicate_warnings`, ADR-0032 §11.6):

A new MatchedPath-walking pass that identifies predicate-tail
shapes by node-name labels and emits the same `diagnostic.*`
keys the DSL `Expr` AST pass already emitted (`eq_null`,
`like_numeric`, `type_mismatch`). Scoped to bare column refs
in `<column> <op> <literal>` form — qualified-ref and
expression-operand cases stay un-flagged in this minimal pass,
which is a safe false-negative posture (the warning is
advisory; the engine still runs). Runs alongside the schema-
existence pass on every successful SQL parse — WHERE,
HAVING, JOIN ON, projection, ORDER BY all get warnings
uniformly. Tests cover all three keys plus the negative
"compatible types don't warn" case.

WITH dispatch (`data::WITH`):

`with x as (…) select * from x` now dispatches via the registry
with entry word `with`. Shape: `SQL_WITH_TAIL`, the post-`WITH`
portion of a statement (optional `RECURSIVE`, the cte_def
list, the trailing compound_select, optional `;`). Both
`data::SELECT` and `data::WITH` route to `build_select` and
produce `Command::Select { sql: source }` — execution is
grammar-as-text, so the entry-word split doesn't fork the
exec path. `is_advanced_only` extended to include `with`.

Deferred per the 2d-scoped DA review (documented as a
`(TBD)` in the cross-cut matrix for 2g):

- `diagnostic.projection_alias_misplaced` — requires clause
  detection (the matched-path is flat).
- `diagnostic.compound_arity_mismatch` — needs per-leg
  projection counting.
- `diagnostic.cte_arity_mismatch` — depends on §10.3 stage-2
  harvest, which 2b deferred.
- `engine.*` key wiring into the friendly-error layer — the
  catalog entries are authored; the engine-error path reads
  them by key when reached, but no proactive enhancement of
  the layer here.

Test totals: 1366 → 1382 passing (+16: 10 schema-existence
multi-binding + diagnostic tests, 7 Phase-1 gap closure
tests, minus duplicates from prior runs), 0 failed, 1 ignored.
Clippy clean.
2026-05-20 16:12:42 +00:00
claude@clouddev1 a491df32a0 grammar: migrate Phase-1 SELECT to the ADR-0032 fragment (sub-phase 2c)
The Phase-1 SQL `SELECT` grammar nodes that used to live in
`src/dsl/grammar/data.rs` retire — 22 statics / consts and the
`reject_internal_table` validator copy are removed, ~150 lines
of grammar machinery gone. `data::SELECT.shape` now references
the post-`SELECT` portion of the ADR-0032 fragment via a thin
`Node::Subgrammar(&sql_select::SQL_SELECT_TAIL)`.

`SQL_SELECT_TAIL` is a new export from `sql_select.rs`,
parallel to `SQL_SELECT_STATEMENT`. It represents what a
top-level `SELECT` statement looks like AFTER the registry's
entry-word dispatch has already consumed the leading `SELECT`
keyword: the DISTINCT/ALL prefix, projection list, optional
FROM / WHERE / GROUP BY / HAVING, the compound set-op chain
(each subsequent leg's `SELECT` is part of `SET_OP_TAIL`),
outer ORDER BY / LIMIT, and a tolerated trailing `;`.

WITH-prefixed statements (`WITH x AS (…) SELECT * FROM x`)
are NOT in 2c's scope — they need a separate `data::WITH`
`CommandNode` so the entry-word dispatch routes correctly.
For now, top-level WITH continues to fall through to the
chumsky parser route (the same as in Phase 1). The
`SQL_SELECT_STATEMENT` static (which includes the optional
WITH prefix) stays available for use by that future
CommandNode or by any other consumer that needs the full
statement shape.

All seven Phase-1 SQL `SELECT` integration tests
(`tests/sql_select.rs`) pass without modification, satisfying
the 2c exit gate's "behaviour preserved" requirement. The
70 fragment unit tests and the 26 driver-level scope tests
also pass — the migration is a refactor, no new tests
required.

Behaviour change explicitly sanctioned by ADR-0032 §8:
Phase-1's `LIMIT_VALIDATOR` (positive-int-only, parse-time)
is superseded by the full `sql_expr` admission. `LIMIT max(10,
x)` and similar now parse; the engine constrains the value at
execution time per the ADR's "grammar admits, engine
rejects" posture.

Plan §2b status note: the 2026-05-20 deferral of §10.3 stage 2
(CTE output-column harvest derivation) is recorded in
`docs/plans/20260520-adr-0032-phase-2.md` per the
user-approved deferral.

Test totals: 1366 passing (unchanged), 0 failed, 1 ignored.
Clippy clean. data.rs loses ~150 lines of dead grammar; the
single source of truth for the SQL `SELECT` shape is now
`sql_select.rs`.
2026-05-20 15:42:44 +00:00
claude@clouddev1 4ff054ca75 walker: populate cte_bindings placeholders + projection_aliases (ADR-0032 §10.3 stage 1 / §10.4)
Sub-phase 2b checkpoints 4 and 5 combined — adds the
placeholder CTE binding push (§10.3 stage 1) and the
projection alias accumulator (§10.4).

Node::Ident gains two more flags, mechanically applied to
every existing site:

- `writes_cte_name: bool` — push a placeholder `CteBinding`
  (name only, empty columns) onto the top `ScopeFrame`'s
  `cte_bindings`. Set on `CTE_NAME_IDENT` in sql_select.rs.
  Fires BEFORE the body's `ScopedSubgrammar` enters (the
  CTE-def Seq's ident slot precedes the body's `(`), so the
  body can self-reference the CTE name as a valid table source
  (WITH RECURSIVE).
- `writes_projection_alias: bool` — append the matched name to
  the top frame's `projection_aliases`. Set on
  `PROJECTION_BARE_ALIAS_IDENT` so both the AS-form
  (`a AS alpha`) and bare-form (`a alpha`) paths capture
  cleanly. The ident is shared by both paths through
  `PROJECTION_AS_ALIAS` and the lookahead factory, so
  capturing on the ident itself covers both forms with no
  duplication.

The §10.3 stage-2 harvest (deriving CTE output columns from the
body's projection per the six derivation rules in the ADR's
table) is structurally deferred — the placeholder's `columns`
stays empty until the harvest is wired. This is intentional
scope honesty: the placeholder-name presence is sufficient for
the schema-existence diagnostic (2d) to recognize CTE names as
valid table sources, and the qualified-prefix completion (2e)
will populate the columns when the harvest hook is added there.
Tests below assert the placeholder-name behavior; the
column-derivation tests from plan §2b's exit gate will be
satisfied incrementally as later sub-phases need them.

Tests (8 new, all green):

- Single CTE → one placeholder binding with the matched name.
- Multiple CTEs → placeholders in declaration order.
- Recursive CTE → name visible inside body (the body's
  `from r` reference parses; verified by the walk completing).
- Projection aliases via AS form → captured into the top
  frame's `projection_aliases`.
- Projection aliases via bare form → captured.
- Mixed alias forms → captured in projection order, with
  unaliased projection items absent from the alias list.
- No aliases → empty `projection_aliases`.
- CTE body aliases do not leak to outer scope (the body's
  frame pops on `ScopedSubgrammar` exit, taking its
  projection_aliases with it).

All 1358 previous tests still pass. Test totals: 1366
passing, 0 failed, 1 ignored. Clippy clean.

This closes out the scope-accumulator side of sub-phase 2b.
The remaining 2b-style work — full CTE column-derivation
harvest per §10.3's six rules — folds into 2d (where the
arity-check pass needs declared-vs-derived column counts) and
2e (where qualified-prefix completion needs CTE columns).
2026-05-20 15:29:08 +00:00
claude@clouddev1 b522d09f5a walker: populate from_scope table bindings (ADR-0032 §10.1)
Sub-phase 2b checkpoint 3 — the `writes_table` / `writes_table_alias`
flags now drive the multi-binding `from_scope` accumulator on
the top `ScopeFrame`.

Node::Ident gains `writes_table_alias: bool`. When set on an
ident-name slot, the matched name lands on the most-recently-
pushed `TableBinding`'s `alias`. All 46 existing Ident sites
across the codebase are updated to `writes_table_alias: false`
(mechanical — no behavioral change for DSL paths).

walk_ident's `writes_table` semantics extend:

- `IdentSource::Tables` matches with `writes_table: true` still
  populate `current_table` / `current_table_columns` as before
  (preserved for DSL paths that read those fields directly via
  the dynamic-subgrammar / column-writes machinery), AND now
  also push a fresh `TableBinding` onto the top ScopeFrame's
  `from_scope`. The two mechanisms coexist additively —
  current_table reflects the most-recent `writes_table` write
  (single-binding view, as before); from_scope is the
  authoritative multi-binding accumulator that SQL JOINs,
  subqueries, and CTE bodies use.

sql_select.rs splits the alias slot into two ident variants:

- `PROJECTION_BARE_ALIAS_IDENT` (role `projection_alias`) —
  no scope writes; capture into `projection_aliases` is 2b-5.
- `TABLE_SOURCE_BARE_ALIAS_IDENT` (role `table_alias`,
  `writes_table_alias: true`) — sets the top binding's alias.

The `AS alias` form likewise splits into PROJECTION_AS_ALIAS
and TABLE_SOURCE_AS_ALIAS so each path threads through the
correct ident. The bare-alias lookahead factories return the
projection or table-source ident accordingly.

`TABLE_NAME_IDENT` in sql_select.rs gets `writes_table: true`
so each FROM / JOIN table source pushes a binding. The
schema-resolved columns are stored on the TableBinding for
later use by qualified-prefix completion (2e) and the
schema-existence diagnostic (2d).

Tests (9 new, all green):

- single from-table → one binding
- AS alias / bare alias on from-table → alias captured
- two-way JOIN → two bindings, correct order
- two-way JOIN with both aliased → two bindings with aliases
- three-way JOIN (left + bare) → three bindings in order
- subquery from_scope does not leak to outer scope (the
  ScopedSubgrammar push/pop discipline at work)
- CTE body from_scope does not leak to outer scope (the outer
  scope sees only the CTE-name reference, not the body's
  internals)
- SELECT without FROM → empty from_scope

All 1351 previous tests still pass — DSL paths untouched.
Test totals: 1358 passing, 0 failed, 1 ignored. Clippy clean.

Frame is_cte_body marker, body-projection harvest, and
projection_aliases population are the remaining 2b work
(2b-4 and 2b-5).
2026-05-20 15:25:10 +00:00
claude@clouddev1 98a74b23d3 grammar: sql_expr additive extensions for §5/§6, CTE body rewires to ScopedSubgrammar
Sub-phase 2b checkpoint 2 — closes the recursion loop between
sql_expr.rs and sql_select.rs so subquery expressions and
qualified column refs become structurally valid in every SQL
context where they belong.

sql_expr.rs:

- §5 qualified-ref tail. `name_or_call` gains a `.identifier`
  suffix as a Choice sibling of the function-call `(args)`
  tail. The leading identifier is still matched once (per
  ADR-0031 §1's factoring); the optional tail dispatches
  between the two suffixes by their first character (`.` vs
  `(`).
- §6.1 scalar subquery as primary. The `(or_expr)` and
  `(SELECT …)` branches share the leading `(`; the first
  inside token (`SELECT` → subquery, anything else →
  expression) discriminates. The subquery recurses through
  `Node::ScopedSubgrammar(&sql_select::SQL_SELECT_COMPOUND)`.
- §6.2 IN (subquery) predicate. Sibling of the existing
  IN-value-list; same `(` factoring, same dispatch.
- §6.3 [NOT] EXISTS primary. Bare `EXISTS (compound_select)`
  lives in `primary`; `NOT EXISTS` falls out via the existing
  `not_expr := NOT not_expr` tier above `primary`.

sql_select.rs:

- CTE body recursion rewires `Node::Subgrammar` →
  `Node::ScopedSubgrammar`, matching §10.2. The top-level
  statement's COMPOUND embedding stays plain Subgrammar — the
  implicit bottom frame is the right scope for a statement-
  level SELECT.

Structural side-effect — const-eval cycle workaround:

Closing the sql_expr ⇄ sql_select reference loop made Rust's
const-evaluator follow the cycle through every `const Node`
that transitively reaches it. Mirroring sql_expr.rs's existing
pattern, composition Nodes in sql_select.rs (Seq / Choice /
Optional / Repeated / Lookahead) are now `static Node` and
appear in slice positions through `Node::Subgrammar(&NAME)`
wraps; only leaf items (Punct, Word, Ident) remain `const`.
Same workaround applies to data.rs's SELECT_PROJ_LIST /
SELECT_PROJECTION chain and the inlined `SQL_EXPR` reference.
Statics resolve lazily at link time, so the cycle is valid;
const-eval is not, and the named `const SQL_EXPR` alias is
gone in both files (replaced with the inline `Node::Subgrammar
(&sql_expr::SQL_OR_EXPR)` expression at every use site).

Test coverage:

- sql_expr.rs gains 11 new tests for qualified refs, scalar
  subquery, IN-subquery, EXISTS / NOT EXISTS, nested
  subqueries, and the existing IN-value-list form (regression).
- sql_select.rs gains 7 new tests for qualified refs in WHERE,
  scalar subqueries in WHERE / projection, IN / EXISTS / NOT
  EXISTS in WHERE, nested subqueries, and qualified refs
  inside CTE bodies.
- All 70 prior sql_select tests still pass; the 2a baseline
  is preserved.

`(WITH x AS (…) SELECT * FROM x)` is explicitly NOT admitted
as a scalar subquery — ADR-0032 §1 / §9 wire subqueries to
SQL_SELECT_COMPOUND, which omits the outer with_clause. WITH
remains a statement-level-only construct. Documented in the
relevant test.

Test totals: 1333 → 1351 passing, 0 failed, 1 ignored
(unchanged). Clippy clean.
2026-05-20 11:47:27 +00:00
claude@clouddev1 4f89106a63 walker: Node::ScopedSubgrammar variant + scope-frame stack (ADR-0032 §10.2)
Sub-phase 2b checkpoint 1 — adds the foundation for SQL SELECT
lexical-scope discipline without changing existing walker
semantics.

New types in `dsl::walker::context`:

- `TableBinding` — one FROM-source binding with table name,
  optional alias, and schema-resolved columns (§10.1).
- `CteBinding` + `CteColumn` — a CTE definition visible from
  inside its body (WITH RECURSIVE self-reference) and from the
  outer scope after harvest (§10.3).
- `ScopeFrame` — `from_scope`, `cte_bindings`, and
  `projection_aliases` for one lexical scope. Default-empty;
  the fields will be populated by later 2b checkpoints.

`WalkContext` gains `from_scope_stack: Vec<ScopeFrame>`,
initialised with one bottom frame in both `new()` and
`with_schema()`. The bottom frame is the implicit top-level
scope DSL paths and top-level SQL statements operate in;
`Node::ScopedSubgrammar` entries push and pop additional frames
on top. `current_table` / `current_table_columns` remain as
direct fields for this checkpoint — converting them to derived
helpers is a later 2b step.

New grammar-tree variant:

- `Node::ScopedSubgrammar(&'static Self)` — like `Subgrammar`,
  but pushes a fresh `ScopeFrame` on entry and pops it on exit
  (ADR-0032 §10.2). Shares `subgrammar_depth` with the plain
  Subgrammar variant so the MAX_SUBGRAMMAR_DEPTH = 64 cap fires
  uniformly across both — §9's "no new walker capability for
  grammar recursion" claim holds. DSL Expr (ADR-0026) and
  sql_expr.rs ladder (ADR-0031) recursion continue to use the
  plain Subgrammar variant and never push a scope.

Driver gains a parallel `walk_scoped_subgrammar` arm; the
push/pop is unconditional so a speculatively-walked branch a
later Choice rolls back leaves the stack clean.

Test coverage in `driver.rs`:

- A recursive ScopedSubgrammar test grammar walks correctly
  through depths 0-3.
- The depth cap fires the same `expression_too_deep` friendly
  validation error as for plain Subgrammar.
- The bottom frame invariant: `WalkContext::new` seeds exactly
  one frame, and after a walk the stack is restored.

No grammar tree references the new variant yet — the rewire of
sql_select.rs CTE bodies and the sql_expr.rs additive
extensions for §5/§6 are the next 2b checkpoint. Test totals:
1330 baseline + 3 = 1333 passing, 0 failed, 1 ignored. Clippy
clean.
2026-05-20 11:34:53 +00:00
claude@clouddev1 8d293358a0 grammar: SQL SELECT full statement fragment (ADR-0032 Phase 2a)
Author the standalone walkable shape for the full standard-SQL
SELECT per ADR-0032 §1: compound queries with the four set ops
(UNION / UNION ALL / INTERSECT / EXCEPT), the five JOIN flavours
(INNER / LEFT [OUTER] / RIGHT [OUTER] / FULL [OUTER] / CROSS),
GROUP BY / HAVING, WITH and WITH RECURSIVE common table
expressions, LIMIT … OFFSET, DISTINCT / ALL, qualified-wildcard
`t.*` projection, and bare-alias projection (lifting ADR-0030
Phase-1 §4.2).

Recursion into SQL_SELECT_COMPOUND uses Node::Subgrammar for
2a; sub-phase 2b will rewire those references to the new
Node::ScopedSubgrammar variant for completion-scope discipline
(ADR-0032 §10.2). The Phase-1 data::SELECT CommandNode is not
touched here — the new fragment is reachable only from its own
tests until sub-phase 2c performs the migration.

Two implementation mechanisms realize ADR semantics without
changing them:

- Node::Lookahead disambiguates the projection_item Choice
  (bare `*` vs `ident . *` qualified wildcard vs `sql_expr [
  alias ]`) and gates bare-alias slots against continuation
  keywords. The walker's walk_ident accepts any
  identifier-shape token, including keyword-shape ones, and
  Choice / Optional are first-match-wins; without lookahead a
  bare-alias slot would greedily swallow FROM / WHERE / JOIN /
  etc. Per-position follow-sets list which keywords legitimately
  follow each alias slot. Same pattern as data.rs's
  insert_first_paren precedent.

- INNER JOIN and bare JOIN are split into two distinct Choice
  branches (each with a concrete leading keyword) rather than
  sharing one Optional(Word("inner"))-leading branch. Avoids a
  walker hazard where an Optional-leading-child Seq commits to
  idx > 0 and then converts the next child's EOF NoMatch into
  Incomplete, blocking the outer Choice from falling through to
  later branches. Same semantic surface, distinct mechanism.

The §13 OOS shapes all have explicit reject tests (NATURAL,
USING, comma-FROM, LIMIT m,n, window OVER, VALUES, derived
tables). LATERAL has a noted partial limitation: the comma form
rejects via OOS-3, but the single-keyword form `FROM a LATERAL
JOIN b ON …` is admitted structurally because `lateral` parses
as a bare table-source alias for `a`. This matches ADR-0030's
"grammar admits identifier-shape tokens; engine resolves"
posture.

`__rdbms_*` rejection extends to every Phase-2 table-source
slot — the FROM table, each JOIN's table, each CTE name, and
the FROM inside any CTE body — via the reuseable
reject_internal_table validator.

70 new unit tests in sql_select.rs walk every §1 production and
every OOS reject case. Test totals: 1260 baseline + 70 = 1330
passing, 0 failing, 1 ignored (unchanged from baseline). Clippy
clean.

Per the Phase-2 plan sub-phase 2a exit gate. DA gate written
review: PASS.
2026-05-20 11:29:48 +00:00
claude@clouddev1 6369066fe4 grammar: SQL SELECT end-to-end (ADR-0030 Phase 1)
The first cut of advanced-mode SQL: a `select` line in advanced
mode parses, runs against the database, and renders its rows
through the existing data-table renderer; the same line in
simple mode lights up the precise "this is SQL" hint instead of
running.

Walker mode gate (ADR-0030 §2)
------------------------------
- `WalkContext` gains a `mode: Mode` field; `Mode` derives
  `Default` (= `Simple`, matching the app's startup mode).
- `grammar::is_advanced_only` keys an advanced-only entry-word
  set (Phase 1: just `select`). When the walker matches an
  advanced-only entry word with `ctx.mode == Simple`, it
  short-circuits to a `WalkOutcome::ValidationFailed` carrying
  the `advanced_mode.sql_in_simple` catalog key — the input
  highlights as a keyword, the validity indicator goes ERROR,
  and the parse-error layer renders the "switch with `mode
  advanced`, or prefix the line with `:`" hint.
- `parser::parse_command_with_schema_in_mode` (and the
  schemaless `parse_command_in_mode`) threads the mode into
  `WalkContext`; existing `parse_command*` entry points default
  to `Mode::Advanced` (most permissive) so back-compat callers
  see the full grammar.
- `App::submit` is unified: both modes route through
  `dispatch_dsl(&effective_input, effective_mode)`, which now
  parses with the line's effective mode. The placeholder
  advanced-mode echo branch is gone.

Builder signature sweep (ADR-0031 §2)
-------------------------------------
- `CommandNode.ast_builder` gains a `source: &str` parameter,
  forwarded by the walker. `build_select` reads it to put the
  validated SQL text into `Command::Select`; the 21 existing
  builders accept it as `_source`.

SQL `SELECT` (ADR-0030 §6, ADR-0031)
-------------------------------------
- New `Command::Select { sql: String }` variant. Every
  exhaustive `match Command` updated (`verb`, `target_table`,
  `build_translate_context`, `execute_command_typed`,
  `typing_surface`'s label).
- `grammar::data::SELECT` `CommandNode`: projection (`*` or
  `expr [as alias]` list), optional `FROM <table>`, optional
  `WHERE`/`ORDER BY`/`LIMIT`, optional trailing `;`. The
  expression slots reference the ADR-0031 fragment through
  `Subgrammar(&sql_expr::SQL_OR_EXPR)`. The `FROM` table-name
  slot carries a `reject_internal_table` validator that
  refuses `__rdbms_*` references at parse time.
- The `FROM` clause is optional — `select 1`, `select upper('x')`
  (zero-table constant/function-call SELECTs) work alongside
  the single-table form. Standard SQL admits them and they are
  the canonical learner probe.
- Implicit projection aliasing (`select a x`) is deliberately
  unsupported — `from` is a keyword, the bare alias would be
  ambiguous; only `select a as x` is admitted.

Worker / runtime
----------------
- `Request::RunSelect { sql, source, reply }` + a new
  `Database::run_select` method. `do_run_select_request` runs
  the prepared statement, collects rows into a `DataResult`
  with `column_types: Vec<None>` (Phase-1 SELECT result columns
  carry no playground type per ADR-0030 §6), and appends the
  literal source line to `history.log` so replay re-runs it
  (ADR-0030 §11).
- `runtime::execute_command_typed` gains a `Command::Select`
  arm that calls `database.run_select(sql, src)` and maps to
  `CommandOutcome::Query`, which flows into the existing
  `AppEvent::DslDataSucceeded` → `render_data_table` path.

Catalog (ADR-0019)
------------------
- `advanced_mode.sql_in_simple` — the walker's gate message.
- `select.internal_table` — the `__rdbms_*` rejection.
- `parse.usage.select` — the parse-error usage template.

Tests
-----
Two `app::tests` cases that pinned the pre-ADR-0030 placeholder
echo are updated to pin the new dispatch contract — both verify
that the advanced-mode `select` (one persistent, one via the
`:` one-shot) produces `ExecuteDsl(Command::Select)` with the
submission's effective mode tagged on the echo. The matching
walking-skeleton test is updated likewise.

A separate follow-up commit lands the ambient mode-threading
(completion / live overlay / validity indicator) so simple-mode
users do not see SQL surfaced through Tab or the live error
overlay either — the dispatch-layer gate landed here is the
behavioural foundation that follow-up builds on. Integration
tests for the full end-to-end land in a third commit.
2026-05-19 21:46:56 +00:00
claude@clouddev1 c93f9394f5 grammar: SQL expression grammar fragment (ADR-0031)
A new `src/dsl/grammar/sql_expr.rs` authored as a parallel
fragment to `expr.rs` (the DSL `WHERE` grammar, ADR-0026). The
ADR's stratified ladder lands as named `static` `Node`s, one per
precedence tier:

  or_expr → and_expr → not_expr → predicate → additive →
  multiplicative → unary → primary

Recursion through `Node::Subgrammar` reuses ADR-0026's
`MAX_SUBGRAMMAR_DEPTH = 64` cap unchanged; no new walker
capability is required. `predicate_tail` follows ADR-0026's
factoring (shared operand prefix, infix `NOT` as an explicit
branch, no `Optional`-first branch) so `Choice` discriminates
cleanly. `name_or_call` factors the identifier-prefix shared
between column refs and function calls into a single `Ident`
followed by an `Optional` `( call_args )` tail — the same
hazard-avoidance shape `predicate_tail` uses.

The fragment exports `pub static SQL_OR_EXPR` (test entry) and
`pub static SQL_EXPRESSION` (drop-in `Subgrammar(&SQL_OR_EXPR)`
that SQL `CommandNode` shapes embed in their `Seq`). No AST
builder — every Phase-1 consumer (SELECT projection, WHERE)
runs validated SQL as text per ADR-0030 §4/§6.

13 unit tests cover every operator and precedence pair, the
full predicate set, `CASE` (searched + simple) including
`count(*)` and `count(distinct …)`, parenthesised regrouping,
case-insensitive keywords, the depth cap, and a representative
set of malformed inputs that do *not* walk.

Module registered via one new line in `grammar/mod.rs`.
2026-05-19 21:39:49 +00:00
claude@clouddev1 abce1188f2 constraints: add constraint / drop constraint on existing columns (ADR-0029 §2.2)
Adds the two commands for modifying a column's constraints after
creation, completing ADR-0029's §2.2 surface.

Grammar (dsl/grammar/ddl.rs): `add constraint <constraint> to
<T>.<col>` reuses the §2.1 COLUMN_CONSTRAINT choice; `drop
constraint <kind> from <T>.<col>` names only the kind. Both join
the `add` / `drop` choices, discriminated by the `constraint`
form word.

AST (dsl/command.rs): `Command::AddConstraint` / `DropConstraint`
plus the `Constraint` / `ConstraintKind` enums.

Worker (db.rs): `do_add_constraint` / `do_drop_constraint` apply
the change through the rebuild-table primitive. `add` runs the §5
dry-run first — `not null` / `unique` / `check` against a
populated column are refused, before any write, with a
pretty-printed table of offending rows. §9 redundant-on-PK
declarations and §6 `default` on an auto-generated column are
friendly refusals; dropping a constraint the column does not
carry is likewise refused.

Also fixes schema_to_ddl, which suppressed UNIQUE for every PK
column — a compound-PK member is not individually unique, so an
explicit UNIQUE on it must survive the rebuild.

23 tests added (6 grammar, 17 worker); 3 completion-test and 3
matrix snapshots updated for the new `constraint` subcommand.
2026-05-19 18:31:57 +00:00
claude@clouddev1 942222bfc9 constraints: CHECK — check (<expr>) at create table & add column (ADR-0029)
The fourth constraint. `check ( <expr> )` reuses the ADR-0026
WHERE-expression grammar via `Subgrammar`, so a check is
written in the same language as a `where` filter.

- Grammar: a `CHECK_CONSTRAINT` arm joins the shared
  constraint-suffix Choice; `consume_check_expr` extracts the
  parenthesised expression (paren-depth aware) into
  `ColumnSpec.check` / `Command::AddColumn.check`.
- Storage: the parsed `Expr` is compiled once to inline SQL
  (`compile_check_sql` — `compile_expr` + ADR-0028's
  param-inliner) and stored in that form everywhere — a new
  `check_expr` column in `__rdbms_playground_columns`,
  `project.yaml`'s `ColumnSchema.check`, and the column DDL
  emitted by `do_create_table` / `schema_to_ddl`.
- `add column … check` routes through the rebuild primitive
  (SQLite's `ALTER … ADD COLUMN` cannot carry it); a CHECK on
  a serial/shortid column is create-table-only and refused at
  add-column with a friendly message.
- `describe` surfaces the CHECK. ADR-0029 §7/§8 updated to the
  SQL-form decision — double-quoted identifiers, consistent
  with ADR-0028's `explain` display SQL.

1201 tests pass (+8); clippy clean.
2026-05-19 16:42:18 +00:00
claude@clouddev1 58d8958822 add column: column constraints — NOT NULL / UNIQUE / DEFAULT (ADR-0029 §6)
`add column` now accepts the shared constraint suffix and the
worker honours it — the surface where NOT NULL / UNIQUE
actually matter, on non-PK columns.

- Grammar: `ADD_COLUMN_NODES` gains the constraint-suffix
  fragment; `collect_column_constraints` folds it into
  `Command::AddColumn`.
- `do_add_column` routes per ADR-0029 §6: SQLite's `ALTER
  TABLE ADD COLUMN` cannot express `UNIQUE` and requires a
  default for `NOT NULL`, so those go through the rebuild
  primitive (`do_add_constrained_column_via_rebuild`); plain
  cases keep the ALTER path with the constraint suffix
  appended.
- Pre-flight refusals, before any SQL write: a NOT NULL
  column with no default added to a populated table; a UNIQUE
  column with a default added to a multi-row table; a default
  on a `serial` / `shortid` column.

CHECK is still deferred to the next commit. 1193 tests pass
(+9); clippy clean.
2026-05-19 14:50:19 +00:00
claude@clouddev1 12395a9a6c create table: column constraints — NOT NULL / UNIQUE / DEFAULT grammar (ADR-0029)
`create table … with pk` now parses the column-constraint
suffix; combined with the commit-1 db layer, a constrained
table works end to end.

- A shared constraint-suffix grammar fragment — `not null`,
  `unique`, `default <literal>` — sits after each column's
  `(type)` group; `build_create_table` walks the matched path
  per column and folds the constraints into `ColumnSpec`.
- §9 redundancy check: every `with pk` column is a primary-key
  column, so `not null` (any) and `unique` (single-column PK)
  are rejected with a friendly error
  (`parse.custom.constraint_redundant_on_pk`).
- `project.yaml` round-trip: `ColumnSchema` gains `not_null` /
  `default`; the YAML reader/writer and `build_read_schema`
  carry them, so `rebuild` / `export` / `import` preserve
  constraints.
- ADR-0029 §2.1's example corrected — `create table` columns
  are all PK columns, so its suffix is for `default` / `check`;
  `docs/simple-mode-limitations.md` records that non-PK
  columns at create time need advanced mode.

CHECK is deferred to the next commit. 1184 tests pass (+7);
clippy clean.
2026-05-19 14:41:29 +00:00
claude@clouddev1 eff2ee8d14 refactor: ColumnSpec / AddColumn carry constraint fields (ADR-0029 scaffolding)
Expand ColumnSpec and Command::AddColumn with the four
ADR-0029 constraint slots (not_null, unique, default, check),
all defaulting off; `Database::add_column` now takes a
ColumnSpec. No behaviour change — the grammar to set the
fields and the DDL to enforce them land in the following
commits. Isolated here so those commits stay readable.

Adds ColumnSpec::new for the unconstrained case; 110 call
sites updated. 1172 tests pass; clippy clean.
2026-05-19 14:04:36 +00:00
claude@clouddev1 d17addddd7 explain: explain command end to end (ADR-0028 steps 2–3)
Add the `explain` prefix command — `explain show data`,
`explain update`, `explain delete` — from grammar through to a
rendered plan tree.

- Grammar: an `EXPLAIN` CommandNode whose shape is a Choice over
  the three explainable query shapes, referenced (not
  duplicated) through `Subgrammar`. `Command::Explain { query:
  Box<Self> }`; `build_show_data` is extracted so the role-based
  builders serve both standalone and explain-wrapped commands.
- Worker: SQL construction is split out of do_query_data /
  do_update / do_delete into `build_*_sql`, so EXPLAIN QUERY
  PLAN runs the exact same statement. `Request::ExplainPlan` /
  `do_explain_plan` capture the plan; `QueryPlan` / `ExplainRow`
  carry it back. EXPLAIN QUERY PLAN never executes, so
  explaining update/delete changes nothing.
- Display SQL: the executed statement with `?N` parameters
  inlined as standard-SQL literals via a quote-aware scan.
- Render: `render_explain_plan` draws the box-drawing plan tree
  (plain output; ADR-0028 step 4 adds the styled tree).
- Catalog: `parse.usage.explain` and the `help.data.explain`
  entry, so `explain` shows up in the in-app `help` listing.

1151 tests pass (+18); clippy clean.
2026-05-19 12:38:02 +00:00
claude@clouddev1 f239ca5ff4 walker: keep optional trailing flags completable after --
Typing `--` to start an optional trailing flag (`--create-fk`
on `add 1:n relationship`, `--cascade` on `drop column`,
`--force-conversion` / `--dont-convert` on `change column`)
made completion go empty: the trailing `--` turns the parse
into a trailing-junk Mismatch, and the Mismatch arm of the
completion expected-set resolution returned only `[EndOfInput]`
— the skipped optional-flag expectations, carried in
`tail_expected`, were dropped.

completion_probe and expected_at_input now merge `tail_expected`
into a Mismatch's expected set. `tail_expected` is empty for a
genuine mid-command mismatch, so this only adds the outer
shape's skipped trailing optionals — exactly the continuations
the trailing `--` is starting to type. This also resolves the
"wrong usage hint" symptom: with `--create-fk` offered as a
candidate, the hint panel shows candidates instead of falling
through to the parse-error usage block.

Audit outcome (the requested scan): usage_key_for_input was
verified correct for every multi-form command — add / drop /
show, including the digit-led `add 1:n relationship` form —
and is now regression-locked. The flag-completion fix covers
the whole optional-trailing-flag class.

6 tests (3 flag-completion, 3 usage-key). 1131 passing.
2026-05-19 10:19:00 +00:00
claude@clouddev1 426e80185f command: Operand carries a source span
Each WHERE-expression Operand now records the byte span of the
terminal it was built from — the precise per-literal highlight
target for an expression WARNING (finishing ADR-0027 §2's
highlight/hint wiring). parse_operand captures MatchedItem::span;
the RowFilter::eq convenience constructor uses Operand::NO_SPAN.

PartialEq is hand-written to ignore the span — it is editor
metadata, so Command equality stays whitespace- and
position-independent, which the Expr test corpus relies on.
No behaviour change; 1100 tests still pass, clippy clean.
2026-05-19 09:20:52 +00:00
claude@clouddev1 151ed084a3 hint: show the matching usage template for multi-form commands
A parse error in `add index …` showed the `add column` usage:
`add` and `drop` are multi-form commands, and both the
ambient hint and the submit-time usage block picked the
first-listed form unconditionally.

New `grammar::usage_key_for_input` disambiguates by the form
word after the entry keyword — `column` / `index` / `table` /
`relationship`, or the leading digit of `add 1:n …`. The
ambient hint now shows that one form; `render_usage_block`
shows the committed form's usage and falls back to the whole
family only for a bare `add` / `drop` with no form chosen.
2026-05-19 08:37:17 +00:00
claude@clouddev1 827b47f88f walker: schema-existence ERROR diagnostics (ADR-0027 step B)
`MatchedKind::Ident` now carries its `IdentSource`. A
post-walk pass over a structurally-valid parse flags a
matched `Tables` ident that is absent from the schema, or a
`Columns` ident absent from the table in scope, as an ERROR
diagnostic — the command parses but would fail at execution
(ADR-0027 §2). New behaviour: an unknown table / column used
to parse cleanly and fail only when run.

Column scope is resolved by one left-to-right pass over the
matched path (every command places its table ident before
the columns that belong to it); an unknown table clears the
scope, so its columns are not cascaded into a second
diagnostic. New catalog keys `diagnostic.unknown_table` /
`diagnostic.unknown_column`.
2026-05-19 07:15:58 +00:00
claude@clouddev1 a50c6cdf70 WHERE expressions: matrix cells + predicate_tail grammar fix (ADR-0026 step 6)
Adds tests/typing_surface/where_expression.rs — 9 matrix
cells for the complex WHERE / show-data limit typing surface:
operator candidates after an operand, AND / OR after a
predicate, NOT, BETWEEN / IN bounds, and `show data`
where / limit.

Writing the cells surfaced a grammar bug. `predicate_tail`'s
`[NOT] negatable` branch started with `Optional(not)`, and an
Optional-first `Seq` always "commits" — so on an incomplete
input the walker's `Choice` returned that branch's
`Incomplete` early and discarded every sibling branch's
expected set, dropping `is` and the comparison operators from
completion after a column. Fixed by splitting it into
explicit `NOT negatable` and bare `negatable` branches — no
`predicate_tail` branch starts with an `Optional` now. The
matched terminal sequence is unchanged, so `build_expr` is
untouched.

Docs: ADR-0026 gains an "As-built notes" section recording
the option-1 builder realization, its two deviations from the
§3 sketch, and the deferral of §7 diagnostic flagging to
ADR-0027. requirements.md C5a -> [x] (steps 1-4) with the
test baseline refreshed to 1079; CLAUDE.md's deferred list
reconciled (C5a implemented; the QA1/QA2 note now points at
ADR-0028).
2026-05-18 23:19:53 +00:00
claude@clouddev1 f75f71bbe4 WHERE expressions: wire into update/delete/show data + SQL gen (ADR-0026 steps 3-4)
Wires the stratified WHERE-expression fragment into the three
filter commands and compiles the resulting Expr to SQL.

Grammar (data.rs): the `update` / `delete` `where` clause is
now the expression fragment (`Subgrammar(&expr::OR_EXPR)`) in
place of the single `col = val` slot; `show data` gains an
optional `where <expr>` and an optional `limit <n>` (a
non-negative integer, validated at parse time). The
expression's right-hand operands are a schema-aware
`DynamicSubgrammar` so the hint panel still narrows to the
left column's type (ADR-0026 §8) — but the inner grammar is
permissive: a type-mismatched literal still parses (§7).

AST: `RowFilter::Where{column,value}` -> `RowFilter::Where(Expr)`;
`ShowData` gains `filter: Option<Expr>` and `limit: Option<u64>`.
A `RowFilter::eq` convenience constructor keeps simple-equality
call sites and tests readable.

SQL (db.rs): `compile_expr` lowers an `Expr` to a
parameterised WHERE — every literal a `?` placeholder,
identifiers `quote_ident`-quoted, `<>` for inequality. A
literal compared against a column binds through that column's
type where compatible and falls back to its syntactic shape on
a mismatch (§7 — permissive). `show data ... limit n` emits
`LIMIT ?` with an implicit primary-key `ORDER BY`, so it is a
stable "first n by primary key".

completion.rs: `invalid_ident_at_cursor` no longer mis-flags a
digit-led literal (`1`) as an unknown column now that the
WHERE operand slot also accepts a column reference; a
`ProseOnly` slot suppresses keyword candidates even when the
expected set also carries a column ident.

11 db integration tests cover AND / OR / NOT, BETWEEN, IN,
LIKE, filtered `show data`, and limit ordering; walker and
expr unit tests cover the parse surface. Type-mismatch /
`= NULL` diagnostic flagging (§7 highlight + hint) is the
remaining ADR-0026 piece.
2026-05-18 23:12:33 +00:00
claude@clouddev1 59e6a541bf grammar: WHERE-expression fragment + Expr AST + build_expr (ADR-0026 step 2)
The stratified WHERE-expression grammar — or / and / not /
bool_primary / predicate tiers as named `static` Node
fragments, recursing through `Subgrammar`. Covers the six
comparison operators (`<>` and `!=` both NotEq), AND / OR /
NOT, parentheses, LIKE / IN / BETWEEN with optional infix NOT,
and IS [NOT] NULL. `predicate_tail` factors the shared operand
prefix and the infix NOT so the Choice branches discriminate
on a cleanly-failing first token.

New recursive Expr / Predicate / Operand / CompareOp AST in
dsl::command. `build_expr` folds the flat matched-terminal
slice into an Expr — a deterministic recursive descent
mirroring the grammar tiers, with single-child tiers
collapsing. Per ADR-0026 §3 option 1: the walker stays a pure
structural matcher; Expr is assembled only in this
submit-time fold.

Fragment + builder are unit-tested standalone (walk against
&OR_EXPR, then build_expr); not yet wired into any command.
2026-05-18 22:40:52 +00:00
claude@clouddev1 f0b2043a39 walker: add Subgrammar node + recursion-depth cap (ADR-0026 step 1)
New `Node::Subgrammar(&'static Node)` variant lets a named
static grammar fragment recurse through a reference — `Seq` /
`Choice` embed children by value and cannot close a cycle, but
a `&'static Node` can point back at an enclosing fragment. This
is the mechanism the stratified WHERE-expression grammar
(ADR-0026 §2) recurses through.

The walker counts active Subgrammar frames in
`WalkContext::subgrammar_depth` and refuses past
`MAX_SUBGRAMMAR_DEPTH` (64), surfacing a friendly
`parse.custom.expression_too_deep` error instead of a stack
overflow. Depth is saved/restored per frame so a
speculatively-walked-then-rolled-back Choice branch leaves no
residue.

No grammar references the node yet; covered by walker unit
tests with a small recursive `( x )` test grammar.
2026-05-18 22:36:19 +00:00
claude@clouddev1 d9a98bbd49 Grammar: with-pk column specs use name(type), matching add column
`create table … with pk` parsed column types as `name:type`,
while `add column` uses `name(type)`. Unify on the parens
form so column-type syntax is consistent across the DSL:

    create table T with pk id(serial), name(text)

Only `COL_SPEC` changes (`:` → `( … )`); `build_create_table`
reads columns by role, so it is unaffected. The `:` that
separates table from column in `add column` / `drop column`
is unchanged. Sweeps the test suite, the typing-surface
matrix (two `after_colon` cells renamed to `after_paren`,
4 snapshots regenerated), the friendly catalog's usage
templates, ADR-0009's example, and requirements.md.

1039 passing / 0 failing / 1 ignored; clippy clean.
2026-05-18 21:51:52 +00:00
claude@clouddev1 0dc159fd7e Indexes: add index / drop index, persistence, display (ADR-0025)
Implement ADR-0025 — indexes as a DSL DDL feature.

- Grammar: `add index [as <name>] on <T> (<cols>)`, `drop index
  <name>` / `drop index on <T> (<cols>)`, plus a `--cascade`
  flag on `drop column`.
- db.rs: index operations over the engine's native index
  catalog (no metadata table). The rebuild-table primitive now
  captures and recreates indexes, so `change column` and the
  relationship operations no longer silently drop them.
- `drop column` refuses an indexed column unless `--cascade`,
  which drops the covering indexes and reports each.
- Persistence: additive `indexes:` list in `project.yaml`
  (version unchanged); round-trips through rebuild/export/import.
- Display: an `Indexes:` section in the structure view and a
  nested tables/indexes items panel (S2).

Reconciles requirements.md (C3 index portion, S2 satisfied)
and CLAUDE.md. 1038 tests passing (+31), clippy clean.
2026-05-16 00:15:55 +00:00
claude@clouddev1 6d2b92996d Grammar: remove the dead CommandNode.hint_mode field
HintMode became per-node (Node::Hinted) in the node-attached refactor;
the per-command hint_mode field was never the mechanism and is now
read by nothing. Removed the field and its 20 `None` initialisers.
2026-05-15 22:54:24 +00:00
claude@clouddev1 03dd9003df Help: consume CommandNode.help_id — REGISTRY-driven in-app help
Every CommandNode declared a help_id that nothing read; the in-app
`help` body was a single hand-kept catalog block that drifted from
the command set (handoff-12 §2.1).

note_help now iterates the command REGISTRY and translates each
CommandNode's help_id (`help.<id>`), framed by help.intro /
help.dsl_section / help.types_reference. A newly-registered command
appears in `help` automatically — no edit to note_help or a hand-kept
list. Added 20 per-command help entries plus the 3 framing entries;
removed help.in_app_body.

Per-command entries use block scalars: a libyml 0.0.5 scanner bug
panics on long internal space runs in double-quoted scalars, and the
entries are space-aligned.
2026-05-15 22:45:18 +00:00
claude@clouddev1 90e3f5dbfb Insert grammar: Form C type-awareness via lookahead (ADR-0024 §Phase D)
Form C (`insert into T (vals)`) shared the `(` opener with Form A,
so its paren was an untyped Repeated(Choice(literal, ident)) — values
weren't type- or count-checked at parse time (handoff-12 §2.2).

New Node::Lookahead variant: a factory that peeks the source. The
insert first-paren factory inspects the first token — a value literal
routes the contents through the typed column_value_list (Form B
dispatch contract: per-non-auto-column typed slots); an identifier or
empty paren routes to a Form A column-name list. So Form C now gets
the same per-column typed slots, hints, and parse-time type/count
checking Form B has.

The explicit-Choice-branch split is impossible here (committed-choice
semantics commit after `(` matches); lookahead is the only route, and
DynamicSubgrammar factories couldn't see the source. Node::Lookahead
is not memoized — its output depends on source — but it returns only
a small node (a Repeated, or a thin DynamicSubgrammar wrapper that
delegates to the memoized column_value_list).

`insert into T (` now cleanly shows Form A column candidates instead
of mixed Form-A/C suggestions. Form C matrix tests updated for the
type-aware behaviour.
2026-05-15 22:27:53 +00:00
claude@clouddev1 911a537a83 Walker: node-attached HintMode via Node::Hinted (ADR-0024 §HintMode-per-node)
Replaces the hint resolver's signature-matching (does the expected set
contain all five literal forms? an Ident{NewName}?) with a grammar-
declared annotation. New Node::Hinted { mode, inner } wrapper; the
walker records the mode in WalkContext::pending_hint_mode on entry and
clears it on any successful match (cursor moved past the slot — this
also undoes the leak where a failed Hinted branch of a Choice would
otherwise strand a stale mode). The resolver reads pending_hint_mode
directly.

Value-literal fallback slots carry ProseOnly; NewName ident slots carry
ForceProse. hint_mode_at_input_inner now delegates to
hint_resolution_at_input — one resolution path, no duplicated logic.
No behaviour change; the typing-surface matrix guards it.
2026-05-15 21:58:22 +00:00
claude@clouddev1 216e7ba61b DDL grammar: writes_table on table-name slots for column narrowing
Handoff-12 §2.2: the DDL TABLE_NAME_EXISTING slot and the
relationship-endpoint table idents didn't set writes_table, so
column-name slots downstream (drop/rename/change column; relationship
qualified columns) couldn't narrow to the active table — candidates
leaked from every table. Set writes_table: true on TABLE_NAME_EXISTING
and on DR_PARENT/DR_CHILD/AR_PARENT/AR_CHILD table idents. The
deliberately-documenting completion test now asserts per-table
narrowing.
2026-05-15 20:50:56 +00:00
claude@clouddev1 0b15ce0306 Walker + parser: surface mid-typing after separators and Form C/A ambiguity
The typing-surface matrix exposed two bugs the existing 859-test suite
missed:

walk_repeated: when the separator consumed but the inner item failed
at EOF, the old path rolled the separator back and reported a definite
error at the rollback position (`insert into T (a, ` flashed red on
the `,` after each comma). Now propagates Incomplete with the inner's
expected set so the input renderer treats it as mid-typing.

build_insert Form C path: `insert into T (col)` walked to a complete
match but produced `values: []` because Form C's value collector drops
ident-shaped items. The user almost certainly meant Form A and just
hasn't typed `values (...)` yet. Reject with a ValidationError naming
the Form-A continuation; classify_input now reports IncompleteAtEof.

completion_probe / expected_at_input: ValidationFailed used to return
an empty expected set, leaving Tab with nothing to offer at the new
Form-A flag point. Now surface result.tail_expected (skipped-Optional
expectations captured before validation fired) so `values` is still
offered as a candidate.
2026-05-15 20:06:52 +00:00
claude@clouddev1 b3f1a20652 Phase D: insert value list mirrors do_insert's user_cols contract
Bug: hint at \`insert into Customers values (\` for a Customers
table with id:serial PK suggested typing an integer for \`id\`,
but the dispatch path (\`db::do_insert\`) deliberately doesn't
accept user-supplied values for auto-generated columns in
Form B. The grammar prompted for a value the dispatch would
refuse.

The fix aligns Phase D's \`column_value_list\` dynamic sub-grammar
with do_insert's three forms (ADR-0014 + ADR-0018 §3):

- **Form A** \`insert into <T> (col1, col2, …) values (…)\` —
  user explicitly lists columns. Slot list mirrors that
  selection; serial / shortid columns CAN appear if the user
  lists them.
- **Form B** \`insert into <T> values (…)\` — bare values. Slot
  list = non-auto-generated columns of the table in
  declaration order. Serial / shortid get auto-filled by the
  dispatch; the grammar doesn't prompt for them.
- **Form C** \`insert into <T> (v1, v2, …)\` — bare value list.
  Not affected by this change (column_value_list isn't on this
  path; Form C's literals route through the schemaless
  INSERT_PAREN_LIST).

Implementation:

\`WalkContext.user_listed_columns: Option<Vec<String>>\` — when
\`Some\`, signals Form A; \`None\` is Form B. Populated by walking
the first paren's column-list idents.

\`Node::Ident.writes_user_listed_column: bool\` — new field;
\`true\` on the INSERT_PAREN_ITEM's Ident child. When the
walker matches that ident in Form A, it appends the
schema-canonical column name (case-corrected against the
schema) to user_listed_columns.

\`column_value_list\` factory:
- If user_listed_columns is Some → resolve each name from the
  schema; one typed slot per listed column.
- Else → filter current_table_columns to non-auto-generated;
  one typed slot per remaining column.
- Empty result → fall back to the schemaless value-literal
  list (a serial-only table in Form B has nothing for the
  user to type).

Tests:
- New \`phase_d_insert_form_b_skips_serial_column\` confirms the
  bug: \`insert into Customers values (1, 'Alice')\` against a
  Customers with serial id rejects at parse time (Form B
  expects 1 value for Name, not 2).
- New \`phase_d_insert_form_a_accepts_serial_when_listed\`
  confirms \`insert into Customers (id, Name) values (1, 'Alice')\`
  works.
- New \`phase_d_insert_form_a_filters_to_user_listed_columns\`
  confirms partial Form A (\`(Name) values ('Alice')\`).
- Updated \`phase_d_insert_with_schema_accepts_typed_values_per_column\`
  to match the new Form B contract (2 user-typed values, not 3).
- Updated typed-hint test matrix split into form-B (8 types)
  and form-A (serial / shortid).
- New \`typed_hint_form_b_skips_serial_column_to_generic_or_text_neighbor\`
  pins the fallback behavior for a serial-only table.

For the user: \`insert into Customers values (\` for a Customers
with \`(id:serial, Name:text, Email:text)\` now hints
\`for \`Name\`: Type a quoted string …\` (skipping id entirely)
and accepts exactly 2 values. To set the serial explicitly,
use Form A: \`insert into Customers (id, Name, Email) values
(1, 'Alice', 'a@b.c')\`.

Tests: 851 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 18:45:47 +00:00
claude@clouddev1 c485189da8 ADR-0024 Phase D: include column name in value-slot hint prose
User-facing improvement: typing into a value slot now surfaces
the column name in the hint. The hint at `insert into Customers
values (` (first column id:int) reads "for `id`: Type an
integer (e.g. 42, -7) or null" instead of the generic
"Type an integer …" prose. After `1, ` the panel updates to
the second column ("for `Name`: Type a quoted string …"). The
same applies to `update T set Email=` and `delete from T where
ts=` — the catalog wrapper threads the column name through.

Implementation:

**`Node::TypedValueSlot.column_name: Option<&'static str>`**
(new field, `src/dsl/grammar/mod.rs`). When `Some`, walker
writes `WalkContext::pending_value_column` on entry; clears
along with `pending_value_type` on inner success.

**Walker driver writes both names** (`src/dsl/walker/driver.rs`):
- `Node::TypedValueSlot` dispatch reads `column_name` and
  populates `pending_value_column`.
- `Ident { writes_column: true }` dispatch also writes
  `pending_value_column` (using the schema-canonical name when
  available, falling back to the user's spelling) so update
  set / where positions surface the column name.

**Shared sub-grammars** (`src/dsl/grammar/shared.rs`):
- New `slot_for_column(ty, name)` builds a `TypedValueSlot`
  with the embedded leaked column name. Used by
  `column_value_list`.
- New `slot_inner_for_type(ty)` returns just the Choice
  (without TypedValueSlot wrapper) for slot_for_column to
  rebuild.
- `column_value_list` factory now constructs per-column slots
  via `slot_for_column(col.user_type, &col.name)`. Each slot
  leaks its column name string with the same per-walk Box::leak
  pattern the rest of dynamic dispatch uses.

**`WalkContext::pending_value_column: Option<String>`** (new
field, `src/dsl/walker/context.rs`). Pairs with
`pending_value_type` to give the hint resolver both pieces.

**Single-walk hint resolver** (`src/dsl/walker/mod.rs`):
- New `HintResolution { mode: HintMode, column: Option<String> }`
  struct.
- New `hint_resolution_at_input(source, schema) -> Option<
  HintResolution>` runs one walk and reports both pieces. The
  ambient_hint dispatch composes per-column prose from the
  result.
- Existing `hint_mode_at_input` / `hint_mode_at_input_with_schema`
  preserved as thinner wrappers for tests / future callers
  that don't need the column name.

**Catalog wrapper** (`src/friendly/strings/en-US.yaml`,
`src/friendly/keys.rs`):
- New `hint.value_slot_for_column: "for `{column}`: {detail}"`
  prefixes the per-type prose with the actual column name when
  the walker has it bound. Schemaless fallback continues to use
  the generic value-literal prose with no column prefix.

**ambient_hint composes** (`src/input_render.rs`): consults
`hint_resolution_at_input`; when `column` is `Some`, wraps the
type prose through `hint.value_slot_for_column`; otherwise
emits the bare type prose.

Tests (846 total, 0 failing):
- 4 new input_render tests assert column names appear in the
  prose at insert/update/where positions plus the
  second-insert-value position (proves column tracking advances
  with comma).
- All existing tests pass unchanged — the column-name addition
  is layered on top of the type-only prose path.

Clippy clean.
2026-05-15 18:33:52 +00:00
claude@clouddev1 82955679ca ADR-0024 Phase D: per-column-type hint prose at value slots
The Phase D commit landed parse-time validation but not the
user-facing payoff — per-column-type hints. Typing
`insert into Customers values (` rightfully expected a hint
like "Type an integer (e.g. 42, -7) or null" at an int column.
This commit closes that gap.

End-to-end:

**`Node::TypedValueSlot { ty, inner }`** (new variant in
`src/dsl/grammar/mod.rs`):
- Walker walks `inner` to consume the literal but tags
  `WalkContext::pending_value_type = Some(ty)` on entry, then
  clears it on a successful inner match. Positions BETWEEN
  slots (`insert into T values (1` mid-input) thus don't carry
  a stale hint type.

**Typed slot factories wrapped in `TypedValueSlot`**
(`src/dsl/grammar/shared.rs`):
- `INT_SLOT`, `REAL_SLOT`, `DECIMAL_SLOT`, `BOOL_SLOT`,
  `TEXT_SLOT`, `DATE_SLOT`, `DATETIME_SLOT`, `BLOB_SLOT`,
  `SERIAL_SLOT`, `SHORTID_SLOT` — each pairs an inner literal
  Choice with its `Type` so the walker can tag context.
- `slot_for_type(ty)` dispatches to the appropriate constant.
- Bug fix: `ShortId` previously dispatched to `INT_SLOT` (a
  pre-Phase-D holdover from the chumsky-side generic
  fallback). `shortid` columns store base58 text (ADR-0011
  fk_target_type shortid → text); the corrected slot accepts
  `StringLit` or `null`.

**Schema-aware hint resolver** (`src/dsl/walker/mod.rs`):
- `hint_mode_at_input_with_schema(source, &SchemaCache) ->
  Option<HintMode>` is the new public entry point. Reads
  `pending_value_type` from the walker's WalkContext and
  emits `HintMode::ProseOnly("hint.value_slot_<type>")` —
  one per Type.
- The schemaless `hint_mode_at_input(source)` falls back to
  the generic `hint.value_literal_slot` at value-literal slots
  (no per-type narrowing without a schema).
- `catalog_key_for_value_type(ty)` is the type → key
  dispatcher.

**Catalog entries** (`src/friendly/strings/en-US.yaml`,
`src/friendly/keys.rs`):
- 10 new `hint.value_slot_<type>` keys with per-type prose:
  - int/serial → "Type an integer (e.g. 42, -7) or null"
  - real/decimal → "Type a number (e.g. 3.14, -0.5) or null"
  - bool → "Type true, false, or null"
  - text → "Type a quoted string (e.g. 'Alice') or null"
  - date → "Type a quoted date as 'YYYY-MM-DD' or null"
  - datetime → "Type a quoted datetime as 'YYYY-MM-DD
    HH:MM:SS' or null"
  - blob → "Type a quoted blob literal or null"
  - shortid → "Type a quoted shortid (or omit to auto-generate)
    or null"

**Ambient-hint dispatch** (`src/input_render.rs::ambient_hint`):
- Passes the SchemaCache through to
  `hint_mode_at_input_with_schema`, so the live hint panel
  surfaces per-column-type prose as the user types into a
  value slot.

Tests:
- 8 walker-side tests cover insert / update / where typed-slot
  hint dispatch, mid-value no-stale-hint behaviour, and a
  full-coverage routing matrix for every `Type` variant.
- 4 input_render integration tests cover the end-to-end
  ambient_hint path: insert first/second value, update set
  value, and the schemaless fallback to generic prose.

Tests: 842 passing, 0 failing, 1 ignored. Clippy clean.

For the user: typing `insert into Customers values (` against
a Customers table whose first column is `id:int` now shows
"Type an integer (e.g. 42, -7) or null" in the hint panel,
replacing the previous generic value-literal prose. After
typing `1, `, the panel updates to whatever the second column
requires — "Type a quoted string (e.g. 'Alice') or null"
for text, "Type a quoted date as 'YYYY-MM-DD'" for date, etc.
2026-05-15 18:05:38 +00:00
claude@clouddev1 abebd7944f ADR-0024 Phase D (full): schema-aware value typing
Schema-aware typed value slots — the central design claim of
ADR-0024 §Phase D. Insert / update / delete value slots now
dispatch on the user-facing column type at parse time, rejecting
mis-shaped input with localised wording instead of waiting for
the bind-time error.

What changed:

**SchemaCache extension** (`src/completion.rs`):
- New `TableColumn { name, user_type }` for per-table column
  metadata.
- `SchemaCache.table_columns: HashMap<String, Vec<TableColumn>>`.
- `SchemaCache::columns_for_table(name)` — case-insensitive
  lookup, mirrors the walker's case-insensitive entry-word
  resolution.

**WalkContext schema plumbing** (`src/dsl/walker/context.rs`):
- `WalkContext<'a>` gains a lifetime and a `schema: Option<&'a
  SchemaCache>`. `WalkContext::new()` keeps the schemaless
  default; `with_schema(s)` is the new schema-aware constructor.

**Parser entry point** (`src/dsl/parser.rs`):
- `parse_command_with_schema(input, schema)` is the new public
  schema-aware variant. `parse_command(input)` becomes a thin
  wrapper that delegates with `None` for back-compat.
- Internal `try_walker_route` accepts an `Option<&SchemaCache>`
  and threads it into the WalkContext.

**Node::Ident writes_table/writes_column** (`src/dsl/grammar/mod.rs`):
- Two new fields on `Node::Ident`. When `writes_table: true` and
  `source: Tables`, the walker writes the matched ident's name
  into `current_table` and resolves `current_table_columns`
  against the schema cache. When `writes_column: true` and
  `source: Columns`, the walker writes the resolved
  `TableColumn` into `current_column`.

**Walker driver DynamicSubgrammar dispatch** (`src/dsl/walker/driver.rs`):
- The `Node::DynamicSubgrammar(factory)` branch now resolves the
  factory at walk time and `Box::leak`s the result so its inner
  static-slice fields (Choice/Seq) have the lifetime the walker
  expects (per ADR-0024 §sub-grammars). The leak is bounded by
  command-shape complexity per walk; per-walk arena is a future
  optimisation.
- `walk_ident` extends to perform the schema writes when the
  flags are set.

**Typed value slot factories + dynamic sub-grammars** (`src/dsl/grammar/shared.rs`):
- `int_slot` / `real_slot` / `decimal_slot` / `bool_slot` /
  `text_slot` / `date_slot` / `datetime_slot` / `blob_slot` —
  one per `Type`. Each accepts the appropriate literal kind plus
  `null`; integer-only validator rejects `3.14` at int columns;
  decimal validator pins numeric shape.
- `slot_for_type(ty) -> Node` is the dispatcher.
- `current_column_value(ctx) -> Node` is the dynamic sub-grammar
  for `set col = …` and `where col = …` values; reads
  `current_column` and dispatches via `slot_for_type`.
- `column_value_list(ctx) -> Node` is the dynamic sub-grammar
  for `insert into T values (…)`; reads `current_table_columns`
  and unfolds a Seq of typed slots separated by commas.
- Both fall back to the schemaless `VALUE_LITERAL` choice when
  the context lacks the schema-resolved entries — keeps
  schemaless `parse_command` callers (tests, replay path)
  working.

**Data-command grammar wires the new types** (`src/dsl/grammar/data.rs`):
- `TABLE_NAME_INSERT` / `TABLE_NAME_WRITES` (new): table-name
  slots that set `writes_table: true`. Used by insert / update /
  delete to populate `current_table_columns`.
- `SET_COLUMN` / `FILTER_COLUMN` (new): column-name slots in
  `set col=…` / `where col=…` set `writes_column: true`.
- `INSERT_VALUES_LIST` becomes `DynamicSubgrammar(column_value_list)`.
- `UPDATE_ASSIGNMENT` and `WHERE_CLAUSE` use
  `PER_COLUMN_VALUE = DynamicSubgrammar(current_column_value)`.

**Runtime plumbs schema-with-types** (`src/runtime.rs`):
- `refresh_schema_cache` calls `describe_table` for each table
  and populates `SchemaCache::table_columns` with
  `TableColumn { name, user_type }` entries. Best-effort: a
  `describe_table` miss leaves that table unpopulated and the
  walker falls back to schemaless dispatch.

**App dispatches with schema** (`src/app.rs`):
- `dispatch_dsl` routes through `parse_command_with_schema(&self
  .schema_cache, …)` so live typing/dispatch sees the typed
  slots. The replay path stays schemaless (deferred — replay
  bind-time errors still catch type mismatches).

**Catalog** (`src/friendly/strings/en-US.yaml`, `src/friendly/keys.rs`):
- New `parse.custom.bind_type_mismatch` entry with `{found}` and
  `{expected}` placeholders. Surfaced by the int_slot /
  decimal_slot validators.

Tests:
- 11 new walker-side Phase D tests cover insert / update /
  delete with schemas — typed acceptance per column, decimal
  rejection at int columns, null acceptance at any slot,
  multi-assignment per-column dispatch, schemaless fallback.
- The pre-existing `parse_command(input)` test suite (no
  schema) still passes — the fallback path is behaviour-
  preserving.
- 828 passing total, 0 failing, 1 ignored. Clippy clean.
2026-05-15 17:45:56 +00:00
claude@clouddev1 85817791dc ADR-0024 HintMode dispatch via walker_hint_mode_at_input
Adds the `HintMode` dispatch layer the ADR specified: the
ambient-hint resolver now consults a single
`walker::hint_mode_at_input(source) -> Option<HintMode>` to
decide between the prose / candidates ladder, rather than
discovering each slot kind through three separate post-hoc
helpers (`value_literal_hint_at_cursor`,
`typing_name_at_cursor`, and so on).

Behaviour at slot positions today:

- **Value-literal slot** (`null`/`true`/`false`/number/string
  all in the expected set) → `HintMode::ProseOnly
  ("hint.value_literal_slot")`. The ambient-hint ladder
  emits the catalog prose at empty prefix; once the user types
  a partial (`n`, `tr`, `fa`) the partial check declines and
  normal candidate completion takes over.
- **NewName ident slot** → `HintMode::ForceProse
  ("hint.ambient_typing_name")`. The ladder still consults
  `typing_name_at_cursor` to learn what comes after the name
  (the post-name probe is unchanged); `ForceProse` is the
  declarative tag telling the resolver *that* we're in this
  mode.

`HintMode` itself gains `PartialEq + Eq` for tests, and
its docstring is rewritten to describe the live semantics.

This is the structural shape ADR-0024 §HintMode-per-node
describes: one slot → one hint mode → one dispatch arm. The
detection inside `hint_mode_at_input` is transitional — it
pattern-matches the walker's expected-set today, which is
exactly what the previous ad-hoc detectors did. Phase D will
replace the signature match with node-attached `HintMode`
annotations on the typed value slots (so `date_slot`,
`int_slot`, etc. each carry a type-specific catalog key).

Two helpers move into `input_render.rs`:
- `hint_leading_slice(input, cursor)` mirrors the look-back
  used by `candidates_at_cursor` so the hint resolver sees the
  same token-boundary view of the world.
- `cursor_partial_is_empty(input, cursor)` distinguishes
  empty-prefix from in-progress identifier shapes.

8 new walker tests pin the hint-mode resolver across
value-literal-after-paren, value-literal-after-set-assign,
value-literal-in-where, two NewName-slot cases, the
entry-keyword position, the complete-command position, and
the schema-ident position.

Tests: 817 passing, 0 failing, 1 ignored. Clippy clean.
2026-05-15 17:32:17 +00:00
claude@clouddev1 266b4c2ef4 ADR-0024 Phase F (full) step 3: delete legacy parser modules
Removes the last consumers of `dsl::lexer`, `dsl::keyword`, and
`dsl::ident_slot`, then deletes the modules.

- `Theme::token_color(&TokenKind)` deleted along with its test;
  `Theme::highlight_class_color(HighlightClass)` is the sole
  highlight-colour mapper (the walker's `per_byte_class` feeds
  it directly).
- `IdentSource` (`dsl::grammar`) absorbs the schema-list /
  expected-label / round-trip semantics that previously lived
  on `IdentSlot`. Adds `completes_from_schema`, `expected_label`,
  and `from_expected_label` methods. The walker's
  `Expectation::Ident { source }` and the schema-lookup request
  on the database worker now share one enum.
- `SchemaCache::for_slot(IdentSlot)` → `for_source(IdentSource)`.
- `Database::list_names_for` and the `Request::ListNamesFor`
  worker variant take `IdentSource`. Internal tables and column
  / relationship lookups dispatch on the same enum.
- `InvalidIdent.slot: IdentSlot` → `InvalidIdent.source: IdentSource`.
  The `invalid_ident_at_cursor` rendering branch in
  `input_render.rs::ambient_hint` updates accordingly.
- Completion's keyword filter (`Keyword::from_word`) becomes
  "backticked items whose payload is all ASCII alphabetic" —
  punct and digit literals still surface through their own
  candidate sources (composite-literal, flag, schema-ident);
  the alphabetic filter excludes them from the keyword bucket.
- `friendly::keys::tests::keyword_and_punct_have_complete_token_vocabulary`
  is dropped. It cross-checked `Keyword::ALL` / `Punct::ALL`
  against catalog entries; both enums are gone. The
  `parse.token.keyword.*` / `parse.token.punct.*` catalog
  entries themselves survive for one more commit (catalog
  cleanup, ADR-0024 §cleanup-pass); the
  `keys_validate_against_catalog` test still pins them.
- Modules deleted: `src/dsl/lexer.rs`, `src/dsl/keyword.rs`,
  `src/dsl/ident_slot.rs`.

Tests: 806 passing, 0 failing, 1 ignored. The drop from 852
reflects the removed module-internal tests (~32 lexer, 7
keyword, 4 ident_slot, 1 theme token_color, 1 friendly keys
keyword/punct), and is the expected outcome.

Clippy clean with `nursery` lints + `-D warnings`.
2026-05-15 08:33:59 +00:00
claude@clouddev1 a41400e532 ADR-0024 Phase F (full) step 2: usage via CommandNode.usage_ids
Migrates parse-error usage-block rendering from the legacy
`dsl::usage::matched_entry` (which scanned a `Vec<Token>` for the
first matched Keyword) to walker-side lookup driven by each
`CommandNode`'s `usage_ids` slice.

`CommandNode.usage_id: Option<&'static str>` becomes
`usage_ids: &'static [&'static str]`. Multi-form families
(`drop`, `add`, `show`) carry every variant — `drop` lists
table/column/relationship templates; `add` lists column /
relationship; `show` lists data / table. The single-shape
commands carry their single catalog key.

App-lifecycle CommandNodes had pointed at non-existent
`parse.usage.app.*` keys (never noticed because the field was
unused); they now point at the real catalog entries
(`parse.usage.quit`, `parse.usage.help`, …).

New helpers in `dsl::grammar`:
- `usage_keys_for_input(source) -> Option<(entry_word, usage_ids)>`
  resolves the first identifier-shape token to a CommandNode and
  returns its usage_ids list. Used by `app::render_usage_block`
  and `input_render::ambient_hint`.
- `entry_words_alphabetised() -> Vec<&'static str>` replaces
  `dsl::usage::entry_keywords_alphabetised`.

`dsl::usage` is deleted. The "available commands:" fallback in
`render_usage_block` now formats entry words as `` `<word>` ``
directly (matching the `parse.token.keyword.*` catalog renders);
the per-keyword catalog wrappers will collapse in the next step
(ADR-0024 §cleanup-pass §F).

`parse_command` and `parse_tokens` slim down:
- `parse_command(input)` no longer pre-lexes — the walker scans
  source bytes directly.
- `parse_tokens` (internal-only `pub` for "future I3/I4 work")
  is removed; its body folded into `parse_command`.
- `unknown_command_error` reads the walker registry directly.

Touched modules also drop their `crate::dsl::lexer::lex` and
`crate::dsl::usage` imports: `app.rs`, `input_render.rs`,
`completion.rs`.

Tests: 852 passing, 0 failing, 1 ignored (down from 860 because
the 8 `dsl::usage::tests::*` tests are gone with the module).
2026-05-15 08:27:16 +00:00
claude@clouddev1 7bdd3987e1 ADR-0024 Phase F (full) step 1: walker-driven highlighting
Replaces the lex()-driven `base_runs` span builder in
`input_render.rs` with `walker::highlight_runs`. The new
walker-side `dsl::walker::highlight` module returns per-byte
`HighlightClass` assignments for every token shape in the source:

- For commands the walker engages on, `WalkResult::per_byte_class`
  is the authoritative source (keyword / identifier / number /
  string / punct / flag).
- Trailing junk past a partial match — and inputs the walker
  doesn't engage on at all (no registered entry word) — fall
  through to a byte-shape scanner over `lex_helpers` so unknown
  command words, stray punctuation, and unterminated strings
  still highlight sensibly.

`Theme::highlight_class_color` is the walker-side analogue of
`token_color(&TokenKind)`; the renderer reads `walker::highlight_runs`
output and looks up colours through it. `token_color` and the
`lex()` pre-pass remain in place for now — the lexer module is
still consumed by usage rendering and completion until the
remaining Phase F steps land.

`HighlightClass`'s and `WalkResult::per_byte_class`'s
`#[allow(dead_code)]` annotations come off — they're now part of
the production highlight path.

Tests:
- 16 new tests under `dsl::walker::highlight` cover end-to-end
  walks, byte-shape fallbacks (unknown commands, bare flags,
  numbers, punctuation), UTF-8 codepoint advance, and trailing-
  token handling after partial walks.
- Existing `input_render` tests pass unchanged.
- 860 total tests passing (727 lib + 133 integration), 1 ignored.

Clippy clean with `nursery` lints + `-D warnings`.
2026-05-15 08:19:52 +00:00
claude@clouddev1 dca472f8a5 ADR-0024 Phase E: replay end-to-end
Migrate `replay <path>` to the walker. Shape is
Choice(StringLit, BarePath); the StringLit branch handles the
quoted form (with the existing `''` escape), and BarePath
handles the unquoted form.

Per ADR-0024's path-bearing UX change (already shipped for
import / export in Phase A), bare `replay` paths terminate at
the first whitespace byte. Paths with spaces require the
quoted form. The legacy `try_parse_replay_with_bare_path`
source-slice helper in dsl/parser.rs is removed; the
chumsky-side replay branch in command_parser stays declared
but unreachable until Phase F sweeps the chumsky path.

Tests:
- 7 new walker-specific tests for replay: bare relative path,
  bare absolute path, quoted with whitespace, quoted with
  escaped quote, case-insensitive keyword, missing-path
  error, empty-quoted-path parses to empty (runtime layer
  rejects).
- Total: 844 passed, 0 failed, 1 ignored (was 838 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 07:23:51 +00:00
claude@clouddev1 c2accc2385 ADR-0024 Phase D: data commands at chumsky parity
Migrate the four data commands at four entry words: show
(show data / show table), insert, update, delete. Walker now
owns the entire command set introduced through ADR-0014.

Scope deviation from ADR-0024: full schema-aware value typing
via DynamicSubgrammar(column_value_list) is deferred. The
walker accepts any value at any position — matching the
existing chumsky parser's behaviour, where per-column type
checks happen at bind time. The DynamicSubgrammar Node
variant and WalkContext schema fields stay declared so the
infrastructure is in place when the schema cache plumbs
through parse_command (a future refinement). All existing
tests pass on the new shape.

Walker extensions:
- StringLit terminal — wired to the consume_string_literal
  helper that mirrors the legacy lexer's `''` escape handling.
  MatchedItem text carries the unescaped payload; span covers
  the surrounding quotes.
- Bridge: Incomplete error wording now appends `, found end
  of input` (matching the chumsky-side structural error
  contract that `structural_error_for_show_data_without_arg`
  asserts on).

Grammar:
- src/dsl/grammar/data.rs: SHOW (Choice of show_data /
  show_table), INSERT (three forms folded into a single shape
  via a Choice ordered to disambiguate Form B's `values`
  keyword from Forms A/C's `(`-prefixed content; the inner
  paren list is a Choice(VALUE_LITERAL, Ident{Columns}) with
  VALUE_LITERAL ordered first so `true`/`false`/`null` match
  their Word branch rather than the broader identifier catch-
  all), UPDATE (assignments + filter), DELETE (filter).
- VALUE_LITERAL = Choice(Word("null"), Word("true"),
  Word("false"), NumberLit, StringLit) — matches the chumsky
  `value_literal()`.
- WHERE_CLAUSE / FILTER_CLAUSE shared between update and
  delete.
- AST builders walk MatchedPath items in order, using role
  tags (`update_set_column`, `filter_column`,
  `insert_first_item`) to discriminate column references
  belonging to different shapes within the same command.

Tests:
- 13 new walker-specific tests covering all data forms:
  show data / show table, insert with each of three forms,
  insert with negative numbers, update with single + multiple
  assignments + where, update with --all-rows, delete with
  where, delete with --all-rows, update/delete without filter
  errors, replay still routes via chumsky.
- Total: 838 passed, 0 failed, 1 ignored (was 825 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 07:20:53 +00:00