ADR-0024 Phase D: include column name in value-slot hint prose

User-facing improvement: typing into a value slot now surfaces
the column name in the hint. The hint at `insert into Customers
values (` (first column id:int) reads "for `id`: Type an
integer (e.g. 42, -7) or null" instead of the generic
"Type an integer …" prose. After `1, ` the panel updates to
the second column ("for `Name`: Type a quoted string …"). The
same applies to `update T set Email=` and `delete from T where
ts=` — the catalog wrapper threads the column name through.

Implementation:

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

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

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

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

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

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

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

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

Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-15 18:33:52 +00:00
parent 82955679ca
commit c485189da8
8 changed files with 304 additions and 23 deletions
+9 -4
View File
@@ -287,12 +287,17 @@ pub enum Node {
/// 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.
/// prefix at this slot. When `column_name` is `Some`, the
/// walker also writes `pending_value_column` so the hint
/// can be rendered with the actual column name (e.g. "for
/// `Email`: Type a quoted string …") rather than a generic
/// type hint. The recorded values clear on a successful
/// inner match — so positions BETWEEN typed slots
/// (`insert into T values (1` mid-input) don't carry stale
/// hint state.
TypedValueSlot {
ty: crate::dsl::types::Type,
column_name: Option<&'static str>,
inner: &'static Self,
},
}
+61 -7
View File
@@ -189,10 +189,12 @@ const INT_SLOT_CHOICES: &[Node] = &[
const INT_SLOT_INNER: Node = Node::Choice(INT_SLOT_CHOICES);
const INT_SLOT: Node = Node::TypedValueSlot {
ty: Type::Int,
column_name: None,
inner: &INT_SLOT_INNER,
};
const SERIAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Serial,
column_name: None,
inner: &INT_SLOT_INNER,
};
@@ -200,6 +202,7 @@ const REAL_SLOT_CHOICES: &[Node] = &[Node::NumberLit { validator: None }, NULL_W
const REAL_SLOT_INNER: Node = Node::Choice(REAL_SLOT_CHOICES);
const REAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Real,
column_name: None,
inner: &REAL_SLOT_INNER,
};
@@ -212,6 +215,7 @@ const DECIMAL_SLOT_CHOICES: &[Node] = &[
const DECIMAL_SLOT_INNER: Node = Node::Choice(DECIMAL_SLOT_CHOICES);
const DECIMAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Decimal,
column_name: None,
inner: &DECIMAL_SLOT_INNER,
};
@@ -223,6 +227,7 @@ const BOOL_SLOT_CHOICES: &[Node] = &[
const BOOL_SLOT_INNER: Node = Node::Choice(BOOL_SLOT_CHOICES);
const BOOL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Bool,
column_name: None,
inner: &BOOL_SLOT_INNER,
};
@@ -230,6 +235,7 @@ const TEXT_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD];
const TEXT_SLOT_INNER: Node = Node::Choice(TEXT_SLOT_CHOICES);
const TEXT_SLOT: Node = Node::TypedValueSlot {
ty: Type::Text,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
@@ -238,30 +244,36 @@ const TEXT_SLOT: Node = Node::TypedValueSlot {
// the YYYY-MM-DD / YYYY-MM-DD HH:MM:SS format examples.
const DATE_SLOT: Node = Node::TypedValueSlot {
ty: Type::Date,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
const DATETIME_SLOT: Node = Node::TypedValueSlot {
ty: Type::DateTime,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
const BLOB_SLOT: Node = Node::TypedValueSlot {
ty: Type::Blob,
column_name: None,
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.
// null.
const SHORTID_SLOT: Node = Node::TypedValueSlot {
ty: Type::ShortId,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
/// Dispatch a value slot per user-facing type
/// (ADR-0024 §slot_for_type). Returns the same node every time
/// for a given Type — fine to call from within a
/// `DynamicSubgrammar` factory.
/// (ADR-0024 §slot_for_type).
///
/// Returns the same node every time for a given Type — fine to
/// call from within a `DynamicSubgrammar` factory. The
/// returned slot does not carry a column name; callers that
/// have one (e.g. `column_value_list` building per-column
/// slots) should use [`slot_for_column`] instead.
#[must_use]
pub const fn slot_for_type(ty: Type) -> Node {
match ty {
@@ -278,6 +290,45 @@ pub const fn slot_for_type(ty: Type) -> Node {
}
}
/// Look up just the inner Choice (no `TypedValueSlot` wrapper)
/// for a given user-facing type. Used by `slot_for_column` to
/// rebuild a TypedValueSlot with an embedded column name.
const fn slot_inner_for_type(ty: Type) -> &'static Node {
match ty {
Type::Int | Type::Serial => &INT_SLOT_INNER,
Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
&TEXT_SLOT_INNER
}
}
}
/// Build a typed value slot with an embedded column name
/// (ADR-0024 §Phase D §typed-value-slots). Used by
/// `column_value_list` to attach each column's name so the
/// hint resolver can render "for `<column>`:" prefixes.
///
/// The walker writes the (leaked) name into
/// `WalkContext::pending_value_column` on entry to the slot
/// and clears it on successful inner match.
#[must_use]
fn slot_for_column(ty: Type, name: &str) -> Node {
// `Box::leak`: column names from the schema cache need a
// `&'static str`-compatible lifetime to plug into the
// static Node enum. The leak is per dynamic walk (factory
// invocation), bounded by the column count — consistent
// with the `DynamicSubgrammar` Box::leak in the walker
// driver.
let leaked: &'static str = Box::leak(name.to_string().into_boxed_str());
Node::TypedValueSlot {
ty,
column_name: Some(leaked),
inner: slot_inner_for_type(ty),
}
}
// =================================================================
// Dynamic sub-grammar: column_value_list
// =================================================================
@@ -329,12 +380,15 @@ pub fn column_value_list(ctx: &WalkContext) -> Node {
return FALLBACK_VALUE_LIST;
}
// Build a Seq of typed slots interleaved with commas.
// Each slot embeds its column name so the hint resolver
// can mention the column by name ("for `Email`: Type a
// quoted string …").
let mut children: Vec<Node> = Vec::with_capacity(cols.len() * 2);
for (i, col) in cols.iter().enumerate() {
if i > 0 {
children.push(Node::Punct(','));
}
children.push(slot_for_type(col.user_type));
children.push(slot_for_column(col.user_type, &col.name));
}
Node::Seq(Box::leak(children.into_boxed_slice()))
}