ADR-0024 Phase D: per-column-type hint prose at value slots

The Phase D commit landed parse-time validation but not the
user-facing payoff — per-column-type hints. Typing
`insert into Customers values (` rightfully expected a hint
like "Type an integer (e.g. 42, -7) or null" at an int column.
This commit closes that gap.

End-to-end:

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

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

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

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

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

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

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

For the user: typing `insert into Customers values (` against
a Customers table whose first column is `id:int` now shows
"Type an integer (e.g. 42, -7) or null" in the hint panel,
replacing the previous generic value-literal prose. After
typing `1, `, the panel updates to whatever the second column
requires — "Type a quoted string (e.g. 'Alice') or null"
for text, "Type a quoted date as 'YYYY-MM-DD'" for date, etc.
This commit is contained in:
claude@clouddev1
2026-05-15 18:05:38 +00:00
parent 124c1d33e9
commit 82955679ca
8 changed files with 416 additions and 48 deletions
+14
View File
@@ -281,6 +281,20 @@ pub enum Node {
/// Phase D+ uses this for `column_value_list`.
#[allow(dead_code)]
DynamicSubgrammar(fn(&WalkContext) -> Self),
/// Typed value-literal slot (ADR-0024 §Phase D §typed-value-slots).
///
/// Walks `inner` to consume the literal but records the
/// column type in `WalkContext::pending_value_type` so the
/// hint resolver can emit per-type catalog prose ("Type an
/// integer", "Type a date as 'YYYY-MM-DD'", …) at empty
/// prefix at this slot. The recorded type clears on a
/// successful inner match — so positions BETWEEN typed
/// slots (`insert into T values (1` mid-input) don't carry
/// a stale hint type.
TypedValueSlot {
ty: crate::dsl::types::Type,
inner: &'static Self,
},
}
/// Top-level entry record. One per command. The `entry` keyword
+56 -14
View File
@@ -186,10 +186,22 @@ const INT_SLOT_CHOICES: &[Node] = &[
},
NULL_WORD,
];
const INT_SLOT: Node = Node::Choice(INT_SLOT_CHOICES);
const INT_SLOT_INNER: Node = Node::Choice(INT_SLOT_CHOICES);
const INT_SLOT: Node = Node::TypedValueSlot {
ty: Type::Int,
inner: &INT_SLOT_INNER,
};
const SERIAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Serial,
inner: &INT_SLOT_INNER,
};
const REAL_SLOT_CHOICES: &[Node] = &[Node::NumberLit { validator: None }, NULL_WORD];
const REAL_SLOT: Node = Node::Choice(REAL_SLOT_CHOICES);
const REAL_SLOT_INNER: Node = Node::Choice(REAL_SLOT_CHOICES);
const REAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Real,
inner: &REAL_SLOT_INNER,
};
const DECIMAL_SLOT_CHOICES: &[Node] = &[
Node::NumberLit {
@@ -197,26 +209,54 @@ const DECIMAL_SLOT_CHOICES: &[Node] = &[
},
NULL_WORD,
];
const DECIMAL_SLOT: Node = Node::Choice(DECIMAL_SLOT_CHOICES);
const DECIMAL_SLOT_INNER: Node = Node::Choice(DECIMAL_SLOT_CHOICES);
const DECIMAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Decimal,
inner: &DECIMAL_SLOT_INNER,
};
const BOOL_SLOT_CHOICES: &[Node] = &[
Node::Word(Word::keyword("true")),
Node::Word(Word::keyword("false")),
NULL_WORD,
];
const BOOL_SLOT: Node = Node::Choice(BOOL_SLOT_CHOICES);
const BOOL_SLOT_INNER: Node = Node::Choice(BOOL_SLOT_CHOICES);
const BOOL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Bool,
inner: &BOOL_SLOT_INNER,
};
const TEXT_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD];
const TEXT_SLOT: Node = Node::Choice(TEXT_SLOT_CHOICES);
const TEXT_SLOT_INNER: Node = Node::Choice(TEXT_SLOT_CHOICES);
const TEXT_SLOT: Node = Node::TypedValueSlot {
ty: Type::Text,
inner: &TEXT_SLOT_INNER,
};
const DATE_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD];
const DATE_SLOT: Node = Node::Choice(DATE_SLOT_CHOICES);
const DATETIME_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD];
const DATETIME_SLOT: Node = Node::Choice(DATETIME_SLOT_CHOICES);
const BLOB_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD];
const BLOB_SLOT: Node = Node::Choice(BLOB_SLOT_CHOICES);
// Date / datetime share the StringLit-or-null shape with text
// but get distinct catalog-prose entries so the hint surfaces
// the YYYY-MM-DD / YYYY-MM-DD HH:MM:SS format examples.
const DATE_SLOT: Node = Node::TypedValueSlot {
ty: Type::Date,
inner: &TEXT_SLOT_INNER,
};
const DATETIME_SLOT: Node = Node::TypedValueSlot {
ty: Type::DateTime,
inner: &TEXT_SLOT_INNER,
};
const BLOB_SLOT: Node = Node::TypedValueSlot {
ty: Type::Blob,
inner: &TEXT_SLOT_INNER,
};
// shortid columns store base58 text (ADR-0011 fk_target_type
// shortid → text); the slot accepts a quoted-text literal or
// null. The pre-Phase-D plain-Choice scaffolding had this
// mapped to INT_SLOT (a holdover from the chumsky-side generic
// VALUE_LITERAL fallback). Per-type dispatch corrects that.
const SHORTID_SLOT: Node = Node::TypedValueSlot {
ty: Type::ShortId,
inner: &TEXT_SLOT_INNER,
};
/// Dispatch a value slot per user-facing type
/// (ADR-0024 §slot_for_type). Returns the same node every time
@@ -225,7 +265,9 @@ const BLOB_SLOT: Node = Node::Choice(BLOB_SLOT_CHOICES);
#[must_use]
pub const fn slot_for_type(ty: Type) -> Node {
match ty {
Type::Int | Type::Serial | Type::ShortId => INT_SLOT,
Type::Int => INT_SLOT,
Type::Serial => SERIAL_SLOT,
Type::ShortId => SHORTID_SLOT,
Type::Real => REAL_SLOT,
Type::Decimal => DECIMAL_SLOT,
Type::Bool => BOOL_SLOT,