fix(completion): flag-aware partial so a dash completes flags, not keywords

The partial-token walk stopped at `-`, so after typing `-` (or `--`)
the partial was empty and the replaced range was a zero-width point
*after* the dash. Two bugs followed at a flag position (e.g.
`add 1:n relationship … -`): the `on` keyword was offered (it
prefix-matched the empty partial), and accepting a candidate inserted
after the dash — `-on`, `---create-fk`, `----all-rows`.

Detect a dash-prefixed token at a word boundary as a flag-in-progress
and fold the whole dash-run into the partial, gated on a flag actually
being expected there (so `where x = -5` stays a signed number, not a
flag). The flag matcher now strips leading dashes and matches the body
uniformly (empty / `-` / `--` → all flags; `--cr` → create-fk).

Keywords like `on` no longer appear after a dash, and accept replaces
the dash(es) so `-` → `--create-fk` and `--all` → `--all-rows`. Two
partial-flag snapshots updated (they had captured the old behaviour).
This commit is contained in:
claude@clouddev1
2026-06-12 10:59:49 +00:00
parent 30b2677bf3
commit c3e010332c
3 changed files with 107 additions and 21 deletions
+103 -17
View File
@@ -333,6 +333,37 @@ pub fn candidates_at_cursor_with_in_mode(
break; break;
} }
} }
// Flag-aware extension. The plain walk above stops at `-`, so a
// flag the user is mid-typing (`-`, `--`, `--all`, `--create-fk`)
// leaves an *empty* partial sitting just after the dash(es) — which
// made the engine offer every keyword (a `-` prefix-matches nothing,
// so the empty-prefix path let `on` through) and, worse, replace an
// empty range so accepting produced `-on` / `---create-fk`. When a
// dash-prefixed token sits at a word boundary AND a flag is actually
// expected here, treat the whole dash-run-plus-body as the partial so
// it is matched and replaced wholesale. The "flag is expected" gate
// (one cheap probe on the pre-dash prefix) keeps a signed number /
// minus (`where x = -5`) from being mis-read as a flag.
{
let mut run = cursor;
while run > 0 {
let p = bytes[run - 1];
if p.is_ascii_alphanumeric() || p == b'_' || p == b'-' {
run -= 1;
} else {
break;
}
}
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) {
start = run;
}
}
}
let partial_prefix = input[start..cursor].to_string(); let partial_prefix = input[start..cursor].to_string();
let leading = &input[..start]; let leading = &input[..start];
@@ -629,29 +660,19 @@ pub fn candidates_at_cursor_with_in_mode(
// Source 1.55: flag candidates (`--name`). Surfaced as a // Source 1.55: flag candidates (`--name`). Surfaced as a
// distinct CandidateKind so the hint panel can colour them // distinct CandidateKind so the hint panel can colour them
// with `tok_flag` (matching how they'll appear after // with `tok_flag` (matching how they'll appear after
// insertion). The standard prefix matcher walks back over // insertion). The flag-aware partial detection above captures any
// alphanumeric + underscore, which does NOT cross `-`, so // leading dash-run, so the partial is one of: empty, all-dashes
// when the user types `--all` the partial is `all` — match // (`-` / `--`), or `[-]+body`. Stripping the leading dashes and
// the flag's body against that. Otherwise match the full // matching the remainder against the flag *body* handles all of
// `--name` against the partial (which may be empty or start // them uniformly (empty / all-dashes → match every flag).
// with `--`). let flag_needle = partial_prefix.trim_start_matches('-').to_lowercase();
let flags: Vec<String> = expected let flags: Vec<String> = expected
.iter() .iter()
.filter_map(|e| match e { .filter_map(|e| match e {
Expectation::Flag(name) => Some(*name), Expectation::Flag(name) => Some(*name),
_ => None, _ => None,
}) })
.filter(|body| { .filter(|body| body.to_lowercase().starts_with(&flag_needle))
if partial_prefix.starts_with("--") {
format!("--{body}")
.to_lowercase()
.starts_with(&lowered_prefix)
} else if partial_prefix.is_empty() {
true
} else {
body.to_lowercase().starts_with(&lowered_prefix)
}
})
.map(|body| format!("--{body}")) .map(|body| format!("--{body}"))
.collect(); .collect();
@@ -1528,6 +1549,71 @@ mod tests {
); );
} }
#[test]
fn single_dash_offers_flags_not_keywords_and_replaces_the_dash() {
// Bug (manual testing): `add 1:n relationship … -` (one dash)
// offered the `on` keyword *and* `--create-fk`, and accepting
// produced `-on` / `---create-fk` because the lone `-` was not
// part of the replaced range. A dash at a flag position is a
// flag-in-progress: offer flags, exclude keywords, replace the
// dash on accept.
let input = "add 1:n relationship from X.a to Y.b -";
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
.expect("a `-` at a flag position offers candidates");
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}");
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
assert_eq!(
c.replaced_range,
(input.len() - 1, input.len()),
"the `-` must be inside the replaced range so accept yields `--create-fk`",
);
}
#[test]
fn double_dash_replaces_both_dashes_on_accept() {
let input = "delete from T --";
let c = candidates_at_cursor_in_mode(
input,
input.len(),
&SchemaCache::default(),
Mode::Simple,
)
.expect("`--` offers the flag");
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
assert_eq!(
c.replaced_range,
(input.len() - 2, input.len()),
"both dashes are replaced so accept yields `--all-rows`, not `----all-rows`",
);
}
#[test]
fn dash_at_a_value_position_is_not_treated_as_a_flag() {
// `show data T where x = -5` — the `-` is a sign, not a flag.
// No flag is expected here, so the dash must not be swallowed
// into a flag partial: the partial stays `5` (the original
// value-operand behaviour), and no `--…` candidate appears.
let mut s = SchemaCache::default();
s.tables.push("T".into());
s.columns.push("x".into());
let input = "show data T where x = -5";
if let Some(c) =
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
{
assert!(
!c.candidates.iter().any(|x| x.text.starts_with("--")),
"no flags at a value position: {:?}",
c.candidates,
);
assert_eq!(
c.replaced_range,
(input.len() - 1, input.len()),
"only the `5` is the partial; the `-` (sign) is not captured",
);
}
}
#[test] #[test]
fn typed_dashes_offer_the_optional_cascade_flag_on_drop_column() { fn typed_dashes_offer_the_optional_cascade_flag_on_drop_column() {
// The same optional-flag class: `drop column … [--cascade]`. // The same optional-flag class: `drop column … [--cascade]`.
@@ -24,10 +24,10 @@ Assessment {
completion: Some( completion: Some(
Completion { Completion {
replaced_range: ( replaced_range: (
24, 22,
27, 27,
), ),
partial_prefix: "all", partial_prefix: "--all",
candidates: [ candidates: [
Candidate { Candidate {
text: "--all-rows", text: "--all-rows",
@@ -24,10 +24,10 @@ Assessment {
completion: Some( completion: Some(
Completion { Completion {
replaced_range: ( replaced_range: (
33, 31,
36, 36,
), ),
partial_prefix: "all", partial_prefix: "--all",
candidates: [ candidates: [
Candidate { Candidate {
text: "--all-rows", text: "--all-rows",