ADR-0024 round-5 follow-up: surface tail-Optional expectations

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<Expectation>` 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.
This commit is contained in:
claude@clouddev1
2026-05-15 17:50:31 +00:00
parent abebd7944f
commit 8188fa5ee1
3 changed files with 103 additions and 16 deletions
+21 -8
View File
@@ -814,14 +814,27 @@ mod tests {
assert!(cs.contains(&"advanced".to_string()), "got {cs:?}"); assert!(cs.contains(&"advanced".to_string()), "got {cs:?}");
} }
// Note: `save ` and `messages ` are deliberately NOT tested // ---- Optional-suffix completion (round-5 gap, closed in Phase D) ----
// here. Both commands accept their bare form as a valid parse //
// `save` opens the save modal, `messages` shows the current // Pre-Phase-D: `save ` parsed as a valid `save` command, so
// verbosity — so the parser returns Ok at those positions // the completion engine had no expected-set to mine and the
// and the completion engine has no expected-set to mine. The // `as` suffix never surfaced as a Tab candidate. Phase D's
// optional-suffix candidates (`as`, `short`, `verbose`) would // `WalkResult::tail_expected` carries the outer shape's
// need a separate probe mechanism (deferred — same shape as // skipped-Optional expectations even on `Match`, so these
// the post-complete-parse gap for `--create-fk` etc.). // 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) ----------- // ---- Value-literal slot suppression (round-6) -----------
+70 -8
View File
@@ -52,7 +52,14 @@ pub fn hint_mode_at_input(source: &str) -> Option<crate::dsl::grammar::HintMode>
use crate::dsl::grammar::{HintMode, IdentSource}; use crate::dsl::grammar::{HintMode, IdentSource};
use crate::dsl::walker::outcome::Expectation; 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() { if expected.is_empty() {
return None; return None;
} }
@@ -125,18 +132,56 @@ pub fn expected_at_input(source: &str) -> Vec<outcome::Expectation> {
} }
let mut ctx = context::WalkContext::new(); let mut ctx = context::WalkContext::new();
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
match result.map(|r| r.outcome) { let Some(result) = result else {
Some(outcome::WalkOutcome::Match { .. }) => Vec::new(),
Some(outcome::WalkOutcome::Incomplete { expected, .. }) => expected,
Some(outcome::WalkOutcome::Mismatch { expected, .. }) => expected,
Some(outcome::WalkOutcome::ValidationFailed { .. }) => Vec::new(),
// Walker didn't engage (unknown entry word): the // Walker didn't engage (unknown entry word): the
// completion engine should still surface the available // completion engine should still surface the available
// entry words so the user can recover. // entry words so the user can recover.
None => REGISTRY return REGISTRY
.iter() .iter()
.map(|c| outcome::Expectation::Word(c.entry.primary)) .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<outcome::Expectation> {
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, class: grammar::HighlightClass::Keyword,
}); });
let mut tail_expected: Vec<Expectation> = Vec::new();
let outcome = match walk_node( let outcome = match walk_node(
effective_source, effective_source,
kw_end, kw_end,
@@ -204,6 +250,21 @@ pub fn walk<'a>(
&mut path, &mut path,
&mut per_byte, &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, .. } => { NodeWalkResult::Matched { end, .. } => {
let trailing = skip_whitespace(effective_source, end); let trailing = skip_whitespace(effective_source, end);
if trailing < effective_source.len() { if trailing < effective_source.len() {
@@ -272,6 +333,7 @@ pub fn walk<'a>(
outcome: final_outcome, outcome: final_outcome,
matched_path: path, matched_path: path,
per_byte_class: per_byte, per_byte_class: per_byte,
tail_expected,
}; };
(Some(result), cmd) (Some(result), cmd)
} }
+12
View File
@@ -164,4 +164,16 @@ pub struct WalkResult {
pub outcome: WalkOutcome, pub outcome: WalkOutcome,
pub matched_path: MatchedPath, pub matched_path: MatchedPath,
pub per_byte_class: Vec<ByteClass>, pub per_byte_class: Vec<ByteClass>,
/// 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<Expectation>,
} }