Files
rdbms-playground/src/completion.rs
T
claude@clouddev1 1d898adf00 feat: V5a show relationship/index <name> detail views
Fold the singular per-item forms into Command::ShowList { kind,
name: Option<String> } (name: Some = one item). Two grammar
branches reuse the relationship/index completion sources; worker
do_show_one renders a labelled detail block or a friendly
"No ... named X." line, reusing the V5 render path. Help +
parse-usage entries, two ADR-0042 near-miss rows, 5 integration
tests. Mark V5a [x] — V5's [<name>] clause now complete.
2026-06-07 14:04:00 +00:00

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