Files
rdbms-playground/src/completion.rs
T
claude@clouddev1 9bbb96e735 Walker: memoize DynamicSubgrammar resolution to bound the Box::leak
Node::DynamicSubgrammar factories build a Node from the WalkContext and
must Box::leak it (the Node enum's combinator children are &'static).
Leaking per walk grew unbounded under per-keystroke completion
(handoff-12 §2.1).

resolve_dynamic now memoizes on the schema state a factory reads
(table columns, current column, user-listed columns) keyed by factory
fn-pointer. Each distinct value-list shape leaks exactly once — total
leak bounded by distinct (schema × form) combinations, not keystroke
count. TableColumn gains Hash for the cache key.

The handoff's original arena sketch needed a lifetime-generic Node
(major refactor); memoization gets the same bound without it.
2026-05-15 22:06:33 +00:00

1616 lines
60 KiB
Rust

//! 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<String>,
pub columns: Vec<String>,
pub relationships: Vec<String>,
/// 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<String, Vec<TableColumn>>,
}
/// One column's user-facing type info, scoped to a table
/// (ADR-0024 §Phase D, §WalkContext).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
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<Expectation> {
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<Candidate>) -> Vec<Candidate>;
/// Identity ranker: returns its input unchanged.
#[must_use]
pub const fn identity_ranker(candidates: Vec<Candidate>) -> Vec<Candidate> {
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<Candidate>,
}
/// 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<Completion> {
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<Completion> {
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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<String> = 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<Candidate> = 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<String> {
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<String>,
}
/// `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<TypingName> {
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<InvalidIdent> {
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<IdentSource> = 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<Candidate>,
/// 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<String> {
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<String> {
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::<Vec<_>>();
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<TableColumn> = 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 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 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] <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<Candidate>) -> Vec<Candidate> {
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<String> = 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<Candidate>) -> Vec<Candidate> {
Vec::new()
}
let cache = SchemaCache::default();
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
}
}