From 8188fa5ee1201e5df8654b500a4c83a90edd197d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 15 May 2026 17:50:31 +0000 Subject: [PATCH] ADR-0024 round-5 follow-up: surface tail-Optional expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-Phase-D, `save ` parsed as a complete `save` command, so the completion engine had nothing to mine: `as` never surfaced as a Tab candidate. This is the round-5 gap the handoffs have been tracking. `WalkResult` gains a `tail_expected: Vec` field. The walker's top-level `Matched` branch copies the outer shape's skipped-Optional expectations into it. `expected_at_input` returns `tail_expected` on `Match` so the completion engine sees the optional-suffix continuations and offers them as Tab candidates. `hint_mode_at_input` deliberately does NOT consume `tail_expected` — surfacing prose like "Type a name" at the end of a valid command would be misleading. A new private `expected_for_hint` returns empty on `Match` to preserve this. The split distinguishes "valid + could continue" (completion helps) from "invalid + must continue" (hint resolver helps). Tests: - `save ` Tab → `as` (new test, the original round-5 gap). - `messages ` Tab → `short` and `verbose` (same shape). - Existing `hint_mode_none_for_complete_command` stays green because hint resolver ignores tail_expected. - 830 total passing, 0 failing, 1 ignored. Clippy clean. --- src/completion.rs | 29 +++++++++++---- src/dsl/walker/mod.rs | 78 +++++++++++++++++++++++++++++++++++---- src/dsl/walker/outcome.rs | 12 ++++++ 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index da6b842..d86b18c 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -814,14 +814,27 @@ mod tests { assert!(cs.contains(&"advanced".to_string()), "got {cs:?}"); } - // Note: `save ` and `messages ` are deliberately NOT tested - // here. Both commands accept their bare form as a valid parse - // — `save` opens the save modal, `messages` shows the current - // verbosity — so the parser returns Ok at those positions - // and the completion engine has no expected-set to mine. The - // optional-suffix candidates (`as`, `short`, `verbose`) would - // need a separate probe mechanism (deferred — same shape as - // the post-complete-parse gap for `--create-fk` etc.). + // ---- Optional-suffix completion (round-5 gap, closed in Phase D) ---- + // + // Pre-Phase-D: `save ` parsed as a valid `save` command, so + // the completion engine had no expected-set to mine and the + // `as` suffix never surfaced as a Tab candidate. Phase D's + // `WalkResult::tail_expected` carries the outer shape's + // skipped-Optional expectations even on `Match`, so these + // surface without a separate probe mechanism. + + #[test] + fn save_space_offers_as_via_tail_expected() { + let cs = cands("save ", 5); + assert_eq!(cs, vec!["as".to_string()]); + } + + #[test] + fn messages_space_offers_short_and_verbose_via_tail_expected() { + let cs = cands("messages ", 9); + assert!(cs.contains(&"short".to_string()), "got {cs:?}"); + assert!(cs.contains(&"verbose".to_string()), "got {cs:?}"); + } // ---- Value-literal slot suppression (round-6) ----------- diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 1fd579c..745cdb7 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -52,7 +52,14 @@ pub fn hint_mode_at_input(source: &str) -> Option use crate::dsl::grammar::{HintMode, IdentSource}; use crate::dsl::walker::outcome::Expectation; - let expected = expected_at_input(source); + // Hint mode is only meaningful at *required* slot positions + // (Incomplete / Mismatch outcomes). For complete commands + // (Match), `tail_expected` may carry optional-suffix + // expectations — completion surfaces those as Tab + // candidates, but the hint resolver should stay silent so + // we don't push prose like "Type a name" at the end of a + // valid command. + let expected = expected_for_hint(source); if expected.is_empty() { return None; } @@ -125,18 +132,56 @@ pub fn expected_at_input(source: &str) -> Vec { } let mut ctx = context::WalkContext::new(); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); - match result.map(|r| r.outcome) { - Some(outcome::WalkOutcome::Match { .. }) => Vec::new(), - Some(outcome::WalkOutcome::Incomplete { expected, .. }) => expected, - Some(outcome::WalkOutcome::Mismatch { expected, .. }) => expected, - Some(outcome::WalkOutcome::ValidationFailed { .. }) => Vec::new(), + let Some(result) = result else { // Walker didn't engage (unknown entry word): the // completion engine should still surface the available // entry words so the user can recover. - None => REGISTRY + return REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) - .collect(), + .collect(); + }; + match result.outcome { + // On Match, surface the outer-shape's skipped-Optional + // expectations so the completion engine can offer + // 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, + outcome::WalkOutcome::ValidationFailed { .. } => Vec::new(), + } +} + +/// Strict-required expected set at the end of `source`. Like +/// `expected_at_input` but returns empty on `WalkOutcome::Match` +/// — optional-suffix continuations are not surfaced. Used by +/// the hint resolver to distinguish "must type more" from +/// "could continue". +#[must_use] +fn expected_for_hint(source: &str) -> Vec { + use crate::dsl::grammar::REGISTRY; + + if source.trim().is_empty() { + return REGISTRY + .iter() + .map(|c| outcome::Expectation::Word(c.entry.primary)) + .collect(); + } + let mut ctx = context::WalkContext::new(); + let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); + let Some(result) = result else { + return REGISTRY + .iter() + .map(|c| outcome::Expectation::Word(c.entry.primary)) + .collect(); + }; + match result.outcome { + outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => { + Vec::new() + } + outcome::WalkOutcome::Incomplete { expected, .. } + | outcome::WalkOutcome::Mismatch { expected, .. } => expected, } } @@ -196,6 +241,7 @@ pub fn walk<'a>( class: grammar::HighlightClass::Keyword, }); + let mut tail_expected: Vec = Vec::new(); let outcome = match walk_node( effective_source, kw_end, @@ -204,6 +250,21 @@ pub fn walk<'a>( &mut path, &mut per_byte, ) { + NodeWalkResult::Matched { end, skipped } => { + // Carry the outer shape's skipped-Optional + // expectations into WalkResult so completion can + // surface optional-suffix candidates (`save` → + // `as`). Empty for shapes with no trailing + // optionals. + tail_expected = skipped; + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } + } + other => other, + }; + let outcome = match outcome { NodeWalkResult::Matched { end, .. } => { let trailing = skip_whitespace(effective_source, end); if trailing < effective_source.len() { @@ -272,6 +333,7 @@ pub fn walk<'a>( outcome: final_outcome, matched_path: path, per_byte_class: per_byte, + tail_expected, }; (Some(result), cmd) } diff --git a/src/dsl/walker/outcome.rs b/src/dsl/walker/outcome.rs index d830d0c..7d30b6a 100644 --- a/src/dsl/walker/outcome.rs +++ b/src/dsl/walker/outcome.rs @@ -164,4 +164,16 @@ pub struct WalkResult { pub outcome: WalkOutcome, pub matched_path: MatchedPath, pub per_byte_class: Vec, + /// Optional-Optional expectations the walker could have + /// accepted but didn't because the outer shape ran out at a + /// node boundary (ADR-0024 §architecture, round-5 follow-up). + /// + /// Populated on `WalkOutcome::Match` so completion can offer + /// optional-suffix candidates at the end of a valid command + /// — e.g., after typing `save` the walker matches the + /// Optional `as` as skipped, the suffix carries it here, and + /// the completion engine surfaces `as` as a Tab candidate. + /// Empty on the non-Match outcomes — those carry expected + /// information inside the outcome variant itself. + pub tail_expected: Vec, }