diff --git a/src/completion.rs b/src/completion.rs index d38b376..d156ee9 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -16,27 +16,18 @@ use crate::dsl::grammar::IdentSource; use crate::dsl::types::Type; +use crate::dsl::walker::outcome::Expectation; use crate::dsl::{ParseError, parse_command}; -/// Label emitted by `type_keyword` (in `dsl::parser`) when it -/// expects a column-type token. Matches the `.labelled("type")` -/// applied on the inner `select_ref!`. Centralised here so the -/// completion engine and the parser agree on the magic string. -const TYPE_SLOT_LABEL: &str = "type"; - /// Composite literal candidates whose lexed shape is more than /// one token but which the user types as a single fluent piece. -/// Pairs of (parser-expected-opener, full-composite-text). +/// Pairs of (walker-expected-literal, full-composite-text). /// -/// The opener is the first token's backticked label as it -/// appears in `ParseError::Invalid::expected` — when present, -/// the engine surfaces the full composite text as a Tab -/// candidate. -/// -/// Currently the only entry is `1:n` (start of -/// `add 1:n relationship`). New entries register here; no -/// parser change required. -const COMPOSITE_CANDIDATES: &[(&str, &str)] = &[("`1`", "1:n")]; +/// When the walker reports `Expectation::Literal(opener)` at the +/// cursor, the engine surfaces the full composite text as a Tab +/// candidate. Today the only entry is `1:n` (the opener for +/// `add 1:n relationship`) — adding more is a one-line edit. +const COMPOSITE_CANDIDATES: &[(&str, &str)] = &[("1", "1:n")]; /// Per-project schema lookup cache (ADR-0022 §9). /// @@ -65,6 +56,14 @@ impl SchemaCache { } } +/// What the grammar would accept at the end of `leading`, +/// expressed as structured `Expectation`s direct from the +/// walker (ADR-0024 §architecture, Phase F walker-driven +/// completion). Replaces the `ParseError`-string round-trip. +fn expected_at(leading: &str) -> Vec { + crate::dsl::walker::expected_at_input(leading) +} + /// A single Tab-insertable item with its source (so the /// renderer can colour keywords differently from schema /// identifiers, and so the ordering can group keywords first). @@ -139,7 +138,7 @@ pub fn candidates_at_cursor( let partial_prefix = input[start..cursor].to_string(); let leading = &input[..start]; - let expected = expected_set(leading); + let expected = expected_at(leading); if expected.is_empty() { return None; } @@ -149,13 +148,9 @@ pub fn candidates_at_cursor( // keyword literals — number / string-literal slots are // descriptive labels, not Tab candidates). Surfacing those // three is actively misleading — the user usually wants a - // number, a quoted string, or a date, and seeing just - // "null true false" implies those are *the* options. We - // suppress the keyword candidates here; the ambient_hint - // ladder falls through to a prose hint with format - // examples instead. Once the user starts typing a prefix - // (`n`, `tr`, `fa`) the normal keyword-completion path - // applies — the suppression only triggers at empty prefix. + // number, a quoted string, or a date. Suppress so the + // ambient_hint ladder falls through to a prose hint with + // format examples instead. if partial_prefix.is_empty() && is_value_literal_signature(&expected) { return None; } @@ -163,35 +158,36 @@ pub fn candidates_at_cursor( let lowered_prefix = partial_prefix.to_lowercase(); let matches_prefix = |s: &str| s.to_lowercase().starts_with(&lowered_prefix); - // Source 1: keyword candidates from the parser's - // expected-set. Preserve `expected`'s order — it reflects - // chumsky's source-order traversal of `or_not` / `choice` - // chains, which matches the canonical command shape (e.g. - // `to` before `table` for `add column [to] [table] …`). + // Source 1: keyword candidates direct from the walker's + // expected set. `Word(primary)` and `Literal(s)` both + // surface here; we keep only the alphabetic ones — + // single-digit literals like `1` go through the composite + // pipeline below, and punct never surfaces as a candidate. + // Declaration order is preserved (matches the canonical + // command shape, e.g. `to` before `table` for + // `add column [to] [table] …`). let mut keywords: Vec = expected .iter() - .filter_map(|item| strip_backticks(item)) - // Backticked items are walker `Expectation::Word`s or - // `Expectation::Literal`s. Keywords are the - // alphabetic-only ones; punct (`,`, `=`) and digit - // literals (`1`) live in the same expected-set but - // surface through other candidate sources. - .filter(|name| !name.is_empty() && name.chars().all(|c| c.is_ascii_alphabetic())) + .filter_map(|e| match e { + Expectation::Word(w) | Expectation::Literal(w) => Some(*w), + _ => None, + }) + .filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_alphabetic())) .map(str::to_string) .filter(|name| matches_prefix(name)) .collect(); let mut seen_kw = std::collections::HashSet::new(); keywords.retain(|k| seen_kw.insert(k.clone())); - // Source 1.5: type-name candidates when the parser expects - // a column-type slot. Type names live outside the Keyword - // enum (ADR-0020 §2 — type names stay as identifiers, - // validated by Type::from_str), so they need their own - // completion path. Preserve `Type::all()` declaration - // order — that's text/int/real/decimal/bool/date/datetime/ - // blob/serial/shortid, the order a learner reads them in - // ADR-0005. - let type_names: Vec = if expected.iter().any(|s| s == TYPE_SLOT_LABEL) { + // Source 1.5: type-name candidates when the walker expects + // a column-type slot. Type names are a closed set sourced + // from `Type::all()` (ADR-0005 declaration order: + // text/int/real/decimal/bool/date/datetime/blob/serial/ + // shortid). The walker surfaces this as + // `Expectation::Ident { source: Types }`. + let type_names: Vec = if expected.iter().any(|e| { + matches!(e, Expectation::Ident { source: IdentSource::Types, .. }) + }) { Type::all() .iter() .map(|t| t.keyword().to_string()) @@ -201,56 +197,66 @@ pub fn candidates_at_cursor( Vec::new() }; - // Source 1.55: flag candidates (`--name`). Like type - // names, flags live outside the Keyword enum — the parser - // labels them as backticked literals like `` `--all-rows` ``. - // Surface them as a distinct CandidateKind so the hint - // panel can colour them with `tok_flag` (matching how - // they'll appear in the input pane after insertion). - // - // The user can either Tab from a bare cursor position - // (partial empty) or after typing `--` (partial = "--"). - // The standard prefix matcher walks back over alphanumeric + - // underscore, which does NOT cross `-`, so when the user - // types `--all` the partial is `all` — match the flag's - // body against that. Otherwise match the full `--name` - // against the partial (which may be empty or start with `--`). + // Source 1.55: flag candidates (`--name`). Surfaced as a + // distinct CandidateKind so the hint panel can colour them + // with `tok_flag` (matching how they'll appear after + // insertion). The standard prefix matcher walks back over + // alphanumeric + underscore, which does NOT cross `-`, so + // when the user types `--all` the partial is `all` — match + // the flag's body against that. Otherwise match the full + // `--name` against the partial (which may be empty or start + // with `--`). let flags: Vec = expected .iter() - .filter_map(|item| strip_backticks(item)) - .filter(|name| name.starts_with("--")) - .filter(|name| { - if partial_prefix.starts_with("--") || partial_prefix.is_empty() { - matches_prefix(name) + .filter_map(|e| match e { + Expectation::Flag(name) => Some(*name), + _ => None, + }) + .filter(|body| { + if partial_prefix.starts_with("--") { + format!("--{body}") + .to_lowercase() + .starts_with(&lowered_prefix) + } else if partial_prefix.is_empty() { + true } else { - // partial is the alphanumeric tail past `--` - let body = &name[2..]; body.to_lowercase().starts_with(&lowered_prefix) } }) - .map(|name| name.to_string()) + .map(|body| format!("--{body}")) .collect(); // Source 1.6: composite-literal candidates. Some commands - // start with a multi-token literal sequence that the lexer - // splits into Number/Punct/Identifier (e.g. `1:n` for - // `add 1:n relationship`). The parser's expected-set - // surfaces just the first token (`` `1` ``), which would - // otherwise be filtered out (not a Keyword variant). We - // surface the full composite so the user can Tab through - // without knowing the surface syntax. + // start with a multi-token literal sequence that the user + // types as a single fluent piece (e.g. `1:n` for + // `add 1:n relationship`). The walker's expected-set + // surfaces the first token only (`Expectation::Literal("1")`); + // the engine surfaces the full composite text so the user + // can Tab through without knowing the surface syntax. let composites: Vec = COMPOSITE_CANDIDATES .iter() - .filter(|(opener, _)| expected.iter().any(|s| s == *opener)) + .filter(|(opener, _)| { + expected.iter().any(|e| match e { + Expectation::Literal(l) | Expectation::Word(l) => *l == *opener, + _ => false, + }) + }) .map(|(_, text)| (*text).to_string()) .filter(|s| matches_prefix(s)) .collect(); // Source 2: schema identifiers — accumulated across every - // matching known-set slot. `NewName` slots return `&[]`. + // matching schema-listable `Ident { source }` expectation. + // `NewName` / `Types` / `Free` sources don't query the + // schema cache and contribute nothing here. let mut identifiers: Vec = expected .iter() - .filter_map(|item| IdentSource::from_expected_label(item)) + .filter_map(|e| match e { + Expectation::Ident { source, .. } if source.completes_from_schema() => { + Some(*source) + } + _ => None, + }) .flat_map(|source| cache.for_source(source).iter().cloned()) .filter(|name| matches_prefix(name)) .collect(); @@ -306,18 +312,22 @@ pub fn candidates_at_cursor( }) } -fn strip_backticks(s: &str) -> Option<&str> { - s.strip_prefix('`').and_then(|s| s.strip_suffix('`')) -} - /// Detect a value-literal expected-set signature. A value-literal -/// slot is the only position where chumsky's expected-set +/// slot is the only position where the walker's expected-set /// simultaneously contains all five forms `null` / `true` / -/// `false` / number / string literal. See the suppression rationale -/// at the call site in `candidates_at_cursor`. -fn is_value_literal_signature(expected: &[String]) -> bool { - let has = |needle: &str| expected.iter().any(|e| e == needle); - has("`null`") && has("`true`") && has("`false`") && has("number") && has("string literal") +/// `false` / number / string literal. See the suppression +/// rationale at the call site in `candidates_at_cursor`. +fn is_value_literal_signature(expected: &[Expectation]) -> bool { + let has_word = |needle: &str| { + expected + .iter() + .any(|e| matches!(e, Expectation::Word(w) if *w == needle)) + }; + has_word("null") + && has_word("true") + && has_word("false") + && expected.iter().any(|e| matches!(e, Expectation::NumberLit)) + && expected.iter().any(|e| matches!(e, Expectation::StringLit)) } /// `Some(prose)` when the cursor sits at an empty-prefix value-literal slot. @@ -350,7 +360,7 @@ pub fn value_literal_hint_at_cursor(input: &str, cursor: usize) -> Option Option { } } let leading = &input[..start]; - let expected = expected_set(leading); - let is_new_name_slot = expected - .iter() - .filter_map(|item| IdentSource::from_expected_label(item)) - .any(|source| source == IdentSource::NewName); + let expected = expected_at(leading); + let is_new_name_slot = expected.iter().any(|e| { + matches!( + e, + Expectation::Ident { + source: IdentSource::NewName, + .. + } + ) + }); if !is_new_name_slot { return None; } @@ -485,15 +500,19 @@ pub fn invalid_ident_at_cursor( } let partial = &input[start..cursor]; let leading = &input[..start]; - let expected = expected_set(leading); + let expected = expected_at(leading); if expected.is_empty() { return None; } - // Find every known-set slot in the expected list. + // Find every schema-listable source in the expected list. let sources: Vec = expected .iter() - .filter_map(|item| IdentSource::from_expected_label(item)) - .filter(|s| s.completes_from_schema()) + .filter_map(|e| match e { + Expectation::Ident { source, .. } if source.completes_from_schema() => { + Some(*source) + } + _ => None, + }) .collect(); if sources.is_empty() { return None; @@ -521,23 +540,10 @@ pub fn invalid_ident_at_cursor( }) } -/// The expected-token set at the end of `leading`. Empty -/// `leading` (whitespace only) yields every command-entry -/// keyword — there's no parser failure to drive this from, so -/// we synthesise it from the usage registry. -fn expected_set(leading: &str) -> Vec { - if leading.trim().is_empty() { - return crate::dsl::grammar::entry_words_alphabetised() - .into_iter() - .map(|w| format!("`{w}`")) - .collect(); - } - match parse_command(leading) { - Ok(_) => Vec::new(), - Err(ParseError::Empty) => Vec::new(), - Err(ParseError::Invalid { expected, .. }) => expected, - } -} +// `expected_set` is gone: the walker-driven `expected_at` above +// returns structured `Expectation`s with full `IdentSource` +// information, avoiding the lossy string round-trip the +// chumsky-era completion engine relied on. /// Snapshot of a freshly-inserted completion. The memo lives /// on `App::last_completion` until any non-Tab/non-Shift-Tab diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 4968dde..9558897 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -30,6 +30,49 @@ use crate::dsl::walker::outcome::{ pub use context::ColumnInfo; pub use highlight::highlight_runs; +/// What the grammar would accept at the end of `source` +/// (ADR-0024 §architecture, Phase F walker-driven completion). +/// +/// Empty / whitespace-only input yields every command-entry word +/// as `Expectation::Word(primary)`. Otherwise the walker is +/// driven to `EndOfInput`; if the input completes a command, +/// the result is empty; if it fails or is incomplete, the +/// walker's expected-set surfaces verbatim — `Ident { source, +/// role }` carries its `IdentSource` (so the completion engine +/// can schema-look-up without a string round-trip), `Word` / +/// `Literal` carry their primary literal, etc. +/// +/// Inputs whose first token is not a registered entry word +/// fall back to listing every entry word — matches the +/// synthetic "unknown command" expectation set the parser +/// produces. +#[must_use] +pub fn expected_at_input(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); + 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(), + // Walker didn't engage (unknown entry word): the + // completion engine should still surface the available + // entry words so the user can recover. + None => REGISTRY + .iter() + .map(|c| outcome::Expectation::Word(c.entry.primary)) + .collect(), + } +} + /// Public walk entry. `bound` is `EndOfInput` for parse; /// `Position(cursor)` for completion / hint (Phase A: not yet /// wired).