feat: ADR-0035 4i(d) — merge shared-entry-word completions

In advanced mode an entry word like `create`/`drop` has several candidate
nodes (the SQL forms + the DSL fallback), but the walker commits to one,
so completion offered only that node's continuations — `drop ` showed
just `table`, and `drop rel` dead-ended at an empty list even though the
DSL drops parse via fallback.

At the entry-word boundary (advanced mode), walk every candidate, keep the
viable (Incomplete) ones, and union their next-keyword continuations:
`drop ` → table·index·column·relationship·constraint; `drop rel` →
relationship; `create ` → table·unique·index. Deeper positions keep the
committed walk untouched (no change to insert/update/delete/select).

Each continuation is classified by producing category (Both/Advanced/
Simple) and block-ordered Both → Advanced → Simple, so they read as
contiguous groups (the foundation for the 4i(e) colour, landing next).
CompletionProbe carries a parallel expected_modes; the parse path is
unchanged (the merge is completion-only).

Tests: completion merge + partial + block-order cases; the two tests that
encoded the old single-node behaviour updated. Full suite 1911 passing /
0 failing / 1 ignored; clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-26 11:36:18 +00:00
parent ca64434a1c
commit 1afcf4ed29
3 changed files with 361 additions and 18 deletions
+108 -15
View File
@@ -167,6 +167,35 @@ pub const fn identity_ranker(candidates: Vec<Candidate>) -> Vec<Candidate> {
candidates
}
/// Which input mode(s) a shared-entry-word continuation belongs to.
///
/// (ADR-0035 §4i d/e.) Computed only where a shared entry word's
/// candidate nodes are merged; everywhere else a continuation is `Both`
/// (neutral). Drives the contiguous colour-block ordering (and, in the
/// UI, the colour) when a candidate list mixes modes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeClass {
/// Valid in both simple (DSL) and advanced (SQL) mode — neutral.
Both,
/// Advanced/SQL-only continuation.
Advanced,
/// Simple/DSL-only continuation.
Simple,
}
impl ModeClass {
/// Block-ordering key: `Both` (0) → `Advanced` (1) → `Simple` (2), so
/// each colour reads as one contiguous block (user-confirmed order).
#[must_use]
pub const fn block_order(self) -> u8 {
match self {
Self::Both => 0,
Self::Advanced => 1,
Self::Simple => 2,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateKind {
/// One of the parser's expected keywords.
@@ -504,6 +533,35 @@ pub fn candidates_at_cursor_with_in_mode(
let mut seen_kw = std::collections::HashSet::new();
keywords.retain(|k| seen_kw.insert(k.clone()));
// (ADR-0035 §4i e) Block-order the keyword continuations by
// mode-class when a shared entry word merged simple + advanced forms,
// so the colours read as contiguous blocks Both → Advanced → Simple
// (user-confirmed order). A single-mode list (all `Both`) is left in
// its declaration order. `sort_by_key` is stable, preserving the
// intra-block order.
let kw_mode = |kw: &str| -> ModeClass {
probe
.expected
.iter()
.zip(&probe.expected_modes)
.find_map(|(e, m)| match e {
Expectation::Word(w) | Expectation::Literal(w) if w.eq_ignore_ascii_case(kw) => {
Some(*m)
}
_ => None,
})
.unwrap_or(ModeClass::Both)
};
let mixed = keywords
.iter()
.map(|k| kw_mode(k.as_str()).block_order())
.collect::<std::collections::HashSet<u8>>()
.len()
> 1;
if mixed {
keywords.sort_by_key(|k| kw_mode(k.as_str()).block_order());
}
// Source 1.5: type-name candidates when the walker expects
// a column-type slot. Type names are a closed set sourced
// from `Type::all()` (ADR-0005 declaration order:
@@ -1177,9 +1235,41 @@ mod tests {
#[test]
fn at_token_boundary_offers_next_expected_keyword() {
// After `create ` the parser expects `table`.
// After `create ` advanced mode offers `table` (valid in both
// modes) plus the SQL-only `unique` (`create unique index`) and
// `index` — the shared-entry-word merge (ADR-0035 §4i d).
// `table` (Both) blocks before the Advanced-only `unique`/`index`.
let cs = cands("create ", 7);
assert_eq!(cs, vec!["table".to_string()]);
assert_eq!(
cs,
vec!["table".to_string(), "unique".to_string(), "index".to_string()]
);
}
#[test]
fn shared_entry_word_drop_merges_all_continuations() {
// ADR-0035 §4i (d): in advanced mode `drop` is a shared entry
// word (SQL `drop table`/`drop index` + the DSL drops). The
// completion must offer EVERY valid continuation, not just the
// first-decided node's. Block order: Both (table, index) then
// Simple-only (column, relationship, constraint).
let cs = cands("drop ", 5);
for kw in ["table", "index", "column", "relationship", "constraint"] {
assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}");
}
// Both-mode continuations block before the simple-only ones.
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
}
#[test]
fn shared_entry_word_drop_partial_keeps_matching_continuation() {
// A partial second keyword (`drop rel`) used to dead-end at an
// empty list (only the SQL node walked); the merge keeps the
// DSL `relationship` continuation.
let cs = cands("drop rel", 8);
assert_eq!(cs, vec!["relationship".to_string()]);
}
#[test]
@@ -1489,20 +1579,23 @@ mod tests {
}
#[test]
fn drop_in_advanced_mode_surfaces_the_sql_drop_table_completion() {
fn drop_in_advanced_mode_surfaces_all_merged_continuations() {
// ADR-0035 §4c: `drop` gained an advanced SQL node
// (`DROP TABLE [IF EXISTS]`). As with the `create`/`insert`/
// `update`/`delete` shared entry words (ADR-0033 Amendment 3),
// advanced mode surfaces the SQL grammar's completion — here
// just `table` — rather than the DSL subcommands. The DSL drops
// (`drop column` etc.) still parse via fallback; only the
// completion hint differs (and a partial DSL keyword like
// `drop rel` returns an empty list — a mid-word dead end).
// ADR-0035 §13 4i (d)/(e) tracks merging the candidate sets for
// shared entry words, and the user's request to visually
// distinguish simple- vs advanced-mode completions in the hint
// UI (likely by colour); this expectation grows when 4i lands.
assert_eq!(cands("drop ", 5), vec!["table".to_string()]);
// (`DROP TABLE [IF EXISTS]`). With the 4i (d) shared-entry-word
// merge, advanced-mode `drop ` now offers EVERY valid
// continuation across the SQL and DSL nodes, block-ordered Both →
// Advanced → Simple: `table`/`index` (valid in both modes) before
// the DSL-only `column`/`relationship`/`constraint`.
assert_eq!(
cands("drop ", 5),
vec![
"table".to_string(),
"index".to_string(),
"column".to_string(),
"relationship".to_string(),
"constraint".to_string(),
]
);
}
#[test]
+96 -3
View File
@@ -242,6 +242,11 @@ const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str
#[derive(Debug, Clone)]
pub struct CompletionProbe {
pub expected: Vec<outcome::Expectation>,
/// Mode-class for each entry in `expected` (same length / order).
/// All `Both` except where a shared entry word's candidate nodes were
/// merged (ADR-0035 §4i d/e), so the completion engine can block-order
/// (and the UI colour) mixed simple/advanced continuations.
pub expected_modes: Vec<crate::completion::ModeClass>,
/// Columns of `current_table` resolved at the cursor (set
/// by an `Ident { source: Tables, writes_table: true }`
/// earlier in the walk). `None` when the walker is
@@ -310,8 +315,10 @@ pub fn completion_probe_in_mode(
};
if source.trim().is_empty() {
let entries = mode_filtered_entries();
return CompletionProbe {
expected: mode_filtered_entries(),
expected_modes: vec![crate::completion::ModeClass::Both; entries.len()],
expected: entries,
current_table_columns: None,
pending_hint_mode: None,
from_scope: Vec::new(),
@@ -322,15 +329,17 @@ pub fn completion_probe_in_mode(
ctx.mode = mode;
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
let Some(result) = result else {
let entries = mode_filtered_entries();
return CompletionProbe {
expected: mode_filtered_entries(),
expected_modes: vec![crate::completion::ModeClass::Both; entries.len()],
expected: entries,
current_table_columns: None,
pending_hint_mode: None,
from_scope: Vec::new(),
cte_bindings: Vec::new(),
};
};
let expected = match result.outcome {
let mut expected = match result.outcome {
outcome::WalkOutcome::Match { .. } => result.tail_expected,
// A trailing-junk Mismatch (the shape matched, then the
// user kept typing) still carries the outer shape's
@@ -377,8 +386,92 @@ 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.
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()
{
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();
for (_, node, category) in candidates {
let mut sctx = context::WalkContext::with_schema(schema);
sctx.mode = mode;
let (res, _) =
walk_one_command(source, source, kw_start, kw_end, 0, node, &mut sctx);
let outcome::WalkOutcome::Incomplete { expected: cont, .. } = res.outcome
else {
continue;
};
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;
}
}
None => tally.push((w, !advanced, advanced)),
}
}
}
}
if !tally.is_empty() {
// Augment `expected` with merged continuations the
// committed node lacked.
for &(w, _, _) in &tally {
let present = expected.iter().any(|e| {
matches!(e,
outcome::Expectation::Word(x) | outcome::Expectation::Literal(x)
if *x == w)
});
if !present {
expected.push(outcome::Expectation::Word(w));
}
}
// Classify every expectation by the merged tally.
expected_modes = expected
.iter()
.map(|e| match e {
outcome::Expectation::Word(w) | outcome::Expectation::Literal(w) => {
tally.iter().find(|(kw, _, _)| kw == w).map_or(
crate::completion::ModeClass::Both,
|&(_, simple, adv)| match (simple, adv) {
(true, true) | (false, false) => {
crate::completion::ModeClass::Both
}
(false, true) => crate::completion::ModeClass::Advanced,
(true, false) => crate::completion::ModeClass::Simple,
},
)
}
_ => crate::completion::ModeClass::Both,
})
.collect();
}
}
}
}
CompletionProbe {
expected,
expected_modes,
current_table_columns: ctx.current_table_columns,
pending_hint_mode: ctx.pending_hint_mode,
from_scope,