//! 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}; /// 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, /// 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>, } /// One column's user-facing type info, scoped to a table /// (ADR-0024 §Phase D, §WalkContext). #[derive(Debug, Clone, PartialEq, Eq)] pub struct TableColumn { pub name: String, pub user_type: crate::dsl::types::Type, } 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::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) -> 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). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Candidate { pub text: String, pub kind: CandidateKind, } /// 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 } #[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 keywords-first then /// identifiers, 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_with(input, cursor, cache, identity_ranker) } /// 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 { 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(input).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). let probe = crate::dsl::walker::completion_probe(leading, cache); let current_table_columns: Option<&[TableColumn]> = probe.current_table_columns.as_deref(); let expected = if probe.expected.is_empty() { expected_at(leading) } 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() ) }); if partial_prefix.is_empty() && 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())); // 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 { current_table_columns.map_or_else( || cache.for_source(source).to_vec(), |cols| cols.iter().map(|c| c.name.clone()).collect(), ) } 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)); // Keywords first (grammar parts read before content), // 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), then schema identifiers. let mut candidates: Vec = Vec::with_capacity( keywords.len() + type_names.len() + composites.len() + punct_candidates.len() + flags.len() + identifiers.len(), ); candidates.extend(keywords.into_iter().map(|text| Candidate { text, kind: CandidateKind::Keyword, })); candidates.extend(type_names.into_iter().map(|text| Candidate { text, kind: CandidateKind::Keyword, })); candidates.extend(composites.into_iter().map(|text| Candidate { text, kind: CandidateKind::Keyword, })); candidates.extend(punct_candidates.into_iter().map(|text| Candidate { text, kind: CandidateKind::Punct, })); candidates.extend(flags.into_iter().map(|text| Candidate { text, kind: CandidateKind::Flag, })); candidates.extend(identifiers.into_iter().map(|text| Candidate { text, kind: CandidateKind::Identifier, })); 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, }) } /// 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); 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); 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 { 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]; let leading = &input[..start]; let expected = expected_at(leading); 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()) } 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 ` the parser expects `table`. let cs = cands("create ", 7); assert_eq!(cs, vec!["table".to_string()]); } #[test] fn multi_candidate_position_offers_column_and_one_to_n() { // After `add ` the parser expects `column` (for // `add column ...`) and `1` (the opener for // `add 1:n relationship ...`). The completion engine // surfaces both: `column` straight from the keyword // expected-set, and `1:n` as a composite literal // candidate so the user can Tab through to the // relationship form without knowing the surface syntax. let cs = cands("add ", 4); assert_eq!(cs, vec!["column".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. let cs = cands("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("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. let kinds = candidates_at_cursor("delete from T ", 14, &SchemaCache::default()) .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("delete from T --", 16); assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}"); } // ---- 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", ] { 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. let cs = cands("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("insert into T values (n", 23), vec!["null".to_string()], ); assert_eq!( cands("insert into T values (tr", 24), vec!["true".to_string()], ); assert_eq!( cands("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("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("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("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_three_alternatives_alphabetised() { let cs = cands("drop ", 5); assert_eq!( cs, vec![ "column".to_string(), "relationship".to_string(), "table".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, }) .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 }, ]); 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 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 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 drop-column path also writes_table → narrowed // columns should appear here too. 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); // Note: drop column's table-name slot doesn't set // writes_table today (DDL paths don't carry Phase D // table-column resolution yet). Falls back to global // cache.columns, which is the documented schemaless // fallback. Either narrowed-or-flat is acceptable; the // test just confirms valid columns appear. assert!(cs.contains(&"Email".to_string()), "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 keywords_come_before_identifiers_in_grammar_order() { // "add column " has both keyword candidates and // schema-identifier candidates. Per the user's stage-8 // feedback round 2: keywords first in *grammar order* // (so `to` before `table` because the canonical shape // is `add column [to] [table] :…`), identifiers // after, alphabetised. The grammar order falls out of // chumsky'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![ ("to".to_string(), CandidateKind::Keyword), ("table".to_string(), CandidateKind::Keyword), ("Customers".to_string(), CandidateKind::Identifier), ("Orders".to_string(), CandidateKind::Identifier), ], ); } #[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 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. // Even if the cache has table/column entries, no // candidates are offered (the user invents the 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.is_empty(), "got {cs:?}"); } fn keyword_cand(text: &str) -> Candidate { Candidate { text: text.to_string(), kind: CandidateKind::Keyword, } } #[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, }, Candidate { text: "a".to_string(), kind: CandidateKind::Keyword, }, Candidate { text: "c".to_string(), kind: CandidateKind::Identifier, }, ]; 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` and `1:n` — alphabetic ranker // flips 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()]); } #[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()); } }