feat(hint): clause-concept hints layered on F1 (issue #37)

Add a hint.concept.* tier-3 layer surfaced when the cursor sits inside a
recognized clause (referential actions, 1:n/m:n cardinality, primary key,
unique, check, foreign key), layered beneath the per-form block. New
Node::Concept grammar wrapper records clause byte-spans; concept_topic_at_cursor
resolves the innermost containing span. Examples are mode-keyed so they stay
syntax-correct in both simple and advanced mode. Draft ADR (number at merge).
This commit is contained in:
claude@clouddev1
2026-06-23 13:24:54 +00:00
parent e845a6ee72
commit 208da81108
13 changed files with 1231 additions and 20 deletions
+5
View File
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Pressing **F1** while the cursor sits inside a clause now adds a short
"About this clause" explanation beneath the command hint — covering
referential actions (`on delete`/`on update`), one-to-many and
many-to-many relationships, primary keys, unique and check constraints,
and foreign keys. The example shown matches whichever mode you're in.
- `help` now covers the advanced-mode SQL commands: `help select`, `help with`, - `help` now covers the advanced-mode SQL commands: `help select`, `help with`,
and the SQL forms of `insert` / `update` / `delete` / `explain` show their own and the SQL forms of `insert` / `update` / `delete` / `explain` show their own
syntax, and the full command list is grouped into "Simple-mode commands" and syntax, and the full command list is grouped into "Simple-mode commands" and
+462
View File
@@ -0,0 +1,462 @@
# ADR-XXXX: Clause-concept hints — a `hint.concept.*` layer surfaced by cursor position (issue #37)
## Status
Draft — **implemented 2026-06-23** on branch `feat/clause-concept-hints`
(pending merge). Number assigned at merge-to-`main` per ADR-0000
"Numbering discipline" (drafted under `draft-clause-concept-hints.md` /
`ADR-XXXX`; README index entry added at merge). Extends **ADR-0053**
(contextual `hint`, D3/D4) additively; does not supersede it. Closes the
deferred extension tracked as Gitea issue **#37**.
Implementation: `Node::Concept { topic, inner }` (`dsl/grammar/mod.rs`) +
the span-recording driver arm (`dsl/walker/driver.rs`) +
`WalkContext::concept_spans` / `ConceptSpan` (`dsl/walker/context.rs`);
the `concept_topic_at_cursor` resolver (`dsl/walker/mod.rs`); 10
`Node::Concept` wrappers across `shared.rs` / `ddl.rs` /
`sql_create_table.rs` (one shared `REFERENTIAL_CLAUSES`, two extracted
cardinality sub-`Seq`s); the mode-keyed `emit_tier3_block` +
`note_hint_for_input` layering (`app.rs`); the seven `hint.concept.*`
blocks + `hint.block.concept_heading` (`strings/en-US.yaml`) with
`keys.rs` declarations; and the test set (14 resolver unit tests, 3
comprehensiveness gates incl. the recursive `Node::Concept` visitor, 3 F1
integration tests, the `hint_block_with_concept` snapshot). Suite green
across all tiers; `fmt`/`clippy` clean.
Revised after a `/runda` review (2026-06-22) that grounded the mechanism
against the code and caught two design gaps: the clause mapping (D4)
covered only the advanced-SQL constraint grammar and missed the
simple-mode constraint suffix (ADR-0029); and the single-`example`
content model collided with ADR-0053 D6's mode-correct-example rule for
topics reachable in both modes. Both are resolved here — D4 now maps both
grammars, and D5 adopts **mode-keyed examples** (user decision,
2026-06-22).
References ADR-0053 (the three-tier `hint` model + the `hint.cmd.*` /
`hint.err.*` corpus), ADR-0024 (the unified grammar/walker, `HintMode`-
per-node, `MatchedPath` spans), ADR-0022 (ambient tier-2 typing
assistance), ADR-0013/0045 (the relationship + `m:n` grammar), ADR-0035
(advanced-mode `CREATE TABLE` constraint grammar).
## Context
ADR-0053 delivers tier-3 teaching hints at **per-form** granularity: one
`hint.cmd.<form>` block (`what` / `example` / `concept`) per command form,
surfaced by F1 on the live input. Position-awareness *within* a form is
owned by tier-2 (the ambient candidate list / slot prose, ADR-0022).
During ADR-0053 Phase B we identified a third teaching point the per-form
model does not serve: **concepts that live inside a clause**, where a
learner may want teaching deeper than tier-2's candidate list but narrower
than the whole-form block. The canonical examples are the most
load-bearing relational concepts in the tool:
- the **referential actions** clause — `… on delete ⟨cascade | set null |
restrict | no action⟩` (and `on update`);
- the **cardinality** marker — `1:n` (one parent, many children) and
`m:n` (many-to-many, auto-junction per ADR-0045);
- the **primary-key** declaration — `with pk(…)` in simple mode and the
`primary key` constraint in `create table (…)`;
- the **column-constraint slots** in `create table (…)` — `unique`,
`check (…)`, `foreign key` / `references`.
A student parked in one of these clauses is, by definition, looking at the
exact relational concept that clause embodies. ADR-0053 explicitly
deferred this ("clause-concept hints", issue #37) and recorded that the
per-form keying does **not** lock it out: a later `hint.concept.<topic>`
namespace can be surfaced when the cursor sits in a recognized clause,
layered on top of the per-form block.
This ADR is that follow-up. It is **pedagogy-led, not metrics-led** — we
do not expect to accumulate usage statistics about which clauses learners
pause at, so the clause set is chosen by relational-teaching value, not
data.
### Two user decisions taken up front (2026-06-22)
The issue left two scope questions open. Both were decided by the user
before design:
1. **Detection precision: "anywhere inside the clause."** The hint fires
whenever the cursor sits *within* a recognized clause's span —
including parked inside already-typed text (e.g. `add 1:n
relation|ship …`) — not only at the slot boundary where the next token
is about to be typed.
2. **Clause scope: all four families** — referential actions, the
create-table constraint slots, `with pk`, and `1:n` / `m:n`
cardinality.
### Why the existing `HintMode`/`pending_hint_mode` mechanism is not enough
Tier-2 slot prose is driven by `Node::Hinted { mode, inner }`: entering
the node sets `ctx.pending_hint_mode`, and the resolver reads it at the
end of the cursor-trimmed walk (`hint_resolution_at_input_in_mode`,
`src/dsl/walker/mod.rs:107`). Crucially, the driver **clears
`pending_hint_mode` on every successful match** (`src/dsl/walker/
driver.rs:154`): it records only "the Hinted slot the cursor is *about to
fill*", and the moment the slot's content matches, the signal is gone.
That is exactly the *boundary* semantics tier-2 wants — and exactly the
opposite of "anywhere inside the clause". The instant the learner finishes
typing `cascade`, `pending_hint_mode` clears, so a cursor parked on the
finished `on delete cascade` would see nothing. Decision (1) above
therefore needs a **span-based** mechanism that survives the clause being
fully matched, not the single-slot `pending_hint_mode`.
## Decision
### D1 — A span-recording grammar wrapper, `Node::Concept`
Add a new grammar combinator, sibling to `Node::Hinted`:
```rust
Node::Concept { topic: &'static str, inner: &'static Node }
```
It is a pass-through for matching (it walks `inner` and returns `inner`'s
result verbatim — it never changes what parses), but as a side effect the
driver records the **byte span the node covered** into a new context
field:
```rust
// WalkContext
pub concept_spans: Vec<ConceptSpan>, // { topic: &'static str, start: usize, end: usize }
```
- `start` = the first non-whitespace byte the node began matching at
(post `skip_whitespace`, matching the driver's existing convention).
- `end` = the byte position the node's walk reached — the end of the
matched span on a full match, or the stop position on an incomplete /
failed inner. This makes the span the real extent of the clause as typed
so far.
Unlike `pending_hint_mode`, `concept_spans` is **append-only across the
whole walk** and is never cleared on match — so a fully-typed clause keeps
its span, satisfying decision (1).
**Precise push rule** (grounded in `NodeWalkResult`,
`src/dsl/walker/driver.rs:97`): walk `inner`, then push `{ topic, start,
end }` iff the inner result is one of `Matched { end }` (end = `end`),
`Incomplete { position }`, or `Failed { position }` (end = `position`) —
i.e. the node **committed** (consumed at least its first token, or ran out
mid-clause). On `NoMatch { .. }` (the node never engaged — e.g. a `Choice`
tried this branch and it didn't apply) push **nothing**. This is the
`NodeWalkResult`-level analogue of the driver's existing
`pending_hint_mode` clear-on-match leak-avoidance, and it means a failed
`Choice` branch leaves no stale span.
`Node::Concept` is **orthogonal to `Node::Hinted`** — a clause that wants
both a tier-2 slot prose *and* a tier-3 concept span nests them (`Concept{
… inner: Hinted{ … } }`). Neither changes the parse.
### D2 — Cursor → topic resolution: innermost containing span
A new resolver:
```rust
pub fn concept_topic_at_cursor(
input: &str, cursor: usize,
schema: Option<&SchemaCache>, mode: Mode,
) -> Option<&'static str>
```
walks the **full** input buffer with `WalkBound::EndOfInput` (the dormant
`WalkBound::Position` path is *not* used — see D3 rationale), collects
`ctx.concept_spans`, and returns the `topic` of the **innermost span
containing `cursor`**. "Innermost" = the containing span with the latest
`start` (equivalently, the narrowest), so when a `references` clause
(constraint slot) wraps a referential-actions clause, a cursor on `on
delete` resolves to the more specific `referential_actions`, while a
cursor on `references Parent` resolves to `foreign_key`.
Containment is inclusive of `start` and `end` (`start <= cursor <= end`),
so the boundary case — cursor *at* the point of entering the clause —
also resolves, meaning this subsumes the simpler boundary detection for
free.
The `cursor` passed in is the one **already computed by `feedback_view()`**
(`src/app.rs:3204` returns `(view, cursor, _off)`), which strips the `:`
one-shot sigil and adjusts the cursor offset to index into the stripped
`view`. The concept resolver walks that same `view`, so the `:`-strip is
handled with no extra offset arithmetic.
### D3 — Why full-buffer span-containment, not `WalkBound::Position`
`WalkBound::Position(cursor)` (defined, `#[allow(dead_code)]`,
`src/dsl/walker/outcome.rs:26`) slices the source at the cursor and walks
the prefix. Two reasons it is the wrong tool here:
- **It discards text after the cursor.** A cursor parked early in a
complete command (`add 1:n| relationship from … on delete cascade`)
would truncate to `add 1:` and lose the parsed structure that tells us
we are inside a well-formed relationship command. Full-buffer walking
keeps the whole parse; the cursor is used *only* for span containment.
- **It still rides `pending_hint_mode`'s clear-on-match**, which D1
already establishes is unsuitable for inside-clause detection.
Span-recording over a full `EndOfInput` walk needs no new walk-bound
behaviour and leaves the dormant `Position` path untouched.
### D4 — The clause set and their topics (both grammars)
**Seven** `hint.concept.<topic>` blocks, placed via `Node::Concept`
wrappers across **both** the simple-mode (`grammar/ddl.rs`, ADR-0029) and
advanced-mode (`grammar/sql_create_table.rs`, ADR-0035) grammars. The
codebase has **two distinct constraint grammars** — simple-mode `create
table` column constraints (`COLUMN_CONSTRAINT_SUFFIX`, `ddl.rs:1082`:
`not null` / `unique` / `default` / `check`; PK is `with pk`, FK is a
relationship) and the advanced-SQL constraint set (`COL_CONSTRAINT_CHOICES`,
`sql_create_table.rs`: adds `primary key` / `references`). A topic
reachable in both modes is wrapped in **both** grammars (or once, if the
node is physically shared — e.g. `REFERENTIAL_CLAUSES` in `shared.rs` is
reused by the relationship command *and* the advanced `references` clause).
| Topic | Modes | Grammar node(s) to wrap |
|---|---|---|
| `referential_actions` | both | `REFERENTIAL_CLAUSES` (`shared.rs` — one wrapper, reused in both) |
| `cardinality_one_to_many` | simple only | a new `CARDINALITY_1N` sub-`Seq` extracted from `ADD_RELATIONSHIP_NODES` (`ddl.rs:479`) |
| `cardinality_many_to_many` | simple only | a new `CARDINALITY_MN` sub-`Seq` extracted from `CREATE_M2N` head (`ddl.rs`) |
| `primary_key` | both | `WITH_PK` (`ddl.rs`, simple) + the `primary key` constraint node (`sql_create_table.rs`, advanced) |
| `unique` | both | the `unique` constraint node in `ddl.rs` (`UNIQUE_CONSTRAINT`, `:1051`) + in `sql_create_table.rs` |
| `check` | both | the `check (…)` constraint node in `ddl.rs` (`:1064`) + in `sql_create_table.rs` |
| `foreign_key` | advanced only | `REFERENCES_CLAUSE` (`sql_create_table.rs:206`) |
If a `unique` / `check` node turns out to be physically shared between the
two grammars (as `REFERENTIAL_CLAUSES` is), one wrapper suffices — the
mode-keyed example (D5) is selected by the **live mode** at F1 time, not by
which grammar node matched, so wrapper-sharing never affects correctness.
The exact wrapper count is pinned during implementation; the *topic* set is
the seven above.
**Nesting is intentional.** `foreign_key`'s `REFERENCES_CLAUSE` contains
`referential_actions`' `REFERENTIAL_CLAUSES`, so a cursor on `references
Parent` resolves to `foreign_key` and a cursor on the inner `on delete`
resolves to `referential_actions` — D2's innermost-wins handles it.
**Deliberate omissions** (faithful to issue #37's explicit list of
"primary key | unique | check | foreign key"): the **`not null`** and
**`default`** column constraints get **no** concept block in v1. They are
mechanical rather than the headline relational concepts #37 targets;
adding them later is purely additive (a wrapper + a block + a coverage
entry).
Cardinality is **split** (`_one_to_many` vs `_many_to_many`) rather than a
single "cardinality" block: the two are pedagogically distinct — `m:n`
auto-creates a junction table (ADR-0045) — the cursor is always
unambiguously in one or the other, and both are **simple-mode-only**
(`1:n`/`m:n` are app syntax, not SQL), so each carries a single plain
example with no mode conflict.
`referential_actions` is one block covering both `on delete` and `on
update` and all four actions (the action set is the concept; the block
explains the choice rather than each action in isolation).
### D5 — The content model: `what` / `example` / `concept`, with mode-keyed examples
Each `hint.concept.<topic>` block keeps the ADR-0053 D3 three-part shape —
`what` / `example` / `concept`. The one extension is the **example**: to
honour ADR-0053 D6's "mode-correct example, no cross-mode sharing" rule
for topics reachable in both modes (the `/runda` finding), a both-mode
topic carries **two** examples keyed by mode; a single-mode topic carries
one plain example.
```yaml
# both-mode topic — mode-keyed example
hint.concept.referential_actions:
what: "Decide what happens to child rows when the parent they point at is deleted, or its key changes."
example:
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
concept: "A foreign key forbids orphans by default (restrict). `cascade`
deletes the children with the parent; `set null` keeps them but clears
the link. The action encodes a real rule about your data."
# single-mode topic — one plain example
hint.concept.cardinality_one_to_many:
what: "Link two tables so one parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "1:n is one parent, many children. The child table holds the
foreign key column pointing back at the parent — that single shared
value is what groups a parent's children together."
```
`what`/`example` are always present; `concept` is the teaching payload
(it is what makes this tier-3). The both-mode topics are
`referential_actions`, `primary_key`, `unique`, `check`; the single-mode
(simple-only) topics are `cardinality_one_to_many`,
`cardinality_many_to_many`; `foreign_key` is single-mode (advanced-only).
The blocks live under the existing `hint:` namespace in
`src/friendly/strings/en-US.yaml`, alongside `hint.cmd.*` / `hint.err.*`,
and every leaf key is declared in
`src/friendly/keys.rs::KEYS_AND_PLACEHOLDERS` (the validator requires
exhaustive declaration — no prefix exemption for the `hint.*` families).
For both-mode topics that means `hint.concept.<topic>.example.simple` +
`.example.advanced`; for single-mode topics, `hint.concept.<topic>.example`.
**Authoring constraints** (project copy rules — see `CLAUDE.md`): concept
text is user-facing, so it must **never name the database engine** and
must **not say "DSL"** — use "simple mode" / "advanced mode". The `concept`
prose teaches the relational idea in plain language; examples are
copyable, mode-correct lines.
### D6 — Rendering: the concept block layers under the form block
This is an **additive** F1 surface. When F1 fires on live input
(`note_hint_for_input`, `src/app.rs:3200`):
1. Resolve and emit the per-form `hint.cmd.<form>` block exactly as today
(unchanged).
2. **Then** call `concept_topic_at_cursor(buffer, cursor, …)`. If it
yields a topic and `hint.concept.<topic>.what` exists, emit a second
tier-3 block beneath the form block.
The two blocks are visually distinguished by their **heading**: the form
block keeps the existing `hint.block.heading` ("Hint"); the concept block
carries a distinct sub-heading from a new `hint.block.concept_heading`
(e.g. "About this clause") so the learner sees the form-level help *and*
the clause-level concept without confusing the two.
`emit_tier3_block` gains **two** small parameters: the heading key, and an
optional mode used to resolve the example. Its example lookup becomes:
prefer `<stem>.example.<mode>` (where `<mode>` is `simple`/`advanced` from
the live `effective_mode`), else fall back to a plain `<stem>.example`.
This keeps a single renderer for all three tier-3 families — `hint.cmd.*`
and `hint.err.*` keep a plain `.example` (lookup falls straight through),
while `hint.concept.*` both-mode topics resolve the mode-keyed variant.
The mode-correct-example contract of ADR-0053 D6 is thereby honoured by
the *content keying* + this lookup, not by duplicating the renderer.
The concept block is **never shown alone** and **never replaces** the form
block — consistent with the issue ("layered on top of … does not
replace"). If the per-form resolution fails (rare; falls back to tier-2),
the concept layer is skipped too — F1 keeps a single coherent behaviour.
The submitted `hint` command (last-error route, D2 of ADR-0053) is
**unchanged** — it has no cursor, so concept hints do not apply there.
The rendered shape of a form-block-plus-concept-block is locked by a new
`insta` snapshot (`hint_block_with_concept`).
### D7 — Comprehensiveness, validation, fallback
- **Validation (build/test gate):** every `hint.concept.<topic>` leaf key
is declared in `keys.rs`; the existing `keys_validate_against_catalog`
test fails on a typo or an undeclared key, in either direction.
- **Comprehensiveness coverage test:** extend the ADR-0053
comprehensiveness test with a `CONCEPT_TOPICS` table — each entry a
`(topic, ModeCoverage)` where `ModeCoverage` is `Both` or
`Single` — asserting: (a) every topic resolves to `hint.concept.<topic>`
with a `.what` (and, per its `ModeCoverage`, either `.example.simple`
+`.example.advanced` for `Both`, or a plain `.example` for `Single`);
and (b) every `Node::Concept` wrapper reachable from the REGISTRY
carries a topic in the table. (b) needs a **recursive `Node`-tree
visitor** — `Node` is a recursive enum (`Seq(&[Node])` / `Choice` /
`Optional` / `Repeated` / `Hinted` / `Concept`), so the test walks each
REGISTRY shape collecting `Node::Concept` topics. A wrapper added
without content (or a topic with a wrapper missing) fails the test —
this is what makes the clause set enforceable rather than aspirational,
since `keys.rs` only checks that *referenced* keys resolve.
- **Graceful degrade:** `emit_tier3_block` already returns `false` when a
`.what` key is absent, so a missing concept block degrades to "form
block only" — never a blank or an error. The validation above means
that path is a safety net, not the plan.
## Forks
- **Detection precision = anywhere inside the clause** (user, 2026-06-22)
rather than slot-boundary-only. Drives the span-recording mechanism
(D1) over the simpler `pending_hint_mode` reuse.
- **Clause scope = all four families** (user, 2026-06-22) rather than the
issue's "start with referential actions + constraint slots" subset.
- **Mechanism = full-buffer span-containment, not `WalkBound::Position`**
(D3) — keeps the whole parse and leaves the dormant cursor-bound path
untouched. *Engineering choice; rationale in D3.*
- **Mode-keyed examples for both-mode topics** (user, 2026-06-22,
resolving the `/runda` finding) rather than splitting into
`_simple`/`_advanced` blocks — one concept block per topic, two examples
selected by live mode (D5/D6). Honours ADR-0053 D6's mode-correct-example
rule without duplicating concept prose.
- **Cardinality split into two blocks** (D4) rather than one — the `1:n`
and `m:n` concepts diverge (junction table). *Pedagogy choice.*
- **`primary_key` shared between `with pk` and the constraint slot** (D4)
— one concept, two syntaxes (a mode-keyed example per D5). *Content
choice.*
- **Distinct sub-heading for the concept block** (D6) rather than merging
into one block or an undifferentiated second "Hint" — keeps form-level
and clause-level guidance legible. *UX choice.*
## Consequences
- **A fourth contextual surface exists** within tier-3: clause-level
teaching, deeper than tier-2's candidate list, narrower than the
per-form block, surfaced by cursor position on F1.
- **One new grammar node** (`Node::Concept`), **one new `WalkContext`
field** (`concept_spans`), **one new resolver**
(`concept_topic_at_cursor`), **two new renderer parameters** (heading
key + example mode), and **~10 `Node::Concept` wrappers** across both
constraint grammars (exact count pinned at implementation per D4's
shared-node note), plus **two extracted cardinality sub-`Seq` nodes**.
No change to what parses; no change to tier-2; no change to the
submitted `hint` path.
- **A new content sub-namespace** — `hint.concept.*` (seven topics; the
four both-mode topics carry two examples each) — enters the catalogue
under `hint:`, validated by `keys.rs` and the comprehensiveness test.
Ongoing surface: a future clause worth teaching ships its
`Node::Concept` wrapper + block + coverage entry together (a checklist
item for feature ADRs that add clauses).
- **CHANGELOG:** a new `[Unreleased] → Added` bullet (user-facing F1
behaviour), phrased under the copy rules (no engine name; "simple
mode"/"advanced mode", not "DSL").
- **Testing:** Tier-1 unit tests for `concept_topic_at_cursor` across each
clause (cursor at boundary, cursor inside typed text, cursor outside →
`None`, nested `references`→`on delete` innermost-wins, both modes);
the F1 layering logic (form block always; concept block appended iff a
topic resolves; concept skipped when the form block falls back); the
`:` one-shot strip carried through (it must still resolve advanced
forms); Tier-2 `insta` snapshot `hint_block_with_concept`; Tier-3
integration tests (type a partial relationship command, move the cursor
into `on delete`, F1 → form block + referential-actions concept block,
**buffer / cursor / completion memo untouched**); the comprehensiveness
+ `keys.rs` validation tests (D7).
## Out of scope
- **Concept hints on the submitted `hint` command** — OOS: no cursor; the
last-error route is unchanged (D6).
- **A concept block for every clause in the grammar** — OOS for v1: the
eight wrappers cover the highest-value relational concepts (D4). More
can be added additively later, gated by the comprehensiveness test.
- **Surfacing concept text in the always-on tier-2 ambient panel** — OOS
(rejected): tier-2 stays terse by design (ADR-0022); the concept layer
is on-demand via F1, consistent with all of tier-3.
- **A `hint.concept.<topic>` reachable by an explicit argument** (e.g.
`hint cascade`) — OOS: ADR-0053 D1 keeps `hint` argument-free; `help
<topic>` owns explicit reference lookup.
- **Localisation beyond `en-US`** — OOS (deferred): the catalogue is i18n-
structured (ADR-0019) but English-only for v1 (requirements X2).
## Content inventory (implementation tracking)
Seven `hint.concept.<topic>` blocks, authored to the ADR-0053 D7 voice
(`what` / `concept` always; example(s) per mode coverage):
- [x] `referential_actions` *(both — 2 examples)* — on delete/update;
cascade / set null / restrict / no action.
- [x] `cardinality_one_to_many` *(simple — 1)* — one parent owns many
children; the FK lives on the child.
- [x] `cardinality_many_to_many` *(simple — 1)* — junction table; ADR-0045
auto-creation.
- [x] `primary_key` *(both — 2)* — uniquely identifies a row; shared by
`with pk` (simple) and the `create table` constraint (advanced).
- [x] `unique` *(both — 2)* — no two rows share the value; distinct from a
PK.
- [x] `check` *(both — 2)* — a per-row rule enforced on write.
- [x] `foreign_key` *(advanced — 1)* — a child column promising to point
at a real parent.
Implementation artifacts: the `Node::Concept` wrappers (D4 table, ~10
across both grammars + the two extracted cardinality sub-`Seq`s); the
`CONCEPT_TOPICS` coverage table (topic + `ModeCoverage`); the `keys.rs`
declarations (mode-keyed example leaves for the four both-mode topics);
the `hint_block_with_concept` snapshot; the CHANGELOG bullet.
+152 -7
View File
@@ -3205,8 +3205,24 @@ impl App {
let probe = view.to_string(); let probe = view.to_string();
let mode = self.effective_mode().as_mode(); let mode = self.effective_mode().as_mode();
if let Some(id) = crate::dsl::grammar::hint_key_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}")) && self.emit_tier3_block(&format!("hint.cmd.{id}"), "hint.block.heading")
{ {
// Clause-concept layer (issue #37 / clause-concept-hints D6):
// if the cursor sits inside a recognized clause, append its
// `hint.concept.<topic>` block beneath the per-form block.
// Only when the per-form block rendered — the concept layer
// never appears alone.
if let Some(topic) = crate::dsl::walker::concept_topic_at_cursor(
&probe,
cursor,
Some(&self.schema_cache),
mode,
) {
self.emit_tier3_block(
&format!("hint.concept.{topic}"),
"hint.block.concept_heading",
);
}
return; return;
} }
// Tier-2 fallback: surface the ambient prose as a persistent // Tier-2 fallback: surface the ambient prose as a persistent
@@ -3242,7 +3258,7 @@ impl App {
/// (ADR-0053 D2/D5). /// (ADR-0053 D2/D5).
fn note_hint_for_recent_error(&mut self) { fn note_hint_for_recent_error(&mut self) {
if let Some(class) = self.last_error_hint_key.clone() if let Some(class) = self.last_error_hint_key.clone()
&& self.emit_tier3_block(&format!("hint.err.{class}")) && self.emit_tier3_block(&format!("hint.err.{class}"), "hint.block.heading")
{ {
return; return;
} }
@@ -3258,28 +3274,51 @@ impl App {
/// absent so the caller can fall back to tier 2. `what` is /// absent so the caller can fall back to tier 2. `what` is
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling /// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
/// polish (the framed block) lands with the corpus. /// polish (the framed block) lands with the corpus.
fn emit_tier3_block(&mut self, stem: &str) -> bool { /// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
/// under `heading_key` (ADR-0053 D4; clause-concept-hints D6).
/// Returns `false` if the `what` part is absent so the caller can
/// fall back. `heading_key` lets the clause-concept block carry a
/// distinct sub-heading ("About this clause") beneath the per-form
/// `Hint` block. The example lookup is **mode-keyed**
/// (clause-concept-hints D5/D6): it prefers `<stem>.example.<mode>`
/// (`simple`/`advanced` from the live effective mode) and falls
/// back to a plain `<stem>.example` — so `hint.cmd.*` / `hint.err.*`
/// (plain example) are unaffected, while a both-mode
/// `hint.concept.*` topic shows the mode-correct example.
fn emit_tier3_block(&mut self, stem: &str, heading_key: &str) -> bool {
let cat = crate::friendly::catalog(); let cat = crate::friendly::catalog();
let what_key = format!("{stem}.what"); let what_key = format!("{stem}.what");
if cat.get(&what_key).is_none() { if cat.get(&what_key).is_none() {
return false; return false;
} }
// Labelled block (ADR-0053 D4): a `Hint` heading, then aligned // Labelled block (ADR-0053 D4): the heading, then aligned
// `What:` / `Example:` / `Concept:` lines. `concept` renders // `What:` / `Example:` / `Concept:` lines. `concept` renders
// muted (`OutputStyleClass::Hint`); the rest are plain system. // muted (`OutputStyleClass::Hint`); the rest are plain system.
let labelled = |label: &str, value: &str| { let labelled = |label: &str, value: &str| {
// Pad `<Label>:` to a common width so the values align. // Pad `<Label>:` to a common width so the values align.
format!(" {:<9}{value}", format!("{label}:")) format!(" {:<9}{value}", format!("{label}:"))
}; };
self.note_system(crate::t!("hint.block.heading")); let mode_str = match self.effective_mode().as_mode() {
crate::mode::Mode::Simple => "simple",
crate::mode::Mode::Advanced => "advanced",
};
let example_key = {
let keyed = format!("{stem}.example.{mode_str}");
if cat.get(&keyed).is_some() {
keyed
} else {
format!("{stem}.example")
}
};
self.note_system(crate::friendly::translate(heading_key, &[]));
self.note_system(labelled( self.note_system(labelled(
&crate::t!("hint.block.what"), &crate::t!("hint.block.what"),
&crate::friendly::translate(&what_key, &[]), &crate::friendly::translate(&what_key, &[]),
)); ));
if cat.get(&format!("{stem}.example")).is_some() { if cat.get(&example_key).is_some() {
self.note_system(labelled( self.note_system(labelled(
&crate::t!("hint.block.example"), &crate::t!("hint.block.example"),
&crate::friendly::translate(&format!("{stem}.example"), &[]), &crate::friendly::translate(&example_key, &[]),
)); ));
} }
if cat.get(&format!("{stem}.concept")).is_some() { if cat.get(&format!("{stem}.concept")).is_some() {
@@ -6150,6 +6189,112 @@ mod tests {
); );
} }
// ── Clause-concept hints (issue #37) ────────────────────────
/// Move the input cursor left `n` times (into already-typed text).
fn cursor_left_n(app: &mut App, n: usize) {
for _ in 0..n {
app.update(key(KeyCode::Left));
}
}
#[test]
fn f1_inside_on_delete_layers_the_referential_actions_concept() {
let mut app = App::new();
type_str(
&mut app,
"add 1:n relationship from Customers.id to Orders.cid on delete cascade",
);
// Park the cursor inside the already-typed `cascade` — the
// cursor-inside case (#37), not merely the slot boundary.
cursor_left_n(&mut app, 3);
let buffer_before = app.input.clone();
let cursor_before = app.input_cursor;
f1(&mut app);
// Per-form block still renders…
assert!(
output_contains(&app, "one parent, many children"),
"the per-form relationship block must still render",
);
// …layered with the clause-concept block under its sub-heading.
assert!(
output_contains(&app, "About this clause"),
"expected the clause-concept sub-heading",
);
assert!(
output_contains(&app, "forbids orphans"),
"expected the referential-actions concept block",
);
// F1 is a read-only overlay (ADR-0053 D1): buffer + cursor intact.
assert_eq!(app.input, buffer_before, "F1 must not alter the buffer");
assert_eq!(
app.input_cursor, cursor_before,
"F1 must not move the cursor"
);
}
#[test]
fn f1_concept_example_is_mode_correct() {
// Advanced mode: the referential-actions concept is reachable
// via `references … on delete`, and its example must be the
// advanced (SQL) one, never the simple-mode `add 1:n …` line
// (ADR-0053 D6 / clause-concept-hints D5).
let mut app = App::new();
app.mode = crate::mode::Mode::Advanced;
type_str(
&mut app,
"create table Orders (cid int references Customers(id) on delete cascade)",
);
cursor_left_n(&mut app, 3); // inside `cascade`
f1(&mut app);
assert!(
output_contains(&app, "references Customers(id) on delete cascade"),
"expected the advanced-mode example",
);
assert!(
!output_contains(&app, "add 1:n relationship"),
"must NOT show the simple-mode example in advanced mode",
);
}
#[test]
fn f1_outside_a_concept_clause_shows_no_concept_block() {
// Cursor at the end of a plain `insert` — not inside any
// recognized clause. The per-form block renders; no concept
// layer, no sub-heading.
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"),
"the per-form block must render",
);
assert!(
!output_contains(&app, "About this clause"),
"no clause-concept block when the cursor is outside any clause",
);
}
/// Locks the rendered shape of the per-form block + the layered
/// clause-concept block (clause-concept-hints D6).
#[test]
fn hint_block_with_concept_renders_layered() {
let mut app = App::new();
type_str(
&mut app,
"add 1:n relationship from Customers.id to Orders.cid on delete cascade",
);
cursor_left_n(&mut app, 3);
f1(&mut app);
let block = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("hint_block_with_concept", block);
}
#[test] #[test]
fn f1_on_add_column_does_not_render_the_relationship_block() { fn f1_on_add_column_does_not_render_the_relationship_block() {
// Per-form disambiguation (ADR-0053 D3): `add column` resolves // Per-form disambiguation (ADR-0053 D3): `add column` resolves
+53 -6
View File
@@ -476,10 +476,23 @@ const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk")); const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
const ADD_RELATIONSHIP_NODES: &[Node] = &[ // The `1:n` cardinality marker, tagged as the
// `cardinality_one_to_many` clause-concept region (issue #37). A
// distinct sub-`Seq` so the concept span covers exactly `1:n`, not
// the whole relationship form (which the per-form `hint.cmd.*` block
// already serves).
const CARDINALITY_1N_NODES: &[Node] = &[
Node::Literal("1"), Node::Literal("1"),
Node::Punct(':'), Node::Punct(':'),
Node::Word(Word::keyword("n")), Node::Word(Word::keyword("n")),
];
const CARDINALITY_1N: Node = Node::Concept {
topic: "cardinality_one_to_many",
inner: &Node::Seq(CARDINALITY_1N_NODES),
};
const ADD_RELATIONSHIP_NODES: &[Node] = &[
CARDINALITY_1N,
Node::Word(Word::keyword("relationship")), Node::Word(Word::keyword("relationship")),
AR_AS_NAME_OPT, AR_AS_NAME_OPT,
Node::Word(Word::keyword("from")), Node::Word(Word::keyword("from")),
@@ -1048,7 +1061,14 @@ const NOT_NULL_NODES: &[Node] = &[
]; ];
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES); const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique")); // Tagged as the `unique` clause-concept region (issue #37). Shared
// across `create table … with pk`, `add constraint`, and (as a bare
// keyword) `drop constraint`, so the concept surfaces anywhere a
// unique constraint is being declared or removed.
const UNIQUE_CONSTRAINT: Node = Node::Concept {
topic: "unique",
inner: &Node::Word(Word::keyword("unique")),
};
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[ const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("default")), Node::Word(Word::keyword("default")),
@@ -1066,7 +1086,13 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
Node::Subgrammar(&super::expr::OR_EXPR), Node::Subgrammar(&super::expr::OR_EXPR),
Node::Punct(')'), Node::Punct(')'),
]; ];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES); // Tagged as the `check` clause-concept region (issue #37). The span
// covers the whole `check ( <expr> )`, so a cursor inside the
// predicate still resolves the concept.
const CHECK_CONSTRAINT: Node = Node::Concept {
topic: "check",
inner: &Node::Seq(CHECK_CONSTRAINT_NODES),
};
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[ const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT, NOT_NULL_CONSTRAINT,
@@ -1158,11 +1184,20 @@ const SPEC_LIST: Node = Node::Repeated {
}; };
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST); const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
const WITH_PK_NODES: &[Node] = &[ // The `with pk` keywords, tagged as the `primary_key` clause-concept
// region (issue #37). Wrapping just the keywords (not the column
// `SPEC_LIST` that follows) keeps the concept crisp: a cursor on
// `with pk` teaches primary keys, while a cursor on a column's own
// `unique` / `check` constraint resolves to that narrower concept.
const WITH_PK_MARKER_NODES: &[Node] = &[
Node::Word(Word::keyword("with")), Node::Word(Word::keyword("with")),
Node::Word(Word::keyword("pk")), Node::Word(Word::keyword("pk")),
SPEC_LIST_OPT,
]; ];
const WITH_PK_MARKER: Node = Node::Concept {
topic: "primary_key",
inner: &Node::Seq(WITH_PK_MARKER_NODES),
};
const WITH_PK_NODES: &[Node] = &[WITH_PK_MARKER, SPEC_LIST_OPT];
const WITH_PK: Node = Node::Seq(WITH_PK_NODES); const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK); const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
@@ -1424,10 +1459,22 @@ const M2N_T2: Node = Node::Ident {
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW]; const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES)); const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
const CREATE_M2N_NODES: &[Node] = &[ // The `m:n` cardinality marker, tagged as the
// `cardinality_many_to_many` clause-concept region (issue #37).
// Mirrors `CARDINALITY_1N` — a distinct sub-`Seq` so the span covers
// exactly `m:n`.
const CARDINALITY_MN_NODES: &[Node] = &[
Node::Literal("m"), Node::Literal("m"),
Node::Punct(':'), Node::Punct(':'),
Node::Word(Word::keyword("n")), Node::Word(Word::keyword("n")),
];
const CARDINALITY_MN: Node = Node::Concept {
topic: "cardinality_many_to_many",
inner: &Node::Seq(CARDINALITY_MN_NODES),
};
const CREATE_M2N_NODES: &[Node] = &[
CARDINALITY_MN,
Node::Word(Word::keyword("relationship")), Node::Word(Word::keyword("relationship")),
Node::Word(Word::keyword("from")), Node::Word(Word::keyword("from")),
M2N_T1, M2N_T1,
+187
View File
@@ -476,6 +476,26 @@ pub enum Node {
mode: HintMode, mode: HintMode,
inner: &'static Self, inner: &'static Self,
}, },
/// Annotates `inner` as a recognized *clause-concept* region
/// (issue #37 / ADR clause-concept-hints, D1). Transparent to
/// matching, highlighting and the expected-set — it walks
/// `inner` and returns its result verbatim — but as a side
/// effect the walker records the byte span the clause covered
/// into `WalkContext::concept_spans`, tagged with `topic` (a
/// `hint.concept.<topic>` catalogue key). The F1 hint surface
/// reads those spans to layer a clause-level teaching block on
/// top of the per-form `hint.cmd.*` block when the cursor sits
/// inside the clause.
///
/// Sibling of `Hinted`, but where `Hinted` records a single
/// *pending* slot mode (cleared on the next match, so it marks
/// the slot the cursor is *about to fill*), `Concept` appends a
/// durable span that survives the clause being fully matched —
/// the "cursor anywhere inside the clause" semantics #37 wants.
Concept {
topic: &'static str,
inner: &'static Self,
},
} }
/// Which mode group a registered command belongs to (ADR-0030 /// Which mode group a registered command belongs to (ADR-0030
@@ -1087,6 +1107,173 @@ mod hint_key_tests {
"expected at least 49 hint.cmd.* examples, checked {checked}", "expected at least 49 hint.cmd.* examples, checked {checked}",
); );
} }
// ── Clause-concept comprehensiveness (issue #37 / clause-concept-
// hints D7) ────────────────────────────────────────────────────
/// A concept topic's mode reachability — drives both the example
/// shape (both-mode topics carry `example.simple`+`.advanced`;
/// single-mode topics a plain `example`) and the mode each example
/// must parse in.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ConceptModes {
Both,
SimpleOnly,
AdvancedOnly,
}
/// Every clause-concept topic + its mode coverage (D4). Source of
/// truth the gates below cross-check against the grammar wrappers
/// and the catalogue, so a wrapper or block can't drift out of sync.
const CONCEPT_TOPICS: &[(&str, ConceptModes)] = &[
("referential_actions", ConceptModes::Both),
("cardinality_one_to_many", ConceptModes::SimpleOnly),
("cardinality_many_to_many", ConceptModes::SimpleOnly),
("primary_key", ConceptModes::Both),
("unique", ConceptModes::Both),
("check", ConceptModes::Both),
("foreign_key", ConceptModes::AdvancedOnly),
];
/// Recursively collect every `Node::Concept` topic reachable from a
/// shape, following the static-child edges **and** `Subgrammar`/
/// `ScopedSubgrammar` references (the SQL `create table` body — and
/// thus the advanced `foreign_key` / constraint wrappers — hangs off
/// the command shape through a `Subgrammar`). Cycles in the
/// expression grammar are broken by a visited-by-address set; every
/// `Node` lives in static memory, so its address is a stable
/// identity. `DynamicSubgrammar` / `Lookahead` build nodes at walk
/// time and can't be traversed statically — no concept wrapper hides
/// behind one (asserted indirectly: every declared topic is reached).
fn collect_concept_topics(
node: &super::Node,
visited: &mut std::collections::BTreeSet<usize>,
out: &mut std::collections::BTreeSet<&'static str>,
) {
use super::Node;
let addr = std::ptr::from_ref(node) as usize;
if !visited.insert(addr) {
return;
}
match node {
Node::Concept { topic, inner } => {
out.insert(topic);
collect_concept_topics(inner, visited, out);
}
Node::Seq(children) | Node::Choice(children) => {
for c in *children {
collect_concept_topics(c, visited, out);
}
}
Node::Optional(inner)
| Node::Repeated { inner, .. }
| Node::Hinted { inner, .. }
| Node::TypedValueSlot { inner, .. }
| Node::Subgrammar(inner)
| Node::ScopedSubgrammar(inner) => {
collect_concept_topics(inner, visited, out);
}
_ => {}
}
}
/// Gate: the set of `Node::Concept` topics wired into the grammar
/// exactly matches `CONCEPT_TOPICS` — neither a wrapper without a
/// declared topic, nor a declared topic without a wrapper.
#[test]
fn concept_wrappers_match_declared_topics() {
let mut found = std::collections::BTreeSet::new();
let mut visited = std::collections::BTreeSet::new();
for (node, _category) in super::REGISTRY {
collect_concept_topics(&node.shape, &mut visited, &mut found);
}
let declared: std::collections::BTreeSet<&str> =
CONCEPT_TOPICS.iter().map(|(t, _)| *t).collect();
for t in &found {
assert!(
declared.contains(t),
"grammar has Node::Concept topic {t:?} missing from CONCEPT_TOPICS",
);
}
for (t, _) in CONCEPT_TOPICS {
assert!(
found.contains(t),
"CONCEPT_TOPICS lists {t:?} but no Node::Concept wrapper reaches it",
);
}
}
/// Gate: every topic resolves to a `hint.concept.<topic>` block with
/// `what` + `concept`, and the example shape matches its mode
/// coverage (mode-keyed for Both, plain for single-mode). `keys.rs`
/// checks referenced keys resolve; this checks every topic *has* a
/// block of the right shape.
#[test]
fn every_concept_topic_has_a_block() {
let cat = crate::friendly::catalog();
for (topic, modes) in CONCEPT_TOPICS {
for part in ["what", "concept"] {
assert!(
cat.get(&format!("hint.concept.{topic}.{part}")).is_some(),
"missing hint.concept.{topic}.{part}",
);
}
let simple = cat.get(&format!("hint.concept.{topic}.example.simple"));
let advanced = cat.get(&format!("hint.concept.{topic}.example.advanced"));
let plain = cat.get(&format!("hint.concept.{topic}.example"));
match modes {
ConceptModes::Both => {
assert!(
simple.is_some() && advanced.is_some() && plain.is_none(),
"{topic} is Both: needs example.simple + example.advanced, no plain example"
);
}
ConceptModes::SimpleOnly | ConceptModes::AdvancedOnly => {
assert!(
plain.is_some() && simple.is_none() && advanced.is_none(),
"{topic} is single-mode: needs a plain example, no mode-keyed variants"
);
}
}
}
}
/// Semantic guard (mirrors `every_cmd_hint_example_parses_in_its_mode`):
/// every clause-concept example parses in the mode it is taught for,
/// so an example can't drift out of the real grammar.
#[test]
fn every_concept_example_parses_in_its_mode() {
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
let cat = crate::friendly::catalog();
let parses = |key: &str, mode: Mode| {
let example = cat.get(key).unwrap_or_else(|| panic!("missing {key}"));
assert!(
parse_command_in_mode(example, mode).is_ok(),
"{key} does not parse in {mode:?} mode: {example:?}",
);
};
for (topic, modes) in CONCEPT_TOPICS {
match modes {
ConceptModes::Both => {
parses(
&format!("hint.concept.{topic}.example.simple"),
Mode::Simple,
);
parses(
&format!("hint.concept.{topic}.example.advanced"),
Mode::Advanced,
);
}
ConceptModes::SimpleOnly => {
parses(&format!("hint.concept.{topic}.example"), Mode::Simple);
}
ConceptModes::AdvancedOnly => {
parses(&format!("hint.concept.{topic}.example"), Mode::Advanced);
}
}
}
}
} }
#[cfg(test)] #[cfg(test)]
+14 -1
View File
@@ -133,12 +133,25 @@ pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
/// Repeated `on <target> <action>` clauses (0..2 occurrences). /// Repeated `on <target> <action>` clauses (0..2 occurrences).
/// Validation of "specified twice" + max=2 lives in the /// Validation of "specified twice" + max=2 lives in the
/// command's AST builder. /// command's AST builder.
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated { const REFERENTIAL_CLAUSES_INNER: Node = Node::Repeated {
inner: &ON_CLAUSE, inner: &ON_CLAUSE,
separator: None, separator: None,
min: 0, min: 0,
}; };
/// As [`REFERENTIAL_CLAUSES_INNER`], tagged as the
/// `referential_actions` clause-concept region (issue #37).
///
/// Shared by the simple-mode `add 1:n relationship … on delete/update`
/// and the advanced-mode `references … on delete/update` clause, so the
/// one wrapper surfaces the concept in both modes (the mode-keyed
/// example is chosen by the live mode at F1 time, not by which grammar
/// matched).
pub const REFERENTIAL_CLAUSES: Node = Node::Concept {
topic: "referential_actions",
inner: &REFERENTIAL_CLAUSES_INNER,
};
// ================================================================= // =================================================================
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots) // Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
// ================================================================= // =================================================================
+27 -5
View File
@@ -204,16 +204,38 @@ static REFERENCES_NODES: &[Node] = &[
shared::REFERENTIAL_CLAUSES, shared::REFERENTIAL_CLAUSES,
]; ];
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES); const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
// The `foreign_key` clause-concept region (issue #37) — wraps the
// whole `references …` clause. It contains `shared::REFERENTIAL_CLAUSES`
// (the `referential_actions` concept), so a cursor on `references
// <parent>` resolves to `foreign_key` while a cursor on the inner `on
// delete …` resolves to the narrower `referential_actions` (D2
// innermost-wins).
const REFERENCES_CONCEPT: Node = Node::Concept {
topic: "foreign_key",
inner: &REFERENCES_CLAUSE,
};
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` | // `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the // `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
// `Choice` never ambiguously commits. // `Choice` never ambiguously commits. The relational-concept branches
// (`unique`, `primary key`, `check`, `references`) carry a
// `Node::Concept` tag (issue #37); `not null` / `default` deliberately
// do not (D4 omission).
static COL_CONSTRAINT_CHOICES: &[Node] = &[ static COL_CONSTRAINT_CHOICES: &[Node] = &[
Node::Seq(NOT_NULL_NODES), Node::Seq(NOT_NULL_NODES),
Node::Word(Word::keyword("unique")), Node::Concept {
Node::Seq(PRIMARY_KEY_NODES), topic: "unique",
inner: &Node::Word(Word::keyword("unique")),
},
Node::Concept {
topic: "primary_key",
inner: &Node::Seq(PRIMARY_KEY_NODES),
},
Node::Seq(DEFAULT_NODES), Node::Seq(DEFAULT_NODES),
Node::Seq(CHECK_NODES), Node::Concept {
REFERENCES_CLAUSE, topic: "check",
inner: &Node::Seq(CHECK_NODES),
},
REFERENCES_CONCEPT,
]; ];
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES); const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
/// Zero-or-more column constraints after the type (`min: 0`). /// Zero-or-more column constraints after the type (`min: 0`).
+27
View File
@@ -134,6 +134,18 @@ pub struct WalkContext<'a> {
/// resolver reads this directly instead of inferring the /// resolver reads this directly instead of inferring the
/// slot kind from the shape of the expected set. /// slot kind from the shape of the expected set.
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>, pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// Byte spans of the clause-concept regions the walk passed
/// through (issue #37 / ADR clause-concept-hints, D1).
///
/// A `Node::Concept { topic, inner }` wrapper pushes one entry
/// per *committed* inner walk (Matched / Incomplete / Failed —
/// never on NoMatch). Unlike `pending_hint_mode`, this is
/// **append-only and never cleared on match**, so a fully-typed
/// clause keeps its span — that is what lets the F1
/// clause-concept resolver report a concept for a cursor parked
/// *inside* already-typed clause text, not only at the slot
/// boundary.
pub concept_spans: Vec<ConceptSpan>,
/// An `IntroProse` hint captured from an *optional* slot that /// An `IntroProse` hint captured from an *optional* slot that
/// the walk skipped (issue #26). Unlike `pending_hint_mode` /// the walk skipped (issue #26). Unlike `pending_hint_mode`
/// (cleared on the very next match — including the empty match /// (cleared on the very next match — including the empty match
@@ -227,6 +239,19 @@ pub struct PendingCteHarvest {
pub cte_name_span: (usize, usize), pub cte_name_span: (usize, usize),
} }
/// A clause-concept region recorded during a walk (issue #37).
///
/// `topic` is the `hint.concept.<topic>` catalogue key; `[start,
/// end]` is the inclusive byte range the clause covered as typed so
/// far (`end` is the matched end on a full match, or the stop
/// position on an incomplete / failed inner).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConceptSpan {
pub topic: &'static str,
pub start: usize,
pub end: usize,
}
impl<'a> WalkContext<'a> { impl<'a> WalkContext<'a> {
/// Schemaless walk context — the legacy default used by /// Schemaless walk context — the legacy default used by
/// pre-Phase-D callers and tests that don't care about /// pre-Phase-D callers and tests that don't care about
@@ -243,6 +268,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None, pending_value_type: None,
pending_value_column: None, pending_value_column: None,
pending_hint_mode: None, pending_hint_mode: None,
concept_spans: Vec::new(),
surviving_intro_hint: None, surviving_intro_hint: None,
user_listed_columns: None, user_listed_columns: None,
subgrammar_depth: 0, subgrammar_depth: 0,
@@ -266,6 +292,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None, pending_value_type: None,
pending_value_column: None, pending_value_column: None,
pending_hint_mode: None, pending_hint_mode: None,
concept_spans: Vec::new(),
surviving_intro_hint: None, surviving_intro_hint: None,
user_listed_columns: None, user_listed_columns: None,
subgrammar_depth: 0, subgrammar_depth: 0,
+27
View File
@@ -290,6 +290,33 @@ fn walk_node_inner(
ctx.pending_hint_mode = Some(*mode); ctx.pending_hint_mode = Some(*mode);
walk_node(source, pos, inner, ctx, path, per_byte) walk_node(source, pos, inner, ctx, path, per_byte)
} }
Node::Concept { topic, inner } => {
// Issue #37 / ADR clause-concept-hints D1. Walk the
// inner clause, then record its covered byte span in
// `ctx.concept_spans` iff the inner *committed* — i.e.
// matched, ran out mid-clause (Incomplete), or hit a
// hard failure after engaging (Failed). On `NoMatch`
// the node never engaged (e.g. a `Choice` tried this
// branch and it didn't apply), so record nothing — no
// stale span. `start` is the post-whitespace position
// the walk wrapper already resolved.
let result = walk_node(source, pos, inner, ctx, path, per_byte);
let end = match &result {
NodeWalkResult::Matched { end, .. } => Some(*end),
NodeWalkResult::Incomplete { position, .. }
| NodeWalkResult::Failed { position, .. } => Some(*position),
NodeWalkResult::NoMatch { .. } => None,
};
if let Some(end) = end {
ctx.concept_spans
.push(crate::dsl::walker::context::ConceptSpan {
topic,
start: pos,
end,
});
}
result
}
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte), Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
Node::Repeated { Node::Repeated {
inner, inner,
+186 -1
View File
@@ -25,7 +25,7 @@ use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node};
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult}; use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult};
pub use context::ColumnInfo; pub use context::{ColumnInfo, ConceptSpan};
pub use highlight::{highlight_runs, highlight_runs_in_mode}; pub use highlight::{highlight_runs, highlight_runs_in_mode};
pub use outcome::{Diagnostic, Severity}; pub use outcome::{Diagnostic, Severity};
@@ -167,6 +167,46 @@ pub fn hint_resolution_at_input_in_mode(
} }
} }
/// Resolve the clause-concept topic the cursor sits inside, if any
/// (issue #37 / ADR clause-concept-hints, D2).
///
/// Walks the **full** `source` (not a cursor-trimmed prefix — the
/// dormant `WalkBound::Position` path is deliberately not used, so
/// the whole parse and every clause span is available), collects the
/// `Node::Concept` spans recorded in `ctx.concept_spans`, and returns
/// the `topic` of the **innermost** span containing `cursor`. The F1
/// hint surface layers `hint.concept.<topic>` on top of the per-form
/// block when this is `Some`.
///
/// "Innermost" = the containing span with the latest `start`, tie-
/// broken by the earliest `end` (narrowest): when a `references`
/// clause (`foreign_key`) wraps an `on delete` clause
/// (`referential_actions`), a cursor on `on delete` resolves to the
/// more specific inner concept. Containment is inclusive at both ends
/// so the boundary case (cursor exactly at the clause start) also
/// resolves.
#[must_use]
pub fn concept_topic_at_cursor(
source: &str,
cursor: usize,
schema: Option<&crate::completion::SchemaCache>,
mode: crate::mode::Mode,
) -> Option<&'static str> {
if source.trim().is_empty() {
return None;
}
let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
context::WalkContext::with_schema(s)
});
ctx.mode = mode;
let _ = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
ctx.concept_spans
.iter()
.filter(|s| s.start <= cursor && cursor <= s.end)
.max_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)))
.map(|s| s.topic)
}
/// Auto-generated columns a Form B insert skips from its value /// Auto-generated columns a Form B insert skips from its value
/// list — but only when the cursor sits at the *first* value /// list — but only when the cursor sits at the *first* value
/// slot, so the pedagogical note fires once per command rather /// slot, so the pedagogical note fires once per command rather
@@ -7028,3 +7068,148 @@ mod order_by_expected_set_tests {
); );
} }
} }
#[cfg(test)]
mod concept_hint_tests {
//! Issue #37 / ADR clause-concept-hints — `concept_topic_at_cursor`.
//!
//! The cursor is positioned by `find`-ing a substring so the
//! tests don't hard-code byte offsets. "inside" means the cursor
//! sits within already-typed clause text (the cursor-inside
//! semantics, not merely the slot boundary).
use super::concept_topic_at_cursor;
use crate::mode::Mode;
/// Cursor at the START of `needle` within `src`.
fn at(src: &str, needle: &str) -> usize {
src.find(needle).expect("needle present")
}
/// Cursor in the MIDDLE of `needle` within `src` (inside typed text).
fn inside(src: &str, needle: &str) -> usize {
at(src, needle) + needle.len() / 2
}
fn topic(src: &str, cursor: usize, mode: Mode) -> Option<&'static str> {
concept_topic_at_cursor(src, cursor, None, mode)
}
#[test]
fn referential_actions_at_on_delete_boundary() {
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
assert_eq!(
topic(s, at(s, "on delete"), Mode::Simple),
Some("referential_actions"),
);
}
#[test]
fn referential_actions_inside_already_typed_action() {
// Cursor parked inside the finished `cascade` — the
// cursor-inside case the slot-boundary mechanism can't serve.
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
assert_eq!(
topic(s, inside(s, "cascade"), Mode::Simple),
Some("referential_actions"),
);
}
#[test]
fn cardinality_one_to_many_on_marker() {
let s = "add 1:n relationship from Customers.id to Orders.cid";
assert_eq!(
topic(s, inside(s, "1:n"), Mode::Simple),
Some("cardinality_one_to_many"),
);
}
#[test]
fn cardinality_many_to_many_on_marker() {
let s = "create m:n relationship from Students to Courses";
assert_eq!(
topic(s, inside(s, "m:n"), Mode::Simple),
Some("cardinality_many_to_many"),
);
}
#[test]
fn primary_key_in_with_pk_simple() {
let s = "create table Orders with pk Code(text)";
assert_eq!(
topic(s, inside(s, "with pk"), Mode::Simple),
Some("primary_key"),
);
}
#[test]
fn primary_key_in_create_table_constraint_advanced() {
let s = "create table Orders (id int primary key)";
assert_eq!(
topic(s, inside(s, "primary key"), Mode::Advanced),
Some("primary_key"),
);
}
#[test]
fn unique_constraint_simple() {
let s = "create table Ages with pk age(int) unique";
assert_eq!(topic(s, inside(s, "unique"), Mode::Simple), Some("unique"));
}
#[test]
fn unique_constraint_advanced() {
let s = "create table Orders (Code text unique)";
assert_eq!(
topic(s, inside(s, "unique"), Mode::Advanced),
Some("unique")
);
}
#[test]
fn check_constraint_simple() {
let s = "create table Ages with pk age(int) check (age > 0)";
assert_eq!(topic(s, inside(s, "check"), Mode::Simple), Some("check"));
}
#[test]
fn check_constraint_advanced() {
let s = "create table Orders (Qty int check (Qty > 0))";
assert_eq!(topic(s, inside(s, "check"), Mode::Advanced), Some("check"));
}
#[test]
fn foreign_key_references_advanced() {
let s = "create table Orders (cid int references Customers(id))";
assert_eq!(
topic(s, inside(s, "references"), Mode::Advanced),
Some("foreign_key"),
);
}
#[test]
fn nested_on_delete_inside_references_resolves_innermost() {
// `references … on delete cascade` nests referential_actions
// inside foreign_key; cursor on `on delete` → inner concept.
let s = "create table Orders (cid int references Customers(id) on delete cascade)";
assert_eq!(
topic(s, at(s, "on delete"), Mode::Advanced),
Some("referential_actions"),
);
// …but a cursor on `references` → the outer foreign_key.
assert_eq!(
topic(s, inside(s, "references"), Mode::Advanced),
Some("foreign_key"),
);
}
#[test]
fn cursor_outside_any_clause_is_none() {
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
// On the entry word `add` — no concept clause there.
assert_eq!(topic(s, at(s, "add"), Mode::Simple), None);
}
#[test]
fn empty_input_is_none() {
assert_eq!(topic("", 0, Mode::Simple), None);
}
}
+30
View File
@@ -237,9 +237,39 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.ambient_expected", &["expected"]), ("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]), ("hint.getting_started", &[]),
("hint.block.heading", &[]), ("hint.block.heading", &[]),
("hint.block.concept_heading", &[]),
("hint.block.what", &[]), ("hint.block.what", &[]),
("hint.block.example", &[]), ("hint.block.example", &[]),
("hint.block.concept", &[]), ("hint.block.concept", &[]),
// Clause-concept blocks (issue #37 / clause-concept-hints D5).
// Both-mode topics carry a mode-keyed `example` (simple + advanced);
// single-mode topics carry a plain `example`. The comprehensiveness
// test (CONCEPT_TOPICS) enforces this shape per topic.
("hint.concept.referential_actions.what", &[]),
("hint.concept.referential_actions.example.simple", &[]),
("hint.concept.referential_actions.example.advanced", &[]),
("hint.concept.referential_actions.concept", &[]),
("hint.concept.cardinality_one_to_many.what", &[]),
("hint.concept.cardinality_one_to_many.example", &[]),
("hint.concept.cardinality_one_to_many.concept", &[]),
("hint.concept.cardinality_many_to_many.what", &[]),
("hint.concept.cardinality_many_to_many.example", &[]),
("hint.concept.cardinality_many_to_many.concept", &[]),
("hint.concept.primary_key.what", &[]),
("hint.concept.primary_key.example.simple", &[]),
("hint.concept.primary_key.example.advanced", &[]),
("hint.concept.primary_key.concept", &[]),
("hint.concept.unique.what", &[]),
("hint.concept.unique.example.simple", &[]),
("hint.concept.unique.example.advanced", &[]),
("hint.concept.unique.concept", &[]),
("hint.concept.check.what", &[]),
("hint.concept.check.example.simple", &[]),
("hint.concept.check.example.advanced", &[]),
("hint.concept.check.concept", &[]),
("hint.concept.foreign_key.what", &[]),
("hint.concept.foreign_key.example", &[]),
("hint.concept.foreign_key.concept", &[]),
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars. // Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
("hint.cmd.insert.what", &[]), ("hint.cmd.insert.what", &[]),
("hint.cmd.insert.example", &[]), ("hint.cmd.insert.example", &[]),
+49
View File
@@ -426,9 +426,58 @@ hint:
# `what` / `example` / `concept` parts render under. # `what` / `example` / `concept` parts render under.
block: block:
heading: "Hint" heading: "Hint"
# Sub-heading for the clause-concept block layered beneath the
# per-form block when the cursor sits inside a recognized clause
# (issue #37 / clause-concept-hints D6).
concept_heading: "About this clause"
what: "What" what: "What"
example: "Example" example: "Example"
concept: "Concept" concept: "Concept"
# ── Tier-3 clause-concept blocks (issue #37 / clause-concept-hints) ─
# Surfaced by F1 when the cursor sits inside a recognized clause,
# layered on top of the per-form `hint.cmd.*` block. Same
# `what`/`example`/`concept` shape; topics reachable in BOTH modes
# carry a mode-keyed `example` (`simple` + `advanced`) so the example
# is syntax-correct for the mode the user is actually in (ADR-0053 D6).
# Single-mode topics carry one plain `example`. Copy rules: no engine
# name; "simple mode"/"advanced mode", never "DSL".
concept:
referential_actions:
what: "Decide what happens to a child row when the parent it points at is deleted, or its key changes."
example:
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
concept: "A foreign key forbids orphans by default (restrict — the delete is refused). `cascade` deletes the children along with the parent; `set null` keeps the children but clears their link. The action you pick encodes a real rule about your data."
cardinality_one_to_many:
what: "Link two tables so one parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "\"1:n\" is one parent, many children — one customer, many orders. The child table holds a foreign key column pointing back at the parent; that shared value is what groups a parent's children together."
cardinality_many_to_many:
what: "Link two tables when rows on each side can relate to many rows on the other."
example: "create m:n relationship from Students to Courses"
concept: "\"m:n\" — many students take many courses — can't be stored as one foreign key. A junction table sits between them, holding one row per pairing; it is created for you, turning the m:n into two 1:n links."
primary_key:
what: "Mark the column (or columns) that uniquely identify each row."
example:
simple: "create table Customers with pk id(serial), Name(text)"
advanced: "create table Customers (id int primary key, Name text)"
concept: "A primary key is the row's identity: every row must have one, and no two rows may share it. Other tables point at this key to reference the row. List several columns for a compound key when one column isn't unique on its own."
unique:
what: "Require that no two rows share the same value in this column."
example:
simple: "create table Customers with pk id(serial), Email(text) unique"
advanced: "create table Customers (id int primary key, Email text unique)"
concept: "Like a primary key, `unique` forbids duplicates — but a table has one primary key (its identity) and may have many `unique` columns (e.g. email, username). Unlike the primary key, a unique column may usually be left empty."
check:
what: "Attach a rule each row must satisfy before it can be saved."
example:
simple: "create table Products with pk id(serial), Price(decimal) check (Price >= 0)"
advanced: "create table Products (id int primary key, Price decimal check (Price >= 0))"
concept: "A check guards the data at write time: any insert or update that breaks the rule is refused, so invalid rows never reach the table. Use it to enforce real-world constraints — a non-negative price, a rating between 1 and 5."
foreign_key:
what: "Make this column point at a row in another table."
example: "create table Orders (id int primary key, customer_id int references Customers(id))"
concept: "A foreign key is a promise that every value here matches a real row in the parent table — no orphaned references. The parent must exist before the child can point at it; an `on delete`/`on update` action decides what happens when that parent later changes."
# ── Tier-3 teaching blocks (ADR-0053 D3) ────────────────────────── # ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
# Per-form command hints (`hint.cmd.<form>`) and per-class error # Per-form command hints (`hint.cmd.<form>`) and per-class error
# hints (`hint.err.<class>`), each a `what` (12 sentences) / `example` # hints (`hint.err.<class>`), each a `what` (12 sentences) / `example`
@@ -0,0 +1,12 @@
---
source: src/app.rs
expression: block
---
Hint
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.
About this clause
What: Decide what happens to a child row when the parent it points at is deleted, or its key changes.
Example: add 1:n relationship from Customers.id to Orders.customer_id on delete cascade
Concept: A foreign key forbids orphans by default (restrict — the delete is refused). `cascade` deletes the children along with the parent; `set null` keeps the children but clears their link. The action you pick encodes a real rule about your data.