fix: advanced CREATE TABLE completion cluster

Three completion / hint bugs in the same advanced-mode grammar
+ walker path:

1. `create table T ` offered only `with` (the DSL fallback) — the
   `(` continuation for the SQL column-def list (ADR-0035 §4) was
   missing because the shared-entry-word completion merge in
   `completion_probe_in_mode` only fired at the entry-word boundary.
   Broadened to fire at any cursor depth and to handle
   `Expectation::Punct` continuations alongside `Word`/`Literal`. A
   shared-entry-word candidate whose grammar has already diverged
   (e.g. SQL `CREATE INDEX` past `create table …`) returns
   Mismatch and is naturally skipped — the viability check stays the
   gate, not the cursor depth.

2. `create table T (` showed only the table-level constraint
   keywords (`primary`, `unique`, `check`, `constraint`, `foreign`)
   in the ambient hint, leaving the column-name role invisible
   because COLUMN_DEF starts with an `Ident::NewName` slot that
   produces no concrete candidate. Added a new `HintMode::IntroProse(
   &'static str)` variant that surfaces catalog prose at slot entry
   without suppressing Tab completion (unlike `ProseOnly`) and
   without requiring `typing_name_at_cursor` to fire (unlike
   `ForceProse`). Wrapped ELEMENT in `Node::Hinted { mode: IntroProse(
   "hint.create_table_element"), … }`, with prose "Type a column
   name, or a table-level constraint: `primary`, `unique`, `check`,
   `constraint`, `foreign`". Tab still cycles every keyword.

3. The SQL_TYPE position leaked the bare keyword `double` (the
   first token of the dedicated `double precision` Choice branch
   per ADR-0035 §6.3) alongside the playground's regular type list.
   Added `("double", "double precision")` to `COMPOSITE_CANDIDATES`
   and extended the keyword filter to drop composite openers so the
   composite phrase replaces the bare opener instead of appearing
   alongside it. Tab now offers `double precision` as a single
   coherent candidate; the partial-typing prose at the same slot is
   subsumed by item 2's IntroProse (the user reads "Type a column
   name…" while mid-typing, then advances to the clean type list).

Tests added (4): pinning each behavioural promise above plus the
no-leakage assertion at the partial-typing prose position. Full
suite 2035 passed / 0 failed / 0 unexpected skips. Clippy clean.

The new `HintMode::IntroProse` variant is an additive extension to
the ADR-0024 HintMode-per-node model; no behaviour change to
existing modes. An ADR-0024 amendment recording it can follow later
if desired — flagged but not written.
This commit is contained in:
claude@clouddev1
2026-05-28 18:56:13 +00:00
parent c12ed1da9a
commit 6f87ad1842
7 changed files with 259 additions and 35 deletions
+148
View File
@@ -693,6 +693,17 @@ fn ambient_hint_core_in_mode(
return Some(AmbientHint::Prose(text));
}
}
Some(crate::dsl::grammar::HintMode::IntroProse(key)) => {
// Slot entry: surface the catalog prose so an
// invisible-by-default ident slot (the column-name
// `NewName` at the CREATE TABLE element position,
// issue #4) reads as the dominant first move with
// the keyword alternatives folded into the prose.
// Tab candidates remain available via the parallel
// completion surface; the user still cycles the
// keyword set.
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
}
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
| None => {}
}
@@ -1111,6 +1122,143 @@ mod tests {
assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none());
}
#[test]
fn advanced_create_table_element_position_introduces_column_name() {
// Issue #4: at `create table T (`, the user is at the
// ELEMENT slot of the column-def list. The current candidate
// list shows only table-level constraint keywords (`primary`,
// `unique`, `check`, `constraint`, `foreign`); a new column
// is the dominant first move and is currently invisible
// because the COLUMN_DEF branch starts with an `Ident::NewName`
// slot which produces no concrete candidate.
//
// The fix wraps the ELEMENT choice in a `Hinted::IntroProse`
// that surfaces a prose hint mentioning the column name first,
// with the constraint keywords as the alternative. Tab
// candidates remain available.
let cache = crate::completion::SchemaCache::default();
let input = "create table Orders (";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.to_lowercase().contains("column name"),
"prose must mention `column name`; got: {p:?}",
);
// Constraint alternatives should still be mentioned.
assert!(
p.contains("primary") && p.contains("unique"),
"prose should mention constraint alternatives; got: {p:?}",
);
}
other => panic!("expected Prose hint at ELEMENT slot; got: {other:?}"),
}
// Tab candidates should remain available (the keywords still cycle).
let comp = crate::completion::candidates_at_cursor_in_mode(
input,
input.len(),
&cache,
Mode::Advanced,
)
.expect("completion must remain available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
for kw in &["primary", "unique"] {
assert!(
texts.contains(kw),
"Tab candidate `{kw}` must remain; got {texts:?}",
);
}
}
#[test]
fn advanced_partial_typing_does_not_leak_bare_double_in_prose() {
// Issue #5 (prose half): at `create table Orders (count` (no
// trailing space), the user is mid-typing what's
// grammatically a column name (`count` could be the start of
// `counterparty`). The bare `Word("double")` from the
// DOUBLE_PRECISION_NODES branch must not appear in the
// ambient hint at this position — the new IntroProse hint
// from issue #4 already covers this position by introducing
// the element slot ("Type a column name, or a table-level
// constraint: …"), and the user discovers the type list
// (with `double precision` as a single composite, not bare
// `double`) when they advance to the SQL_TYPE slot.
let cache = crate::completion::SchemaCache::default();
let input = "create table Orders (count";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
Some(AmbientHint::Prose(p)) => {
assert!(
!p.contains("`double`"),
"bare `double` must not appear in the prose; got: {p:?}",
);
}
other => panic!("expected Prose hint at partial column name; got: {other:?}"),
}
}
#[test]
fn advanced_type_position_offers_double_precision_not_bare_double() {
// Issue #5: at the SQL_TYPE position (`create table Orders
// (count `), the candidate list previously surfaced `double`
// as a peer of the playground's regular types — the user
// sees a leading "double" alongside int/text/etc. and has
// to know it's the start of the two-word `double precision`
// alias. The fix surfaces `double precision` as a single
// composite candidate and suppresses the bare `double`.
let cache = crate::completion::SchemaCache::default();
let input = "create table Orders (count ";
let comp = crate::completion::candidates_at_cursor_in_mode(
input,
input.len(),
&cache,
Mode::Advanced,
)
.expect("completion expected at the SQL_TYPE position");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
assert!(
!texts.contains(&"double"),
"bare `double` must NOT appear as a type candidate; got {texts:?}",
);
assert!(
texts.contains(&"double precision"),
"`double precision` should appear as a composite type candidate; got {texts:?}",
);
// The regular type vocabulary still appears.
for t in &["int", "text", "real", "serial"] {
assert!(
texts.iter().any(|x| x == t),
"regular type `{t}` must remain a candidate; got {texts:?}",
);
}
}
#[test]
fn advanced_create_table_offers_open_paren_after_name() {
// Issue #3: typing `create table Orders ` in advanced mode
// should offer both `with` (DSL form, ADR-0009) and `(` (SQL
// form, ADR-0035 §4) as the next-step continuation. Today
// only `with` surfaces — the shared-entry-word completion
// merge only fires at the entry-word boundary, so deeper
// positions show only the committed node's continuations.
let cache = crate::completion::SchemaCache::default();
let input = "create table Orders ";
let comp = crate::completion::candidates_at_cursor_in_mode(
input,
input.len(),
&cache,
Mode::Advanced,
)
.expect("completion expected for advanced create-table after name");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
assert!(
texts.contains(&"("),
"advanced mode must offer `(` for the SQL column-def list; got {texts:?}",
);
assert!(
texts.contains(&"with"),
"advanced mode must keep `with` for the DSL form; got {texts:?}",
);
}
#[test]
fn advanced_mode_ambient_offers_sql_from_slot_candidate() {
// ADR-0022 Amendment 1: advanced-mode ambient assistance