From f239ca5ff40689809b918f2c02f15b8b1d161618 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 10:19:00 +0000 Subject: [PATCH] walker: keep optional trailing flags completable after `--` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `--` to start an optional trailing flag (`--create-fk` on `add 1:n relationship`, `--cascade` on `drop column`, `--force-conversion` / `--dont-convert` on `change column`) made completion go empty: the trailing `--` turns the parse into a trailing-junk Mismatch, and the Mismatch arm of the completion expected-set resolution returned only `[EndOfInput]` — the skipped optional-flag expectations, carried in `tail_expected`, were dropped. completion_probe and expected_at_input now merge `tail_expected` into a Mismatch's expected set. `tail_expected` is empty for a genuine mid-command mismatch, so this only adds the outer shape's skipped trailing optionals — exactly the continuations the trailing `--` is starting to type. This also resolves the "wrong usage hint" symptom: with `--create-fk` offered as a candidate, the hint panel shows candidates instead of falling through to the parse-error usage block. Audit outcome (the requested scan): usage_key_for_input was verified correct for every multi-form command — add / drop / show, including the digit-led `add 1:n relationship` form — and is now regression-locked. The flag-completion fix covers the whole optional-trailing-flag class. 6 tests (3 flag-completion, 3 usage-key). 1131 passing. --- src/completion.rs | 42 ++++++++++++++++++++++++++++++ src/dsl/grammar/mod.rs | 59 ++++++++++++++++++++++++++++++++++++++++++ src/dsl/walker/mod.rs | 31 +++++++++++++++++++--- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index f9a325e..445f9e8 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -909,6 +909,48 @@ mod tests { assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}"); } + #[test] + fn typed_dashes_still_offer_an_optional_trailing_flag() { + // Regression: `add 1:n relationship … [--create-fk]` — + // at a trailing space the flag is offered, but once the + // user typed `--` the trailing-junk Mismatch dropped the + // skipped optional's expectation and completion went + // empty. Both positions must offer `--create-fk`. + let at_space = cands("add 1:n relationship from X.a to Y.b ", 37); + assert!( + at_space.contains(&"--create-fk".to_string()), + "trailing space should offer --create-fk, got {at_space:?}", + ); + let at_dashes = cands("add 1:n relationship from X.a to Y.b --", 39); + assert!( + at_dashes.contains(&"--create-fk".to_string()), + "typed `--` should still offer --create-fk, got {at_dashes:?}", + ); + } + + #[test] + fn typed_dashes_offer_the_optional_cascade_flag_on_drop_column() { + // The same optional-flag class: `drop column … [--cascade]`. + let at_dashes = cands("drop column from table T: c --", 30); + assert!( + at_dashes.contains(&"--cascade".to_string()), + "typed `--` should offer --cascade, got {at_dashes:?}", + ); + } + + #[test] + fn typed_dashes_offer_the_change_column_conversion_flags() { + // `change column … [--force-conversion | --dont-convert]` + // — the flags sit in a `Repeated { min: 0 }`; the same + // trailing-junk-Mismatch fix must surface them. + let at_dashes = cands("change column T: c (int) --", 27); + assert!( + at_dashes.contains(&"--force-conversion".to_string()) + && at_dashes.contains(&"--dont-convert".to_string()), + "typed `--` should offer both conversion flags, got {at_dashes:?}", + ); + } + // ---- App-lifecycle command completion (round-5 fold-in) ---- #[test] diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 723548f..a041e15 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -498,3 +498,62 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode .find(|(_, c)| c.entry.matches(word)) .map(|(i, c)| (i, *c)) } + +#[cfg(test)] +mod usage_key_tests { + use super::usage_key_for_input; + + /// Every multi-form command resolves a typed form to its + /// own usage key — a parse error in one form must never + /// show another form's usage (the handoff-18 `151ed08` fix; + /// regression-locked here, including the `add 1:n + /// relationship` digit-led form). + #[test] + fn multi_form_commands_resolve_to_the_typed_form() { + let cases = [ + ("add column to T: c (int)", "parse.usage.add_column"), + ("add index on T (c)", "parse.usage.add_index"), + ( + "add 1:n relationship from A.x to B.y", + "parse.usage.add_relationship", + ), + // Trailing junk must not change the resolved form. + ( + "add 1:n relationship from A.x to B.y --", + "parse.usage.add_relationship", + ), + ("drop table T", "parse.usage.drop_table"), + ("drop column from table T: c", "parse.usage.drop_column"), + ("drop index i", "parse.usage.drop_index"), + ( + "drop relationship r", + "parse.usage.drop_relationship", + ), + ("show data T", "parse.usage.show_data"), + ("show table T", "parse.usage.show_table"), + ]; + for (input, expected) in cases { + assert_eq!( + usage_key_for_input(input), + Some(expected), + "usage key for {input:?}", + ); + } + } + + #[test] + fn a_bare_multi_form_entry_word_resolves_to_no_single_form() { + // `add` / `drop` alone — no form chosen; the caller + // shows the whole family rather than guessing. + assert_eq!(usage_key_for_input("add "), None); + assert_eq!(usage_key_for_input("drop "), None); + } + + #[test] + fn a_single_form_command_resolves_to_its_one_key() { + assert_eq!( + usage_key_for_input("create table T with pk"), + Some("parse.usage.create_table"), + ); + } +} diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 268fadd..5c502a5 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -276,8 +276,19 @@ pub fn completion_probe( }; let expected = match result.outcome { outcome::WalkOutcome::Match { .. } => result.tail_expected, - outcome::WalkOutcome::Incomplete { expected, .. } - | outcome::WalkOutcome::Mismatch { expected, .. } => expected, + // A trailing-junk Mismatch (the shape matched, then the + // user kept typing) still carries the outer shape's + // skipped trailing optionals in `tail_expected` — e.g. + // an optional `--create-fk` flag the trailing `--` is + // starting to type. Merge them so completion still + // offers the optional continuation. A genuine + // mid-command mismatch has an empty `tail_expected`. + outcome::WalkOutcome::Mismatch { expected, .. } => { + let mut merged = expected; + merged.extend(result.tail_expected); + merged + } + outcome::WalkOutcome::Incomplete { expected, .. } => expected, // Validation failure path: the walker matched the // structural shape but the AST builder rejected (e.g. // Form C with column-shaped items). The walker still @@ -699,8 +710,20 @@ pub fn expected_at_input(source: &str) -> Vec { // optional-suffix candidates at the end of a valid // command (`save` → `as`, etc.). outcome::WalkOutcome::Match { .. } => result.tail_expected, - outcome::WalkOutcome::Incomplete { expected, .. } - | outcome::WalkOutcome::Mismatch { expected, .. } => expected, + // A trailing-junk Mismatch (the shape matched, then the + // user kept typing) still carries the outer shape's + // skipped trailing optionals in `tail_expected` — e.g. + // an optional `--create-fk` flag the trailing `--` is + // starting to type. Surface those alongside the + // mismatch's own expected set so completion still offers + // them. A genuine mid-command mismatch has an empty + // `tail_expected`, so this is a no-op there. + outcome::WalkOutcome::Mismatch { expected, .. } => { + let mut merged = expected; + merged.extend(result.tail_expected); + merged + } + outcome::WalkOutcome::Incomplete { expected, .. } => expected, // Validation failure path: the walker matched the // structural shape but the AST builder rejected (e.g. // Form C with column-shaped items). The walker still