From ee3ccd8d771c0e598d27093ae6bd2bc2bb4db8bf Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:34:48 +0000 Subject: [PATCH] feat(hint): advertise the optional seed count in the hint panel (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At `seed ▮` 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. --- docs/adr/0022-ambient-typing-assistance.md | 52 +++++++++++++ docs/adr/README.md | 2 +- src/dsl/grammar/data.rs | 13 +++- src/dsl/walker/context.rs | 13 ++++ src/dsl/walker/driver.rs | 17 +++++ src/dsl/walker/mod.rs | 27 +++++++ src/friendly/keys.rs | 1 + src/friendly/strings/en-US.yaml | 6 ++ src/input_render.rs | 87 ++++++++++++++++++++++ 9 files changed, 216 insertions(+), 2 deletions(-) diff --git a/docs/adr/0022-ambient-typing-assistance.md b/docs/adr/0022-ambient-typing-assistance.md index 254b45e..1150d34 100644 --- a/docs/adr/0022-ambient-typing-assistance.md +++ b/docs/adr/0022-ambient-typing-assistance.md @@ -772,6 +772,58 @@ invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`; `theme::function_colour_is_distinct_from_keyword_identifier_and_type`. See ADR-0031's status note for the grammar-side anchor. +## Amendment 7 — optional positional args reach the hint panel (2026-06-12) + +Issue #26. At `seed
▮` the hint panel showed only the +`set` / `--seed` continuation chips and never mentioned the +**optional row count** — even though a count (`seed users 50`) is +the most common next move. The count is a bare positional +`NumberLit` with no keyword/candidate text, so the candidate ladder +can't surface it; and `seed
` is already a *complete* +command, so the hint resolver short-circuits (empty expected set). + +The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node; +issue #4's CREATE-TABLE element hint) is the right tool — it shows +prose that *introduces* a position whose first-class move has no +candidate, with the keyword alternatives folded into the prose and +Tab still cycling them. But it did not reach this position: a +`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very +next match clears — including the **empty** match of a skipped +`Optional`. The CREATE-TABLE element survives only because it sits +in a *required* `Repeated(min:1)`; an optional positional followed +by more optionals (the seed count) is cleared before the resolver +reads it. + +### Mechanism + +A small, general carry: when `walk_optional` skips its inner (the +inner didn't engage), it stashes any `IntroProse` key the inner +left in `pending_hint_mode` into a new `WalkContext` field, +`surviving_intro_hint: Option<(key, position)>`, **before** the +empty match clears `pending_hint_mode`. The trailing optionals, +which are not `IntroProse`, don't overwrite it. The hint snapshot +keeps the key **only when `position == cursor`** (the slice end), +so it shows while the cursor sits at the count slot but not once a +later clause (`set …`) consumes input past it, nor once the count +itself is supplied. The resolver returns that `IntroProse` even for +an otherwise-complete command (ahead of the empty-expected +short-circuit). + +The seed grammar wraps the count in +`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose +names the count (with its default 20) plus the `.column` +column-fill form and the `set` / `--seed` keywords (user-chosen +scope: mention every option). Only `IntroProse` is carried — +`ProseOnly` / `ForceProse` mark *active* slots and reach the +resolver through the normal path, unchanged. The CREATE-TABLE +element (in a `Repeated`, not an `Optional`) is untouched. + +This is a refinement of ADR-0024 §HintMode-per-node and a sibling +of issue #4; no `AmbientHint` / renderer change. Covered by +`input_render::{seed_count_is_advertised_at_the_optional_position, +seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given, +seed_count_hint_also_fires_after_a_column_fill_target}`. + ## Out of scope Deliberately deferred to keep this ADR shippable as a single diff --git a/docs/adr/README.md b/docs/adr/README.md index 13a0fb0..9389da8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,7 +27,7 @@ This directory contains the project's ADRs, recorded per - [ADR-0019 — Friendly error layer (H1) and i18n message catalog](0019-friendly-error-layer-and-i18n.md) - [ADR-0020 — Tokenization layer for the DSL parser](0020-tokenization-layer-for-the-dsl-parser.md) — **Superseded by ADR-0024 (never implemented).** Specified a `chumsky`-over-tokens architecture (separate lexer, `define_keywords!`, `&[Token]` grammar). ADR-0024 adopted a scannerless hand-rolled walker and removed `chumsky` entirely; the lexer/keyword/token model here does not exist. Kept as institutional memory of the path not taken. - [ADR-0021 — Parser-as-source-of-truth for H1a (per-command usage in parse errors)](0021-parser-as-source-of-truth-for-h1a.md) — **Mechanism superseded by ADR-0024; H1a scope continued in ADR-0042.** The *intent* (show the command's grammar at the point of error) shipped — `usage_ids` on each `CommandNode`, the `parse.usage.*` templates, and the `available_commands` fallback all exist — but via grammar nodes, not the `chumsky` `UsageEntry` registry / `parse.token.*` keys described here (which were never built). -- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor +- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor; **Amendment 7 surfaces optional positional args in the hint panel** (issue #26): at `seed
▮` the optional row count (a bare `NumberLit` with no candidate) was invisible next to the `set`/`--seed` chips, and the resolver short-circuits on the already-complete command. Extends the issue-#4 `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node) to survive trailing optionals: `walk_optional` stashes a skipped inner's `IntroProse` key into a new `WalkContext.surviving_intro_hint` (key + position) before the empty match clears `pending_hint_mode`, and the snapshot keeps it only when the skip position is the cursor (so it never leaks past a later-consumed `set …` clause or 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")}`; prose names the count (default 20), the `.column` column-fill form, and `set`/`--seed` (user-chosen scope). Only `IntroProse` is carried; `ProseOnly`/`ForceProse` and the CREATE-TABLE element (a required `Repeated`) are untouched; no `AmbientHint`/renderer change - [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024) - [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note) - [ADR-0025 — Indexes](0025-indexes.md) — **Accepted** (**Amendment 1, 2026-05-25**: UNIQUE indexes admitted on the **advanced-mode** surface via `CREATE UNIQUE INDEX` — ADR-0035 §4d; the `IndexSchema.unique` flag round-trips through `project.yaml` with no new metadata table since the engine reports uniqueness natively; simple-mode `add unique index` stays deferred), `add index` / `drop index`, persistence, rebuild-table preservation, and items-list display (`C3` index portion + `S2`) diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index b111075..3f14b93 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -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
▮` (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 ` — 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), ]; diff --git a/src/dsl/walker/context.rs b/src/dsl/walker/context.rs index 807f41e..6306119 100644 --- a/src/dsl/walker/context.rs +++ b/src/dsl/walker/context.rs @@ -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, + /// 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
▮`, 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 (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()], diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index e14f04f..231bc36 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -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; diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 3723f20..575ec48 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -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
` 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, + /// An `IntroProse` catalog key for an *optional* positional slot at + /// the cursor that produced no candidate (issue #26 — `seed
` + /// 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>, /// `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, } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 57e2ed3..f5244b6 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -231,6 +231,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // slot (`create table T (`) so the otherwise-invisible // column-name role reads as the dominant first move. ("hint.create_table_element", &[]), + ("hint.seed_count", &[]), ("hint.value_literal_slot", &[]), ( "hint.ambient_typing_name_then", diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 5999ff8..40930df 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -400,6 +400,12 @@ hint: # at `create table T (` so the column-name role is visible # alongside the table-level constraint keywords. create_table_element: "Type a column name, or a table-level constraint: `primary`, `unique`, `check`, `constraint`, `foreign`" + # Issue #26: the `seed
▮` position. The optional row count is + # a bare number with no Tab candidate, so it (and the `.column` + # column-fill form) would be invisible next to the `set` / `--seed` + # chips. Names every option so the most common next move (a count) is + # discoverable. + seed_count: "Optionally a row count, e.g. `50` (default 20); `.column` to fill one column on existing rows; `set` to pin a column; `--seed` to fix the RNG" # Value-literal slot — `insert ... values (`, `update ... set # col=`, `where col=`. Replaces the misleading "null true # false" keyword candidate list with format guidance for all diff --git a/src/input_render.rs b/src/input_render.rs index cf11299..01af66b 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -1356,6 +1356,93 @@ mod tests { } } + fn seed_cache() -> crate::completion::SchemaCache { + use crate::completion::TableColumn; + use crate::dsl::types::Type; + let mut cache = crate::completion::SchemaCache::default(); + cache.tables.push("users".to_string()); + cache.columns.push("email".to_string()); + cache + .table_columns + .insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]); + cache + } + + #[test] + fn seed_count_is_advertised_at_the_optional_position() { + // Issue #26: `seed users ▮` is a complete command, so the hint + // ladder shows only the `set` / `--seed` continuation chips — + // the optional row count (a bare number with no candidate) was + // invisible. An IntroProse hint that survives the trailing + // optionals now advertises it; Tab still cycles the keywords. + let cache = seed_cache(); + let input = "seed users "; + match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) { + Some(AmbientHint::Prose(p)) => { + assert!( + p.contains("row count") && p.contains("20"), + "prose must mention the row count and the default; got: {p:?}", + ); + assert!( + p.contains("set") && p.contains("--seed") && p.contains(".column"), + "prose should fold in the keyword + column-fill options; got: {p:?}", + ); + } + other => panic!("expected a Prose count hint; got: {other:?}"), + } + // Tab candidates remain available (completion is independent). + let comp = crate::completion::candidates_at_cursor_in_mode( + input, input.len(), &cache, Mode::Simple, + ) + .expect("completion remains available"); + let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); + assert!( + texts.contains(&"set") && texts.contains(&"--seed"), + "Tab must still cycle `set` / `--seed`; got {texts:?}", + ); + + // `seed` runs in both modes (ADR-0048), so the hint must fire in + // advanced mode too — not only simple. + match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { + Some(AmbientHint::Prose(p)) => assert!( + p.contains("row count"), + "count hint must also fire in advanced mode; got: {p:?}", + ), + other => panic!("expected the count hint in advanced mode; got: {other:?}"), + } + } + + #[test] + fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() { + // Position guard: the hint shows only while the cursor sits at + // the count slot. Once the count is supplied — or a later clause + // consumes input past it — it must not reappear. + let cache = seed_cache(); + for input in ["seed users 50 ", "seed users set email = 'x' "] { + let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple); + let is_count_prose = matches!( + &hint, + Some(AmbientHint::Prose(p)) if p.contains("row count") + ); + assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}"); + } + } + + #[test] + fn seed_count_hint_also_fires_after_a_column_fill_target() { + // The count is valid after `seed users.email` too, so the hint + // fires there — `.email` is a real column (no diagnostic). + let cache = seed_cache(); + let input = "seed users.email "; + match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) { + Some(AmbientHint::Prose(p)) => assert!( + p.contains("row count"), + "count hint expected after a column-fill target; got: {p:?}", + ), + other => panic!("expected a Prose count hint; got: {other:?}"), + } + } + #[test] fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() { // Issue #6 trade-off lockdown: dropping the typing-time