walker+completion: surface list trailing-optionals + identifiers-first ordering (ADR-0022 Amendment 2)

walk_repeated discarded the last matched item's trailing-optional
expectations at a clean item boundary, so a comma-separated list
offered no continuation after a complete item: `order by Name `
gave no asc/desc, `select Name ` no `as`, `create table …
Code(text) ` no not/unique/default/check. Capture the last item's
skipped set and surface it when the list ends at an item boundary
(the separator `,` itself is deliberately not surfaced).

That fix made expression-position candidate lists long, which
exposed a visibility problem: the hint panel's candidate line is
single-row and window-scrolls on overflow, centring on item 0 when
nothing is selected — so with keywords-first, schema identifiers
scrolled off behind the `>` marker. Reverse the ordering: schema
identifiers (table/column/relationship names) now sort before
keywords, since a name the user would have to look up is the
highest-value completion and must stay visible (keywords are
learned over time; the tok_identifier/tok_keyword colour split
marks the boundary). This reverses the handoff-14 keywords-first
call, now recorded in ADR-0022 Amendment 2.

Tests: walker expected-set + completion-layer regressions for the
trailing-optionals and the ordering; candidate_ordering.rs header
invariant inverted; ~20 typing-surface snapshots re-baselined; a
two-line hint box recorded as a deferred follow-up.
This commit is contained in:
claude@clouddev1
2026-05-21 21:52:49 +00:00
parent 43c49f4d1b
commit 7f68a53f86
28 changed files with 716 additions and 329 deletions
+101 -25
View File
@@ -158,8 +158,8 @@ pub struct Completion {
/// Partial prefix the user has typed at the cursor. Empty
/// when the cursor is at a token boundary.
pub partial_prefix: String,
/// Fitting candidates, ordered keywords-first then
/// identifiers, alphabetised within each group, deduplicated.
/// Fitting candidates, ordered schema-identifiers-first then
/// keywords, alphabetised within each group, deduplicated.
pub candidates: Vec<Candidate>,
}
@@ -585,19 +585,26 @@ pub fn candidates_at_cursor_with_in_mode(
// ambiguity in the live render.
identifiers.retain(|name| !keywords.contains(name));
// Keywords first (grammar parts read before content),
// then type names (closed-set grammar — coloured as
// keywords), then composite literals (`1:n`, …), then
// branching punct (`(` opening a sub-shape), then flags
// (own colour), then schema identifiers.
// Schema identifiers first: a column / table name the user
// would otherwise have to look up is the highest-value
// completion (valuable to experts, not just learners, who
// come to know the keywords over time). Keywords and the
// other closed-set grammar parts follow: keywords, then type
// names (closed-set grammar — coloured as keywords), then
// composite literals (`1:n`, …), then branching punct (`(`
// opening a sub-shape), then flags (own colour).
let mut candidates: Vec<Candidate> = Vec::with_capacity(
keywords.len()
identifiers.len()
+ keywords.len()
+ type_names.len()
+ composites.len()
+ punct_candidates.len()
+ flags.len()
+ identifiers.len(),
+ flags.len(),
);
candidates.extend(identifiers.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Identifier,
}));
candidates.extend(keywords.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Keyword,
@@ -618,10 +625,6 @@ pub fn candidates_at_cursor_with_in_mode(
text,
kind: CandidateKind::Flag,
}));
candidates.extend(identifiers.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Identifier,
}));
if candidates.is_empty() {
return None;
@@ -1506,6 +1509,78 @@ mod tests {
}
}
#[test]
fn order_by_after_sort_item_offers_direction() {
use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete
// sort item the direction keywords surface as
// continuations (previously discarded at the Repeated
// boundary, so completion offered neither).
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let input = "select Name from Things order by Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
assert!(cs.contains(&"desc".to_string()), "got {cs:?}");
}
#[test]
fn projection_after_item_offers_alias_keyword() {
use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete
// projection item the `as` alias keyword surfaces.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let input = "select Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
}
#[test]
fn create_table_after_column_spec_offers_constraints() {
// walk_repeated trailing-optional fix: after a complete
// column spec the optional column constraints surface as
// continuations (was a bare "Submit with Enter" prose).
let input = "create table Customers with pk Code(text) ";
let cs = cands_with(input, input.len(), &SchemaCache::default());
for kw in ["not", "unique", "default", "check"] {
assert!(
cs.contains(&kw.to_string()),
"expected column-constraint `{kw}`; got {cs:?}",
);
}
}
#[test]
fn identifiers_precede_keywords_at_expression_position() {
use crate::dsl::types::Type;
// ADR-0022 Amendment 2: at an expression position offering
// both column names and keywords, every column precedes
// every keyword so the names stay visible by default.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let input = "select * from Things where ";
let cs = cands_with(input, input.len(), &cache);
let pos = |needle: &str| {
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
panic!("{needle:?} not in candidates: {cs:?}")
})
};
// Both columns come before any expression-start keyword.
let last_ident = pos("Name").max(pos("Qty"));
let first_kw = pos("not").min(pos("exists"));
assert!(
last_ident < first_kw,
"identifiers must precede keywords; got {cs:?}",
);
}
#[test]
fn update_where_offers_only_current_table_columns() {
use crate::dsl::types::Type;
@@ -1681,15 +1756,16 @@ mod tests {
}
#[test]
fn keywords_come_before_identifiers_in_grammar_order() {
// "add column " has both keyword candidates and
// schema-identifier candidates. Per the user's stage-8
// feedback round 2: keywords first in *grammar order*
// (so `to` before `table` because the canonical shape
// is `add column [to] [table] <Table>:…`), identifiers
// after, alphabetised. The grammar order falls out of
// chumsky's source-order expected-set traversal — we
// preserve that order through `describe_expected`.
fn identifiers_come_before_keywords_in_grammar_order() {
// "add column " has both schema-identifier candidates and
// keyword candidates. Per ADR-0022 Amendment 2: schema
// identifiers first (alphabetised) so the names the user
// would have to look up stay visible, then keywords in
// *grammar order* (`to` before `table` because the
// canonical shape is `add column [to] [table] <Table>:…`).
// The grammar order falls out of the walker's source-order
// expected-set traversal — we preserve that order through
// `describe_expected`.
let cache = SchemaCache {
tables: vec!["Customers".to_string(), "Orders".to_string()],
..SchemaCache::default()
@@ -1698,10 +1774,10 @@ mod tests {
assert_eq!(
kinds,
vec![
("to".to_string(), CandidateKind::Keyword),
("table".to_string(), CandidateKind::Keyword),
("Customers".to_string(), CandidateKind::Identifier),
("Orders".to_string(), CandidateKind::Identifier),
("to".to_string(), CandidateKind::Keyword),
("table".to_string(), CandidateKind::Keyword),
],
);
}
+29 -8
View File
@@ -698,6 +698,20 @@ fn walk_repeated(
let mut cur = position;
let mut count = 0_usize;
let mut last_expected: Option<Vec<Expectation>> = None;
// Trailing-optional expectations carried by the most recently
// matched item — e.g. `asc`/`desc` after an ORDER BY sort
// item, or a projection's `as` alias. Surfaced when the list
// ends cleanly at an item boundary so completion still offers
// the optional suffix the user could type next (handoff 31 —
// the `desc` follow-up to F5). The separator itself is
// deliberately NOT surfaced.
let mut last_item_skipped: Vec<Expectation> = Vec::new();
// Set when the loop stops because the separator did not match
// at an item boundary (a clean end of list), as opposed to an
// inner mismatch past an already-consumed separator. Only at a
// clean boundary are the last item's trailing optionals valid
// continuations at the cursor.
let mut ended_at_item_boundary = false;
loop {
let saved_path_len = path.items.len();
let saved_byte_len = per_byte.len();
@@ -720,6 +734,7 @@ fn walk_repeated(
NodeWalkResult::NoMatch { .. } => {
path.items.truncate(sep_saved_path);
per_byte.truncate(sep_saved_byte);
ended_at_item_boundary = true;
break;
}
other => return other,
@@ -728,9 +743,10 @@ fn walk_repeated(
walk_node(source, cur, inner, ctx, path, per_byte)
};
match result {
NodeWalkResult::Matched { end, .. } => {
NodeWalkResult::Matched { end, skipped } => {
cur = end;
count += 1;
last_item_skipped = skipped;
}
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
// Mid-typing-the-next-item recovery: if the
@@ -771,13 +787,18 @@ fn walk_repeated(
expected: last_expected.unwrap_or_default(),
};
}
// The "could continue with another inner" expectations
// become this Repeated's `skipped` set so the caller's
// expected-set surfaces them at completion time.
NodeWalkResult::Matched {
end: cur,
skipped: last_expected.unwrap_or_default(),
}
// The "could continue" expectations become this Repeated's
// `skipped` set so the caller's expected-set surfaces them at
// completion time. When the list ended cleanly at an item
// boundary, that is the last item's trailing optionals (e.g.
// `asc`/`desc`); otherwise it is whatever the final inner
// attempt expected.
let skipped = if ended_at_item_boundary {
last_item_skipped
} else {
last_expected.unwrap_or_default()
};
NodeWalkResult::Matched { end: cur, skipped }
}
fn walk_bare_path(
+20
View File
@@ -4844,6 +4844,26 @@ mod order_by_expected_set_tests {
);
}
#[test]
fn order_by_after_sort_item_offers_direction() {
// After a complete sort item (`order by Name`) the
// sort-direction keywords are valid continuations.
// walk_repeated used to discard the item's trailing
// optionals, so completion offered neither.
let words = expected_words("select Name from T order by Name ");
assert!(words.contains(&"asc"), "expected `asc`; got {words:?}");
assert!(words.contains(&"desc"), "expected `desc`; got {words:?}");
// The separator is deliberately not surfaced (user choice).
let full = expected_at_input_in_mode(
"select Name from T order by Name ",
Mode::Advanced,
);
assert!(
!full.iter().any(|e| matches!(e, Expectation::Punct(','))),
"`,` separator should not be surfaced; got {full:?}",
);
}
#[test]
fn order_by_still_offers_a_sort_item() {
// Guard against over-correction: the legitimate sort-item