From 4a5fd1b5c19a338beb8c05d2184589030c4f9368 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 12:18:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(hint):=20H2=20Phase=20B=20=E2=80=94=20per-?= =?UTF-8?q?form=20keying=20+=20the=20three=20exemplars=20(ADR-0053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first exemplar (`add 1:n relationship`) showed per-node keying is too coarse for multi-form commands, so revise the mechanism to per-form. - CommandNode `hint_id: Option<&str>` -> `hint_ids: &[&str]` (mirrors usage_ids); hint_key_for_input_in_mode reuses a factored-out pick_form_key (shared digit/m:n/suffix form disambiguation with usage_key_for_input_in_mode) - wire INSERT + ADD (all four forms) with hint_ids - author the three approved exemplars: hint.cmd.insert, hint.cmd.add_relationship, hint.err.foreign_key.child_side (what/example/concept) + keys.rs registration - revise ADR-0053 D3 to per-form; record clause-concept hints as a deferred extension (issue #37); update README + plan - +5 tests; 2488 pass / 1 ignored, clippy clean --- ...-contextual-hint-command-and-keybinding.md | 84 +++++++++----- docs/adr/README.md | 2 +- .../20260614-adr-0053-contextual-hint-H2.md | 34 ++++-- src/app.rs | 51 +++++++- src/dsl/grammar/app.rs | 28 ++--- src/dsl/grammar/data.rs | 27 ++--- src/dsl/grammar/ddl.rs | 31 +++-- src/dsl/grammar/mod.rs | 109 +++++++++++++----- src/dsl/walker/mod.rs | 4 +- src/friendly/keys.rs | 10 ++ src/friendly/strings/en-US.yaml | 21 ++++ 11 files changed, 292 insertions(+), 109 deletions(-) diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md index 34a9197..7bac135 100644 --- a/docs/adr/0053-contextual-hint-command-and-keybinding.md +++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md @@ -2,14 +2,18 @@ ## Status -Accepted — implementation pending. Revised after a `/runda` review +Accepted — implementation in progress. Revised after a `/runda` review (2026-06-14): corrected the verbosity-default fact; re-keyed tier-3 -content on a new `hint_id` (not `help_id`) so every command form — simple -and advanced-SQL — gets distinct, mode-correct content; split the -pre-submit-diagnostic and runtime-error paths; added a comprehensiveness -coverage test. The parallel question of whether the in-app `help` command -should likewise distinguish advanced-SQL forms is tracked **separately** -as Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour). +content off `help_id`; split the pre-submit-diagnostic and runtime-error +paths; added a comprehensiveness coverage test. Revised again during +Phase B implementation (2026-06-15): the first exemplar showed per-*node* +keying is too coarse for multi-form commands (`add`/`drop`/`show`/ +`create`), so D3 now keys tier-3 content **per form** via a +`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept +hints** are recorded as a deferred extension (separate tracking issue). +The parallel question of whether the in-app `help` command should +likewise distinguish advanced-SQL forms is tracked **separately** as +Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour). Decided in conversation 2026-06-14. Closes the last open piece of **A1** (the canonical app-command set, ADR-0003): every app command is @@ -132,22 +136,42 @@ top-level namespace (where tier-2 ambient strings already live), in two new sub-namespaces: - **`hint.cmd.`** — one per command **form**, keyed by a **new - `hint_id: Option<&'static str>`** field added to `CommandNode` - (`src/dsl/grammar/mod.rs:512`, parallel to the existing `help_id` / - `usage_ids`). The F1 live-input path resolves the current input to its - command node and looks up `hint.cmd.`. + `hint_ids: &'static [&'static str]`** field on `CommandNode` + (`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**. + The F1 live-input path resolves the current input to its form's hint key + via `hint_key_for_input_in_mode`, which reuses the same form-word + disambiguation as `usage_key_for_input_in_mode`. - **Why a new field, not `help_id`:** `help_id` is **not** 1:1 with - command forms. The 7 advanced-mode SQL nodes (`SELECT`, `WITH`, - `SQL_INSERT/UPDATE/DELETE`, `EXPLAIN_SQL`) carry `help_id: None` *purely - to dedup the `help` command's printed list* (they share an entry word - with a simple sibling — see `grammar/mod.rs:915-918`), not because they - lack distinct content. Their SQL syntax differs from the simple-DSL - sibling's, so they **must get their own tier-3 block**. A dedicated - `hint_id` gives every one of the ~37 REGISTRY nodes — simple and - advanced-SQL alike — its own key and its own mode-correct example, with - no sharing or deferral. (The analogous gap in the `help` command is out - of scope here — issue #36.) + **Why an array mirroring `usage_ids`, not a per-node `hint_id`** + *(`/runda`/implementation revision, 2026-06-15)*: a single per-node key + is too coarse. Several entry words are **one node spanning many forms** — + `add` (column/relationship/index/constraint), `drop` (table/column/ + relationship/index), `show` (data/table/tables/relationships/indexes), + `create` (table/index). A live-input hint for `add 1:n relationship` is + only useful if it is *specific to relationships*, so the content must be + **per form**, not per node. The project already solved exactly this for + usage templates (`usage_ids` is a per-form array, disambiguated by the + form word), so `hint_ids` mirrors it. Single-form nodes carry one entry; + multi-form nodes carry one per form. This also covers the advanced-SQL + forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`, + `EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of + usage, with mode-correct SQL examples. (The `help`-list collapse of + advanced-SQL forms is a separate gap — issue #36.) + + **Deferred extension — clause-concept hints** (issue #37): per-form is + the right granularity for tier-3 *teaching* (position-awareness within a + form is owned by tier-2 ambient + the live `Next:` line, D4). But some + **concepts live inside a clause**, not a form — `… on delete ⟨cascade| + set null|restrict⟩` (referential actions), the `create table` constraint + slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n` + cardinality. A learner parked in such a clause may want teaching deeper + than tier-2's candidate list but narrower than the whole-form block. v1 + does **not** build this (it would multiply content for points whose value + we can't yet measure, and we don't expect to accumulate usage statistics + to drive it empirically — it will be tackled as a deliberate follow-up + job). The keying does not lock it out: a later `hint.concept.` + namespace can be surfaced when the cursor sits in a recognized clause, + layered on top of the per-form block. - **`hint.err.`** — one per error/diagnostic class, keyed by the friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`, `hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by @@ -309,10 +333,12 @@ Hint — add relationship the ambient one-liner, and the verbose error hint — without cluttering those terse defaults. - **One new keybinding (F1)** joins the keymap and the ADR-0051 strip. -- **A new `hint_id` field on `CommandNode`** (parallel to `help_id`), one - new field of `App` state (`last_error_hint_key`), and one new renderer - family (`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a - `HINT` node, the REGISTRY one entry. +- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring + `usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the + `usage_key_for_input_in_mode` form-disambiguation), one new field of + `App` state (`last_error_hint_key`), and one new renderer family + (`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT` + node, the REGISTRY one entry. - **A large, durable content corpus** (~37 command blocks + ~42 error/ diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` / `hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new @@ -349,6 +375,12 @@ Hint — add relationship created a table — here's what an index would add") — OOS (deferred): a plausible future tier-3 use, but v1 scopes the command path to errors and the F1 path to in-progress input. +- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots, + `with pk`, cardinality) — OOS (deferred, issue #37): a + `hint.concept.` layer surfaced when the cursor sits in a + recognized clause, deeper than tier-2's candidate list but narrower than + the per-form block. Per-form keying (D3) does not lock it out. To be + tackled as a deliberate follow-up job, not gated on usage statistics. ## Content inventory (implementation tracking) diff --git a/docs/adr/README.md b/docs/adr/README.md index 594a97b..aaa1e8a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,4 +58,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) -- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation pending (2026-06-14)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed on a new `hint_id` field on `CommandNode`, not `help_id`** (`/runda` correction): `help_id` is not 1:1 with command forms — the 7 advanced-mode SQL nodes carry `help_id: None` purely to dedup the `help` *list*, so they'd be unkeyable and would wrongly share their simple sibling's content despite different syntax; a dedicated `hint_id` gives every one of the ~37 REGISTRY nodes its own mode-correct block (the parallel `help`-side gap is tracked as issue **#36**). Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_id` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36) +- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (2026-06-14; Phase A done, Phase B underway)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36) diff --git a/docs/plans/20260614-adr-0053-contextual-hint-H2.md b/docs/plans/20260614-adr-0053-contextual-hint-H2.md index 0992a1e..00391ae 100644 --- a/docs/plans/20260614-adr-0053-contextual-hint-H2.md +++ b/docs/plans/20260614-adr-0053-contextual-hint-H2.md @@ -39,19 +39,29 @@ Build order: **Phase A** (mechanism skeleton, falls back to tier-2) → **Phase C** (comprehensive content, batched) → **Phase D** (polish: strip advertisement, snapshots, full green). -## 3. Grammar: the `hint_id` field + the `HINT` node +## 3. Grammar: the `hint_ids` field + the `HINT` node -### 3a. New `CommandNode.hint_id` -- Add `pub hint_id: Option<&'static str>` to `CommandNode` - (`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`), with a - doc comment mirroring `help_id`'s. Compiler will force every node - literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to set it — - in Phase A set them all to `None` (everything falls back to tier-2); - fill them in Phase C. -- **Why `hint_id` not `help_id`** (ADR-0053 D3): `help_id` is `None` on the - 7 advanced-SQL forms purely to dedup the `help` *list*; those forms have - distinct SQL syntax and need their own block. `hint_id` is 1:1 with - forms. +### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B) +- Add `pub hint_ids: &'static [&'static str]` to `CommandNode` + (`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`), + **mirroring `usage_ids`** — *not* a per-node `Option<&str>`. The Phase-B + exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse: + `add`/`drop`/`show`/`create` are each one node spanning many forms, and + a live-input hint must be specific to the typed form. Compiler forces + every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to + set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them. + **Multi-form nodes list ALL their form keys** (e.g. `add` → + `["add_column", "add_relationship", "add_index", "add_constraint"]`) so + the form-word disambiguation resolves correctly and unauthored forms fall + back at render rather than mis-resolving to a sibling. +- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single + typed form's hint stem, reusing `pick_form_key` (factored out of + `usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation). +- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on + the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms + have distinct SQL syntax and need their own block. `hint_ids` is per + form. (The parallel `help`-side gap is issue #36; clause-concept hints + are deferred — issue #37.) ### 3b. `AppCommand::Hint` + the `HINT` node - `AppCommand::Hint` variant (no fields — no topic arg) in diff --git a/src/app.rs b/src/app.rs index e8a645d..cf22c16 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3152,7 +3152,7 @@ impl App { let (view, cursor, _off) = self.feedback_view(); let probe = view.to_string(); let mode = self.effective_mode().as_mode(); - if let Some(id) = crate::dsl::grammar::hint_id_for_input_in_mode(&probe, mode) + if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode) && self.emit_tier3_block(&format!("hint.cmd.{id}")) { return; @@ -5800,6 +5800,55 @@ mod tests { assert!(output_contains(&app, "explain the most recent error")); } + // ── Phase B: tier-3 exemplar content renders ──────────────── + + #[test] + fn f1_on_insert_input_renders_the_insert_hint_block() { + let mut app = App::new(); + type_str(&mut app, "insert into Customers "); + f1(&mut app); + assert!( + output_contains(&app, "Add one or more rows to a table"), + "expected the insert tier-3 block" + ); + } + + #[test] + fn f1_on_add_relationship_renders_the_relationship_block() { + let mut app = App::new(); + type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust "); + f1(&mut app); + assert!( + output_contains(&app, "one parent, many children"), + "expected the add-relationship tier-3 block" + ); + } + + #[test] + fn f1_on_add_column_does_not_render_the_relationship_block() { + // Per-form disambiguation (ADR-0053 D3): `add column` resolves + // to `add_column` (no tier-3 block yet → tier-2 fallback), NOT + // the relationship block — proving the multi-form node keys + // per form, not per node. + let mut app = App::new(); + type_str(&mut app, "add column Note text to Customers"); + f1(&mut app); + assert!(!output_contains(&app, "one parent, many children")); + assert!(!output_contains(&app, "1:n")); + } + + #[test] + fn hint_renders_the_foreign_key_error_block() { + let mut app = App::new(); + app.last_error_hint_key = Some("foreign_key.child_side".to_string()); + type_str(&mut app, "hint"); + submit(&mut app); + assert!( + output_contains(&app, "doesn't match any parent row"), + "expected the FK child-side tier-3 block" + ); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 75dfada..1deaea7 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -266,7 +266,7 @@ pub static QUIT: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_quit, help_id: Some("app.quit"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.quit"],}; pub static HELP: CommandNode = CommandNode { @@ -274,7 +274,7 @@ pub static HELP: CommandNode = CommandNode { shape: HELP_TOPIC_OPT, ast_builder: build_help, help_id: Some("app.help"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.help"],}; pub static HINT: CommandNode = CommandNode { @@ -283,7 +283,7 @@ pub static HINT: CommandNode = CommandNode { ast_builder: build_hint, help_id: Some("app.hint"), // hint_id assigned in Phase C with the tier-3 corpus (ADR-0053). - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.hint"],}; pub static REBUILD: CommandNode = CommandNode { @@ -291,7 +291,7 @@ pub static REBUILD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_rebuild, help_id: Some("app.rebuild"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.rebuild"],}; pub static SAVE: CommandNode = CommandNode { @@ -299,7 +299,7 @@ pub static SAVE: CommandNode = CommandNode { shape: SAVE_AS_OPT, ast_builder: build_save, help_id: Some("app.save"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.save"],}; pub static NEW: CommandNode = CommandNode { @@ -307,7 +307,7 @@ pub static NEW: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_new, help_id: Some("app.new"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.new"],}; pub static LOAD: CommandNode = CommandNode { @@ -315,7 +315,7 @@ pub static LOAD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_load, help_id: Some("app.load"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.load"],}; pub static EXPORT: CommandNode = CommandNode { @@ -323,7 +323,7 @@ pub static EXPORT: CommandNode = CommandNode { shape: EXPORT_PATH_OPT, ast_builder: build_export, help_id: Some("app.export"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.export"],}; pub static IMPORT: CommandNode = CommandNode { @@ -331,7 +331,7 @@ pub static IMPORT: CommandNode = CommandNode { shape: IMPORT_BODY_OPT, ast_builder: build_import, help_id: Some("app.import"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.import"],}; pub static MODE: CommandNode = CommandNode { @@ -339,7 +339,7 @@ pub static MODE: CommandNode = CommandNode { shape: MODE_VALUE, ast_builder: build_mode, help_id: Some("app.mode"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.mode"],}; pub static MESSAGES: CommandNode = CommandNode { @@ -347,7 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode { shape: MESSAGES_VALUE_OPT, ast_builder: build_messages, help_id: Some("app.messages"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.messages"],}; pub static UNDO: CommandNode = CommandNode { @@ -355,7 +355,7 @@ pub static UNDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_undo, help_id: Some("app.undo"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.undo"],}; pub static REDO: CommandNode = CommandNode { @@ -363,7 +363,7 @@ pub static REDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_redo, help_id: Some("app.redo"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.redo"],}; pub static COPY: CommandNode = CommandNode { @@ -371,5 +371,5 @@ pub static COPY: CommandNode = CommandNode { shape: COPY_VALUE_OPT, ast_builder: build_copy, help_id: Some("app.copy"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.copy"],}; diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index f76cebe..752014d 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -1790,7 +1790,7 @@ pub static SHOW: CommandNode = CommandNode { shape: SHOW_SHAPE, ast_builder: build_show, help_id: Some("data.show"), - hint_id: None, + hint_ids: &[], usage_ids: &[ "parse.usage.show_data", "parse.usage.show_table", @@ -1806,7 +1806,7 @@ pub static SEED: CommandNode = CommandNode { shape: SEED_SHAPE, ast_builder: build_seed, help_id: Some("data.seed"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.seed"], }; @@ -1815,7 +1815,8 @@ pub static INSERT: CommandNode = CommandNode { shape: INSERT_SHAPE, ast_builder: build_insert, help_id: Some("data.insert"), - hint_id: None, + // ADR-0053 Phase-B exemplar. + hint_ids: &["insert"], usage_ids: &["parse.usage.insert"],}; pub static UPDATE: CommandNode = CommandNode { @@ -1823,7 +1824,7 @@ pub static UPDATE: CommandNode = CommandNode { shape: UPDATE_SHAPE, ast_builder: build_update, help_id: Some("data.update"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.update"],}; pub static DELETE: CommandNode = CommandNode { @@ -1831,7 +1832,7 @@ pub static DELETE: CommandNode = CommandNode { shape: DELETE_SHAPE, ast_builder: build_delete, help_id: Some("data.delete"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.delete"],}; pub static REPLAY: CommandNode = CommandNode { @@ -1839,7 +1840,7 @@ pub static REPLAY: CommandNode = CommandNode { shape: REPLAY_PATH, ast_builder: build_replay, help_id: Some("data.replay"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.replay"],}; pub static EXPLAIN: CommandNode = CommandNode { @@ -1847,7 +1848,7 @@ pub static EXPLAIN: CommandNode = CommandNode { shape: EXPLAIN_SHAPE, ast_builder: build_explain, help_id: Some("data.explain"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.explain"],}; /// `explain` over advanced-mode SQL (ADR-0039). @@ -1867,7 +1868,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode { // too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // precedent; otherwise `note_help` would print `explain` twice. help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[],}; /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). @@ -1883,7 +1884,7 @@ pub static SELECT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), ast_builder: build_select, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.select"],}; /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). @@ -1898,7 +1899,7 @@ pub static WITH: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), ast_builder: build_select, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.with"],}; /// SQL `INSERT` — the `Advanced`-category node of the shared @@ -1916,7 +1917,7 @@ pub static SQL_INSERT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), ast_builder: build_sql_insert, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; @@ -1930,7 +1931,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), ast_builder: build_sql_update, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; @@ -1946,7 +1947,7 @@ pub static SQL_DELETE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), ast_builder: build_sql_delete, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index e6189d9..69e7774 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -968,7 +968,7 @@ pub static DROP: CommandNode = CommandNode { shape: DROP_SHAPE, ast_builder: build_drop, help_id: Some("ddl.drop"), - hint_id: None, + hint_ids: &[], usage_ids: &[ "parse.usage.drop_table", "parse.usage.drop_column", @@ -982,7 +982,16 @@ pub static ADD: CommandNode = CommandNode { shape: ADD_SHAPE, ast_builder: build_add, help_id: Some("ddl.add"), - hint_id: None, + // Per-form (ADR-0053 D3): every form is listed so the form-word + // disambiguation resolves correctly; forms without an authored + // block yet fall back to tier-2 at render. `add_relationship` is + // authored as a Phase-B exemplar. + hint_ids: &[ + "add_column", + "add_relationship", + "add_index", + "add_constraint", + ], usage_ids: &[ "parse.usage.add_column", "parse.usage.add_relationship", @@ -995,7 +1004,7 @@ pub static RENAME: CommandNode = CommandNode { shape: RENAME_COLUMN, ast_builder: build_rename_column, help_id: Some("ddl.rename"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.rename_column"],}; pub static CHANGE: CommandNode = CommandNode { @@ -1003,7 +1012,7 @@ pub static CHANGE: CommandNode = CommandNode { shape: CHANGE_COLUMN, ast_builder: build_change_column, help_id: Some("ddl.change"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.change_column"],}; // ================================================================= @@ -1364,7 +1373,7 @@ pub static CREATE: CommandNode = CommandNode { shape: CREATE_TABLE, ast_builder: build_create_table, help_id: Some("ddl.create"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.create_table"],}; // ================================================================= @@ -1433,7 +1442,7 @@ pub static CREATE_M2N: CommandNode = CommandNode { shape: CREATE_M2N_SHAPE, ast_builder: build_create_m2n, help_id: Some("ddl.create_m2n"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.create_m2n"], }; @@ -1864,7 +1873,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode { shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), ast_builder: build_sql_create_table, help_id: Some("ddl.sql_create_table"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_create_table"], }; @@ -1884,7 +1893,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode { shape: SQL_DROP_TABLE_SHAPE, ast_builder: build_sql_drop_table, help_id: Some("ddl.sql_drop_table"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_drop_table"], }; @@ -1904,7 +1913,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode { shape: SQL_DROP_INDEX_SHAPE, ast_builder: build_sql_drop_index, help_id: Some("ddl.sql_drop_index"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_drop_index"], }; @@ -1986,7 +1995,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode { shape: SQL_CREATE_INDEX_SHAPE, ast_builder: build_sql_create_index, help_id: Some("ddl.sql_create_index"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_create_index"], }; @@ -2545,7 +2554,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode { shape: SQL_ALTER_TABLE_SHAPE, ast_builder: build_sql_alter_table, help_id: Some("ddl.sql_alter_table"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_alter_table"], }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 772d2b1..4ba3c1e 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -530,15 +530,18 @@ pub struct CommandNode { /// so a newly-registered command appears in `help` /// automatically (ADR-0024 §help_id). pub help_id: Option<&'static str>, - /// Catalog key stem (`hint.cmd.`) for this command form's - /// **tier-3** contextual hint (ADR-0053 / H2). Unlike `help_id` - /// — which is `None` on advanced-SQL forms purely to dedup the - /// `help` list — `hint_id` is 1:1 with command *forms*, so each - /// advanced-SQL form carries its own id and renders SQL-syntax - /// content distinct from its simple-DSL sibling. `None` until a - /// form's tier-3 block is authored (the surface falls back to - /// tier-2 ambient/error text). - pub hint_id: Option<&'static str>, + /// Catalog key stems (`hint.cmd.`) for this command's + /// **tier-3** contextual hints (ADR-0053 / H2), **one per form**, + /// mirroring `usage_ids`. A single-form command carries one; a + /// multi-form command (`add`, `drop`, `show`, `create`) carries + /// one per form so a live-input hint can be specific to the form + /// being typed (`hint.cmd.add_relationship`, not a shared `add` + /// block). `hint_key_for_input_in_mode` disambiguates by the form + /// word, reusing `usage_key_for_input_in_mode`'s logic. Empty + /// until a form's tier-3 block is authored (the surface falls back + /// to tier-2 ambient/error text). Distinct from `help_id` (which is + /// `None` on advanced-SQL forms purely to dedup the `help` list). + pub hint_ids: &'static [&'static str], /// Catalog keys under `parse.usage.*` to render in the /// "usage:" block when a parse error fires for this command /// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families @@ -602,20 +605,30 @@ pub fn usage_keys_for_input_in_mode( Some((entry, keys)) } -/// The tier-3 `hint_id` of the command form `source` is currently -/// typing, in `mode` (H2 / ADR-0053). +/// The single tier-3 hint key (`hint.cmd.` stem) for the command +/// **form** `source` is currently typing, in `mode` (H2 / ADR-0053). /// -/// Reuses the same mode-aware -/// selection as [`usage_keys_for_input_in_mode`] and returns the -/// **mode-primary** node's `hint_id` — so an advanced-SQL form -/// resolves to its *own* id, not its simple-DSL sibling's. `None` if -/// no entry word matches, or the chosen form has no tier-3 block yet -/// (the caller then falls back to tier-2 ambient text). +/// Mirrors [`usage_key_for_input_in_mode`]: the union of the +/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by +/// [`pick_form_key`] — so `add 1:n relationship` resolves to the +/// relationship hint, and an advanced-SQL form resolves to its own +/// (not its simple sibling's). `None` if no entry word matches or the +/// form has no tier-3 block yet (the caller falls back to tier-2). #[must_use] -pub fn hint_id_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { - selected_nodes_for_input_in_mode(source, mode) - .first() - .and_then(|(_, node, _)| node.hint_id) +pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { + let nodes = selected_nodes_for_input_in_mode(source, mode); + if nodes.is_empty() { + return None; + } + let mut keys: Vec<&'static str> = Vec::new(); + for (_, node, _) in &nodes { + for k in node.hint_ids { + if !keys.contains(k) { + keys.push(*k); + } + } + } + pick_form_key(source, &keys) } /// Shared mode-aware command-form selection for the entry word at the @@ -694,14 +707,24 @@ pub fn usage_key_for_input_in_mode( source: &str, mode: crate::mode::Mode, ) -> Option<&'static str> { - use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?; + pick_form_key(source, &keys) +} + +/// From the form word after the entry keyword, pick the single `keys` +/// entry for the form `source` names. +/// +/// A single-entry list resolves to its one key; a multi-form list +/// disambiguates by the form word (`add 1:n relationship` → the +/// `…relationship` key, `create m:n …` → the `…m2n` key, else the +/// identifier form word matched against each key's suffix). Shared by +/// the usage-template and tier-3-hint single-key lookups so they agree. +fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> { + use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let first = *keys.first()?; if keys.len() == 1 { return Some(first); } - // Multi-form: the form is named by the token right after - // the entry keyword. let start = skip_whitespace(source, 0); let (_, entry_end) = consume_ident(source, start)?; let after = skip_whitespace(source, entry_end); @@ -710,14 +733,12 @@ pub fn usage_key_for_input_in_mode( return keys.iter().copied().find(|k| k.ends_with("relationship")); } // The `create m:n relationship` form (ADR-0045) opens with `m:n` - // — a letter, so the digit branch misses it, and its usage key ends - // `…create_m2n` (not `relationship`). + // — a letter, so the digit branch misses it; its key ends `…m2n`. if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) { return keys.iter().copied().find(|k| k.ends_with("m2n")); } - // Otherwise the form word is an identifier — `column`, - // `index`, `table`, `relationship` — matched against the - // usage key's suffix. + // Otherwise the form word is an identifier — `column`, `index`, + // `table`, `relationship` — matched against each key's suffix. let (s, e) = consume_ident(source, after)?; let form = source[s..e].to_ascii_lowercase(); keys.iter().copied().find(|k| k.ends_with(form.as_str())) @@ -873,6 +894,36 @@ pub fn commands_for_entry_word( .collect() } +#[cfg(test)] +mod hint_key_tests { + use super::hint_key_for_input_in_mode; + use crate::mode::Mode; + + /// Per-form hint keying (ADR-0053 D3): a multi-form command + /// resolves the *typed* form, not the node — `add 1:n + /// relationship` → the relationship hint, `add column` → the + /// (as-yet-unauthored) column hint, never the wrong form. + #[test] + fn hint_key_resolves_the_typed_form() { + assert_eq!( + hint_key_for_input_in_mode("add 1:n relationship from A.x to B.y", Mode::Simple), + Some("add_relationship") + ); + assert_eq!( + hint_key_for_input_in_mode("add column Note text to T", Mode::Simple), + Some("add_column") + ); + assert_eq!( + hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple), + Some("insert") + ); + // A node with no hint_ids yet → None (tier-2 fallback). + assert_eq!(hint_key_for_input_in_mode("drop table T", Mode::Simple), None); + // Unknown entry word → None. + assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None); + } +} + #[cfg(test)] mod usage_key_tests { use super::usage_key_for_input; diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 2ee5f17..99a8e0b 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -6910,7 +6910,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("dsltail")), ast_builder: dsl_builder, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; static SMOKE_SQL: CommandNode = CommandNode { @@ -6918,7 +6918,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("sqltail")), ast_builder: sql_builder, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index e8ff14d..989876d 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -224,6 +224,16 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ), ("hint.ambient_expected", &["expected"]), ("hint.getting_started", &[]), + // Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars. + ("hint.cmd.insert.what", &[]), + ("hint.cmd.insert.example", &[]), + ("hint.cmd.insert.concept", &[]), + ("hint.cmd.add_relationship.what", &[]), + ("hint.cmd.add_relationship.example", &[]), + ("hint.cmd.add_relationship.concept", &[]), + ("hint.err.foreign_key.child_side.what", &[]), + ("hint.err.foreign_key.child_side.example", &[]), + ("hint.err.foreign_key.child_side.concept", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index d9581e8..27ae330 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -391,6 +391,27 @@ hint: # H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific # to expand on (no recent error, empty input). getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list." + # ── Tier-3 teaching blocks (ADR-0053 D3) ────────────────────────── + # Per-form command hints (`hint.cmd.
`) and per-class error + # hints (`hint.err.`), each a `what` (1–2 sentences) / `example` + # (one runnable, mode-correct line) / `concept` (the relational idea — + # the teaching part). Phase B seeds the three approved exemplars; the + # rest are authored in Phase C. + cmd: + insert: + what: "Add one or more rows to a table." + example: "insert into Customers values ('Ann', 'ann@example.io')" + concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out." + add_relationship: + what: "Link two tables so a parent row can own many child rows." + example: "add 1:n relationship from Customers.id to Orders.customer_id" + concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet." + err: + foreign_key: + child_side: + what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at." + example: "First insert the parent (insert into Customers …), then the child that references it." + concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`." # Invalid identifier in a schema slot (ADR-0022 stage 8e # + the user's #5). Voice mirrors ADR-0019's "no such # {kind}" wording for consistency with engine errors.