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:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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"]),
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user