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`. /// Phase D+ uses this for `column_value_list`.
#[allow(dead_code)] #[allow(dead_code)]
DynamicSubgrammar(fn(&WalkContext) -> Self), 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 /// 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, 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_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] = &[ const DECIMAL_SLOT_CHOICES: &[Node] = &[
Node::NumberLit { Node::NumberLit {
@@ -197,26 +209,54 @@ const DECIMAL_SLOT_CHOICES: &[Node] = &[
}, },
NULL_WORD, 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] = &[ const BOOL_SLOT_CHOICES: &[Node] = &[
Node::Word(Word::keyword("true")), Node::Word(Word::keyword("true")),
Node::Word(Word::keyword("false")), Node::Word(Word::keyword("false")),
NULL_WORD, 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_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]; // Date / datetime share the StringLit-or-null shape with text
const DATE_SLOT: Node = Node::Choice(DATE_SLOT_CHOICES); // but get distinct catalog-prose entries so the hint surfaces
// the YYYY-MM-DD / YYYY-MM-DD HH:MM:SS format examples.
const DATETIME_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD]; const DATE_SLOT: Node = Node::TypedValueSlot {
const DATETIME_SLOT: Node = Node::Choice(DATETIME_SLOT_CHOICES); ty: Type::Date,
inner: &TEXT_SLOT_INNER,
const BLOB_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD]; };
const BLOB_SLOT: Node = Node::Choice(BLOB_SLOT_CHOICES); 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 /// Dispatch a value slot per user-facing type
/// (ADR-0024 §slot_for_type). Returns the same node every time /// (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] #[must_use]
pub const fn slot_for_type(ty: Type) -> Node { pub const fn slot_for_type(ty: Type) -> Node {
match ty { 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::Real => REAL_SLOT,
Type::Decimal => DECIMAL_SLOT, Type::Decimal => DECIMAL_SLOT,
Type::Bool => BOOL_SLOT, Type::Bool => BOOL_SLOT,
+8
View File
@@ -31,6 +31,13 @@ pub struct WalkContext<'a> {
pub current_table: Option<String>, pub current_table: Option<String>,
pub current_table_columns: Option<Vec<TableColumn>>, pub current_table_columns: Option<Vec<TableColumn>>,
pub current_column: Option<TableColumn>, pub current_column: Option<TableColumn>,
/// The column type the walker is *about* to consume a value
/// for (ADR-0024 §Phase D §typed-value-slots). Set by the
/// walker on entry to a `Node::TypedValueSlot`, cleared on
/// successful inner match. The hint resolver reads this to
/// emit per-type prose ("Type an integer", "Type a date as
/// 'YYYY-MM-DD'", …) at empty prefix at typed value slots.
pub pending_value_type: Option<crate::dsl::types::Type>,
} }
impl<'a> WalkContext<'a> { impl<'a> WalkContext<'a> {
@@ -52,6 +59,7 @@ impl<'a> WalkContext<'a> {
current_table: None, current_table: None,
current_table_columns: None, current_table_columns: None,
current_column: None, current_column: None,
pending_value_type: None,
} }
} }
} }
+14
View File
@@ -130,6 +130,20 @@ pub fn walk_node(
let resolved: &'static Node = Box::leak(Box::new(factory(ctx))); let resolved: &'static Node = Box::leak(Box::new(factory(ctx)));
walk_node(source, pos, resolved, ctx, path, per_byte) walk_node(source, pos, resolved, ctx, path, per_byte)
} }
Node::TypedValueSlot { ty, inner } => {
// ADR-0024 §Phase D §typed-value-slots. Tag the
// pending column type so the hint resolver can emit
// per-type prose at empty prefix. Clear on
// successful inner match — positions BETWEEN typed
// slots (post-comma, between values) don't carry a
// stale hint type.
ctx.pending_value_type = Some(*ty);
let result = walk_node(source, pos, inner, ctx, path, per_byte);
if matches!(result, NodeWalkResult::Matched { .. }) {
ctx.pending_value_type = None;
}
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,
+194 -30
View File
@@ -30,25 +30,44 @@ use crate::dsl::walker::outcome::{
pub use context::ColumnInfo; pub use context::ColumnInfo;
pub use highlight::highlight_runs; pub use highlight::highlight_runs;
/// Resolve the hint-panel mode at the end of `source` (ADR-0024 /// Resolve the hint-panel mode at the end of `source`
/// §HintMode-per-node). /// (ADR-0024 §HintMode-per-node, §Phase D §typed-value-slots).
/// ///
/// Today this is a detection-based bridge: the walker's /// Schemaless variant. Surfaces:
/// expected-set is pattern-matched for the value-literal slot /// - `HintMode::ProseOnly("hint.value_literal_slot")` at generic
/// signature (`null`/`true`/`false`/number/string) and for /// value-literal positions (all five forms in the expected
/// `Ident { source: NewName }`. The mapping is exactly what the /// set), and
/// post-hoc ad-hoc cases in `input_render.rs::ambient_hint` /// - `HintMode::ForceProse("hint.ambient_typing_name")` at
/// used to compute inline — relocated to one place so the hint /// `NewName` ident slots.
/// resolver dispatches on a `HintMode` enum rather than
/// rediscovering the cases at every call site.
/// ///
/// Phase D will replace this with node-attached `HintMode` /// Schema-aware callers should use `hint_mode_at_input_with_schema`
/// annotations on the typed value slots (so `date_slot` carries /// instead — that variant narrows the prose to the column's
/// `ProseOnly("hint.date_format")`, `int_slot` carries an /// user-facing type at typed value slots (e.g. "Type a date
/// integer-specific hint, etc.). The signature pattern-match /// as 'YYYY-MM-DD'" at a date column).
/// here becomes obsolete once that lands.
#[must_use] #[must_use]
pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode> { pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode> {
hint_mode_at_input_inner(source, None)
}
/// Schema-aware hint-mode resolution (ADR-0024 §Phase D).
///
/// Uses the same schema reference the walker drives parse-time
/// dispatch from. When the walker enters a `Node::TypedValueSlot`
/// at the cursor position, the catalog prose narrows to the
/// column's user-facing type (e.g. `hint.value_slot_int` at an
/// int column).
#[must_use]
pub fn hint_mode_at_input_with_schema(
source: &str,
schema: &crate::completion::SchemaCache,
) -> Option<crate::dsl::grammar::HintMode> {
hint_mode_at_input_inner(source, Some(schema))
}
fn hint_mode_at_input_inner(
source: &str,
schema: Option<&crate::completion::SchemaCache>,
) -> Option<crate::dsl::grammar::HintMode> {
use crate::dsl::grammar::{HintMode, IdentSource}; use crate::dsl::grammar::{HintMode, IdentSource};
use crate::dsl::walker::outcome::Expectation; use crate::dsl::walker::outcome::Expectation;
@@ -59,11 +78,18 @@ pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode>
// candidates, but the hint resolver should stay silent so // candidates, but the hint resolver should stay silent so
// we don't push prose like "Type a name" at the end of a // we don't push prose like "Type a name" at the end of a
// valid command. // valid command.
let expected = expected_for_hint(source); let (expected, pending_value_type) = expected_for_hint_with_ctx(source, schema);
if expected.is_empty() { if expected.is_empty() {
return None; return None;
} }
// Typed value slot at the cursor: the walker tagged
// ctx.pending_value_type on entry to the slot but did not
// clear it (no inner literal matched). Emit per-type prose.
if let Some(ty) = pending_value_type {
return Some(HintMode::ProseOnly(catalog_key_for_value_type(ty)));
}
// Value-literal slot signature: all five forms present. // Value-literal slot signature: all five forms present.
let has_word = |w: &str| { let has_word = |w: &str| {
expected expected
@@ -76,8 +102,10 @@ pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode>
&& expected.iter().any(|e| matches!(e, Expectation::NumberLit)) && expected.iter().any(|e| matches!(e, Expectation::NumberLit))
&& expected.iter().any(|e| matches!(e, Expectation::StringLit)); && expected.iter().any(|e| matches!(e, Expectation::StringLit));
if value_literal_slot { if value_literal_slot {
// The catalog wording lists all valid literal forms with // Fallback prose: lists every literal form with format
// format examples. Phase D will narrow per column type. // examples. Fires when the walker can't resolve a column
// type at the cursor (schemaless caller, missing table,
// unknown column).
return Some(HintMode::ProseOnly("hint.value_literal_slot")); return Some(HintMode::ProseOnly("hint.value_literal_slot"));
} }
@@ -104,6 +132,22 @@ pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode>
None None
} }
const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str {
use crate::dsl::types::Type;
match ty {
Type::Int => "hint.value_slot_int",
Type::Real => "hint.value_slot_real",
Type::Decimal => "hint.value_slot_decimal",
Type::Bool => "hint.value_slot_bool",
Type::Text => "hint.value_slot_text",
Type::Date => "hint.value_slot_date",
Type::DateTime => "hint.value_slot_datetime",
Type::Blob => "hint.value_slot_blob",
Type::Serial => "hint.value_slot_serial",
Type::ShortId => "hint.value_slot_shortid",
}
}
/// What the grammar would accept at the end of `source` /// What the grammar would accept at the end of `source`
/// (ADR-0024 §architecture, Phase F walker-driven completion). /// (ADR-0024 §architecture, Phase F walker-driven completion).
/// ///
@@ -153,36 +197,46 @@ pub fn expected_at_input(source: &str) -> Vec<outcome::Expectation> {
} }
} }
/// Strict-required expected set at the end of `source`. Like /// Strict-required expected set at the end of `source`, plus
/// `expected_at_input` but returns empty on `WalkOutcome::Match` /// the walker's `pending_value_type` at the cursor.
/// — optional-suffix continuations are not surfaced. Used by ///
/// the hint resolver to distinguish "must type more" from /// Like `expected_at_input` but returns empty on
/// "could continue". /// `WalkOutcome::Match` — optional-suffix continuations are not
#[must_use] /// surfaced. Used by the hint resolver to distinguish "must
fn expected_for_hint(source: &str) -> Vec<outcome::Expectation> { /// type more" from "could continue", and to dispatch per-type
/// prose when the cursor is inside a typed value slot.
fn expected_for_hint_with_ctx(
source: &str,
schema: Option<&crate::completion::SchemaCache>,
) -> (Vec<outcome::Expectation>, Option<crate::dsl::types::Type>) {
use crate::dsl::grammar::REGISTRY; use crate::dsl::grammar::REGISTRY;
if source.trim().is_empty() { if source.trim().is_empty() {
return REGISTRY let expected = REGISTRY
.iter() .iter()
.map(|c| outcome::Expectation::Word(c.entry.primary)) .map(|c| outcome::Expectation::Word(c.entry.primary))
.collect(); .collect();
return (expected, None);
} }
let mut ctx = context::WalkContext::new(); let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
context::WalkContext::with_schema(s)
});
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
let Some(result) = result else { let Some(result) = result else {
return REGISTRY let expected = REGISTRY
.iter() .iter()
.map(|c| outcome::Expectation::Word(c.entry.primary)) .map(|c| outcome::Expectation::Word(c.entry.primary))
.collect(); .collect();
return (expected, None);
}; };
match result.outcome { let expected = match result.outcome {
outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => { outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => {
Vec::new() Vec::new()
} }
outcome::WalkOutcome::Incomplete { expected, .. } outcome::WalkOutcome::Incomplete { expected, .. }
| outcome::WalkOutcome::Mismatch { expected, .. } => expected, | outcome::WalkOutcome::Mismatch { expected, .. } => expected,
} };
(expected, ctx.pending_value_type)
} }
/// Public walk entry. `bound` is `EndOfInput` for parse; /// Public walk entry. `bound` is `EndOfInput` for parse;
@@ -1428,6 +1482,116 @@ mod tests {
} }
} }
// ---- Typed-slot HintMode (Phase D + HintMode dispatch) ----
use crate::dsl::walker::hint_mode_at_input_with_schema;
#[test]
fn typed_hint_at_insert_first_value_position_for_int_column() {
let schema = schema_with(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
match hint_mode_at_input_with_schema("insert into Customers values (", &schema) {
Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
}
}
#[test]
fn typed_hint_at_insert_second_value_position_for_text_column() {
let schema = schema_with(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
match hint_mode_at_input_with_schema("insert into Customers values (1, ", &schema) {
Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
}
}
#[test]
fn typed_hint_at_update_set_value_uses_column_type() {
let schema = schema_with(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
match hint_mode_at_input_with_schema("update Customers set Email=", &schema) {
Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
}
}
#[test]
fn typed_hint_at_update_set_value_for_int_column() {
let schema = schema_with(
"Customers",
&[("id", Type::Int), ("Score", Type::Int)],
);
match hint_mode_at_input_with_schema("update Customers set Score=", &schema) {
Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
}
}
#[test]
fn typed_hint_at_where_value_uses_column_type() {
let schema = schema_with("Events", &[("ts", Type::DateTime)]);
match hint_mode_at_input_with_schema("delete from Events where ts=", &schema) {
Some(HintMode::ProseOnly("hint.value_slot_datetime")) => {}
other => panic!("expected ProseOnly value_slot_datetime, got {other:?}"),
}
}
#[test]
fn typed_hint_falls_back_to_generic_when_schema_missing() {
// Empty schema: walker can't resolve column types.
let schema = SchemaCache::default();
match hint_mode_at_input_with_schema("insert into T values (", &schema) {
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
other => panic!("expected generic ProseOnly, got {other:?}"),
}
}
#[test]
fn typed_hint_not_emitted_after_complete_value() {
// `insert into T values (1` — the int slot just MATCHED
// (`1` is a valid int). Pending_value_type was cleared on
// the successful match. No hint at this position
// (between values).
let schema = schema_with("T", &[("id", Type::Int)]);
// Walker is now waiting for `,` or `)`. No HintMode.
let mode = hint_mode_at_input_with_schema("insert into T values (1", &schema);
// The current position isn't a typed slot; expected is
// `,` / `)`. No HintMode fires.
assert!(mode.is_none(), "got {mode:?}");
}
#[test]
fn typed_hint_for_each_type_routes_to_correct_catalog_key() {
// Confirm each Type maps to its expected catalog key
// via insert at a single-column table.
for (ty, key) in [
(Type::Int, "hint.value_slot_int"),
(Type::Real, "hint.value_slot_real"),
(Type::Decimal, "hint.value_slot_decimal"),
(Type::Bool, "hint.value_slot_bool"),
(Type::Text, "hint.value_slot_text"),
(Type::Date, "hint.value_slot_date"),
(Type::DateTime, "hint.value_slot_datetime"),
(Type::Blob, "hint.value_slot_blob"),
(Type::Serial, "hint.value_slot_serial"),
(Type::ShortId, "hint.value_slot_shortid"),
] {
let schema = schema_with("T", &[("c", ty)]);
let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
assert!(
matches!(mode, Some(HintMode::ProseOnly(k)) if k == key),
"expected ProseOnly({key}) for type {ty:?}, got {mode:?}",
);
}
}
#[test] #[test]
fn phase_d_update_multi_assignment_uses_per_column_types() { fn phase_d_update_multi_assignment_uses_per_column_types() {
let schema = schema_with( let schema = schema_with(
+11
View File
@@ -138,6 +138,17 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"hint.ambient_typing_name_then", "hint.ambient_typing_name_then",
&["next"], &["next"],
), ),
// Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]),
("hint.value_slot_date", &[]),
("hint.value_slot_datetime", &[]),
("hint.value_slot_decimal", &[]),
("hint.value_slot_int", &[]),
("hint.value_slot_real", &[]),
("hint.value_slot_serial", &[]),
("hint.value_slot_shortid", &[]),
("hint.value_slot_text", &[]),
// ---- Parse error rendering ---- // ---- Parse error rendering ----
("parse.available_commands", &["commands"]), ("parse.available_commands", &["commands"]),
("parse.caret", &["padding"]), ("parse.caret", &["padding"]),
+17 -3
View File
@@ -285,10 +285,24 @@ hint:
# Value-literal slot — `insert ... values (`, `update ... set # Value-literal slot — `insert ... values (`, `update ... set
# col=`, `where col=`. Replaces the misleading "null true # col=`, `where col=`. Replaces the misleading "null true
# false" keyword candidate list with format guidance for all # false" keyword candidate list with format guidance for all
# accepted literal forms. Schema-aware narrowing (showing only # accepted literal forms. Used when the walker can't resolve a
# the relevant format for the column's type) waits on # column type (schemaless parse, missing table, unknown column).
# ADR-0023.
value_literal_slot: "Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')" value_literal_slot: "Type a value: number, 'text', true/false, null (dates as 'YYYY-MM-DD', datetimes as 'YYYY-MM-DDTHH:MM:SS')"
# Per-column-type value-slot hints (ADR-0024 §Phase D §typed-value-slots).
# Fired when the walker resolved the column's user-facing type
# at the current value slot; narrows the prose to the relevant
# literal forms for that type. Falls back to
# `value_literal_slot` when the type can't be resolved.
value_slot_int: "Type an integer (e.g. 42, -7) or null"
value_slot_real: "Type a number (e.g. 3.14, -0.5) or null"
value_slot_decimal: "Type a number (e.g. 19.95, -2.50) or null"
value_slot_bool: "Type true, false, or null"
value_slot_text: "Type a quoted string (e.g. 'Alice') or null"
value_slot_date: "Type a quoted date as 'YYYY-MM-DD' or null"
value_slot_datetime: "Type a quoted datetime as 'YYYY-MM-DD HH:MM:SS' or null"
value_slot_blob: "Type a quoted blob literal or null"
value_slot_serial: "Type an integer (or omit to auto-generate) or null"
value_slot_shortid: "Type a quoted shortid (or omit to auto-generate) or null"
parse: parse:
# Wrapper around chumsky's structural error message. The # Wrapper around chumsky's structural error message. The
+102 -1
View File
@@ -189,7 +189,10 @@ pub fn ambient_hint(
// mode reflects the slot expected at the token boundary, // mode reflects the slot expected at the token boundary,
// not whatever the partial would resolve to. // not whatever the partial would resolve to.
let leading = hint_leading_slice(input, cursor); let leading = hint_leading_slice(input, cursor);
let hint_mode = crate::dsl::walker::hint_mode_at_input(leading); // ADR-0024 §Phase D §typed-value-slots: pass the schema so
// the resolver can narrow value-slot prose per column type
// (Date → "Type a date as 'YYYY-MM-DD'", etc.).
let hint_mode = crate::dsl::walker::hint_mode_at_input_with_schema(leading, cache);
match hint_mode { match hint_mode {
Some(crate::dsl::grammar::HintMode::ProseOnly(key)) => { Some(crate::dsl::grammar::HintMode::ProseOnly(key)) => {
// The cursor sits at a slot where Tab candidates // The cursor sits at a slot where Tab candidates
@@ -577,6 +580,104 @@ mod tests {
assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none()); assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none());
} }
// ---- Phase D typed-slot hints (end-to-end) -------
fn schema_with_columns(
table: &str,
cols: &[(&str, crate::dsl::types::Type)],
) -> crate::completion::SchemaCache {
use crate::completion::{SchemaCache, TableColumn};
let mut cache = SchemaCache::default();
cache.tables.push(table.to_string());
let columns: Vec<TableColumn> = cols
.iter()
.map(|(n, t)| TableColumn {
name: (*n).to_string(),
user_type: *t,
})
.collect();
for c in &columns {
cache.columns.push(c.name.clone());
}
cache
.table_columns
.insert(table.to_string(), columns);
cache
}
#[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("integer"),
"expected int-slot prose, got: {p:?}",
);
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("quoted string"),
"expected text-slot prose, got: {p:?}",
);
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_at_update_set_shows_per_column_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Birthday", Type::Date)],
);
let input = "update Customers set Birthday=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("YYYY-MM-DD"),
"expected date-slot prose, got: {p:?}",
);
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test]
fn ambient_hint_at_value_slot_falls_back_to_generic_without_schema() {
// Empty cache: the walker can't resolve the column type
// → falls back to the generic value-literal prose.
let cache = empty_cache();
let input = "insert into T values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
// Generic prose lists all forms.
assert!(p.contains("number"), "got: {p:?}");
assert!(p.contains("true/false") || p.contains("true"), "got: {p:?}");
}
other => panic!("expected Prose, got {other:?}"),
}
}
#[test] #[test]
fn ambient_hint_for_valid_input_invites_submit() { fn ambient_hint_for_valid_input_invites_submit() {
let h = prose("create table T with pk", 22).expect("prose hint"); let h = prose("create table T with pk", 22).expect("prose hint");