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:
@@ -149,6 +149,15 @@ impl IdentSource {
|
||||
/// - `ForceProse(catalog_key)` — force this prose at the
|
||||
/// catalog key regardless of candidates. Used today by
|
||||
/// `NewName` ident slots ("Type a name, then `(`").
|
||||
/// - `IntroProse(catalog_key)` — show prose at slot entry to
|
||||
/// *introduce* a position whose first-class candidate is an
|
||||
/// ident slot (which would be invisible in a pure-candidate
|
||||
/// render) but whose keyword alternatives are also available.
|
||||
/// Unlike `ProseOnly`, Tab candidates remain available — the
|
||||
/// user still cycles through the keyword set. Used at the
|
||||
/// advanced-mode CREATE TABLE element slot, where the
|
||||
/// column-name `NewName` slot would otherwise be invisible
|
||||
/// alongside the table-level constraint keywords (issue #4).
|
||||
/// - `SuppressProse` — show only candidates; never fall back
|
||||
/// to a prose ladder.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -156,6 +165,7 @@ pub enum HintMode {
|
||||
Default,
|
||||
ForceProse(&'static str),
|
||||
ProseOnly(&'static str),
|
||||
IntroProse(&'static str),
|
||||
SuppressProse,
|
||||
}
|
||||
|
||||
|
||||
@@ -390,7 +390,18 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
|
||||
// the same trade real SQL makes with its reserved words.)
|
||||
static ELEMENT_CHOICES: &[Node] =
|
||||
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
|
||||
const ELEMENT: Node = Node::Choice(ELEMENT_CHOICES);
|
||||
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
|
||||
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
|
||||
// position (`create table T (` and after every `,`) surfaces a prose
|
||||
// hint that names the column-name role *and* the table-level
|
||||
// constraint keywords. The bare candidate render shows only the
|
||||
// constraint keywords because the `COLUMN_DEF` branch starts with a
|
||||
// `NewName` ident that has no concrete candidate to offer; the prose
|
||||
// makes the dominant first move visible without suppressing Tab.
|
||||
const ELEMENT: Node = Node::Hinted {
|
||||
mode: crate::dsl::grammar::HintMode::IntroProse("hint.create_table_element"),
|
||||
inner: &ELEMENT_INNER,
|
||||
};
|
||||
|
||||
static COLUMN_LIST_NODES: &[Node] = &[
|
||||
Node::Punct('('),
|
||||
|
||||
+59
-29
@@ -147,13 +147,15 @@ pub fn hint_resolution_at_input_in_mode(
|
||||
// value-literal fallback slot; `ForceProse` covers `NewName`
|
||||
// ident slots ("Type a name").
|
||||
match snap.pending_hint_mode {
|
||||
Some(mode @ (HintMode::ProseOnly(_) | HintMode::ForceProse(_))) => {
|
||||
Some(HintResolution {
|
||||
mode,
|
||||
column: None,
|
||||
form_b_autogen_skipped: Vec::new(),
|
||||
})
|
||||
}
|
||||
Some(
|
||||
mode @ (HintMode::ProseOnly(_)
|
||||
| HintMode::ForceProse(_)
|
||||
| HintMode::IntroProse(_)),
|
||||
) => Some(HintResolution {
|
||||
mode,
|
||||
column: None,
|
||||
form_b_autogen_skipped: Vec::new(),
|
||||
}),
|
||||
Some(HintMode::SuppressProse | HintMode::Default) | None => None,
|
||||
}
|
||||
}
|
||||
@@ -386,28 +388,39 @@ pub fn completion_probe_in_mode(
|
||||
}
|
||||
(top_from, ctes)
|
||||
};
|
||||
// ADR-0035 §4i (d/e): shared-entry-word completion merge. In advanced
|
||||
// mode an entry word like `create` / `drop` has several candidate
|
||||
// nodes (SQL forms + the DSL fallback), but the walk above committed
|
||||
// to ONE, so only that node's continuations are in `expected`. At the
|
||||
// entry-word boundary (nothing typed after the entry word yet — the
|
||||
// divergence point), walk every candidate, keep the viable
|
||||
// (Incomplete) ones, and union their next-keyword continuations,
|
||||
// tagging each by the producing category — so completion offers all
|
||||
// valid continuations and can colour/order them by mode. Deeper
|
||||
// positions keep the committed walk's `expected` untouched.
|
||||
// ADR-0035 §4i (d/e) + issue #3 (2026-05-28): shared-entry-word
|
||||
// completion merge. In advanced mode an entry word like `create` /
|
||||
// `drop` has several candidate nodes (SQL forms + the DSL fallback),
|
||||
// but the walk above committed to ONE, so only that node's
|
||||
// continuations are in `expected`. Walk every viable
|
||||
// (`Incomplete`) candidate from the entry word to the cursor, union
|
||||
// their next-token continuations, tag each by the producing category
|
||||
// — so completion offers all valid continuations at the current
|
||||
// depth and can colour/order them by mode.
|
||||
//
|
||||
// Originally limited to the entry-word boundary; broadened to every
|
||||
// depth so that e.g. `create table T ` in advanced mode surfaces
|
||||
// both `with` (DSL, ADR-0009) and `(` (SQL, ADR-0035 §4) as
|
||||
// continuations. A candidate whose grammar has already diverged
|
||||
// (e.g. SQL `CREATE INDEX` after `create table …`) returns
|
||||
// Mismatch and is naturally skipped — the viability check is the
|
||||
// gate, not the cursor depth.
|
||||
let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()];
|
||||
if mode == crate::mode::Mode::Advanced {
|
||||
let s = skip_whitespace(source, 0);
|
||||
if let Some((kw_start, kw_end)) = consume_ident(source, s)
|
||||
&& skip_whitespace(source, kw_end) >= source.len()
|
||||
{
|
||||
if let Some((kw_start, kw_end)) = consume_ident(source, s) {
|
||||
let entry = &source[kw_start..kw_end];
|
||||
let candidates = grammar::commands_for_entry_word(entry);
|
||||
if candidates.len() > 1 {
|
||||
use crate::dsl::grammar::CommandCategory;
|
||||
// (continuation word, produced-by-simple, produced-by-advanced)
|
||||
let mut tally: Vec<(&'static str, bool, bool)> = Vec::new();
|
||||
// Continuations that aren't keyword/literal-shaped
|
||||
// (notably `Punct`, used for `(` at the SQL column-list
|
||||
// boundary — issue #3). Tracked separately because the
|
||||
// tally is keyed by `&'static str`; mode classification
|
||||
// for punctuation defaults to `Both`.
|
||||
let mut punct_tally: Vec<char> = Vec::new();
|
||||
for (_, node, category) in candidates {
|
||||
let mut sctx = context::WalkContext::with_schema(schema);
|
||||
sctx.mode = mode;
|
||||
@@ -419,21 +432,28 @@ pub fn completion_probe_in_mode(
|
||||
};
|
||||
let advanced = category == CommandCategory::Advanced;
|
||||
for e in &cont {
|
||||
if let outcome::Expectation::Word(w) | outcome::Expectation::Literal(w) = e {
|
||||
match tally.iter_mut().find(|(kw, _, _)| kw == w) {
|
||||
Some(rec) => {
|
||||
if advanced {
|
||||
rec.2 = true;
|
||||
} else {
|
||||
rec.1 = true;
|
||||
match e {
|
||||
outcome::Expectation::Word(w)
|
||||
| outcome::Expectation::Literal(w) => {
|
||||
match tally.iter_mut().find(|(kw, _, _)| kw == w) {
|
||||
Some(rec) => {
|
||||
if advanced {
|
||||
rec.2 = true;
|
||||
} else {
|
||||
rec.1 = true;
|
||||
}
|
||||
}
|
||||
None => tally.push((w, !advanced, advanced)),
|
||||
}
|
||||
None => tally.push((w, !advanced, advanced)),
|
||||
}
|
||||
outcome::Expectation::Punct(c) if !punct_tally.contains(c) => {
|
||||
punct_tally.push(*c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !tally.is_empty() {
|
||||
if !tally.is_empty() || !punct_tally.is_empty() {
|
||||
// Augment `expected` with merged continuations the
|
||||
// committed node lacked.
|
||||
for &(w, _, _) in &tally {
|
||||
@@ -446,7 +466,17 @@ pub fn completion_probe_in_mode(
|
||||
expected.push(outcome::Expectation::Word(w));
|
||||
}
|
||||
}
|
||||
for &c in &punct_tally {
|
||||
let present = expected
|
||||
.iter()
|
||||
.any(|e| matches!(e, outcome::Expectation::Punct(x) if *x == c));
|
||||
if !present {
|
||||
expected.push(outcome::Expectation::Punct(c));
|
||||
}
|
||||
}
|
||||
// Classify every expectation by the merged tally.
|
||||
// Word/Literal route through the tally; Punct (and
|
||||
// other expectation shapes) default to `Both`.
|
||||
expected_modes = expected
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
|
||||
Reference in New Issue
Block a user