2d1112d0f3
Extend SchemaCache TableColumn with not_null + has_default (with a TableColumn::new constructor for the common no-constraint case), populated in build_schema_cache from ColumnDescription (a PK column counts as not-null). New dml_not_null_missing_diagnostics pass: a WARNING when a SQL INSERT's explicit column list omits a column that is NOT NULL with no DEFAULT — advisory (the engine enforces it). serial/shortid (auto-filled) and defaulted columns are excluded. Anchored on the target-table ident (no token for the omitted column). Catalog key diagnostic.not_null_missing (engine-neutral). Tests (+4): fires on omitted required column; silent when included, when defaulted, and for auto-gen serial/shortid. ~24 TableColumn literal sites updated for the two new fields (build clean). 1591 pass / 0 fail / 1 ignored. Clippy clean. All three ADR-0033 §8 DML diagnostics now implemented. Remaining 3i: cross-cut verification + #12 UPSERT DO UPDATE validation.
2386 lines
90 KiB
Rust
2386 lines
90 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_in_mode;
|
|
use crate::mode::Mode;
|
|
|
|
/// Composite literal candidates whose lexed shape is more than
|
|
/// one token but which the user types as a single fluent piece.
|
|
/// Pairs of (walker-expected-literal, full-composite-text).
|
|
///
|
|
/// When the walker reports `Expectation::Literal(opener)` at the
|
|
/// cursor, the engine surfaces the full composite text as a Tab
|
|
/// candidate. Today the only entry is `1:n` (the opener for
|
|
/// `add 1:n relationship`) — adding more is a one-line edit.
|
|
const COMPOSITE_CANDIDATES: &[(&str, &str)] = &[("1", "1:n")];
|
|
|
|
/// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D).
|
|
///
|
|
/// Held by `App::schema_cache` and consulted by the completion
|
|
/// engine for identifier slots and by the walker for schema-aware
|
|
/// value-slot dispatch (Phase D full). Empty by default; the
|
|
/// runtime refreshes on project load and after successful DDL.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SchemaCache {
|
|
pub tables: Vec<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 index names (ADR-0025). Keyed by table
|
|
/// name; drives the nested tables/indexes items panel (S2).
|
|
pub table_indexes: std::collections::HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
/// Re-ranker for a freshly-computed candidate list (ADR-0024
|
|
/// §ranker-layer).
|
|
///
|
|
/// The grammar tree declares *what's valid*; the ranker decides
|
|
/// *what's likely useful first*. Lives outside the trie so
|
|
/// frequency-based ranking, content-aware priors (e.g. `Email`
|
|
/// → text first), and recency hooks can plug in without
|
|
/// touching grammar declarations.
|
|
///
|
|
/// Default is `identity_ranker` — declaration order from the
|
|
/// grammar tree is preserved.
|
|
pub type Ranker = fn(Vec<Candidate>) -> Vec<Candidate>;
|
|
|
|
/// Identity ranker: returns its input unchanged.
|
|
#[must_use]
|
|
pub const fn identity_ranker(candidates: Vec<Candidate>) -> Vec<Candidate> {
|
|
candidates
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CandidateKind {
|
|
/// One of the parser's expected keywords.
|
|
Keyword,
|
|
/// A schema entity (table, column, relationship).
|
|
Identifier,
|
|
/// A `--name`-style flag. Coloured with `tok_flag` so the
|
|
/// hint matches the way it'll render in the input pane.
|
|
Flag,
|
|
/// A single-char punctuation token the walker expects next
|
|
/// (e.g. `(` at the start of `insert into T (cols)`). Used
|
|
/// to surface branching alternatives the user might not
|
|
/// otherwise discover — at `insert into Orders ` the walker
|
|
/// expects either `values` or `(`, and surfacing both makes
|
|
/// the Form A path discoverable.
|
|
Punct,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Completion {
|
|
/// Byte range in `input` to be replaced when a candidate
|
|
/// is accepted. Equal `(cursor, cursor)` when the user is
|
|
/// at a token boundary (no partial prefix).
|
|
pub replaced_range: (usize, usize),
|
|
/// Partial prefix the user has typed at the cursor. Empty
|
|
/// when the cursor is at a token boundary.
|
|
pub partial_prefix: String,
|
|
/// Fitting candidates, ordered 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.
|
|
let input_parses_complete = parse_command_in_mode(input, mode).is_ok();
|
|
|
|
// Schema-aware probe: one walk yields both the expected set
|
|
// and the table-context snapshot (ADR-0024 §Phase D
|
|
// §column-narrowing). The engine reads
|
|
// `current_table_columns` to narrow column candidates to the
|
|
// active table rather than the flat `cache.columns` (which
|
|
// unions every table's columns). ADR-0030 §2: the probe runs
|
|
// with the active mode so simple-mode users don't see SQL
|
|
// commands offered.
|
|
let probe = crate::dsl::walker::completion_probe_in_mode(leading, cache, mode);
|
|
|
|
// ADR-0032 §10.6 follow-up — the "edit an existing query"
|
|
// workflow: the user goes back inside a projection list to
|
|
// type a column, but the FROM clause is already typed
|
|
// *after* the cursor. The leading-only walk sees an empty
|
|
// from_scope and the column source falls back to the global
|
|
// column list — noisy. A second walk on the FULL input
|
|
// populates from_scope_stack[0] with the trailing FROM's
|
|
// bindings, which we can narrow against. The cost is one
|
|
// extra walk on each Tab press; gated on the leading walk
|
|
// having produced no from_scope (so the common case where
|
|
// the cursor is past FROM pays nothing).
|
|
let lookahead_probe = if probe.from_scope.is_empty()
|
|
&& probe.cte_bindings.is_empty()
|
|
&& input.len() > leading.len()
|
|
{
|
|
let direct = crate::dsl::walker::completion_probe_in_mode(input, cache, mode);
|
|
if direct.from_scope.is_empty() && direct.cte_bindings.is_empty() {
|
|
// The slot at the cursor is empty/incomplete — e.g. the
|
|
// projection list of `select <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] …`).
|
|
let mut keywords: Vec<String> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Word(w) | Expectation::Literal(w) => Some(*w),
|
|
_ => None,
|
|
})
|
|
.filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_alphabetic()))
|
|
.map(str::to_string)
|
|
.filter(|name| matches_prefix(name))
|
|
.collect();
|
|
let mut seen_kw = std::collections::HashSet::new();
|
|
keywords.retain(|k| seen_kw.insert(k.clone()));
|
|
|
|
// Source 1.5: type-name candidates when the walker expects
|
|
// a column-type slot. Type names are a closed set sourced
|
|
// from `Type::all()` (ADR-0005 declaration order:
|
|
// text/int/real/decimal/bool/date/datetime/blob/serial/
|
|
// shortid). The walker surfaces this as
|
|
// `Expectation::Ident { source: Types }`.
|
|
let type_names: Vec<String> = if expected.iter().any(|e| {
|
|
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
|
|
}) {
|
|
Type::all()
|
|
.iter()
|
|
.map(|t| t.keyword().to_string())
|
|
.filter(|s| matches_prefix(s))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Source 1.55: flag candidates (`--name`). Surfaced as a
|
|
// distinct CandidateKind so the hint panel can colour them
|
|
// with `tok_flag` (matching how they'll appear after
|
|
// insertion). The standard prefix matcher walks back over
|
|
// alphanumeric + underscore, which does NOT cross `-`, so
|
|
// when the user types `--all` the partial is `all` — match
|
|
// the flag's body against that. Otherwise match the full
|
|
// `--name` against the partial (which may be empty or start
|
|
// with `--`).
|
|
let flags: Vec<String> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Flag(name) => Some(*name),
|
|
_ => None,
|
|
})
|
|
.filter(|body| {
|
|
if partial_prefix.starts_with("--") {
|
|
format!("--{body}")
|
|
.to_lowercase()
|
|
.starts_with(&lowered_prefix)
|
|
} else if partial_prefix.is_empty() {
|
|
true
|
|
} else {
|
|
body.to_lowercase().starts_with(&lowered_prefix)
|
|
}
|
|
})
|
|
.map(|body| format!("--{body}"))
|
|
.collect();
|
|
|
|
// Source 1.6: composite-literal candidates. Some commands
|
|
// start with a multi-token literal sequence that the user
|
|
// types as a single fluent piece (e.g. `1:n` for
|
|
// `add 1:n relationship`). The walker's expected-set
|
|
// surfaces the first token only (`Expectation::Literal("1")`);
|
|
// the engine surfaces the full composite text so the user
|
|
// can Tab through without knowing the surface syntax.
|
|
let composites: Vec<String> = COMPOSITE_CANDIDATES
|
|
.iter()
|
|
.filter(|(opener, _)| {
|
|
expected.iter().any(|e| match e {
|
|
Expectation::Literal(l) | Expectation::Word(l) => *l == *opener,
|
|
_ => false,
|
|
})
|
|
})
|
|
.map(|(_, text)| (*text).to_string())
|
|
.filter(|s| matches_prefix(s))
|
|
.collect();
|
|
|
|
// Source 1.7: branching-punct candidates. At positions
|
|
// where the walker expects a punct character that opens a
|
|
// sub-shape (notably `(` opening Form A or C of insert),
|
|
// surface the punct as a Tab candidate so the user
|
|
// discovers the option. Closing punct (`)`, `,`, etc.) and
|
|
// expected-after-content punct (`:`, `=`, `.`) are not
|
|
// surfaced — they're trailing terminals the user types
|
|
// naturally, not "shape branches" worth advertising in the
|
|
// hint panel.
|
|
let punct_candidates: Vec<String> = if partial_prefix.is_empty() {
|
|
expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Punct('(') => Some("(".to_string()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Source 2: schema identifiers — accumulated across every
|
|
// matching schema-listable `Ident { source }` expectation.
|
|
// `NewName` / `Types` / `Free` sources don't query the
|
|
// schema cache and contribute nothing here.
|
|
//
|
|
// Column candidates narrow to `current_table_columns` when
|
|
// the walker resolved the active table — `update T set ` at
|
|
// table T offers T's columns, not every table's columns
|
|
// (ADR-0024 §Phase D §column-narrowing). Schemaless / table
|
|
// not in schema falls back to the global `cache.columns`.
|
|
let mut identifiers: Vec<String> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
|
Some(*source)
|
|
}
|
|
_ => None,
|
|
})
|
|
.flat_map(|source| {
|
|
if source == IdentSource::Columns {
|
|
qualified_columns.as_deref().map_or_else(
|
|
|| {
|
|
current_table_columns.map_or_else(
|
|
|| cache.for_source(source).to_vec(),
|
|
|cols| cols.iter().map(|c| c.name.clone()).collect(),
|
|
)
|
|
},
|
|
<[_]>::to_vec,
|
|
)
|
|
} else {
|
|
cache.for_source(source).to_vec()
|
|
}
|
|
})
|
|
.filter(|name| matches_prefix(name))
|
|
.collect();
|
|
identifiers.sort();
|
|
identifiers.dedup();
|
|
// If an identifier shares its name with a keyword candidate
|
|
// (rare in practice), the keyword wins — keywords are
|
|
// grammar; the user can name a table the same thing but
|
|
// resolving collisions in the user's favour would create
|
|
// ambiguity in the live render.
|
|
identifiers.retain(|name| !keywords.contains(name));
|
|
|
|
// Schema identifiers first: a column / table name the user
|
|
// would otherwise have to look up is the highest-value
|
|
// completion (valuable to experts, not just learners, who
|
|
// come to know the keywords over time). Keywords and the
|
|
// other closed-set grammar parts follow: keywords, then type
|
|
// names (closed-set grammar — coloured as keywords), then
|
|
// composite literals (`1:n`, …), then branching punct (`(`
|
|
// opening a sub-shape), then flags (own colour).
|
|
let mut candidates: Vec<Candidate> = Vec::with_capacity(
|
|
identifiers.len()
|
|
+ keywords.len()
|
|
+ type_names.len()
|
|
+ composites.len()
|
|
+ punct_candidates.len()
|
|
+ flags.len(),
|
|
);
|
|
candidates.extend(identifiers.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Identifier,
|
|
}));
|
|
candidates.extend(keywords.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Keyword,
|
|
}));
|
|
candidates.extend(type_names.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Keyword,
|
|
}));
|
|
candidates.extend(composites.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Keyword,
|
|
}));
|
|
candidates.extend(punct_candidates.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Punct,
|
|
}));
|
|
candidates.extend(flags.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Flag,
|
|
}));
|
|
|
|
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> {
|
|
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::Advanced);
|
|
if expected.is_empty() {
|
|
return None;
|
|
}
|
|
// Find every schema-listable source in the expected list.
|
|
let sources: Vec<IdentSource> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
|
Some(*source)
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
if sources.is_empty() {
|
|
return None;
|
|
}
|
|
let lowered = partial.to_lowercase();
|
|
// If any schema entry across the matching slots matches
|
|
// the prefix, the partial is not "invalid" — it's an
|
|
// in-progress lookup.
|
|
let any_match = sources
|
|
.iter()
|
|
.flat_map(|s| cache.for_source(*s))
|
|
.any(|name| name.to_lowercase().starts_with(&lowered));
|
|
if any_match {
|
|
return None;
|
|
}
|
|
// Pick the first source kind for the diagnostic — when
|
|
// multiple are expected (e.g. `drop relationship …`
|
|
// expects Relationships *or* the `from` keyword;
|
|
// here only the schema source survives the filter) we
|
|
// surface the first.
|
|
Some(InvalidIdent {
|
|
range: (start, cursor),
|
|
found: partial.to_string(),
|
|
source: sources[0],
|
|
})
|
|
}
|
|
|
|
// `expected_set` is gone: the walker-driven `expected_at` above
|
|
// returns structured `Expectation`s with full `IdentSource`
|
|
// information, avoiding the lossy string round-trip the
|
|
// chumsky-era completion engine relied on.
|
|
|
|
/// Snapshot of a freshly-inserted completion. The memo lives
|
|
/// on `App::last_completion` until any non-Tab/non-Shift-Tab
|
|
/// keystroke clears it (or Esc/Backspace consumes it).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LastCompletion {
|
|
/// Byte range in the input currently occupied by the
|
|
/// inserted candidate (including the trailing space).
|
|
pub inserted_range: (usize, usize),
|
|
/// The text that was at `inserted_range` *before* any
|
|
/// completion was applied — restored by Esc / Backspace.
|
|
pub original_text: String,
|
|
/// Cycle list, fixed at memo-creation time. Each candidate
|
|
/// carries its kind so the hint panel keeps the
|
|
/// keyword-vs-identifier colour coding stable across cycle
|
|
/// transitions.
|
|
pub candidates: Vec<Candidate>,
|
|
/// Which `candidates[i]` is currently visible.
|
|
pub selection_idx: usize,
|
|
}
|
|
|
|
impl LastCompletion {
|
|
/// Wrap-around forward step.
|
|
#[must_use]
|
|
pub const fn next_idx(&self) -> usize {
|
|
(self.selection_idx + 1) % self.candidates.len()
|
|
}
|
|
|
|
/// Wrap-around backward step.
|
|
#[must_use]
|
|
pub const fn prev_idx(&self) -> usize {
|
|
(self.selection_idx + self.candidates.len() - 1) % self.candidates.len()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn cands(input: &str, cursor: usize) -> Vec<String> {
|
|
candidates_at_cursor(input, cursor, &SchemaCache::default())
|
|
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
|
}
|
|
|
|
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
|
|
candidates_at_cursor(input, cursor, cache)
|
|
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
|
}
|
|
|
|
fn cand_kinds_with(
|
|
input: &str,
|
|
cursor: usize,
|
|
cache: &SchemaCache,
|
|
) -> Vec<(String, CandidateKind)> {
|
|
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
|
|
c.candidates
|
|
.into_iter()
|
|
.map(|c| (c.text, c.kind))
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_offers_all_command_entry_keywords() {
|
|
let cs = cands("", 0);
|
|
// Ten command-entry keywords.
|
|
assert!(cs.contains(&"create".to_string()));
|
|
assert!(cs.contains(&"drop".to_string()));
|
|
assert!(cs.contains(&"add".to_string()));
|
|
assert!(cs.contains(&"insert".to_string()));
|
|
assert!(cs.contains(&"update".to_string()));
|
|
assert!(cs.contains(&"delete".to_string()));
|
|
assert!(cs.contains(&"show".to_string()));
|
|
assert!(cs.contains(&"rename".to_string()));
|
|
assert!(cs.contains(&"change".to_string()));
|
|
assert!(cs.contains(&"replay".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn partial_keyword_narrows_to_matching_entries() {
|
|
// Typed `cr` — only `create` starts with that.
|
|
let cs = cands("cr", 2);
|
|
assert_eq!(cs, vec!["create".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn partial_keyword_is_case_insensitive() {
|
|
let cs = cands("CR", 2);
|
|
assert_eq!(cs, vec!["create".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn at_token_boundary_offers_next_expected_keyword() {
|
|
// After `create ` the parser expects `table`.
|
|
let cs = cands("create ", 7);
|
|
assert_eq!(cs, vec!["table".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_candidate_position_offers_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.
|
|
let cs = cands("update T set Name='hi' ", 23);
|
|
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn delete_filter_position_offers_where_and_all_rows() {
|
|
let cs = cands("delete from T ", 14);
|
|
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn flag_candidates_are_classified_as_flag_kind() {
|
|
// Hint-panel colouring distinguishes flags from
|
|
// keywords (amber vs purple) — flags get their own
|
|
// CandidateKind so the renderer can apply tok_flag.
|
|
let kinds = candidates_at_cursor("delete from T ", 14, &SchemaCache::default())
|
|
.expect("some completion")
|
|
.candidates
|
|
.into_iter()
|
|
.map(|c| (c.text, c.kind))
|
|
.collect::<Vec<_>>();
|
|
let flag = kinds
|
|
.iter()
|
|
.find(|(t, _)| t == "--all-rows")
|
|
.expect("--all-rows present");
|
|
assert_eq!(flag.1, CandidateKind::Flag);
|
|
}
|
|
|
|
#[test]
|
|
fn flag_candidates_filter_by_partial_prefix() {
|
|
let cs = cands("delete from T --", 16);
|
|
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[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",
|
|
] {
|
|
assert!(
|
|
cs.contains(&expected.to_string()),
|
|
"missing {expected:?} in entry-keyword candidates: {cs:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn load_prefix_offers_load_only() {
|
|
let cs = cands("l", 1);
|
|
assert_eq!(cs, vec!["load".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn save_prefix_offers_save() {
|
|
let cs = cands("sa", 2);
|
|
assert_eq!(cs, vec!["save".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn mode_then_space_offers_simple_and_advanced() {
|
|
// `mode ` requires a value; the parser fails at EOF and
|
|
// the expected-set contains the two known keywords.
|
|
let cs = cands("mode ", 5);
|
|
assert!(cs.contains(&"simple".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"advanced".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
// ---- Optional-suffix completion (round-5 gap, closed in Phase D) ----
|
|
//
|
|
// Pre-Phase-D: `save ` parsed as a valid `save` command, so
|
|
// the completion engine had no expected-set to mine and the
|
|
// `as` suffix never surfaced as a Tab candidate. Phase D's
|
|
// `WalkResult::tail_expected` carries the outer shape's
|
|
// skipped-Optional expectations even on `Match`, so these
|
|
// surface without a separate probe mechanism.
|
|
|
|
#[test]
|
|
fn save_space_offers_as_via_tail_expected() {
|
|
let cs = cands("save ", 5);
|
|
assert_eq!(cs, vec!["as".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn messages_space_offers_short_and_verbose_via_tail_expected() {
|
|
let cs = cands("messages ", 9);
|
|
assert!(cs.contains(&"short".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"verbose".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
// ---- Value-literal slot suppression (round-6) -----------
|
|
|
|
#[test]
|
|
fn value_literal_slot_suppresses_keyword_candidates_at_empty_prefix() {
|
|
// After `insert into T values (` the parser's expected
|
|
// set contains null/true/false/number/string literal.
|
|
// The keyword pipeline would otherwise surface `null`,
|
|
// `true`, `false` as Tab candidates — actively
|
|
// misleading at a slot where the user is more likely
|
|
// entering a number / text / date. Suppress.
|
|
let cs = cands("insert into T values (", 22);
|
|
assert!(cs.is_empty(), "got misleading candidates {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_slot_with_partial_prefix_still_completes() {
|
|
// Once the user types a prefix, normal keyword
|
|
// completion applies — `n` → `null`, `tr` → `true`,
|
|
// `fa` → `false`.
|
|
assert_eq!(
|
|
cands("insert into T values (n", 23),
|
|
vec!["null".to_string()],
|
|
);
|
|
assert_eq!(
|
|
cands("insert into T values (tr", 24),
|
|
vec!["true".to_string()],
|
|
);
|
|
assert_eq!(
|
|
cands("insert into T values (fa", 24),
|
|
vec!["false".to_string()],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_slot_after_first_value_also_suppresses() {
|
|
// Comma-separated value positions all hit the same slot
|
|
// signature. `insert into T values (1, ` → expected:
|
|
// null/true/false/number/string. Suppress.
|
|
let cs = cands("insert into T values (1, ", 25);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn update_set_value_slot_suppresses() {
|
|
// `update T set col=` is also a value-literal slot.
|
|
let cs = cands("update T set col=", 17);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn where_value_slot_suppresses() {
|
|
// `where col=` is also a value-literal slot.
|
|
let cs = cands("delete from T where col=", 24);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_hint_fires_at_empty_value_slot() {
|
|
let hint = value_literal_hint_at_cursor("insert into T values (", 22);
|
|
let s = hint.expect("hint should fire at value-literal slot");
|
|
// Lists each literal form so the user sees the full set
|
|
// of valid inputs rather than just three keywords.
|
|
assert!(s.contains("number"), "got {s:?}");
|
|
assert!(s.contains("text") || s.contains("'"), "got {s:?}");
|
|
assert!(s.contains("true"), "got {s:?}");
|
|
assert!(s.contains("false") || s.contains("/false"), "got {s:?}");
|
|
assert!(s.contains("null"), "got {s:?}");
|
|
// Format examples for the cases users typically can't
|
|
// guess (date, datetime).
|
|
assert!(
|
|
s.contains("YYYY-MM-DD"),
|
|
"should include date format, got {s:?}",
|
|
);
|
|
assert!(
|
|
s.contains("HH:MM:SS"),
|
|
"should include datetime format, got {s:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_hint_does_not_fire_at_partial_prefix() {
|
|
// With a partial prefix the keyword-completion path
|
|
// handles it; the prose hint short-circuit only
|
|
// applies to empty-prefix positions.
|
|
assert!(value_literal_hint_at_cursor("insert into T values (n", 23).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_hint_does_not_fire_at_keyword_slot() {
|
|
// Entry keyword position is not a value-literal slot.
|
|
assert!(value_literal_hint_at_cursor("", 0).is_none());
|
|
assert!(value_literal_hint_at_cursor("insert ", 7).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn show_offers_data_and_table_alphabetised() {
|
|
let cs = cands("show ", 5);
|
|
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn drop_offers_all_five_subcommands() {
|
|
// `drop` branches: column / relationship / table / index
|
|
// (ADR-0025) / constraint (ADR-0029 §2.2). Candidates
|
|
// follow grammar declaration order, so `constraint` —
|
|
// added last — appears last.
|
|
let cs = cands("drop ", 5);
|
|
assert_eq!(
|
|
cs,
|
|
vec![
|
|
"column".to_string(),
|
|
"relationship".to_string(),
|
|
"table".to_string(),
|
|
"index".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 schema_cache_offers_column_names_at_column_slot() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string()],
|
|
columns: vec!["Email".to_string(), "Name".to_string()],
|
|
relationships: vec![],
|
|
..SchemaCache::default()
|
|
};
|
|
// After `drop column from Customers: ` the parser
|
|
// expects a column name (existing).
|
|
let cs = cands_with("drop column from Customers: ", 28, &cache);
|
|
assert_eq!(cs, vec!["Email".to_string(), "Name".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_cache_offers_relationship_names_at_relationship_slot() {
|
|
let cache = SchemaCache {
|
|
tables: vec![],
|
|
columns: vec![],
|
|
relationships: vec!["cust_orders".to_string(), "ord_items".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
// After `drop relationship ` the parser expects either
|
|
// an identifier (relationship name) or `from`. Schema
|
|
// candidates plus the `from` keyword.
|
|
let cs = cands_with("drop relationship ", 18, &cache);
|
|
assert!(cs.contains(&"cust_orders".to_string()));
|
|
assert!(cs.contains(&"ord_items".to_string()));
|
|
assert!(cs.contains(&"from".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn schema_candidates_filtered_by_partial_prefix() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string(), "Orders".to_string()],
|
|
columns: vec![],
|
|
relationships: vec![],
|
|
..SchemaCache::default()
|
|
};
|
|
// Typed `Cu` after `show data ` — only `Customers`
|
|
// matches.
|
|
let cs = cands_with("show data Cu", 12, &cache);
|
|
assert_eq!(cs, vec!["Customers".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_cache_at_table_slot_returns_no_candidates() {
|
|
// Pre-population (or NewName-only positions) yield no
|
|
// identifier candidates.
|
|
let cache = SchemaCache::default();
|
|
let cs = cands_with("show data ", 10, &cache);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
// ---- typing_name_at_cursor (round-3 follow-up) ----
|
|
|
|
#[test]
|
|
fn typing_name_fires_at_new_column_slot_with_next_token() {
|
|
// After `add column to table T: ` the parser expects
|
|
// an identifier (NewName slot) followed by `(`. The
|
|
// probe substitutes a placeholder name and reads back
|
|
// that the next token is `(`.
|
|
let t = typing_name_at_cursor("add column to table T: ", 23)
|
|
.expect("should fire at NewName slot");
|
|
assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_fires_when_partial_name_already_typed() {
|
|
// Mid-typing the column name. typing_name_at_cursor
|
|
// walks back over the partial to find the slot, then
|
|
// probes forward as if the partial were a complete name.
|
|
let t = typing_name_at_cursor("add column to table T: Na", 25)
|
|
.expect("should fire at NewName slot with partial");
|
|
assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_does_not_fire_at_table_name_slot() {
|
|
// `show data ` — the slot is TableName, not NewName.
|
|
// The candidates path (or invalid-ident) handles it;
|
|
// typing_name should not fire.
|
|
assert!(typing_name_at_cursor("show data ", 10).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_does_not_fire_at_keyword_slot() {
|
|
// `cr` at position 2 is a keyword slot.
|
|
assert!(typing_name_at_cursor("cr", 2).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_yields_no_next_when_probe_succeeds() {
|
|
// `add column to table T: Name (text)` — the user is
|
|
// inside `Name`, and substituting any name there
|
|
// produces a complete command. No useful "next after
|
|
// name" hint.
|
|
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
|
|
.expect("should fire");
|
|
assert_eq!(t.next_after_name, None);
|
|
}
|
|
|
|
// ---- invalid_ident_at_cursor (stage 8e) ----
|
|
|
|
#[test]
|
|
fn invalid_ident_fires_for_unknown_table_prefix() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
// `show data Cust` matches → no invalid.
|
|
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
|
|
// `show data Cust` plus a typo: `show data Custp`. No
|
|
// table starts with "Custp" → invalid.
|
|
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
|
|
.expect("should be invalid");
|
|
assert_eq!(invalid.range, (10, 15));
|
|
assert_eq!(invalid.found, "Custp");
|
|
assert_eq!(invalid.source, IdentSource::Tables);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_when_partial_matches_some_schema_entry() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string(), "Orders".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
// "C" matches Customers (prefix), so not invalid.
|
|
assert!(invalid_ident_at_cursor("show data C", 11, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_in_new_name_slot() {
|
|
// `create table Cust` — Cust is a NewName slot. Even
|
|
// if no schema entry matches, the user invents the
|
|
// name; not invalid.
|
|
let cache = SchemaCache {
|
|
tables: vec!["Existing".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
assert!(invalid_ident_at_cursor("create table Cust", 17, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_when_cursor_not_at_partial_token() {
|
|
let cache = SchemaCache::default();
|
|
// Cursor at a whitespace position — no partial token.
|
|
assert!(invalid_ident_at_cursor("show data ", 10, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_at_keyword_slot() {
|
|
// `cra` at the entry-keyword position — no keyword
|
|
// starts with "cra", but the slot is keyword (not a
|
|
// known-schema slot), so invalid_ident doesn't fire.
|
|
// The render path's regular parse-error overlay handles
|
|
// this case.
|
|
let cache = SchemaCache::default();
|
|
assert!(invalid_ident_at_cursor("cra", 3, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn new_name_slot_offers_no_candidates_even_with_populated_cache() {
|
|
// `create table ` — the table-name slot is NewName.
|
|
// Even if the cache has table/column entries, no
|
|
// candidates are offered (the user invents the name).
|
|
let cache = SchemaCache {
|
|
tables: vec!["Existing".to_string()],
|
|
columns: vec!["AlsoExisting".to_string()],
|
|
relationships: vec![],
|
|
..SchemaCache::default()
|
|
};
|
|
let cs = cands_with("create table ", 13, &cache);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
fn keyword_cand(text: &str) -> Candidate {
|
|
Candidate {
|
|
text: text.to_string(),
|
|
kind: CandidateKind::Keyword,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn last_completion_next_idx_wraps_around() {
|
|
let mut memo = LastCompletion {
|
|
inserted_range: (0, 0),
|
|
original_text: String::new(),
|
|
candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")],
|
|
selection_idx: 0,
|
|
};
|
|
assert_eq!(memo.next_idx(), 1);
|
|
memo.selection_idx = 1;
|
|
assert_eq!(memo.next_idx(), 2);
|
|
memo.selection_idx = 2;
|
|
assert_eq!(memo.next_idx(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn last_completion_prev_idx_wraps_around() {
|
|
let mut memo = LastCompletion {
|
|
inserted_range: (0, 0),
|
|
original_text: String::new(),
|
|
candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")],
|
|
selection_idx: 0,
|
|
};
|
|
assert_eq!(memo.prev_idx(), 2);
|
|
memo.selection_idx = 2;
|
|
assert_eq!(memo.prev_idx(), 1);
|
|
memo.selection_idx = 1;
|
|
assert_eq!(memo.prev_idx(), 0);
|
|
}
|
|
|
|
// ---- Ranker hook (ADR-0024 §ranker-layer) ----
|
|
|
|
#[test]
|
|
fn identity_ranker_preserves_input_order() {
|
|
let input = vec![
|
|
Candidate {
|
|
text: "b".to_string(),
|
|
kind: CandidateKind::Keyword,
|
|
},
|
|
Candidate {
|
|
text: "a".to_string(),
|
|
kind: CandidateKind::Keyword,
|
|
},
|
|
Candidate {
|
|
text: "c".to_string(),
|
|
kind: CandidateKind::Identifier,
|
|
},
|
|
];
|
|
let out = identity_ranker(input.clone());
|
|
assert_eq!(out, input);
|
|
}
|
|
|
|
#[test]
|
|
fn ranker_can_reorder_candidates() {
|
|
// Hooks like frequency-based ranking or content-aware
|
|
// priors plug in through `Ranker` without touching the
|
|
// grammar. Smoke-test the call site with a sorter.
|
|
fn alphabetic_ranker(mut c: Vec<Candidate>) -> Vec<Candidate> {
|
|
c.sort_by(|a, b| a.text.cmp(&b.text));
|
|
c
|
|
}
|
|
// `add ` exposes `column`, `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 ----
|
|
|
|
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 = "sqlinsert 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());
|
|
}
|
|
}
|