feat(hint): advertise the optional seed count in the hint panel (#26)

At `seed <table> ▮` the hint showed only the `set`/`--seed` chips and
never mentioned the optional row count — a bare positional number with no
candidate, on an already-complete command, so neither the candidate
ladder nor the resolver surfaced it. (A prior IntroProse attempt was
reverted: pending_hint_mode is cleared by the trailing optionals.)

Carry a skipped Optional's IntroProse hint: walk_optional stashes the
inner's key into a new WalkContext.surviving_intro_hint (key + position)
before the empty match clears pending_hint_mode; the snapshot keeps it
only when the skip position is the cursor (so it never leaks past a
later-consumed `set …` clause, nor once the count is given); the
resolver returns it ahead of the empty-expected short-circuit. The seed
count is wrapped Hinted{IntroProse("hint.seed_count")}; the prose names
the count (default 20), the `.column` column-fill form, and `set` /
`--seed`. Tab still cycles the keywords.

Only IntroProse is carried; ProseOnly/ForceProse and the CREATE-TABLE
element (a required Repeated) are untouched. No AmbientHint/renderer
change. Fires in both modes.

ADR-0022 Amendment 7; +3 tests.
This commit is contained in:
claude@clouddev1
2026-06-12 21:34:48 +00:00
parent deb0948d6c
commit ee3ccd8d77
9 changed files with 216 additions and 2 deletions
+12 -1
View File
@@ -438,6 +438,17 @@ const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
const SEED_COUNT: Node = Node::NumberLit {
validator: Some(LIMIT_VALIDATOR),
};
/// Issue #26: the row count is a bare positional number, so it produces
/// no Tab candidate and was invisible in the hint panel at
/// `seed <table> ▮` (only `set` / `--seed` showed). Wrapping it in
/// `IntroProse` advertises it (and the other options) in prose; the
/// skipped-optional carry (`surviving_intro_hint`) makes the hint reach
/// the resolver despite the trailing optionals. Tab still cycles the
/// keyword candidates.
const SEED_COUNT_HINTED: Node = Node::Hinted {
mode: crate::dsl::grammar::HintMode::IntroProse("hint.seed_count"),
inner: &SEED_COUNT,
};
/// `--seed <n>` — a reproducible-generation flag carrying a numeric
/// seed (ADR-0048 D4). The only flag in the DSL that takes a value;
/// `build_seed` reads the number immediately after the flag.
@@ -567,7 +578,7 @@ const SEED_NODES: &[Node] = &[
// against this table.
TABLE_NAME_WRITES,
SEED_DOT_COLUMN,
Node::Optional(&SEED_COUNT),
Node::Optional(&SEED_COUNT_HINTED),
Node::Optional(&SEED_SET_CLAUSE),
Node::Optional(&SEED_FLAG),
];
+13
View File
@@ -134,6 +134,17 @@ pub struct WalkContext<'a> {
/// resolver reads this directly instead of inferring the
/// slot kind from the shape of the expected set.
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// An `IntroProse` hint captured from an *optional* slot that
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
/// (cleared on the very next match — including the empty match
/// of a skipped `Optional`), this survives the trailing
/// optional siblings so the hint reaches the resolver for a
/// position like `seed <table> ▮`, where the optional row
/// count is otherwise invisible. Carries the catalog key and
/// the byte position the optional was skipped at; the resolver
/// uses it only when that position is the cursor (so it doesn't
/// leak past a later-consumed clause).
pub surviving_intro_hint: Option<(&'static str, usize)>,
/// The columns the user explicitly listed in
/// `insert into <T> (col1, col2, …) values (…)` (Form A),
/// in declaration order.
@@ -232,6 +243,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
from_scope_stack: vec![ScopeFrame::default()],
@@ -254,6 +266,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
from_scope_stack: vec![ScopeFrame::default()],
+17
View File
@@ -990,6 +990,21 @@ fn walk_seq(
}
}
/// Issue #26: when an `Optional` is skipped (its inner didn't engage),
/// stash any `IntroProse` hint the inner left in `pending_hint_mode`
/// into the surviving slot before it is cleared by this empty match.
/// `position` is where the optional was skipped — the resolver compares
/// it to the cursor so the hint only shows while the cursor sits at that
/// optional, not after a later clause consumes input past it. Only
/// `IntroProse` is carried (it is the "introduce an optional position"
/// mode); `ProseOnly` / `ForceProse` mark active slots and reach the
/// resolver through the normal `pending_hint_mode` path.
const fn capture_skipped_intro_hint(ctx: &mut WalkContext, position: usize) {
if let Some(crate::dsl::grammar::HintMode::IntroProse(key)) = ctx.pending_hint_mode {
ctx.surviving_intro_hint = Some((key, position));
}
}
fn walk_optional(
source: &str,
position: usize,
@@ -1008,6 +1023,7 @@ fn walk_optional(
// Inner didn't engage at all — skip the Optional
// but carry the inner's expectations so the caller's
// expected-set sees them.
capture_skipped_intro_hint(ctx, position);
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
NodeWalkResult::Matched {
@@ -1019,6 +1035,7 @@ fn walk_optional(
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
capture_skipped_intro_hint(ctx, position);
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
let _ = p;
+27
View File
@@ -116,6 +116,19 @@ pub fn hint_resolution_at_input_in_mode(
use crate::dsl::grammar::HintMode;
let snap = expected_for_hint_snapshot(source, schema, mode);
// Issue #26: an optional positional slot with no candidate text
// (the `seed <table>` row count) left an `IntroProse` hint that
// survived the trailing optionals. It is shown even for an
// otherwise-complete command (empty expected set) — that is exactly
// the `seed users ▮` case where the count is invisible. Checked
// first, before the complete-command short-circuit below.
if let Some(key) = snap.surviving_intro_hint {
return Some(HintResolution {
mode: HintMode::IntroProse(key),
column: None,
form_b_autogen_skipped: Vec::new(),
});
}
// Empty expected set means the command is already complete
// (`WalkOutcome::Match`) — no slot to hint at.
if snap.expected.is_empty() {
@@ -2599,6 +2612,11 @@ struct HintWalkSnapshot {
/// The grammar-declared `HintMode` at the cursor's slot
/// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node).
pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// An `IntroProse` catalog key for an *optional* positional slot at
/// the cursor that produced no candidate (issue #26 — `seed <table>`
/// row count). Survives the trailing optional siblings that clear
/// `pending_hint_mode`; already filtered to the cursor position.
surviving_intro_hint: Option<&'static str>,
current_table_columns: Option<Vec<crate::completion::TableColumn>>,
/// `Some` when the input used Form A's explicit column list.
/// `None` for Form B (`insert into T values …`) and for
@@ -2625,6 +2643,7 @@ fn expected_for_hint_snapshot(
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
current_table_columns: None,
user_listed_columns: None,
};
@@ -2652,6 +2671,14 @@ fn expected_for_hint_snapshot(
pending_value_type: ctx.pending_value_type,
pending_value_column: ctx.pending_value_column,
pending_hint_mode: ctx.pending_hint_mode,
// Issue #26: only surface the skipped-optional hint when the
// optional was skipped *at the cursor* (the end of the walked
// slice). Captured earlier (before a later clause consumed past
// it) → stale, so drop it.
surviving_intro_hint: ctx
.surviving_intro_hint
.filter(|(_, pos)| *pos == source.len())
.map(|(key, _)| key),
current_table_columns: ctx.current_table_columns,
user_listed_columns: ctx.user_listed_columns,
}