//! Keyword + identifier completion for ambient typing //! assistance (ADR-0022 stage 8). //! //! This stage 1 cut covers keyword completion only. Identifier //! completion (schema-aware) lands in a follow-on substage //! that builds on this scaffolding. //! //! Concept: `candidates_at_cursor(input, cursor)` returns a //! `Completion` describing what would happen if the user //! pressed Tab right now — the byte range to replace, the //! partial prefix already typed, and the list of fitting //! candidates. Empty / no-candidate cases return `None`. //! //! The cycling memo (`LastCompletion` on `App`) lives in //! `app.rs`; this module owns the candidate computation. use crate::dsl::grammar::IdentSource; use crate::dsl::types::Type; use crate::dsl::walker::outcome::Expectation; use crate::dsl::{ParseError, parse_command}; use crate::dsl::parser::parse_command_in_mode; use crate::mode::Mode; /// Composite literal candidates whose lexed shape is more than /// one token but which the user types as a single fluent piece. /// Pairs of (walker-expected-literal, full-composite-text). /// /// 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, ADR-0024 §Phase D). /// /// Held by `App::schema_cache` and consulted by the completion /// engine for identifier slots and by the walker for schema-aware /// value-slot dispatch (Phase D full). Empty by default; the /// runtime refreshes on project load and after successful DDL. #[derive(Debug, Clone, Default)] pub struct SchemaCache { pub tables: Vec, pub columns: Vec, pub relationships: Vec, pub indexes: Vec, /// Per-table column metadata with user-facing types /// (ADR-0024 §Phase D). Keyed by table name; lookup is /// case-insensitive in `columns_for_table` so the walker /// can resolve `Customers` regardless of how it was typed. pub table_columns: std::collections::HashMap>, /// Per-table user indexes (ADR-0025). Keyed by table name; drives /// the nested tables/indexes items panel (S2). Each entry carries /// the index's uniqueness so the panel can mark a UNIQUE index /// (ADR-0035 §4d). pub table_indexes: std::collections::HashMap>, } /// One per-table index for the items panel (ADR-0025 / ADR-0035 §4d): /// its name and whether it is a UNIQUE index. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IndexEntry { pub name: String, pub unique: bool, } /// One column's user-facing type info, scoped to a table /// (ADR-0024 §Phase D, §WalkContext). /// /// `not_null` / `has_default` (ADR-0033 §8.3, sub-phase 3i) let the /// walker pre-flight a `not_null_missing` WARNING — an `INSERT` /// whose column list omits a required column. They default to /// `false`, so callers/tests that don't care construct via /// [`TableColumn::new`]. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct TableColumn { pub name: String, pub user_type: crate::dsl::types::Type, /// The column is declared `NOT NULL` (a PK column is also /// effectively not-null; the cache builder records that). pub not_null: bool, /// The column has a `DEFAULT` — so omitting it on `INSERT` is /// fine even when `not_null`. pub has_default: bool, } impl TableColumn { /// A column with no NOT-NULL / default info — the common case /// for callers and tests that don't exercise ADR-0033 §8.3. #[must_use] pub fn new(name: impl Into, user_type: crate::dsl::types::Type) -> Self { Self { name: name.into(), user_type, not_null: false, has_default: false, } } } impl SchemaCache { /// Lookup the candidate list for an identifier slot. /// Sources that don't read from the schema (`NewName`, /// `Types`, `Free`) return `&[]`. #[must_use] pub fn for_source(&self, source: IdentSource) -> &[String] { match source { IdentSource::Tables => &self.tables, IdentSource::Columns => &self.columns, IdentSource::Relationships => &self.relationships, IdentSource::Indexes => &self.indexes, IdentSource::NewName | IdentSource::Types | IdentSource::Free => &[], } } /// Per-table column metadata lookup. Case-insensitive on /// the table name so the walker can resolve identifiers /// the user typed in either case (ADR-0009 — keywords are /// case-insensitive, identifiers preserve case; this helper /// matches the walker's case-insensitive entry-word lookup /// rather than the strict-case `tables` Vec). /// /// Returns `None` when no table matches; an empty `Vec` /// when the table exists but has no columns (rare — /// CSV-empty tables still carry PK columns in metadata). #[must_use] pub fn columns_for_table(&self, table: &str) -> Option<&[TableColumn]> { self.table_columns .iter() .find(|(name, _)| name.eq_ignore_ascii_case(table)) .map(|(_, cols)| cols.as_slice()) } } /// 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, mode: Mode) -> Vec { crate::dsl::walker::expected_at_input_in_mode(leading, mode) } /// 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). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Candidate { pub text: String, pub kind: CandidateKind, /// Source-mode classification (ADR-0035 §4i e). `Both` (neutral) /// except for the merged continuations of a shared entry word, where /// the hint UI colours `Advanced`/`Simple` differently — but only /// when the candidate list actually mixes modes. pub mode: ModeClass, } /// Re-ranker for a freshly-computed candidate list (ADR-0024 /// §ranker-layer). /// /// The grammar tree declares *what's valid*; the ranker decides /// *what's likely useful first*. Lives outside the trie so /// frequency-based ranking, content-aware priors (e.g. `Email` /// → text first), and recency hooks can plug in without /// touching grammar declarations. /// /// Default is `identity_ranker` — declaration order from the /// grammar tree is preserved. pub type Ranker = fn(Vec) -> Vec; /// Identity ranker: returns its input unchanged. #[must_use] pub const fn identity_ranker(candidates: Vec) -> Vec { candidates } /// Which input mode(s) a shared-entry-word continuation belongs to. /// /// (ADR-0035 §4i d/e.) Computed only where a shared entry word's /// candidate nodes are merged; everywhere else a continuation is `Both` /// (neutral). Drives the contiguous colour-block ordering (and, in the /// UI, the colour) when a candidate list mixes modes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModeClass { /// Valid in both simple (DSL) and advanced (SQL) mode — neutral. Both, /// Advanced/SQL-only continuation. Advanced, /// Simple/DSL-only continuation. Simple, } impl ModeClass { /// Block-ordering key: `Both` (0) → `Advanced` (1) → `Simple` (2), so /// each colour reads as one contiguous block (user-confirmed order). #[must_use] pub const fn block_order(self) -> u8 { match self { Self::Both => 0, Self::Advanced => 1, Self::Simple => 2, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CandidateKind { /// One of the parser's expected keywords. Keyword, /// A schema entity (table, column, relationship). Identifier, /// A `--name`-style flag. Coloured with `tok_flag` so the /// hint matches the way it'll render in the input pane. Flag, /// A single-char punctuation token the walker expects next /// (e.g. `(` at the start of `insert into T (cols)`). Used /// to surface branching alternatives the user might not /// otherwise discover — at `insert into Orders ` the walker /// expects either `values` or `(`, and surfacing both makes /// the Form A path discoverable. Punct, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Completion { /// Byte range in `input` to be replaced when a candidate /// is accepted. Equal `(cursor, cursor)` when the user is /// at a token boundary (no partial prefix). pub replaced_range: (usize, usize), /// Partial prefix the user has typed at the cursor. Empty /// when the cursor is at a token boundary. pub partial_prefix: String, /// Fitting candidates, ordered schema-identifiers-first then /// keywords, alphabetised within each group, deduplicated. pub candidates: Vec, } /// Compute what would happen if the user pressed Tab right /// now (ADR-0022 stage 8). `None` means there is nothing to /// complete (no candidates fit / cursor at end of complete /// input). /// /// Candidates are built from two sources: /// - **Keywords**: the parser's expected-set entries that are /// bare keywords (excluding punctuation and descriptive /// labels per ADR-0022 §10). /// - **Schema identifiers**: when the parser's expected-set /// includes an `IdentSource::expected_label()`, the matching /// schema list from `cache` is added (skipping the `NewName` /// slot — the user invents those). /// /// Both sources are filtered by the typed partial prefix /// (case-insensitive starts-with), combined, sorted, and /// deduplicated. #[must_use] pub fn candidates_at_cursor( input: &str, cursor: usize, cache: &SchemaCache, ) -> Option { candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced) } /// Mode-aware [`candidates_at_cursor`] (ADR-0030 §2). Tab in /// simple mode no longer offers advanced-mode-only commands; /// the walker's mode gate flows through the probe inside. #[must_use] pub fn candidates_at_cursor_in_mode( input: &str, cursor: usize, cache: &SchemaCache, mode: Mode, ) -> Option { candidates_at_cursor_with_in_mode(input, cursor, cache, identity_ranker, mode) } /// Variant of [`candidates_at_cursor`] that applies a custom /// `Ranker` to the final candidate list (ADR-0024 §ranker-layer). /// The default `candidates_at_cursor` calls this with /// `identity_ranker`. #[must_use] pub fn candidates_at_cursor_with( input: &str, cursor: usize, cache: &SchemaCache, ranker: Ranker, ) -> Option { candidates_at_cursor_with_in_mode(input, cursor, cache, ranker, Mode::Advanced) } /// Mode-aware [`candidates_at_cursor_with`]. #[must_use] pub fn candidates_at_cursor_with_in_mode( input: &str, cursor: usize, cache: &SchemaCache, ranker: Ranker, mode: Mode, ) -> Option { let cursor = cursor.min(input.len()); // Walk backward from the cursor over identifier-shaped // characters to find the partial prefix the user is mid-typing. let bytes = input.as_bytes(); let mut start = cursor; while start > 0 { let prev = bytes[start - 1]; if prev.is_ascii_alphanumeric() || prev == b'_' { start -= 1; } else { break; } } let partial_prefix = input[start..cursor].to_string(); let leading = &input[..start]; // When the full input already parses, the cursor is at // the end of a "complete enough" command — but the leading // slice still reports an expected set (the words the user // just finished typing, or optional-suffix continuations). // We let the completion engine run normally, then below // filter out candidates that exactly equal the partial // prefix the user already typed: it's not useful to suggest // `pk` at the end of `create table T with pk`. The // optional-suffix case (`save ` → `as`) is preserved // because there `partial_prefix` is empty. let input_parses_complete = parse_command_in_mode(input, mode).is_ok(); // Schema-aware probe: one walk yields both the expected set // and the table-context snapshot (ADR-0024 §Phase D // §column-narrowing). The engine reads // `current_table_columns` to narrow column candidates to the // active table rather than the flat `cache.columns` (which // unions every table's columns). ADR-0030 §2: the probe runs // with the active mode so simple-mode users don't see SQL // commands offered. let probe = crate::dsl::walker::completion_probe_in_mode(leading, cache, mode); // ADR-0032 §10.6 follow-up — the "edit an existing query" // workflow: the user goes back inside a projection list to // type a column, but the FROM clause is already typed // *after* the cursor. The leading-only walk sees an empty // from_scope and the column source falls back to the global // column list — noisy. A second walk on the FULL input // populates from_scope_stack[0] with the trailing FROM's // bindings, which we can narrow against. The cost is one // extra walk on each Tab press; gated on the leading walk // having produced no from_scope (so the common case where // the cursor is past FROM pays nothing). let lookahead_probe = if probe.from_scope.is_empty() && probe.cte_bindings.is_empty() && input.len() > leading.len() { let direct = crate::dsl::walker::completion_probe_in_mode(input, cache, mode); if direct.from_scope.is_empty() && direct.cte_bindings.is_empty() { // The slot at the cursor is empty/incomplete — e.g. the // projection list of `select from T` after the // user deleted `*` — so the full-input walk never // reached FROM and recovered no scope. Repair by // inserting a neutral expression placeholder at the // cursor and re-walking, so the trailing FROM/CTE scope // is recovered for column narrowing (ADR-0032 §10.6). // Only the repaired walk's `from_scope` / `cte_bindings` // are consumed (table + columns), so the inserted token // doesn't perturb the expected set, which comes from the // leading probe. let mut repaired = String::with_capacity(input.len() + 2); repaired.push_str(&input[..start]); repaired.push_str("1 "); repaired.push_str(&input[start..]); let repaired_probe = crate::dsl::walker::completion_probe_in_mode(&repaired, cache, mode); if repaired_probe.from_scope.is_empty() && repaired_probe.cte_bindings.is_empty() { Some(direct) } else { Some(repaired_probe) } } else { Some(direct) } } else { None }; // Resolution scope = leading probe's scope first; fall back // to the look-ahead probe when the leading walk produced // nothing. The leading walk has precedence so a cursor // inside a subquery's projection (where the inner FROM is // local to that subquery's frame) doesn't get mis-narrowed // by the outer SELECT's bindings. let resolution_from_scope: &[crate::dsl::walker::context::TableBinding] = if !probe.from_scope.is_empty() { &probe.from_scope } else { lookahead_probe .as_ref() .map_or(&[][..], |la| &la.from_scope[..]) }; let resolution_cte_bindings: &[crate::dsl::walker::context::CteBinding] = if !probe.cte_bindings.is_empty() { &probe.cte_bindings } else { lookahead_probe .as_ref() .map_or(&[][..], |la| &la.cte_bindings[..]) }; // For unqualified column completion, prefer the leading // walk's `current_table_columns`; fall back to "the union of // the look-ahead from_scope's bindings' columns" when leading // produced no in-scope columns. Phase-1 DSL paths unaffected. let lookahead_union_columns: Vec = if probe.current_table_columns.is_none() { let mut out: Vec = Vec::new(); for binding in resolution_from_scope { for col in &binding.columns { if !out.iter().any(|c| { c.name.eq_ignore_ascii_case(&col.name) }) { out.push(col.clone()); } } } out } else { Vec::new() }; let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() { None } else { Some(lookahead_union_columns.as_slice()) }; let current_table_columns: Option<&[TableColumn]> = probe.current_table_columns.as_deref().or(lookahead_slice); // ADR-0032 §10.5 — qualified-prefix completion. When the // cursor sits immediately after ` .` (ignoring // whitespace), narrow `IdentSource::Columns` candidates to // that qualifier's binding columns alone. The qualifier // resolves against the active `from_scope` (alias matches // first, then table names), falling back to `cte_bindings` // for `cte_alias.|` shapes. Unresolved qualifier → empty // column list (the structural error path surfaces the // unresolved-prefix message). let prefix_qualifier = peek_back_qualifier(input, start); let qualified_columns: Option> = prefix_qualifier .as_ref() .map(|q| { // ADR-0033 §9: `excluded.|` inside an `INSERT … ON // CONFLICT … DO UPDATE` completes to the target table's // columns — `excluded` mirrors the would-be-inserted row. // The target's columns are the INSERT's // `current_table_columns` (set by the target-table slot). // The diagnostic pass enforces the strict DO-UPDATE // byte-range; completion is the softer surface and offers // the columns whenever the INSERT target is in hand. if q.eq_ignore_ascii_case("excluded") && let Some(cols) = current_table_columns { cols.iter().map(|c| c.name.clone()).collect() } else { resolve_qualifier_columns_in( q, resolution_from_scope, resolution_cte_bindings, cache, ) } }); let expected = if probe.expected.is_empty() { expected_at(leading, mode) } else { probe.expected.clone() }; if expected.is_empty() { return None; } // Value-literal slot: at an empty-prefix position the only // candidates we'd surface are `null`/`true`/`false` (the // 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. Suppress so the // ambient_hint ladder falls through to a prose hint with // format examples instead. // // EXCEPT when the same expected set also contains a // schema-listable Ident expectation — that's the ambiguous // position at `insert into T (` where Form A (column list) // and Form C (bare value list) both apply. There the user // has actionable column candidates that shouldn't be // hidden by the value-literal suppression. let has_schema_ident = expected.iter().any(|e| { matches!( e, Expectation::Ident { source, .. } if source.completes_from_schema() ) }); // A slot the grammar explicitly marked `ProseOnly` // (`Node::Hinted`) suppresses its keyword candidates // regardless of `has_schema_ident`. The WHERE-expression // operand is exactly this case: it accepts a column // reference *and* a value literal, so the signature // heuristic alone would surface the misleading // `null`/`true`/`false` trio (ADR-0026 §8). let prose_only_slot = matches!( probe.pending_hint_mode, Some(crate::dsl::grammar::HintMode::ProseOnly(_)) ); if partial_prefix.is_empty() && (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident)) { return None; } let lowered_prefix = partial_prefix.to_lowercase(); let matches_prefix = |s: &str| s.to_lowercase().starts_with(&lowered_prefix); // 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(|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())); // (ADR-0035 §4i e) Block-order the keyword continuations by // mode-class when a shared entry word merged simple + advanced forms, // so the colours read as contiguous blocks Both → Advanced → Simple // (user-confirmed order). A single-mode list (all `Both`) is left in // its declaration order. `sort_by_key` is stable, preserving the // intra-block order. let kw_mode = |kw: &str| -> ModeClass { probe .expected .iter() .zip(&probe.expected_modes) .find_map(|(e, m)| match e { Expectation::Word(w) | Expectation::Literal(w) if w.eq_ignore_ascii_case(kw) => { Some(*m) } _ => None, }) .unwrap_or(ModeClass::Both) }; let mixed = keywords .iter() .map(|k| kw_mode(k.as_str()).block_order()) .collect::>() .len() > 1; if mixed { keywords.sort_by_key(|k| kw_mode(k.as_str()).block_order()); } // 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()) .filter(|s| matches_prefix(s)) .collect() } else { Vec::new() }; // 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(|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 { body.to_lowercase().starts_with(&lowered_prefix) } }) .map(|body| format!("--{body}")) .collect(); // Source 1.6: composite-literal candidates. Some commands // 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(|e| match e { Expectation::Literal(l) | Expectation::Word(l) => *l == *opener, _ => false, }) }) .map(|(_, text)| (*text).to_string()) .filter(|s| matches_prefix(s)) .collect(); // Source 1.7: branching-punct candidates. At positions // where the walker expects a punct character that opens a // sub-shape (notably `(` opening Form A or C of insert), // surface the punct as a Tab candidate so the user // discovers the option. Closing punct (`)`, `,`, etc.) and // expected-after-content punct (`:`, `=`, `.`) are not // surfaced — they're trailing terminals the user types // naturally, not "shape branches" worth advertising in the // hint panel. let punct_candidates: Vec = if partial_prefix.is_empty() { expected .iter() .filter_map(|e| match e { Expectation::Punct('(') => Some("(".to_string()), _ => None, }) .collect() } else { Vec::new() }; // Source 2: schema identifiers — accumulated across every // matching schema-listable `Ident { source }` expectation. // `NewName` / `Types` / `Free` sources don't query the // schema cache and contribute nothing here. // // Column candidates narrow to `current_table_columns` when // the walker resolved the active table — `update T set ` at // table T offers T's columns, not every table's columns // (ADR-0024 §Phase D §column-narrowing). Schemaless / table // not in schema falls back to the global `cache.columns`. let mut identifiers: Vec = expected .iter() .filter_map(|e| match e { Expectation::Ident { source, .. } if source.completes_from_schema() => { Some(*source) } _ => None, }) .flat_map(|source| { if source == IdentSource::Columns { qualified_columns.as_deref().map_or_else( || { current_table_columns.map_or_else( || cache.for_source(source).to_vec(), |cols| cols.iter().map(|c| c.name.clone()).collect(), ) }, <[_]>::to_vec, ) } else { cache.for_source(source).to_vec() } }) .filter(|name| matches_prefix(name)) .collect(); identifiers.sort(); identifiers.dedup(); // If an identifier shares its name with a keyword candidate // (rare in practice), the keyword wins — keywords are // grammar; the user can name a table the same thing but // resolving collisions in the user's favour would create // ambiguity in the live render. identifiers.retain(|name| !keywords.contains(name)); // Schema identifiers first: a column / table name the user // would otherwise have to look up is the highest-value // completion (valuable to experts, not just learners, who // come to know the keywords over time). Keywords and the // other closed-set grammar parts follow: keywords, then type // names (closed-set grammar — coloured as keywords), then // composite literals (`1:n`, …), then branching punct (`(` // opening a sub-shape), then flags (own colour). let mut candidates: Vec = Vec::with_capacity( identifiers.len() + keywords.len() + type_names.len() + composites.len() + punct_candidates.len() + flags.len(), ); candidates.extend(identifiers.into_iter().map(|text| Candidate { text, kind: CandidateKind::Identifier, mode: ModeClass::Both, })); // Keywords carry their merged mode-class (Both unless a shared entry // word mixed simple + advanced continuations — ADR-0035 §4i e). candidates.extend(keywords.into_iter().map(|text| { let mode = kw_mode(text.as_str()); Candidate { text, kind: CandidateKind::Keyword, mode, } })); candidates.extend(type_names.into_iter().map(|text| Candidate { text, kind: CandidateKind::Keyword, mode: ModeClass::Both, })); candidates.extend(composites.into_iter().map(|text| Candidate { text, kind: CandidateKind::Keyword, mode: ModeClass::Both, })); candidates.extend(punct_candidates.into_iter().map(|text| Candidate { text, kind: CandidateKind::Punct, mode: ModeClass::Both, })); candidates.extend(flags.into_iter().map(|text| Candidate { text, kind: CandidateKind::Flag, mode: ModeClass::Both, })); if candidates.is_empty() { return None; } // When the input is already a valid complete command, drop // candidates that exactly match the partial prefix — those // are the words the user just finished typing (e.g. `pk` in // `create table T with pk`), not useful suggestions. Keeps // schema-narrowing intact (`show data Cu` → `Customers` is // not an exact match; preserved). if input_parses_complete && !partial_prefix.is_empty() { let lowered_partial = partial_prefix.to_lowercase(); candidates.retain(|c| c.text.to_lowercase() != lowered_partial); } let candidates = ranker(candidates); if candidates.is_empty() { return None; } Some(Completion { replaced_range: (start, cursor), partial_prefix, candidates, }) } /// Peek backward from `start` over whitespace and look for a /// ` .` shape — the qualifier of a qualified reference /// the cursor is mid-typing past. Returns the qualifier text in /// case-preserved form, or `None` when no qualifier is present. /// /// Identifier characters are ASCII alphanumeric + underscore, /// matching the partial-prefix recogniser used in /// `candidates_at_cursor_with_in_mode`. fn peek_back_qualifier(input: &str, start: usize) -> Option { let bytes = input.as_bytes(); // Skip whitespace immediately before the partial. let mut i = start; while i > 0 && bytes[i - 1].is_ascii_whitespace() { i -= 1; } // Expect a `.` next. if i == 0 || bytes[i - 1] != b'.' { return None; } i -= 1; // Skip whitespace between the `.` and the qualifier ident. while i > 0 && bytes[i - 1].is_ascii_whitespace() { i -= 1; } // Walk back over identifier characters. let ident_end = i; while i > 0 { let c = bytes[i - 1]; if c.is_ascii_alphanumeric() || c == b'_' { i -= 1; } else { break; } } if i == ident_end { return None; } Some(input[i..ident_end].to_string()) } /// Resolve a qualifier to a column-name list against the /// supplied from_scope (alias-then-table match) and cte_bindings /// (for `cte_alias.|`). An unresolved qualifier returns an empty /// list per ADR-0032 §10.5. fn resolve_qualifier_columns_in( qualifier: &str, from_scope: &[crate::dsl::walker::context::TableBinding], cte_bindings: &[crate::dsl::walker::context::CteBinding], cache: &SchemaCache, ) -> Vec { // First: alias match in the active from_scope. if let Some(binding) = from_scope.iter().find(|b| { b.alias .as_deref() .is_some_and(|a| a.eq_ignore_ascii_case(qualifier)) }) { if !binding.columns.is_empty() { return binding.columns.iter().map(|c| c.name.clone()).collect(); } if let Some(cte) = cte_bindings .iter() .find(|c| c.name.eq_ignore_ascii_case(&binding.table)) { return cte .columns .iter() .filter_map(|c| c.name.clone()) .collect(); } } // Second: table-name match in the active from_scope. if let Some(binding) = from_scope .iter() .find(|b| b.table.eq_ignore_ascii_case(qualifier)) { if !binding.columns.is_empty() { return binding.columns.iter().map(|c| c.name.clone()).collect(); } if let Some(cte) = cte_bindings .iter() .find(|c| c.name.eq_ignore_ascii_case(&binding.table)) { return cte .columns .iter() .filter_map(|c| c.name.clone()) .collect(); } } // Third: direct cte_bindings match (cte_alias.|). if let Some(cte) = cte_bindings .iter() .find(|c| c.name.eq_ignore_ascii_case(qualifier)) { return cte .columns .iter() .filter_map(|c| c.name.clone()) .collect(); } // Fourth: a bare table name from the schema cache — DSL // paths reach this for `from .` shapes where // the probe didn't push a from_scope binding (no SQL FROM // matched). Preserves Phase-1 behaviour for the DSL. if let Some(cols) = cache.columns_for_table(qualifier) { return cols.iter().map(|c| c.name.clone()).collect(); } Vec::new() } /// Detect a value-literal expected-set signature. A value-literal /// 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: &[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. /// /// The hint panel surfaces format guidance (number, quoted text, /// date, datetime, bool, null) instead of the misleading "null /// true false" keyword-only candidate list. /// /// Note: this is a stopgap until ADR-0023 lands schema-aware /// completion (which would surface format examples specific to /// the column's type — e.g. just the datetime format at a /// datetime column). Today the hint lists all valid literal /// shapes regardless of context. #[must_use] pub fn value_literal_hint_at_cursor(input: &str, cursor: usize) -> Option { let cursor = cursor.min(input.len()); let bytes = input.as_bytes(); let mut start = cursor; while start > 0 { let prev = bytes[start - 1]; if prev.is_ascii_alphanumeric() || prev == b'_' { start -= 1; } else { break; } } if start != cursor { // Partial prefix is non-empty — keyword completion // handles it (e.g. `n` → `null`). return None; } let leading = &input[..start]; let expected = expected_at(leading, Mode::Advanced); if !is_value_literal_signature(&expected) { return None; } Some(crate::t!("hint.value_literal_slot")) } /// What the user has typed in an identifier slot whose schema /// list contains nothing matching the prefix (ADR-0022 stage 8e /// + the user's #5). /// /// The renderer overlays the partial token with `tok_error`; /// the hint panel renders an "invalid …" message. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InvalidIdent { /// Byte range of the typed-but-not-found identifier. pub range: (usize, usize), /// The text the user typed in the slot. pub found: String, /// Which known-set slot this position expected. pub source: IdentSource, } /// "User is typing a name" cursor state (round-3 follow-up). /// /// Fires at `NewName` slots — positions where the user is /// expected to invent a name (new table, new column, new /// relationship). Used by the hint panel to surface a friendly /// "Type a name" hint instead of the technical "next: `(`" /// that would otherwise appear once the partial identifier /// gets consumed by the parser. /// /// `next_after_name` is what the parser would expect once the /// user finishes typing the name — derived by re-parsing with /// a single-letter placeholder identifier substituted at the /// cursor. `None` when the post-name parse succeeds (the rest /// of the command is already in place) or has no meaningful /// next-token information (custom errors with empty expected /// set). #[derive(Debug, Clone, PartialEq, Eq)] pub struct TypingName { pub next_after_name: Option, } /// `Some(_)` when the cursor is at or inside a `NewName`-slot /// position. Otherwise `None`. #[must_use] pub fn typing_name_at_cursor(input: &str, cursor: usize) -> Option { let cursor = cursor.min(input.len()); let bytes = input.as_bytes(); let mut start = cursor; while start > 0 { let prev = bytes[start - 1]; if prev.is_ascii_alphanumeric() || prev == b'_' { start -= 1; } else { break; } } let leading = &input[..start]; let expected = expected_at(leading, Mode::Advanced); let is_new_name_slot = expected.iter().any(|e| { matches!( e, Expectation::Ident { source: IdentSource::NewName, .. } ) }); if !is_new_name_slot { return None; } // Probe what comes after the name by substituting a // single-letter identifier placeholder. Walk forward over // any partial text past the cursor first so the probe // replaces the user's in-progress name as a whole. let mut end = cursor; while end < bytes.len() { let c = bytes[end]; if c.is_ascii_alphanumeric() || c == b'_' { end += 1; } else { break; } } let probe = format!("{}X{}", &input[..start], &input[end..]); let next_after_name = match parse_command(&probe) { Ok(_) => None, Err(ParseError::Empty) => None, Err(ParseError::Invalid { expected, .. }) if expected.is_empty() => None, Err(ParseError::Invalid { expected, .. }) => Some(oxford_or(&expected)), }; Some(TypingName { next_after_name }) } /// English-style "A, B, or C" join used by the hint panel /// prose. Lifted out of `input_render` so the completion /// module can produce ready-to-render strings. fn oxford_or(items: &[String]) -> String { match items { [] => String::new(), [a] => a.clone(), [a, b] => format!("{a} or {b}"), rest => { let (last, head) = rest.split_last().expect("len >= 3"); format!("{}, or {last}", head.join(", ")) } } } /// Detect "the user has typed an identifier here that the /// schema doesn't have." Returns `None` for any of: /// - cursor at empty / whitespace partial; /// - cursor at a position that doesn't expect a known-set /// identifier (keyword slot, NewName slot, complete input); /// - cursor partial matches at least one schema name. #[must_use] pub fn invalid_ident_at_cursor( input: &str, cursor: usize, cache: &SchemaCache, ) -> Option { invalid_ident_at_cursor_in_mode(input, cursor, cache, Mode::Advanced) } /// Mode-aware [`invalid_ident_at_cursor`]. /// /// The slot's expected set is computed in `mode`, so a simple-mode /// caller doesn't get the advanced (SQL) grammar's view of a shared /// `insert`/`update`/`delete` entry word — e.g. it won't flag `rows` /// as an "unknown column" in `update T … --all-rows`, which is a DSL /// flag in simple mode rather than the SQL expression `- -all - rows` /// (ADR-0033 Amendment 3). #[must_use] pub fn invalid_ident_at_cursor_in_mode( input: &str, cursor: usize, cache: &SchemaCache, mode: Mode, ) -> Option { let cursor = cursor.min(input.len()); let bytes = input.as_bytes(); let mut start = cursor; while start > 0 { let prev = bytes[start - 1]; if prev.is_ascii_alphanumeric() || prev == b'_' { start -= 1; } else { break; } } if start == cursor { // No partial token at the cursor — nothing to flag. return None; } let partial = &input[start..cursor]; // A token that starts with a digit cannot be an identifier // (the identifier shape requires a letter or `_` first) — // it is a numeric literal, never an "invalid column". // Without this guard a literal like `1` at a slot that // *also* accepts a column reference — the WHERE-expression // operand (ADR-0026) — is mis-flagged as an unknown column. if partial.starts_with(|c: char| c.is_ascii_digit()) { return None; } let leading = &input[..start]; let expected = expected_at(leading, mode); if expected.is_empty() { return None; } // Find every schema-listable source in the expected list. let sources: Vec = expected .iter() .filter_map(|e| match e { Expectation::Ident { source, .. } if source.completes_from_schema() => { Some(*source) } _ => None, }) .collect(); if sources.is_empty() { return None; } let lowered = partial.to_lowercase(); // If any schema entry across the matching slots matches // the prefix, the partial is not "invalid" — it's an // in-progress lookup. let any_match = sources .iter() .flat_map(|s| cache.for_source(*s)) .any(|name| name.to_lowercase().starts_with(&lowered)); if any_match { return None; } // Pick the first source kind for the diagnostic — when // multiple are expected (e.g. `drop relationship …` // expects Relationships *or* the `from` keyword; // here only the schema source survives the filter) we // surface the first. Some(InvalidIdent { range: (start, cursor), found: partial.to_string(), source: sources[0], }) } // `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 /// keystroke clears it (or Esc/Backspace consumes it). #[derive(Debug, Clone, PartialEq, Eq)] pub struct LastCompletion { /// Byte range in the input currently occupied by the /// inserted candidate (including the trailing space). pub inserted_range: (usize, usize), /// The text that was at `inserted_range` *before* any /// completion was applied — restored by Esc / Backspace. pub original_text: String, /// Cycle list, fixed at memo-creation time. Each candidate /// carries its kind so the hint panel keeps the /// keyword-vs-identifier colour coding stable across cycle /// transitions. pub candidates: Vec, /// Which `candidates[i]` is currently visible. pub selection_idx: usize, } impl LastCompletion { /// Wrap-around forward step. #[must_use] pub const fn next_idx(&self) -> usize { (self.selection_idx + 1) % self.candidates.len() } /// Wrap-around backward step. #[must_use] pub const fn prev_idx(&self) -> usize { (self.selection_idx + self.candidates.len() - 1) % self.candidates.len() } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn cands(input: &str, cursor: usize) -> Vec { candidates_at_cursor(input, cursor, &SchemaCache::default()) .map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) } fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec { candidates_at_cursor(input, cursor, cache) .map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) } /// Simple-mode completion candidates — the DSL surface /// (ADR-0003). Used by tests of DSL-only completion (the /// `--all-rows` flag, the DSL value-literal slots), which since /// sub-phase 3j must run in Simple mode: `insert`/`update`/ /// `delete` are shared entry words (ADR-0033 Amendment 3) and /// Advanced mode surfaces the SQL grammar's completions instead. fn cands_simple(input: &str, cursor: usize) -> Vec { candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple) .map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect()) } fn cand_kinds_with( input: &str, cursor: usize, cache: &SchemaCache, ) -> Vec<(String, CandidateKind)> { candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| { c.candidates .into_iter() .map(|c| (c.text, c.kind)) .collect() }) } #[test] fn empty_input_offers_all_command_entry_keywords() { let cs = cands("", 0); // Ten command-entry keywords. assert!(cs.contains(&"create".to_string())); assert!(cs.contains(&"drop".to_string())); assert!(cs.contains(&"add".to_string())); assert!(cs.contains(&"insert".to_string())); assert!(cs.contains(&"update".to_string())); assert!(cs.contains(&"delete".to_string())); assert!(cs.contains(&"show".to_string())); assert!(cs.contains(&"rename".to_string())); assert!(cs.contains(&"change".to_string())); assert!(cs.contains(&"replay".to_string())); } #[test] fn partial_keyword_narrows_to_matching_entries() { // Typed `cr` — only `create` starts with that. let cs = cands("cr", 2); assert_eq!(cs, vec!["create".to_string()]); } #[test] fn partial_keyword_is_case_insensitive() { let cs = cands("CR", 2); assert_eq!(cs, vec!["create".to_string()]); } #[test] fn at_token_boundary_offers_next_expected_keyword() { // After `create ` advanced mode offers `table` (valid in both // modes) plus the SQL-only `unique` (`create unique index`) and // `index` — the shared-entry-word merge (ADR-0035 §4i d). // `table` (Both) blocks before the Advanced-only `unique`/`index`. let cs = cands("create ", 7); assert_eq!( cs, vec!["table".to_string(), "unique".to_string(), "index".to_string()] ); } #[test] fn shared_entry_word_drop_merges_all_continuations() { // ADR-0035 §4i (d): in advanced mode `drop` is a shared entry // word (SQL `drop table`/`drop index` + the DSL drops). The // completion must offer EVERY valid continuation, not just the // first-decided node's. Block order: Both (table, index) then // Simple-only (column, relationship, constraint). let cs = cands("drop ", 5); for kw in ["table", "index", "column", "relationship", "constraint"] { assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}"); } // Both-mode continuations block before the simple-only ones. let pos = |k: &str| cs.iter().position(|c| c == k).unwrap(); assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}"); assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}"); } #[test] fn shared_entry_word_drop_partial_keeps_matching_continuation() { // A partial second keyword (`drop rel`) used to dead-end at an // empty list (only the SQL node walked); the merge keeps the // DSL `relationship` continuation. let cs = cands("drop rel", 8); assert_eq!(cs, vec!["relationship".to_string()]); } #[test] fn multi_candidate_position_offers_add_subcommands() { // After `add ` the parser expects `column` (for // `add column ...`), `index` (for `add index ...`, // ADR-0025), `constraint` (for `add constraint ...`, // ADR-0029 §2.2), and `1` (the opener for // `add 1:n relationship ...`). The completion engine // sections keyword candidates ahead of the `1:n` // composite literal, so the literal sorts last even // though `add 1:n` is declared second. let cs = cands("add ", 4); assert_eq!( cs, vec![ "column".to_string(), "index".to_string(), "constraint".to_string(), "1:n".to_string(), ] ); } #[test] fn one_to_n_filters_to_prefix_match() { // Typed `1` after `add ` — only `1:n` matches. let cs = cands("add 1", 5); assert_eq!(cs, vec!["1:n".to_string()]); } #[test] fn update_filter_position_offers_where_and_all_rows() { // After `update T set Name='hi' ` the parser expects // a `,` (more assignments), `where` (where clause), // or `--all-rows` (flag). Punctuation isn't surfaced; // `where` and `--all-rows` should appear. // `--all-rows` is a DSL-only rail (Simple mode); in Advanced // mode `update`/`delete` route to the SQL grammar, which has // no such flag (ADR-0033 Amendment 3). let cs = cands_simple("update T set Name='hi' ", 23); assert!(cs.contains(&"where".to_string()), "got {cs:?}"); assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}"); } #[test] fn delete_filter_position_offers_where_and_all_rows() { let cs = cands_simple("delete from T ", 14); assert!(cs.contains(&"where".to_string()), "got {cs:?}"); assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}"); } #[test] fn flag_candidates_are_classified_as_flag_kind() { // Hint-panel colouring distinguishes flags from // keywords (amber vs purple) — flags get their own // CandidateKind so the renderer can apply tok_flag. // Simple mode: `--all-rows` is the DSL rail. let kinds = candidates_at_cursor_in_mode( "delete from T ", 14, &SchemaCache::default(), Mode::Simple, ) .expect("some completion") .candidates .into_iter() .map(|c| (c.text, c.kind)) .collect::>(); let flag = kinds .iter() .find(|(t, _)| t == "--all-rows") .expect("--all-rows present"); assert_eq!(flag.1, CandidateKind::Flag); } #[test] fn flag_candidates_filter_by_partial_prefix() { let cs = cands_simple("delete from T --", 16); assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}"); } #[test] fn typed_dashes_still_offer_an_optional_trailing_flag() { // Regression: `add 1:n relationship … [--create-fk]` — // at a trailing space the flag is offered, but once the // user typed `--` the trailing-junk Mismatch dropped the // skipped optional's expectation and completion went // empty. Both positions must offer `--create-fk`. let at_space = cands("add 1:n relationship from X.a to Y.b ", 37); assert!( at_space.contains(&"--create-fk".to_string()), "trailing space should offer --create-fk, got {at_space:?}", ); let at_dashes = cands("add 1:n relationship from X.a to Y.b --", 39); assert!( at_dashes.contains(&"--create-fk".to_string()), "typed `--` should still offer --create-fk, got {at_dashes:?}", ); } #[test] fn typed_dashes_offer_the_optional_cascade_flag_on_drop_column() { // The same optional-flag class: `drop column … [--cascade]`. let at_dashes = cands("drop column from table T: c --", 30); assert!( at_dashes.contains(&"--cascade".to_string()), "typed `--` should offer --cascade, got {at_dashes:?}", ); } #[test] fn typed_dashes_offer_the_change_column_conversion_flags() { // `change column … [--force-conversion | --dont-convert]` // — the flags sit in a `Repeated { min: 0 }`; the same // trailing-junk-Mismatch fix must surface them. let at_dashes = cands("change column T: c (int) --", 27); assert!( at_dashes.contains(&"--force-conversion".to_string()) && at_dashes.contains(&"--dont-convert".to_string()), "typed `--` should offer both conversion flags, got {at_dashes:?}", ); } // ---- App-lifecycle command completion (round-5 fold-in) ---- #[test] fn empty_input_offers_app_command_entry_keywords() { let cs = cands("", 0); // App-lifecycle commands now appear alongside DSL // commands in the entry-keyword set. for expected in &[ "quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode", "messages", "undo", "redo", ] { assert!( cs.contains(&expected.to_string()), "missing {expected:?} in entry-keyword candidates: {cs:?}", ); } } #[test] fn load_prefix_offers_load_only() { let cs = cands("l", 1); assert_eq!(cs, vec!["load".to_string()]); } #[test] fn save_prefix_offers_save() { let cs = cands("sa", 2); assert_eq!(cs, vec!["save".to_string()]); } #[test] fn mode_then_space_offers_simple_and_advanced() { // `mode ` requires a value; the parser fails at EOF and // the expected-set contains the two known keywords. let cs = cands("mode ", 5); assert!(cs.contains(&"simple".to_string()), "got {cs:?}"); assert!(cs.contains(&"advanced".to_string()), "got {cs:?}"); } // ---- 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) ----------- #[test] fn value_literal_slot_suppresses_keyword_candidates_at_empty_prefix() { // After `insert into T values (` the parser's expected // set contains null/true/false/number/string literal. // The keyword pipeline would otherwise surface `null`, // `true`, `false` as Tab candidates — actively // misleading at a slot where the user is more likely // entering a number / text / date. Suppress. // DSL value-literal slot (Simple mode); in Advanced mode // `insert` routes to the SQL grammar (ADR-0033 Amendment 3). let cs = cands_simple("insert into T values (", 22); assert!(cs.is_empty(), "got misleading candidates {cs:?}"); } #[test] fn value_literal_slot_with_partial_prefix_still_completes() { // Once the user types a prefix, normal keyword // completion applies — `n` → `null`, `tr` → `true`, // `fa` → `false`. assert_eq!( cands_simple("insert into T values (n", 23), vec!["null".to_string()], ); assert_eq!( cands_simple("insert into T values (tr", 24), vec!["true".to_string()], ); assert_eq!( cands_simple("insert into T values (fa", 24), vec!["false".to_string()], ); } #[test] fn value_literal_slot_after_first_value_also_suppresses() { // Comma-separated value positions all hit the same slot // signature. `insert into T values (1, ` → expected: // null/true/false/number/string. Suppress. let cs = cands_simple("insert into T values (1, ", 25); assert!(cs.is_empty(), "got {cs:?}"); } #[test] fn update_set_value_slot_suppresses() { // `update T set col=` is also a value-literal slot. let cs = cands_simple("update T set col=", 17); assert!(cs.is_empty(), "got {cs:?}"); } #[test] fn where_value_slot_suppresses() { // `where col=` is also a value-literal slot. let cs = cands_simple("delete from T where col=", 24); assert!(cs.is_empty(), "got {cs:?}"); } #[test] fn value_literal_hint_fires_at_empty_value_slot() { let hint = value_literal_hint_at_cursor("insert into T values (", 22); let s = hint.expect("hint should fire at value-literal slot"); // Lists each literal form so the user sees the full set // of valid inputs rather than just three keywords. assert!(s.contains("number"), "got {s:?}"); assert!(s.contains("text") || s.contains("'"), "got {s:?}"); assert!(s.contains("true"), "got {s:?}"); assert!(s.contains("false") || s.contains("/false"), "got {s:?}"); assert!(s.contains("null"), "got {s:?}"); // Format examples for the cases users typically can't // guess (date, datetime). assert!( s.contains("YYYY-MM-DD"), "should include date format, got {s:?}", ); assert!( s.contains("HH:MM:SS"), "should include datetime format, got {s:?}", ); } #[test] fn value_literal_hint_does_not_fire_at_partial_prefix() { // With a partial prefix the keyword-completion path // handles it; the prose hint short-circuit only // applies to empty-prefix positions. assert!(value_literal_hint_at_cursor("insert into T values (n", 23).is_none()); } #[test] fn value_literal_hint_does_not_fire_at_keyword_slot() { // Entry keyword position is not a value-literal slot. assert!(value_literal_hint_at_cursor("", 0).is_none()); assert!(value_literal_hint_at_cursor("insert ", 7).is_none()); } #[test] fn show_offers_data_and_table_alphabetised() { let cs = cands("show ", 5); assert_eq!(cs, vec!["data".to_string(), "table".to_string()]); } #[test] fn drop_offers_all_five_subcommands_in_simple_mode() { // The DSL `drop` branches: column / relationship / table / index // (ADR-0025) / constraint (ADR-0029 §2.2). Candidates follow // grammar declaration order, so `constraint` — added last — // appears last. Simple mode, because `drop` is a shared entry // word: advanced mode surfaces the SQL `DROP TABLE` completion // instead (ADR-0033 Amendment 3 / ADR-0035 §4c — see below). let cs = cands_simple("drop ", 5); assert_eq!( cs, vec![ "column".to_string(), "relationship".to_string(), "table".to_string(), "index".to_string(), "constraint".to_string(), ], ); } #[test] fn drop_in_advanced_mode_surfaces_all_merged_continuations() { // ADR-0035 §4c: `drop` gained an advanced SQL node // (`DROP TABLE [IF EXISTS]`). With the 4i (d) shared-entry-word // merge, advanced-mode `drop ` now offers EVERY valid // continuation across the SQL and DSL nodes, block-ordered Both → // Advanced → Simple: `table`/`index` (valid in both modes) before // the DSL-only `column`/`relationship`/`constraint`. assert_eq!( cands("drop ", 5), vec![ "table".to_string(), "index".to_string(), "column".to_string(), "relationship".to_string(), "constraint".to_string(), ] ); } #[test] fn complete_command_offers_no_candidates() { // `create table T with pk` is a complete command — // no candidates to offer. let input = "create table T with pk"; let cs = cands(input, input.len()); assert!(cs.is_empty(), "got {cs:?}"); } #[test] fn non_branching_punctuation_is_not_surfaced_as_candidate() { // After `add column to table T` the walker expects `:`. // `:` is a "trailing-content" punct — the user types it // naturally as they continue the command, so the hint // panel doesn't surface it. Only branching punct (`(` // opening a sub-shape) becomes a Tab candidate. let input = "add column to table T"; let cs = cands(input, input.len()); assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}"); } #[test] fn open_paren_branching_punct_surfaces_after_insert_into_table() { // After `insert into Orders ` the walker expects either // `values` (Form B) or `(` (Forms A / C). Both surface // as Tab candidates so the user discovers the column- // list form. let cs = cands("insert into Orders ", 19); assert!(cs.contains(&"values".to_string()), "got {cs:?}"); assert!(cs.contains(&"(".to_string()), "got {cs:?}"); } fn schema_with_table( table: &str, columns: &[(&str, crate::dsl::types::Type)], ) -> SchemaCache { let mut cache = SchemaCache::default(); cache.tables.push(table.to_string()); let cols: Vec = columns .iter() .map(|(n, t)| TableColumn { name: (*n).to_string(), user_type: *t, not_null: false, has_default: false, }) .collect(); for c in &cols { cache.columns.push(c.name.clone()); } cache.table_columns.insert(table.to_string(), cols); cache } #[test] fn update_set_offers_only_current_table_columns() { use crate::dsl::types::Type; // SchemaCache.columns has columns from many tables, but // at `update Customers set ` only Customers' columns // should appear. let mut cache = schema_with_table( "Customers", &[("id", Type::Int), ("Email", Type::Text)], ); // Pretend the global flat list has columns from a second // table that aren't in Customers. cache.columns.push("OrderTotal".to_string()); cache.columns.push("Stock".to_string()); cache .table_columns .insert("Orders".to_string(), vec![ TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false }, ]); cache.tables.push("Orders".to_string()); let cs = cands_with("update Customers set ", 21, &cache); // Customers's columns should appear: assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); // Other tables' columns should NOT: assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}"); assert!(!cs.contains(&"Stock".to_string()), "got {cs:?}"); } #[test] fn order_by_completion_omits_preceding_clause_keywords() { use crate::dsl::types::Type; // F5 (handoff 30 §3.3): at `… order by ` the candidate // list is the start of a sort item — the table's columns // plus expression-start keywords. It must NOT be padded // with clause keywords belonging to clauses positioned // *before* ORDER BY (the FROM's JOIN options, WHERE / // GROUP BY / HAVING, set-ops). Those used to shove the // columns off-screen. let cache = schema_with_table( "Things", &[("Name", Type::Text), ("Qty", Type::Int)], ); let input = "select Name from Things order by "; let cs = cands_with(input, input.len(), &cache); // The columns the user wants are offered: assert!(cs.contains(&"Name".to_string()), "got {cs:?}"); assert!(cs.contains(&"Qty".to_string()), "got {cs:?}"); // Preceding-clause keywords must not leak in: for kw in [ "where", "group", "having", "join", "union", "intersect", "except", "left", "right", "full", "cross", "inner", "as", ] { assert!( !cs.contains(&kw.to_string()), "preceding-clause keyword `{kw}` leaked into ORDER BY \ completion; got {cs:?}", ); } } #[test] fn order_by_after_sort_item_offers_direction() { use crate::dsl::types::Type; // walk_repeated trailing-optional fix: after a complete // sort item the direction keywords surface as // continuations (previously discarded at the Repeated // boundary, so completion offered neither). let cache = schema_with_table( "Things", &[("Name", Type::Text), ("Qty", Type::Int)], ); let input = "select Name from Things order by Name "; let cs = cands_with(input, input.len(), &cache); assert!(cs.contains(&"asc".to_string()), "got {cs:?}"); assert!(cs.contains(&"desc".to_string()), "got {cs:?}"); } #[test] fn projection_after_item_offers_alias_keyword() { use crate::dsl::types::Type; // walk_repeated trailing-optional fix: after a complete // projection item the `as` alias keyword surfaces. let cache = schema_with_table( "Things", &[("Name", Type::Text), ("Qty", Type::Int)], ); let input = "select Name "; let cs = cands_with(input, input.len(), &cache); assert!(cs.contains(&"as".to_string()), "got {cs:?}"); } #[test] fn create_table_after_column_spec_offers_constraints() { // walk_repeated trailing-optional fix: after a complete // column spec the optional column constraints surface as // continuations (was a bare "Submit with Enter" prose). let input = "create table Customers with pk Code(text) "; let cs = cands_with(input, input.len(), &SchemaCache::default()); for kw in ["not", "unique", "default", "check"] { assert!( cs.contains(&kw.to_string()), "expected column-constraint `{kw}`; got {cs:?}", ); } } #[test] fn identifiers_precede_keywords_at_expression_position() { use crate::dsl::types::Type; // ADR-0022 Amendment 2: at an expression position offering // both column names and keywords, every column precedes // every keyword so the names stay visible by default. let cache = schema_with_table( "Things", &[("Name", Type::Text), ("Qty", Type::Int)], ); let input = "select * from Things where "; let cs = cands_with(input, input.len(), &cache); let pos = |needle: &str| { cs.iter().position(|c| c == needle).unwrap_or_else(|| { panic!("{needle:?} not in candidates: {cs:?}") }) }; // Both columns come before any expression-start keyword. let last_ident = pos("Name").max(pos("Qty")); let first_kw = pos("not").min(pos("exists")); assert!( last_ident < first_kw, "identifiers must precede keywords; got {cs:?}", ); } #[test] fn update_where_offers_only_current_table_columns() { use crate::dsl::types::Type; let mut cache = schema_with_table( "Customers", &[("id", Type::Int), ("Email", Type::Text)], ); cache.columns.push("OrderTotal".to_string()); let cs = cands_with("update Customers set Email='x' where ", 37, &cache); assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}"); } #[test] fn numeric_literal_in_where_is_not_flagged_as_invalid_column() { use crate::dsl::types::Type; // ADR-0026: the WHERE-expression operand accepts a // column reference *or* a literal. A number literal // (`1`) sits at a slot that also expects a column — // it must not be mis-flagged as an unknown column. let cache = schema_with_table("Customers", &[("id", Type::Int)]); let input = "delete from Customers where id=1"; assert!( invalid_ident_at_cursor(input, input.len(), &cache).is_none(), "a numeric literal must not be reported as an invalid column", ); } #[test] fn insert_into_open_paren_offers_current_table_columns() { use crate::dsl::types::Type; let cache = schema_with_table( "Customers", &[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)], ); let cs = cands_with("insert into Customers (", 23, &cache); // The user is at Form A's column-list position. All // three columns of Customers should appear so they can // pick. assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); assert!(cs.contains(&"Name".to_string()), "got {cs:?}"); } #[test] fn insert_into_open_paren_does_not_offer_unrelated_columns() { use crate::dsl::types::Type; let mut cache = schema_with_table( "Customers", &[("id", Type::Int), ("Email", Type::Text)], ); cache.columns.push("OrderTotal".to_string()); let cs = cands_with("insert into Customers (", 23, &cache); assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}"); } #[test] fn drop_column_from_offers_only_current_table_columns() { // The DDL `TABLE_NAME_EXISTING` slot now sets // `writes_table` (handoff-13 §2.2 fix), so the // column-name slot after the table name narrows to that // table's columns. `OrderTotal` belongs to no table in // this cache's `table_columns`, so it must not leak. use crate::dsl::types::Type; let mut cache = schema_with_table( "Customers", &[("id", Type::Int), ("Email", Type::Text)], ); cache.columns.push("OrderTotal".to_string()); let cs = cands_with("drop column from Customers: ", 28, &cache); assert!(cs.contains(&"Email".to_string()), "got {cs:?}"); assert!(cs.contains(&"id".to_string()), "got {cs:?}"); assert!( !cs.contains(&"OrderTotal".to_string()), "OrderTotal (not a Customers column) must not leak: got {cs:?}", ); } #[test] fn open_paren_candidate_is_classified_as_punct_kind() { // The `(` candidate gets its own kind so the hint // renderer can colour it as punctuation rather than // mis-classifying it as a keyword. let comp = candidates_at_cursor("insert into Orders ", 19, &SchemaCache::default()) .expect("some completion"); let paren = comp .candidates .iter() .find(|c| c.text == "(") .expect("( present"); assert_eq!(paren.kind, CandidateKind::Punct); } #[test] fn cursor_mid_keyword_replaces_only_the_partial_prefix() { let comp = candidates_at_cursor("cre", 3, &SchemaCache::default()) .expect("some completion"); assert_eq!(comp.replaced_range, (0, 3)); assert_eq!(comp.partial_prefix, "cre"); assert_eq!(comp.candidates.len(), 1); assert_eq!(comp.candidates[0].text, "create"); assert_eq!(comp.candidates[0].kind, CandidateKind::Keyword); } #[test] fn cursor_at_word_boundary_has_empty_partial_prefix() { let comp = candidates_at_cursor("create ", 7, &SchemaCache::default()) .expect("some completion"); assert_eq!(comp.replaced_range, (7, 7)); assert_eq!(comp.partial_prefix, ""); } // ---- type-name completion (round-3 follow-up #2) ---- #[test] fn type_slot_offers_full_type_vocabulary_when_partial_empty() { // After `add column to T: Name (` the parser expects // a column type. With no partial typed, all ten types // from `Type::all()` are offered in declaration order. let cs = cands("add column to T: Name (", 23); assert_eq!( cs, vec![ "text".to_string(), "int".to_string(), "real".to_string(), "decimal".to_string(), "bool".to_string(), "date".to_string(), "datetime".to_string(), "blob".to_string(), "serial".to_string(), "shortid".to_string(), ], ); } #[test] fn type_slot_narrows_to_prefix_matches() { // `de` matches only `decimal` (despite the surface // resemblance to `date`/`datetime`, those start with // `da`). The user-reported case from real testing // round 4. let cs = cands("add column to T: Name (de", 25); assert_eq!(cs, vec!["decimal".to_string()]); } #[test] fn type_slot_narrows_to_da_for_date_family() { // `da` correctly returns date and datetime — in // Type::all() declaration order (date before datetime, // matching ADR-0005's grouping). let cs = cands("add column to T: Name (da", 25); assert_eq!(cs, vec!["date".to_string(), "datetime".to_string()]); } #[test] fn type_slot_single_match_for_unique_prefix() { // `sh` uniquely identifies `shortid`. let cs = cands("add column to T: Name (sh", 25); assert_eq!(cs, vec!["shortid".to_string()]); } #[test] fn type_slot_no_match_for_invalid_prefix() { // `var` matches nothing — Tab is a no-op; the parser's // unknown-type custom error still fires on submit. let cs = cands("add column to T: Name (var", 26); assert!(cs.is_empty(), "got {cs:?}"); } #[test] fn identifiers_come_before_keywords_in_grammar_order() { // "add column " has both schema-identifier candidates and // keyword candidates. Per ADR-0022 Amendment 2: schema // identifiers first (alphabetised) so the names the user // would have to look up stay visible, then keywords in // *grammar order* (`to` before `table` because the // canonical shape is `add column [to] [table]
:…`). // The grammar order falls out of the walker's source-order // expected-set traversal — we preserve that order through // `describe_expected`. let cache = SchemaCache { tables: vec!["Customers".to_string(), "Orders".to_string()], ..SchemaCache::default() }; let kinds = cand_kinds_with("add column ", 11, &cache); assert_eq!( kinds, vec![ ("Customers".to_string(), CandidateKind::Identifier), ("Orders".to_string(), CandidateKind::Identifier), ("to".to_string(), CandidateKind::Keyword), ("table".to_string(), CandidateKind::Keyword), ], ); } #[test] fn keyword_wins_when_keyword_text_collides_with_schema_name() { // Pathological: a table named "table". Keywords // dominate the slot — the user can still reference // their table via different syntax. let cache = SchemaCache { tables: vec!["table".to_string(), "Customers".to_string()], ..SchemaCache::default() }; let kinds = cand_kinds_with("add column ", 11, &cache); // `table` appears once, as a keyword (not duplicated // as identifier). let table_entries: Vec<_> = kinds.iter().filter(|(t, _)| t == "table").collect(); assert_eq!(table_entries.len(), 1); assert_eq!(table_entries[0].1, CandidateKind::Keyword); } // ---- SchemaCache + identifier completion (stage 8c) ---- #[test] fn schema_cache_offers_table_names_at_table_slot() { let cache = SchemaCache { tables: vec!["Customers".to_string(), "Orders".to_string()], columns: vec![], relationships: vec![], ..SchemaCache::default() }; // After `show data ` the parser expects a table name. let cs = cands_with("show data ", 10, &cache); assert_eq!(cs, vec!["Customers".to_string(), "Orders".to_string()]); } #[test] fn insert_into_offers_table_names_at_target_slot() { // 3k cross-cut (matrix A3): after `insert into ` the target // table slot completes to the schema's table names. let cache = SchemaCache { tables: vec!["Customers".to_string(), "Orders".to_string()], columns: vec![], relationships: vec![], ..SchemaCache::default() }; let cs = cands_with("insert into ", 12, &cache); assert_eq!(cs, vec!["Customers".to_string(), "Orders".to_string()]); } #[test] fn schema_cache_offers_column_names_at_column_slot() { let cache = SchemaCache { tables: vec!["Customers".to_string()], columns: vec!["Email".to_string(), "Name".to_string()], relationships: vec![], ..SchemaCache::default() }; // After `drop column from Customers: ` the parser // expects a column name (existing). let cs = cands_with("drop column from Customers: ", 28, &cache); assert_eq!(cs, vec!["Email".to_string(), "Name".to_string()]); } #[test] fn schema_cache_offers_relationship_names_at_relationship_slot() { let cache = SchemaCache { tables: vec![], columns: vec![], relationships: vec!["cust_orders".to_string(), "ord_items".to_string()], ..SchemaCache::default() }; // After `drop relationship ` the parser expects either // an identifier (relationship name) or `from`. Schema // candidates plus the `from` keyword. let cs = cands_with("drop relationship ", 18, &cache); assert!(cs.contains(&"cust_orders".to_string())); assert!(cs.contains(&"ord_items".to_string())); assert!(cs.contains(&"from".to_string())); } #[test] fn schema_candidates_filtered_by_partial_prefix() { let cache = SchemaCache { tables: vec!["Customers".to_string(), "Orders".to_string()], columns: vec![], relationships: vec![], ..SchemaCache::default() }; // Typed `Cu` after `show data ` — only `Customers` // matches. let cs = cands_with("show data Cu", 12, &cache); assert_eq!(cs, vec!["Customers".to_string()]); } #[test] fn empty_cache_at_table_slot_returns_no_candidates() { // Pre-population (or NewName-only positions) yield no // identifier candidates. let cache = SchemaCache::default(); let cs = cands_with("show data ", 10, &cache); assert!(cs.is_empty(), "got {cs:?}"); } // ---- typing_name_at_cursor (round-3 follow-up) ---- #[test] fn typing_name_fires_at_new_column_slot_with_next_token() { // After `add column to table T: ` the parser expects // an identifier (NewName slot) followed by `(`. The // probe substitutes a placeholder name and reads back // that the next token is `(`. let t = typing_name_at_cursor("add column to table T: ", 23) .expect("should fire at NewName slot"); assert_eq!(t.next_after_name.as_deref(), Some("`(`")); } #[test] fn typing_name_fires_when_partial_name_already_typed() { // Mid-typing the column name. typing_name_at_cursor // walks back over the partial to find the slot, then // probes forward as if the partial were a complete name. let t = typing_name_at_cursor("add column to table T: Na", 25) .expect("should fire at NewName slot with partial"); assert_eq!(t.next_after_name.as_deref(), Some("`(`")); } #[test] fn typing_name_does_not_fire_at_table_name_slot() { // `show data ` — the slot is TableName, not NewName. // The candidates path (or invalid-ident) handles it; // typing_name should not fire. assert!(typing_name_at_cursor("show data ", 10).is_none()); } #[test] fn typing_name_does_not_fire_at_keyword_slot() { // `cr` at position 2 is a keyword slot. assert!(typing_name_at_cursor("cr", 2).is_none()); } #[test] fn typing_name_yields_no_next_when_probe_succeeds() { // `add column to table T: Name (text)` — the user is // inside `Name`, and substituting any name there // produces a complete command. No useful "next after // name" hint. let t = typing_name_at_cursor("add column to table T: Name (text)", 27) .expect("should fire"); assert_eq!(t.next_after_name, None); } // ---- invalid_ident_at_cursor (stage 8e) ---- #[test] fn invalid_ident_fires_for_unknown_table_prefix() { let cache = SchemaCache { tables: vec!["Customers".to_string()], ..SchemaCache::default() }; // `show data Cust` matches → no invalid. assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none()); // `show data Cust` plus a typo: `show data Custp`. No // table starts with "Custp" → invalid. let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache) .expect("should be invalid"); assert_eq!(invalid.range, (10, 15)); assert_eq!(invalid.found, "Custp"); assert_eq!(invalid.source, IdentSource::Tables); } #[test] fn invalid_ident_does_not_fire_when_partial_matches_some_schema_entry() { let cache = SchemaCache { tables: vec!["Customers".to_string(), "Orders".to_string()], ..SchemaCache::default() }; // "C" matches Customers (prefix), so not invalid. assert!(invalid_ident_at_cursor("show data C", 11, &cache).is_none()); } #[test] fn invalid_ident_does_not_fire_in_new_name_slot() { // `create table Cust` — Cust is a NewName slot. Even // if no schema entry matches, the user invents the // name; not invalid. let cache = SchemaCache { tables: vec!["Existing".to_string()], ..SchemaCache::default() }; assert!(invalid_ident_at_cursor("create table Cust", 17, &cache).is_none()); } #[test] fn invalid_ident_does_not_fire_when_cursor_not_at_partial_token() { let cache = SchemaCache::default(); // Cursor at a whitespace position — no partial token. assert!(invalid_ident_at_cursor("show data ", 10, &cache).is_none()); } #[test] fn invalid_ident_does_not_fire_at_keyword_slot() { // `cra` at the entry-keyword position — no keyword // starts with "cra", but the slot is keyword (not a // known-schema slot), so invalid_ident doesn't fire. // The render path's regular parse-error overlay handles // this case. let cache = SchemaCache::default(); assert!(invalid_ident_at_cursor("cra", 3, &cache).is_none()); } #[test] fn new_name_slot_offers_no_candidates_even_with_populated_cache() { // `create table ` — the table-name slot is NewName, so even // with a populated cache no *schema* candidates are offered // (the user invents the name). In advanced mode the sole // candidate here is the optional `if` keyword (the // `IF NOT EXISTS` prefix, ADR-0035 §4) — never a cached // table/column name. let cache = SchemaCache { tables: vec!["Existing".to_string()], columns: vec!["AlsoExisting".to_string()], relationships: vec![], ..SchemaCache::default() }; let cs = cands_with("create table ", 13, &cache); assert!( !cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"), "NewName slot must not surface schema candidates; got {cs:?}" ); assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword"); } fn keyword_cand(text: &str) -> Candidate { Candidate { text: text.to_string(), kind: CandidateKind::Keyword, mode: ModeClass::Both, } } #[test] fn last_completion_next_idx_wraps_around() { let mut memo = LastCompletion { inserted_range: (0, 0), original_text: String::new(), candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")], selection_idx: 0, }; assert_eq!(memo.next_idx(), 1); memo.selection_idx = 1; assert_eq!(memo.next_idx(), 2); memo.selection_idx = 2; assert_eq!(memo.next_idx(), 0); } #[test] fn last_completion_prev_idx_wraps_around() { let mut memo = LastCompletion { inserted_range: (0, 0), original_text: String::new(), candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")], selection_idx: 0, }; assert_eq!(memo.prev_idx(), 2); memo.selection_idx = 2; assert_eq!(memo.prev_idx(), 1); memo.selection_idx = 1; assert_eq!(memo.prev_idx(), 0); } // ---- Ranker hook (ADR-0024 §ranker-layer) ---- #[test] fn identity_ranker_preserves_input_order() { let input = vec![ Candidate { text: "b".to_string(), kind: CandidateKind::Keyword, mode: ModeClass::Both, }, Candidate { text: "a".to_string(), kind: CandidateKind::Keyword, mode: ModeClass::Both, }, Candidate { text: "c".to_string(), kind: CandidateKind::Identifier, mode: ModeClass::Both, }, ]; let out = identity_ranker(input.clone()); assert_eq!(out, input); } #[test] fn ranker_can_reorder_candidates() { // Hooks like frequency-based ranking or content-aware // priors plug in through `Ranker` without touching the // grammar. Smoke-test the call site with a sorter. fn alphabetic_ranker(mut c: Vec) -> Vec { c.sort_by(|a, b| a.text.cmp(&b.text)); c } // `add ` exposes `column`, `1:n`, `index` and // `constraint` — the alphabetic ranker reorders them. let cache = SchemaCache::default(); let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker) .expect("some completion"); let texts: Vec = comp.candidates.into_iter().map(|c| c.text).collect(); assert_eq!( texts, vec![ "1:n".to_string(), "column".to_string(), "constraint".to_string(), "index".to_string(), ] ); } // ---- ADR-0032 §10.5 qualified-prefix completion ---- fn two_table_schema() -> SchemaCache { use crate::dsl::types::Type; let mut s = SchemaCache::default(); s.tables.push("a".to_string()); s.tables.push("b".to_string()); s.columns.push("id".to_string()); s.columns.push("name".to_string()); s.columns.push("total".to_string()); s.table_columns.insert( "a".to_string(), vec![ TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false }, ], ); s.table_columns.insert( "b".to_string(), vec![ TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false }, TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false }, ], ); s } #[test] fn qualified_prefix_narrows_to_table_columns() { // `select a.|` — candidates should be a's columns // alone, not the union of a's and b's. let cache = two_table_schema(); let input = "select a."; let cs = cands_with(input, input.len(), &cache); assert!( cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()), "expected a's columns; got {cs:?}", ); assert!( !cs.contains(&"total".to_string()), "b's `total` must NOT appear under `a.|`; got {cs:?}", ); } #[test] fn excluded_prefix_completes_to_target_columns() { // ADR-0033 §9: `excluded.|` inside a DO UPDATE action // completes to the INSERT target table's columns. let cache = two_table_schema(); let input = "insert into a (id, name) values (1, 'x') \ on conflict (id) do update set name = excluded."; let cs = cands_with(input, input.len(), &cache); assert!( cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()), "excluded.| should offer the target table's columns; got {cs:?}", ); assert!( !cs.contains(&"total".to_string()), "a column from an unrelated table must not appear; got {cs:?}", ); } #[test] fn qualified_prefix_with_partial_filters_prefix() { // `select a.na` — only `name` (starts with `na`). let cache = two_table_schema(); let input = "select a.na"; let cs = cands_with(input, input.len(), &cache); assert_eq!(cs, vec!["name".to_string()]); } #[test] fn qualified_prefix_alias_narrows_through_alias() { // `select * from a x where x.|` — at the cursor, the // walker has seen the FROM clause, so `x` is bound to // `a`. The qualified-prefix resolves through the alias // and narrows to a's columns. (The mid-projection // case `select x.| from a x` falls under §10.6's // projection-before-FROM problem and is handled by // the post-walk fixup pass, not by this engine.) let cache = two_table_schema(); let input = "select * from a x where x."; let cs = cands_with(input, input.len(), &cache); assert!( cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()), "expected a's columns via alias `x`; got {cs:?}", ); assert!( !cs.contains(&"total".to_string()), "b's columns must NOT appear under alias `x` of `a`; got {cs:?}", ); } #[test] fn qualified_prefix_unresolved_qualifier_no_columns() { // `select z.|` where `z` resolves to nothing — empty // column candidate list (the structural error path // surfaces the unresolved-prefix message). let cache = two_table_schema(); let input = "select z."; let cs = cands_with(input, input.len(), &cache); // The candidate list should NOT include b's columns // (the bug we're fixing — global fallback). assert!( !cs.contains(&"total".to_string()), "unknown qualifier must not fall back to global columns; got {cs:?}", ); } #[test] fn qualified_prefix_cte_columns_narrow() { // `with x as (select id, name from a) select x.|` // should offer `id`, `name` only (x's harvested // columns). let cache = two_table_schema(); let input = "with x as (select id, name from a) select x."; let cs = cands_with(input, input.len(), &cache); assert!( cs.contains(&"id".to_string()) && cs.contains(&"name".to_string()), "expected x's harvested columns; got {cs:?}", ); assert!( !cs.contains(&"total".to_string()), "b's `total` must not appear under `x.|`; got {cs:?}", ); } // ---- Look-ahead probe for the edit scenario ---- #[test] fn lookahead_narrows_unqualified_completion_when_from_follows_cursor() { // User edits an existing `select X from a` query, cursor // sits at the start of the partial column name. Leading // walk sees only `select ` → from_scope is empty. Full // input `select X from a` parses; look-ahead picks up // `a`, and the column candidates narrow to a's columns. let cache = two_table_schema(); let input = "select X from a"; // Cursor on the `X` so the partial prefix is `X` and // look-ahead has a parseable full input. let cursor = "select ".len(); let cs = cands_with(input, cursor, &cache); // The partial `X` is shadowed by a partial-prefix filter // so the candidates that come back must START with `X` // (case-insensitive). Neither `id`/`name`/`total` start // with `X`, so the candidate set is empty rather than // global. Move the cursor to before the partial to test // narrowing instead. assert!( !cs.contains(&"total".to_string()), "global fallback must not fire; got {cs:?}", ); // Empty-prefix narrowing test: let input2 = "select n from a"; let cursor2 = "select ".len(); let cs2 = cands_with(input2, cursor2, &cache); assert!( cs2.contains(&"name".to_string()), "expected a's `name` via look-ahead; got {cs2:?}", ); assert!( !cs2.contains(&"total".to_string()), "b's `total` must NOT appear when FROM is `a` only; got {cs2:?}", ); } #[test] fn lookahead_qualified_resolves_alias_with_from_after_cursor() { // `select x.id from a x` — leading walks `select x.` // (no FROM bound yet). Look-ahead walks the full input; // x is bound to `a`. The qualified prefix narrows // candidates to a's columns alone. let cache = two_table_schema(); let input = "select x.id from a x"; let cursor = "select x.".len(); let cs = cands_with(input, cursor, &cache); // Partial prefix is `id`, so candidates that match must // start with `id`. Only `id` itself does — and it does // appear, meaning the qualifier resolved correctly. assert!( cs.contains(&"id".to_string()), "expected `id` via alias `x` look-ahead; got {cs:?}", ); // Empty-prefix variant: cursor right after `x.` (move // back from `.id` to `.`). let input2 = "select x. from a x"; // Doesn't parse cleanly mid-projection — leading walk // alone produces the right scope ONLY if the full input // happens to parse. Use a where-clause anchor instead: let input3 = "select * from a x where x. = 1"; let cursor3 = "select * from a x where x.".len(); let cs3 = cands_with(input3, cursor3, &cache); assert!( cs3.contains(&"id".to_string()) && cs3.contains(&"name".to_string()), "expected a's columns under alias `x` in WHERE; got {cs3:?}", ); assert!( !cs3.contains(&"total".to_string()), "b's columns must NOT appear under alias `x` of `a`; got {cs3:?}", ); let _ = input2; } #[test] fn lookahead_multi_table_from_unions_columns() { // Two bindings in scope via look-ahead — candidates are // the union of both, deduplicated. let cache = two_table_schema(); let input = "select n from a join b on a.id = b.id"; let cursor = "select ".len(); let cs = cands_with(input, cursor, &cache); assert!(cs.contains(&"name".to_string())); assert!(cs.contains(&"total".to_string())); } #[test] fn sql_expr_column_completion_inside_where() { // ADR-0031 §5 — column completion works for // IdentSource::Columns slots inside SQL expressions. // At `select * from a where i|`, the partial prefix `i` // walks `where`'s sql_expr, expects an Ident{Columns}, // and offers `id` (a's column starting with `i`). let cache = two_table_schema(); let input = "select * from a where i"; let cursor = input.len(); let cs = cands_with(input, cursor, &cache); assert!( cs.contains(&"id".to_string()), "expected `id` candidate via sql_expr WHERE column slot; got {cs:?}", ); } #[test] fn sql_expr_column_completion_inside_having() { let cache = two_table_schema(); let input = "select * from a group by id having n"; let cursor = input.len(); let cs = cands_with(input, cursor, &cache); assert!( cs.contains(&"name".to_string()), "expected `name` candidate via sql_expr HAVING column slot; got {cs:?}", ); } #[test] fn lookahead_with_partial_prefix_filters_correctly() { // `select na| from a` — narrowing via look-ahead + // partial-prefix filter yields just `name`. let cache = two_table_schema(); let input = "select na from a"; let cursor = "select na".len(); let cs = cands_with(input, cursor, &cache); assert_eq!(cs, vec!["name".to_string()]); } #[test] fn ranker_can_filter_to_empty() { // A ranker that returns an empty list collapses the // completion to `None`. fn empty_ranker(_: Vec) -> Vec { Vec::new() } let cache = SchemaCache::default(); assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none()); } }