619a8bd707
Two related fixes:
1. \`update MyTable set \` was offering columns from every
table in the project — completion fetched
\`cache.for_source(IdentSource::Columns)\` which returns the
flat \`cache.columns\` (union of every table's columns).
The walker's WalkContext had \`current_table_columns\`
populated (because the update-table-name slot is
\`writes_table: true\`) but the completion engine never
consulted it.
2. \`insert into MyTable (\` was offering nothing — the
value-literal suppression fired because the expected set at
this position contains both Form A column-list candidates
(\`Ident{Columns}\`) and Form C bare-value-list literals
(null/true/false/NumberLit/StringLit). \`is_value_literal_signature\`
matched and the engine returned \`None\` before the column
candidates were considered.
The fix threads the walker's \`current_table_columns\` through
to the completion engine and narrows the suppression rule:
**Walker:**
- New \`walker::CompletionProbe { expected, current_table_columns }\`
struct.
- New \`walker::completion_probe(source, schema) -> CompletionProbe\`
runs one schema-aware walk and reports both the expected
set (or tail_expected on Match) and the resolved table-column
snapshot.
**Completion engine:**
- \`candidates_at_cursor_with\` calls \`completion_probe\` and
reads \`current_table_columns\` for the \`Columns\` ident
source. Schemaless or unknown-table falls back to the flat
\`cache.columns\` (preserves pre-fix behavior).
- Value-literal suppression now gated on
\`!has_schema_ident\` — if the expected set also offers a
schema-listable Ident, the user has actionable candidates
beyond the misleading null/true/false trio and we shouldn't
hide them.
Tests:
- \`update_set_offers_only_current_table_columns\` confirms
Customers' columns appear while Orders' columns don't.
- \`update_where_offers_only_current_table_columns\` covers
the where path.
- \`insert_into_open_paren_offers_current_table_columns\` and
\`insert_into_open_paren_does_not_offer_unrelated_columns\`
cover the Form A column-list position.
- \`drop_column_from_offers_only_current_table_columns\`
documents the DDL fallback (drop-column's table-name slot
doesn't currently \`writes_table\` — falls back to the flat
list).
For the user: \`update MyTable set \` now offers only
MyTable's columns. \`insert into MyTable (\` offers all of
MyTable's columns so Form A is fully discoverable.
Tests: 859 passing, 0 failing, 1 ignored. Clippy clean.
1614 lines
60 KiB
Rust
1614 lines
60 KiB
Rust
//! Keyword + identifier completion for ambient typing
|
|
//! assistance (ADR-0022 stage 8).
|
|
//!
|
|
//! This stage 1 cut covers keyword completion only. Identifier
|
|
//! completion (schema-aware) lands in a follow-on substage
|
|
//! that builds on this scaffolding.
|
|
//!
|
|
//! Concept: `candidates_at_cursor(input, cursor)` returns a
|
|
//! `Completion` describing what would happen if the user
|
|
//! pressed Tab right now — the byte range to replace, the
|
|
//! partial prefix already typed, and the list of fitting
|
|
//! candidates. Empty / no-candidate cases return `None`.
|
|
//!
|
|
//! The cycling memo (`LastCompletion` on `App`) lives in
|
|
//! `app.rs`; this module owns the candidate computation.
|
|
|
|
use crate::dsl::grammar::IdentSource;
|
|
use crate::dsl::types::Type;
|
|
use crate::dsl::walker::outcome::Expectation;
|
|
use crate::dsl::{ParseError, parse_command};
|
|
|
|
/// Composite literal candidates whose lexed shape is more than
|
|
/// one token but which the user types as a single fluent piece.
|
|
/// Pairs of (walker-expected-literal, full-composite-text).
|
|
///
|
|
/// When the walker reports `Expectation::Literal(opener)` at the
|
|
/// cursor, the engine surfaces the full composite text as a Tab
|
|
/// candidate. Today the only entry is `1:n` (the opener for
|
|
/// `add 1:n relationship`) — adding more is a one-line edit.
|
|
const COMPOSITE_CANDIDATES: &[(&str, &str)] = &[("1", "1:n")];
|
|
|
|
/// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D).
|
|
///
|
|
/// Held by `App::schema_cache` and consulted by the completion
|
|
/// engine for identifier slots and by the walker for schema-aware
|
|
/// value-slot dispatch (Phase D full). Empty by default; the
|
|
/// runtime refreshes on project load and after successful DDL.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct SchemaCache {
|
|
pub tables: Vec<String>,
|
|
pub columns: Vec<String>,
|
|
pub relationships: Vec<String>,
|
|
/// Per-table column metadata with user-facing types
|
|
/// (ADR-0024 §Phase D). Keyed by table name; lookup is
|
|
/// case-insensitive in `columns_for_table` so the walker
|
|
/// can resolve `Customers` regardless of how it was typed.
|
|
pub table_columns: std::collections::HashMap<String, Vec<TableColumn>>,
|
|
}
|
|
|
|
/// One column's user-facing type info, scoped to a table
|
|
/// (ADR-0024 §Phase D, §WalkContext).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TableColumn {
|
|
pub name: String,
|
|
pub user_type: crate::dsl::types::Type,
|
|
}
|
|
|
|
impl SchemaCache {
|
|
/// Lookup the candidate list for an identifier slot.
|
|
/// Sources that don't read from the schema (`NewName`,
|
|
/// `Types`, `Free`) return `&[]`.
|
|
#[must_use]
|
|
pub fn for_source(&self, source: IdentSource) -> &[String] {
|
|
match source {
|
|
IdentSource::Tables => &self.tables,
|
|
IdentSource::Columns => &self.columns,
|
|
IdentSource::Relationships => &self.relationships,
|
|
IdentSource::NewName | IdentSource::Types | IdentSource::Free => &[],
|
|
}
|
|
}
|
|
|
|
/// Per-table column metadata lookup. Case-insensitive on
|
|
/// the table name so the walker can resolve identifiers
|
|
/// the user typed in either case (ADR-0009 — keywords are
|
|
/// case-insensitive, identifiers preserve case; this helper
|
|
/// matches the walker's case-insensitive entry-word lookup
|
|
/// rather than the strict-case `tables` Vec).
|
|
///
|
|
/// Returns `None` when no table matches; an empty `Vec`
|
|
/// when the table exists but has no columns (rare —
|
|
/// CSV-empty tables still carry PK columns in metadata).
|
|
#[must_use]
|
|
pub fn columns_for_table(&self, table: &str) -> Option<&[TableColumn]> {
|
|
self.table_columns
|
|
.iter()
|
|
.find(|(name, _)| name.eq_ignore_ascii_case(table))
|
|
.map(|(_, cols)| cols.as_slice())
|
|
}
|
|
}
|
|
|
|
/// What the grammar would accept at the end of `leading`,
|
|
/// expressed as structured `Expectation`s direct from the
|
|
/// walker (ADR-0024 §architecture, Phase F walker-driven
|
|
/// completion). Replaces the `ParseError`-string round-trip.
|
|
fn expected_at(leading: &str) -> Vec<Expectation> {
|
|
crate::dsl::walker::expected_at_input(leading)
|
|
}
|
|
|
|
/// A single Tab-insertable item with its source (so the
|
|
/// renderer can colour keywords differently from schema
|
|
/// identifiers, and so the ordering can group keywords first).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Candidate {
|
|
pub text: String,
|
|
pub kind: CandidateKind,
|
|
}
|
|
|
|
/// Re-ranker for a freshly-computed candidate list (ADR-0024
|
|
/// §ranker-layer).
|
|
///
|
|
/// The grammar tree declares *what's valid*; the ranker decides
|
|
/// *what's likely useful first*. Lives outside the trie so
|
|
/// frequency-based ranking, content-aware priors (e.g. `Email`
|
|
/// → text first), and recency hooks can plug in without
|
|
/// touching grammar declarations.
|
|
///
|
|
/// Default is `identity_ranker` — declaration order from the
|
|
/// grammar tree is preserved.
|
|
pub type Ranker = fn(Vec<Candidate>) -> Vec<Candidate>;
|
|
|
|
/// Identity ranker: returns its input unchanged.
|
|
#[must_use]
|
|
pub const fn identity_ranker(candidates: Vec<Candidate>) -> Vec<Candidate> {
|
|
candidates
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CandidateKind {
|
|
/// One of the parser's expected keywords.
|
|
Keyword,
|
|
/// A schema entity (table, column, relationship).
|
|
Identifier,
|
|
/// A `--name`-style flag. Coloured with `tok_flag` so the
|
|
/// hint matches the way it'll render in the input pane.
|
|
Flag,
|
|
/// A single-char punctuation token the walker expects next
|
|
/// (e.g. `(` at the start of `insert into T (cols)`). Used
|
|
/// to surface branching alternatives the user might not
|
|
/// otherwise discover — at `insert into Orders ` the walker
|
|
/// expects either `values` or `(`, and surfacing both makes
|
|
/// the Form A path discoverable.
|
|
Punct,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Completion {
|
|
/// Byte range in `input` to be replaced when a candidate
|
|
/// is accepted. Equal `(cursor, cursor)` when the user is
|
|
/// at a token boundary (no partial prefix).
|
|
pub replaced_range: (usize, usize),
|
|
/// Partial prefix the user has typed at the cursor. Empty
|
|
/// when the cursor is at a token boundary.
|
|
pub partial_prefix: String,
|
|
/// Fitting candidates, ordered keywords-first then
|
|
/// identifiers, alphabetised within each group, deduplicated.
|
|
pub candidates: Vec<Candidate>,
|
|
}
|
|
|
|
/// Compute what would happen if the user pressed Tab right
|
|
/// now (ADR-0022 stage 8). `None` means there is nothing to
|
|
/// complete (no candidates fit / cursor at end of complete
|
|
/// input).
|
|
///
|
|
/// Candidates are built from two sources:
|
|
/// - **Keywords**: the parser's expected-set entries that are
|
|
/// bare keywords (excluding punctuation and descriptive
|
|
/// labels per ADR-0022 §10).
|
|
/// - **Schema identifiers**: when the parser's expected-set
|
|
/// includes an `IdentSource::expected_label()`, the matching
|
|
/// schema list from `cache` is added (skipping the `NewName`
|
|
/// slot — the user invents those).
|
|
///
|
|
/// Both sources are filtered by the typed partial prefix
|
|
/// (case-insensitive starts-with), combined, sorted, and
|
|
/// deduplicated.
|
|
#[must_use]
|
|
pub fn candidates_at_cursor(
|
|
input: &str,
|
|
cursor: usize,
|
|
cache: &SchemaCache,
|
|
) -> Option<Completion> {
|
|
candidates_at_cursor_with(input, cursor, cache, identity_ranker)
|
|
}
|
|
|
|
/// Variant of [`candidates_at_cursor`] that applies a custom
|
|
/// `Ranker` to the final candidate list (ADR-0024 §ranker-layer).
|
|
/// The default `candidates_at_cursor` calls this with
|
|
/// `identity_ranker`.
|
|
#[must_use]
|
|
pub fn candidates_at_cursor_with(
|
|
input: &str,
|
|
cursor: usize,
|
|
cache: &SchemaCache,
|
|
ranker: Ranker,
|
|
) -> Option<Completion> {
|
|
let cursor = cursor.min(input.len());
|
|
|
|
// Walk backward from the cursor over identifier-shaped
|
|
// characters to find the partial prefix the user is mid-typing.
|
|
let bytes = input.as_bytes();
|
|
let mut start = cursor;
|
|
while start > 0 {
|
|
let prev = bytes[start - 1];
|
|
if prev.is_ascii_alphanumeric() || prev == b'_' {
|
|
start -= 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
let partial_prefix = input[start..cursor].to_string();
|
|
let leading = &input[..start];
|
|
|
|
// When the full input already parses, the cursor is at
|
|
// the end of a "complete enough" command — but the leading
|
|
// slice still reports an expected set (the words the user
|
|
// just finished typing, or optional-suffix continuations).
|
|
// We let the completion engine run normally, then below
|
|
// filter out candidates that exactly equal the partial
|
|
// prefix the user already typed: it's not useful to suggest
|
|
// `pk` at the end of `create table T with pk`. The
|
|
// optional-suffix case (`save ` → `as`) is preserved
|
|
// because there `partial_prefix` is empty.
|
|
let input_parses_complete = parse_command(input).is_ok();
|
|
|
|
// Schema-aware probe: one walk yields both the expected set
|
|
// and the table-context snapshot (ADR-0024 §Phase D
|
|
// §column-narrowing). The engine reads
|
|
// `current_table_columns` to narrow column candidates to the
|
|
// active table rather than the flat `cache.columns` (which
|
|
// unions every table's columns).
|
|
let probe = crate::dsl::walker::completion_probe(leading, cache);
|
|
let current_table_columns: Option<&[TableColumn]> =
|
|
probe.current_table_columns.as_deref();
|
|
|
|
let expected = if probe.expected.is_empty() {
|
|
expected_at(leading)
|
|
} else {
|
|
probe.expected.clone()
|
|
};
|
|
if expected.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Value-literal slot: at an empty-prefix position the only
|
|
// candidates we'd surface are `null`/`true`/`false` (the
|
|
// keyword literals — number / string-literal slots are
|
|
// descriptive labels, not Tab candidates). Surfacing those
|
|
// three is actively misleading — the user usually wants a
|
|
// number, a quoted string, or a date. Suppress so the
|
|
// ambient_hint ladder falls through to a prose hint with
|
|
// format examples instead.
|
|
//
|
|
// EXCEPT when the same expected set also contains a
|
|
// schema-listable Ident expectation — that's the ambiguous
|
|
// position at `insert into T (` where Form A (column list)
|
|
// and Form C (bare value list) both apply. There the user
|
|
// has actionable column candidates that shouldn't be
|
|
// hidden by the value-literal suppression.
|
|
let has_schema_ident = expected.iter().any(|e| {
|
|
matches!(
|
|
e,
|
|
Expectation::Ident { source, .. } if source.completes_from_schema()
|
|
)
|
|
});
|
|
if partial_prefix.is_empty()
|
|
&& is_value_literal_signature(&expected)
|
|
&& !has_schema_ident
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let lowered_prefix = partial_prefix.to_lowercase();
|
|
let matches_prefix = |s: &str| s.to_lowercase().starts_with(&lowered_prefix);
|
|
|
|
// Source 1: keyword candidates direct from the walker's
|
|
// expected set. `Word(primary)` and `Literal(s)` both
|
|
// surface here; we keep only the alphabetic ones —
|
|
// single-digit literals like `1` go through the composite
|
|
// pipeline below, and punct never surfaces as a candidate.
|
|
// Declaration order is preserved (matches the canonical
|
|
// command shape, e.g. `to` before `table` for
|
|
// `add column [to] [table] …`).
|
|
let mut keywords: Vec<String> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Word(w) | Expectation::Literal(w) => Some(*w),
|
|
_ => None,
|
|
})
|
|
.filter(|w| !w.is_empty() && w.chars().all(|c| c.is_ascii_alphabetic()))
|
|
.map(str::to_string)
|
|
.filter(|name| matches_prefix(name))
|
|
.collect();
|
|
let mut seen_kw = std::collections::HashSet::new();
|
|
keywords.retain(|k| seen_kw.insert(k.clone()));
|
|
|
|
// Source 1.5: type-name candidates when the walker expects
|
|
// a column-type slot. Type names are a closed set sourced
|
|
// from `Type::all()` (ADR-0005 declaration order:
|
|
// text/int/real/decimal/bool/date/datetime/blob/serial/
|
|
// shortid). The walker surfaces this as
|
|
// `Expectation::Ident { source: Types }`.
|
|
let type_names: Vec<String> = if expected.iter().any(|e| {
|
|
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
|
|
}) {
|
|
Type::all()
|
|
.iter()
|
|
.map(|t| t.keyword().to_string())
|
|
.filter(|s| matches_prefix(s))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Source 1.55: flag candidates (`--name`). Surfaced as a
|
|
// distinct CandidateKind so the hint panel can colour them
|
|
// with `tok_flag` (matching how they'll appear after
|
|
// insertion). The standard prefix matcher walks back over
|
|
// alphanumeric + underscore, which does NOT cross `-`, so
|
|
// when the user types `--all` the partial is `all` — match
|
|
// the flag's body against that. Otherwise match the full
|
|
// `--name` against the partial (which may be empty or start
|
|
// with `--`).
|
|
let flags: Vec<String> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Flag(name) => Some(*name),
|
|
_ => None,
|
|
})
|
|
.filter(|body| {
|
|
if partial_prefix.starts_with("--") {
|
|
format!("--{body}")
|
|
.to_lowercase()
|
|
.starts_with(&lowered_prefix)
|
|
} else if partial_prefix.is_empty() {
|
|
true
|
|
} else {
|
|
body.to_lowercase().starts_with(&lowered_prefix)
|
|
}
|
|
})
|
|
.map(|body| format!("--{body}"))
|
|
.collect();
|
|
|
|
// Source 1.6: composite-literal candidates. Some commands
|
|
// start with a multi-token literal sequence that the user
|
|
// types as a single fluent piece (e.g. `1:n` for
|
|
// `add 1:n relationship`). The walker's expected-set
|
|
// surfaces the first token only (`Expectation::Literal("1")`);
|
|
// the engine surfaces the full composite text so the user
|
|
// can Tab through without knowing the surface syntax.
|
|
let composites: Vec<String> = COMPOSITE_CANDIDATES
|
|
.iter()
|
|
.filter(|(opener, _)| {
|
|
expected.iter().any(|e| match e {
|
|
Expectation::Literal(l) | Expectation::Word(l) => *l == *opener,
|
|
_ => false,
|
|
})
|
|
})
|
|
.map(|(_, text)| (*text).to_string())
|
|
.filter(|s| matches_prefix(s))
|
|
.collect();
|
|
|
|
// Source 1.7: branching-punct candidates. At positions
|
|
// where the walker expects a punct character that opens a
|
|
// sub-shape (notably `(` opening Form A or C of insert),
|
|
// surface the punct as a Tab candidate so the user
|
|
// discovers the option. Closing punct (`)`, `,`, etc.) and
|
|
// expected-after-content punct (`:`, `=`, `.`) are not
|
|
// surfaced — they're trailing terminals the user types
|
|
// naturally, not "shape branches" worth advertising in the
|
|
// hint panel.
|
|
let punct_candidates: Vec<String> = if partial_prefix.is_empty() {
|
|
expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Punct('(') => Some("(".to_string()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Source 2: schema identifiers — accumulated across every
|
|
// matching schema-listable `Ident { source }` expectation.
|
|
// `NewName` / `Types` / `Free` sources don't query the
|
|
// schema cache and contribute nothing here.
|
|
//
|
|
// Column candidates narrow to `current_table_columns` when
|
|
// the walker resolved the active table — `update T set ` at
|
|
// table T offers T's columns, not every table's columns
|
|
// (ADR-0024 §Phase D §column-narrowing). Schemaless / table
|
|
// not in schema falls back to the global `cache.columns`.
|
|
let mut identifiers: Vec<String> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
|
Some(*source)
|
|
}
|
|
_ => None,
|
|
})
|
|
.flat_map(|source| {
|
|
if source == IdentSource::Columns {
|
|
current_table_columns.map_or_else(
|
|
|| cache.for_source(source).to_vec(),
|
|
|cols| cols.iter().map(|c| c.name.clone()).collect(),
|
|
)
|
|
} else {
|
|
cache.for_source(source).to_vec()
|
|
}
|
|
})
|
|
.filter(|name| matches_prefix(name))
|
|
.collect();
|
|
identifiers.sort();
|
|
identifiers.dedup();
|
|
// If an identifier shares its name with a keyword candidate
|
|
// (rare in practice), the keyword wins — keywords are
|
|
// grammar; the user can name a table the same thing but
|
|
// resolving collisions in the user's favour would create
|
|
// ambiguity in the live render.
|
|
identifiers.retain(|name| !keywords.contains(name));
|
|
|
|
// Keywords first (grammar parts read before content),
|
|
// then type names (closed-set grammar — coloured as
|
|
// keywords), then composite literals (`1:n`, …), then
|
|
// branching punct (`(` opening a sub-shape), then flags
|
|
// (own colour), then schema identifiers.
|
|
let mut candidates: Vec<Candidate> = Vec::with_capacity(
|
|
keywords.len()
|
|
+ type_names.len()
|
|
+ composites.len()
|
|
+ punct_candidates.len()
|
|
+ flags.len()
|
|
+ identifiers.len(),
|
|
);
|
|
candidates.extend(keywords.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Keyword,
|
|
}));
|
|
candidates.extend(type_names.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Keyword,
|
|
}));
|
|
candidates.extend(composites.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Keyword,
|
|
}));
|
|
candidates.extend(punct_candidates.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Punct,
|
|
}));
|
|
candidates.extend(flags.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Flag,
|
|
}));
|
|
candidates.extend(identifiers.into_iter().map(|text| Candidate {
|
|
text,
|
|
kind: CandidateKind::Identifier,
|
|
}));
|
|
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// When the input is already a valid complete command, drop
|
|
// candidates that exactly match the partial prefix — those
|
|
// are the words the user just finished typing (e.g. `pk` in
|
|
// `create table T with pk`), not useful suggestions. Keeps
|
|
// schema-narrowing intact (`show data Cu` → `Customers` is
|
|
// not an exact match; preserved).
|
|
if input_parses_complete && !partial_prefix.is_empty() {
|
|
let lowered_partial = partial_prefix.to_lowercase();
|
|
candidates.retain(|c| c.text.to_lowercase() != lowered_partial);
|
|
}
|
|
|
|
let candidates = ranker(candidates);
|
|
if candidates.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(Completion {
|
|
replaced_range: (start, cursor),
|
|
partial_prefix,
|
|
candidates,
|
|
})
|
|
}
|
|
|
|
/// Detect a value-literal expected-set signature. A value-literal
|
|
/// slot is the only position where the walker's expected-set
|
|
/// simultaneously contains all five forms `null` / `true` /
|
|
/// `false` / number / string literal. See the suppression
|
|
/// rationale at the call site in `candidates_at_cursor`.
|
|
fn is_value_literal_signature(expected: &[Expectation]) -> bool {
|
|
let has_word = |needle: &str| {
|
|
expected
|
|
.iter()
|
|
.any(|e| matches!(e, Expectation::Word(w) if *w == needle))
|
|
};
|
|
has_word("null")
|
|
&& has_word("true")
|
|
&& has_word("false")
|
|
&& expected.iter().any(|e| matches!(e, Expectation::NumberLit))
|
|
&& expected.iter().any(|e| matches!(e, Expectation::StringLit))
|
|
}
|
|
|
|
/// `Some(prose)` when the cursor sits at an empty-prefix value-literal slot.
|
|
///
|
|
/// The hint panel surfaces format guidance (number, quoted text,
|
|
/// date, datetime, bool, null) instead of the misleading "null
|
|
/// true false" keyword-only candidate list.
|
|
///
|
|
/// Note: this is a stopgap until ADR-0023 lands schema-aware
|
|
/// completion (which would surface format examples specific to
|
|
/// the column's type — e.g. just the datetime format at a
|
|
/// datetime column). Today the hint lists all valid literal
|
|
/// shapes regardless of context.
|
|
#[must_use]
|
|
pub fn value_literal_hint_at_cursor(input: &str, cursor: usize) -> Option<String> {
|
|
let cursor = cursor.min(input.len());
|
|
let bytes = input.as_bytes();
|
|
let mut start = cursor;
|
|
while start > 0 {
|
|
let prev = bytes[start - 1];
|
|
if prev.is_ascii_alphanumeric() || prev == b'_' {
|
|
start -= 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if start != cursor {
|
|
// Partial prefix is non-empty — keyword completion
|
|
// handles it (e.g. `n` → `null`).
|
|
return None;
|
|
}
|
|
let leading = &input[..start];
|
|
let expected = expected_at(leading);
|
|
if !is_value_literal_signature(&expected) {
|
|
return None;
|
|
}
|
|
Some(crate::t!("hint.value_literal_slot"))
|
|
}
|
|
|
|
/// What the user has typed in an identifier slot whose schema
|
|
/// list contains nothing matching the prefix (ADR-0022 stage 8e
|
|
/// + the user's #5).
|
|
///
|
|
/// The renderer overlays the partial token with `tok_error`;
|
|
/// the hint panel renders an "invalid …" message.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InvalidIdent {
|
|
/// Byte range of the typed-but-not-found identifier.
|
|
pub range: (usize, usize),
|
|
/// The text the user typed in the slot.
|
|
pub found: String,
|
|
/// Which known-set slot this position expected.
|
|
pub source: IdentSource,
|
|
}
|
|
|
|
/// "User is typing a name" cursor state (round-3 follow-up).
|
|
///
|
|
/// Fires at `NewName` slots — positions where the user is
|
|
/// expected to invent a name (new table, new column, new
|
|
/// relationship). Used by the hint panel to surface a friendly
|
|
/// "Type a name" hint instead of the technical "next: `(`"
|
|
/// that would otherwise appear once the partial identifier
|
|
/// gets consumed by the parser.
|
|
///
|
|
/// `next_after_name` is what the parser would expect once the
|
|
/// user finishes typing the name — derived by re-parsing with
|
|
/// a single-letter placeholder identifier substituted at the
|
|
/// cursor. `None` when the post-name parse succeeds (the rest
|
|
/// of the command is already in place) or has no meaningful
|
|
/// next-token information (custom errors with empty expected
|
|
/// set).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TypingName {
|
|
pub next_after_name: Option<String>,
|
|
}
|
|
|
|
/// `Some(_)` when the cursor is at or inside a `NewName`-slot
|
|
/// position. Otherwise `None`.
|
|
#[must_use]
|
|
pub fn typing_name_at_cursor(input: &str, cursor: usize) -> Option<TypingName> {
|
|
let cursor = cursor.min(input.len());
|
|
let bytes = input.as_bytes();
|
|
let mut start = cursor;
|
|
while start > 0 {
|
|
let prev = bytes[start - 1];
|
|
if prev.is_ascii_alphanumeric() || prev == b'_' {
|
|
start -= 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
let leading = &input[..start];
|
|
let expected = expected_at(leading);
|
|
let is_new_name_slot = expected.iter().any(|e| {
|
|
matches!(
|
|
e,
|
|
Expectation::Ident {
|
|
source: IdentSource::NewName,
|
|
..
|
|
}
|
|
)
|
|
});
|
|
if !is_new_name_slot {
|
|
return None;
|
|
}
|
|
// Probe what comes after the name by substituting a
|
|
// single-letter identifier placeholder. Walk forward over
|
|
// any partial text past the cursor first so the probe
|
|
// replaces the user's in-progress name as a whole.
|
|
let mut end = cursor;
|
|
while end < bytes.len() {
|
|
let c = bytes[end];
|
|
if c.is_ascii_alphanumeric() || c == b'_' {
|
|
end += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
let probe = format!("{}X{}", &input[..start], &input[end..]);
|
|
let next_after_name = match parse_command(&probe) {
|
|
Ok(_) => None,
|
|
Err(ParseError::Empty) => None,
|
|
Err(ParseError::Invalid { expected, .. }) if expected.is_empty() => None,
|
|
Err(ParseError::Invalid { expected, .. }) => Some(oxford_or(&expected)),
|
|
};
|
|
Some(TypingName { next_after_name })
|
|
}
|
|
|
|
/// English-style "A, B, or C" join used by the hint panel
|
|
/// prose. Lifted out of `input_render` so the completion
|
|
/// module can produce ready-to-render strings.
|
|
fn oxford_or(items: &[String]) -> String {
|
|
match items {
|
|
[] => String::new(),
|
|
[a] => a.clone(),
|
|
[a, b] => format!("{a} or {b}"),
|
|
rest => {
|
|
let (last, head) = rest.split_last().expect("len >= 3");
|
|
format!("{}, or {last}", head.join(", "))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Detect "the user has typed an identifier here that the
|
|
/// schema doesn't have." Returns `None` for any of:
|
|
/// - cursor at empty / whitespace partial;
|
|
/// - cursor at a position that doesn't expect a known-set
|
|
/// identifier (keyword slot, NewName slot, complete input);
|
|
/// - cursor partial matches at least one schema name.
|
|
#[must_use]
|
|
pub fn invalid_ident_at_cursor(
|
|
input: &str,
|
|
cursor: usize,
|
|
cache: &SchemaCache,
|
|
) -> Option<InvalidIdent> {
|
|
let cursor = cursor.min(input.len());
|
|
let bytes = input.as_bytes();
|
|
let mut start = cursor;
|
|
while start > 0 {
|
|
let prev = bytes[start - 1];
|
|
if prev.is_ascii_alphanumeric() || prev == b'_' {
|
|
start -= 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if start == cursor {
|
|
// No partial token at the cursor — nothing to flag.
|
|
return None;
|
|
}
|
|
let partial = &input[start..cursor];
|
|
let leading = &input[..start];
|
|
let expected = expected_at(leading);
|
|
if expected.is_empty() {
|
|
return None;
|
|
}
|
|
// Find every schema-listable source in the expected list.
|
|
let sources: Vec<IdentSource> = expected
|
|
.iter()
|
|
.filter_map(|e| match e {
|
|
Expectation::Ident { source, .. } if source.completes_from_schema() => {
|
|
Some(*source)
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
if sources.is_empty() {
|
|
return None;
|
|
}
|
|
let lowered = partial.to_lowercase();
|
|
// If any schema entry across the matching slots matches
|
|
// the prefix, the partial is not "invalid" — it's an
|
|
// in-progress lookup.
|
|
let any_match = sources
|
|
.iter()
|
|
.flat_map(|s| cache.for_source(*s))
|
|
.any(|name| name.to_lowercase().starts_with(&lowered));
|
|
if any_match {
|
|
return None;
|
|
}
|
|
// Pick the first source kind for the diagnostic — when
|
|
// multiple are expected (e.g. `drop relationship …`
|
|
// expects Relationships *or* the `from` keyword;
|
|
// here only the schema source survives the filter) we
|
|
// surface the first.
|
|
Some(InvalidIdent {
|
|
range: (start, cursor),
|
|
found: partial.to_string(),
|
|
source: sources[0],
|
|
})
|
|
}
|
|
|
|
// `expected_set` is gone: the walker-driven `expected_at` above
|
|
// returns structured `Expectation`s with full `IdentSource`
|
|
// information, avoiding the lossy string round-trip the
|
|
// chumsky-era completion engine relied on.
|
|
|
|
/// Snapshot of a freshly-inserted completion. The memo lives
|
|
/// on `App::last_completion` until any non-Tab/non-Shift-Tab
|
|
/// keystroke clears it (or Esc/Backspace consumes it).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LastCompletion {
|
|
/// Byte range in the input currently occupied by the
|
|
/// inserted candidate (including the trailing space).
|
|
pub inserted_range: (usize, usize),
|
|
/// The text that was at `inserted_range` *before* any
|
|
/// completion was applied — restored by Esc / Backspace.
|
|
pub original_text: String,
|
|
/// Cycle list, fixed at memo-creation time. Each candidate
|
|
/// carries its kind so the hint panel keeps the
|
|
/// keyword-vs-identifier colour coding stable across cycle
|
|
/// transitions.
|
|
pub candidates: Vec<Candidate>,
|
|
/// Which `candidates[i]` is currently visible.
|
|
pub selection_idx: usize,
|
|
}
|
|
|
|
impl LastCompletion {
|
|
/// Wrap-around forward step.
|
|
#[must_use]
|
|
pub const fn next_idx(&self) -> usize {
|
|
(self.selection_idx + 1) % self.candidates.len()
|
|
}
|
|
|
|
/// Wrap-around backward step.
|
|
#[must_use]
|
|
pub const fn prev_idx(&self) -> usize {
|
|
(self.selection_idx + self.candidates.len() - 1) % self.candidates.len()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn cands(input: &str, cursor: usize) -> Vec<String> {
|
|
candidates_at_cursor(input, cursor, &SchemaCache::default())
|
|
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
|
}
|
|
|
|
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
|
|
candidates_at_cursor(input, cursor, cache)
|
|
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
|
|
}
|
|
|
|
fn cand_kinds_with(
|
|
input: &str,
|
|
cursor: usize,
|
|
cache: &SchemaCache,
|
|
) -> Vec<(String, CandidateKind)> {
|
|
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
|
|
c.candidates
|
|
.into_iter()
|
|
.map(|c| (c.text, c.kind))
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_offers_all_command_entry_keywords() {
|
|
let cs = cands("", 0);
|
|
// Ten command-entry keywords.
|
|
assert!(cs.contains(&"create".to_string()));
|
|
assert!(cs.contains(&"drop".to_string()));
|
|
assert!(cs.contains(&"add".to_string()));
|
|
assert!(cs.contains(&"insert".to_string()));
|
|
assert!(cs.contains(&"update".to_string()));
|
|
assert!(cs.contains(&"delete".to_string()));
|
|
assert!(cs.contains(&"show".to_string()));
|
|
assert!(cs.contains(&"rename".to_string()));
|
|
assert!(cs.contains(&"change".to_string()));
|
|
assert!(cs.contains(&"replay".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn partial_keyword_narrows_to_matching_entries() {
|
|
// Typed `cr` — only `create` starts with that.
|
|
let cs = cands("cr", 2);
|
|
assert_eq!(cs, vec!["create".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn partial_keyword_is_case_insensitive() {
|
|
let cs = cands("CR", 2);
|
|
assert_eq!(cs, vec!["create".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn at_token_boundary_offers_next_expected_keyword() {
|
|
// After `create ` the parser expects `table`.
|
|
let cs = cands("create ", 7);
|
|
assert_eq!(cs, vec!["table".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_candidate_position_offers_column_and_one_to_n() {
|
|
// After `add ` the parser expects `column` (for
|
|
// `add column ...`) and `1` (the opener for
|
|
// `add 1:n relationship ...`). The completion engine
|
|
// surfaces both: `column` straight from the keyword
|
|
// expected-set, and `1:n` as a composite literal
|
|
// candidate so the user can Tab through to the
|
|
// relationship form without knowing the surface syntax.
|
|
let cs = cands("add ", 4);
|
|
assert_eq!(cs, vec!["column".to_string(), "1:n".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn one_to_n_filters_to_prefix_match() {
|
|
// Typed `1` after `add ` — only `1:n` matches.
|
|
let cs = cands("add 1", 5);
|
|
assert_eq!(cs, vec!["1:n".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn update_filter_position_offers_where_and_all_rows() {
|
|
// After `update T set Name='hi' ` the parser expects
|
|
// a `,` (more assignments), `where` (where clause),
|
|
// or `--all-rows` (flag). Punctuation isn't surfaced;
|
|
// `where` and `--all-rows` should appear.
|
|
let cs = cands("update T set Name='hi' ", 23);
|
|
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn delete_filter_position_offers_where_and_all_rows() {
|
|
let cs = cands("delete from T ", 14);
|
|
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn flag_candidates_are_classified_as_flag_kind() {
|
|
// Hint-panel colouring distinguishes flags from
|
|
// keywords (amber vs purple) — flags get their own
|
|
// CandidateKind so the renderer can apply tok_flag.
|
|
let kinds = candidates_at_cursor("delete from T ", 14, &SchemaCache::default())
|
|
.expect("some completion")
|
|
.candidates
|
|
.into_iter()
|
|
.map(|c| (c.text, c.kind))
|
|
.collect::<Vec<_>>();
|
|
let flag = kinds
|
|
.iter()
|
|
.find(|(t, _)| t == "--all-rows")
|
|
.expect("--all-rows present");
|
|
assert_eq!(flag.1, CandidateKind::Flag);
|
|
}
|
|
|
|
#[test]
|
|
fn flag_candidates_filter_by_partial_prefix() {
|
|
let cs = cands("delete from T --", 16);
|
|
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
// ---- App-lifecycle command completion (round-5 fold-in) ----
|
|
|
|
#[test]
|
|
fn empty_input_offers_app_command_entry_keywords() {
|
|
let cs = cands("", 0);
|
|
// App-lifecycle commands now appear alongside DSL
|
|
// commands in the entry-keyword set.
|
|
for expected in &[
|
|
"quit", "help", "rebuild", "save", "new", "load", "export",
|
|
"import", "mode", "messages",
|
|
] {
|
|
assert!(
|
|
cs.contains(&expected.to_string()),
|
|
"missing {expected:?} in entry-keyword candidates: {cs:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn load_prefix_offers_load_only() {
|
|
let cs = cands("l", 1);
|
|
assert_eq!(cs, vec!["load".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn save_prefix_offers_save() {
|
|
let cs = cands("sa", 2);
|
|
assert_eq!(cs, vec!["save".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn mode_then_space_offers_simple_and_advanced() {
|
|
// `mode ` requires a value; the parser fails at EOF and
|
|
// the expected-set contains the two known keywords.
|
|
let cs = cands("mode ", 5);
|
|
assert!(cs.contains(&"simple".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"advanced".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
// ---- Optional-suffix completion (round-5 gap, closed in Phase D) ----
|
|
//
|
|
// Pre-Phase-D: `save ` parsed as a valid `save` command, so
|
|
// the completion engine had no expected-set to mine and the
|
|
// `as` suffix never surfaced as a Tab candidate. Phase D's
|
|
// `WalkResult::tail_expected` carries the outer shape's
|
|
// skipped-Optional expectations even on `Match`, so these
|
|
// surface without a separate probe mechanism.
|
|
|
|
#[test]
|
|
fn save_space_offers_as_via_tail_expected() {
|
|
let cs = cands("save ", 5);
|
|
assert_eq!(cs, vec!["as".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn messages_space_offers_short_and_verbose_via_tail_expected() {
|
|
let cs = cands("messages ", 9);
|
|
assert!(cs.contains(&"short".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"verbose".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
// ---- Value-literal slot suppression (round-6) -----------
|
|
|
|
#[test]
|
|
fn value_literal_slot_suppresses_keyword_candidates_at_empty_prefix() {
|
|
// After `insert into T values (` the parser's expected
|
|
// set contains null/true/false/number/string literal.
|
|
// The keyword pipeline would otherwise surface `null`,
|
|
// `true`, `false` as Tab candidates — actively
|
|
// misleading at a slot where the user is more likely
|
|
// entering a number / text / date. Suppress.
|
|
let cs = cands("insert into T values (", 22);
|
|
assert!(cs.is_empty(), "got misleading candidates {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_slot_with_partial_prefix_still_completes() {
|
|
// Once the user types a prefix, normal keyword
|
|
// completion applies — `n` → `null`, `tr` → `true`,
|
|
// `fa` → `false`.
|
|
assert_eq!(
|
|
cands("insert into T values (n", 23),
|
|
vec!["null".to_string()],
|
|
);
|
|
assert_eq!(
|
|
cands("insert into T values (tr", 24),
|
|
vec!["true".to_string()],
|
|
);
|
|
assert_eq!(
|
|
cands("insert into T values (fa", 24),
|
|
vec!["false".to_string()],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_slot_after_first_value_also_suppresses() {
|
|
// Comma-separated value positions all hit the same slot
|
|
// signature. `insert into T values (1, ` → expected:
|
|
// null/true/false/number/string. Suppress.
|
|
let cs = cands("insert into T values (1, ", 25);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn update_set_value_slot_suppresses() {
|
|
// `update T set col=` is also a value-literal slot.
|
|
let cs = cands("update T set col=", 17);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn where_value_slot_suppresses() {
|
|
// `where col=` is also a value-literal slot.
|
|
let cs = cands("delete from T where col=", 24);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_hint_fires_at_empty_value_slot() {
|
|
let hint = value_literal_hint_at_cursor("insert into T values (", 22);
|
|
let s = hint.expect("hint should fire at value-literal slot");
|
|
// Lists each literal form so the user sees the full set
|
|
// of valid inputs rather than just three keywords.
|
|
assert!(s.contains("number"), "got {s:?}");
|
|
assert!(s.contains("text") || s.contains("'"), "got {s:?}");
|
|
assert!(s.contains("true"), "got {s:?}");
|
|
assert!(s.contains("false") || s.contains("/false"), "got {s:?}");
|
|
assert!(s.contains("null"), "got {s:?}");
|
|
// Format examples for the cases users typically can't
|
|
// guess (date, datetime).
|
|
assert!(
|
|
s.contains("YYYY-MM-DD"),
|
|
"should include date format, got {s:?}",
|
|
);
|
|
assert!(
|
|
s.contains("HH:MM:SS"),
|
|
"should include datetime format, got {s:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_hint_does_not_fire_at_partial_prefix() {
|
|
// With a partial prefix the keyword-completion path
|
|
// handles it; the prose hint short-circuit only
|
|
// applies to empty-prefix positions.
|
|
assert!(value_literal_hint_at_cursor("insert into T values (n", 23).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn value_literal_hint_does_not_fire_at_keyword_slot() {
|
|
// Entry keyword position is not a value-literal slot.
|
|
assert!(value_literal_hint_at_cursor("", 0).is_none());
|
|
assert!(value_literal_hint_at_cursor("insert ", 7).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn show_offers_data_and_table_alphabetised() {
|
|
let cs = cands("show ", 5);
|
|
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn drop_offers_three_alternatives_alphabetised() {
|
|
let cs = cands("drop ", 5);
|
|
assert_eq!(
|
|
cs,
|
|
vec![
|
|
"column".to_string(),
|
|
"relationship".to_string(),
|
|
"table".to_string(),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn complete_command_offers_no_candidates() {
|
|
// `create table T with pk` is a complete command —
|
|
// no candidates to offer.
|
|
let input = "create table T with pk";
|
|
let cs = cands(input, input.len());
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn non_branching_punctuation_is_not_surfaced_as_candidate() {
|
|
// After `add column to table T` the walker expects `:`.
|
|
// `:` is a "trailing-content" punct — the user types it
|
|
// naturally as they continue the command, so the hint
|
|
// panel doesn't surface it. Only branching punct (`(`
|
|
// opening a sub-shape) becomes a Tab candidate.
|
|
let input = "add column to table T";
|
|
let cs = cands(input, input.len());
|
|
assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn open_paren_branching_punct_surfaces_after_insert_into_table() {
|
|
// After `insert into Orders ` the walker expects either
|
|
// `values` (Form B) or `(` (Forms A / C). Both surface
|
|
// as Tab candidates so the user discovers the column-
|
|
// list form.
|
|
let cs = cands("insert into Orders ", 19);
|
|
assert!(cs.contains(&"values".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
fn schema_with_table(
|
|
table: &str,
|
|
columns: &[(&str, crate::dsl::types::Type)],
|
|
) -> SchemaCache {
|
|
let mut cache = SchemaCache::default();
|
|
cache.tables.push(table.to_string());
|
|
let cols: Vec<TableColumn> = columns
|
|
.iter()
|
|
.map(|(n, t)| TableColumn {
|
|
name: (*n).to_string(),
|
|
user_type: *t,
|
|
})
|
|
.collect();
|
|
for c in &cols {
|
|
cache.columns.push(c.name.clone());
|
|
}
|
|
cache.table_columns.insert(table.to_string(), cols);
|
|
cache
|
|
}
|
|
|
|
#[test]
|
|
fn update_set_offers_only_current_table_columns() {
|
|
use crate::dsl::types::Type;
|
|
// SchemaCache.columns has columns from many tables, but
|
|
// at `update Customers set ` only Customers' columns
|
|
// should appear.
|
|
let mut cache = schema_with_table(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text)],
|
|
);
|
|
// Pretend the global flat list has columns from a second
|
|
// table that aren't in Customers.
|
|
cache.columns.push("OrderTotal".to_string());
|
|
cache.columns.push("Stock".to_string());
|
|
cache
|
|
.table_columns
|
|
.insert("Orders".to_string(), vec![
|
|
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real },
|
|
]);
|
|
cache.tables.push("Orders".to_string());
|
|
let cs = cands_with("update Customers set ", 21, &cache);
|
|
// Customers's columns should appear:
|
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
|
// Other tables' columns should NOT:
|
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
|
assert!(!cs.contains(&"Stock".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn update_where_offers_only_current_table_columns() {
|
|
use crate::dsl::types::Type;
|
|
let mut cache = schema_with_table(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text)],
|
|
);
|
|
cache.columns.push("OrderTotal".to_string());
|
|
let cs =
|
|
cands_with("update Customers set Email='x' where ", 37, &cache);
|
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn insert_into_open_paren_offers_current_table_columns() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_table(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
|
|
);
|
|
let cs = cands_with("insert into Customers (", 23, &cache);
|
|
// The user is at Form A's column-list position. All
|
|
// three columns of Customers should appear so they can
|
|
// pick.
|
|
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
|
assert!(cs.contains(&"Name".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
|
|
use crate::dsl::types::Type;
|
|
let mut cache = schema_with_table(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text)],
|
|
);
|
|
cache.columns.push("OrderTotal".to_string());
|
|
let cs = cands_with("insert into Customers (", 23, &cache);
|
|
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn drop_column_from_offers_only_current_table_columns() {
|
|
// The drop-column path also writes_table → narrowed
|
|
// columns should appear here too.
|
|
use crate::dsl::types::Type;
|
|
let mut cache = schema_with_table(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text)],
|
|
);
|
|
cache.columns.push("OrderTotal".to_string());
|
|
let cs =
|
|
cands_with("drop column from Customers: ", 28, &cache);
|
|
// Note: drop column's table-name slot doesn't set
|
|
// writes_table today (DDL paths don't carry Phase D
|
|
// table-column resolution yet). Falls back to global
|
|
// cache.columns, which is the documented schemaless
|
|
// fallback. Either narrowed-or-flat is acceptable; the
|
|
// test just confirms valid columns appear.
|
|
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn open_paren_candidate_is_classified_as_punct_kind() {
|
|
// The `(` candidate gets its own kind so the hint
|
|
// renderer can colour it as punctuation rather than
|
|
// mis-classifying it as a keyword.
|
|
let comp = candidates_at_cursor("insert into Orders ", 19, &SchemaCache::default())
|
|
.expect("some completion");
|
|
let paren = comp
|
|
.candidates
|
|
.iter()
|
|
.find(|c| c.text == "(")
|
|
.expect("( present");
|
|
assert_eq!(paren.kind, CandidateKind::Punct);
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
|
|
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
|
|
.expect("some completion");
|
|
assert_eq!(comp.replaced_range, (0, 3));
|
|
assert_eq!(comp.partial_prefix, "cre");
|
|
assert_eq!(comp.candidates.len(), 1);
|
|
assert_eq!(comp.candidates[0].text, "create");
|
|
assert_eq!(comp.candidates[0].kind, CandidateKind::Keyword);
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_at_word_boundary_has_empty_partial_prefix() {
|
|
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
|
|
.expect("some completion");
|
|
assert_eq!(comp.replaced_range, (7, 7));
|
|
assert_eq!(comp.partial_prefix, "");
|
|
}
|
|
|
|
// ---- type-name completion (round-3 follow-up #2) ----
|
|
|
|
#[test]
|
|
fn type_slot_offers_full_type_vocabulary_when_partial_empty() {
|
|
// After `add column to T: Name (` the parser expects
|
|
// a column type. With no partial typed, all ten types
|
|
// from `Type::all()` are offered in declaration order.
|
|
let cs = cands("add column to T: Name (", 23);
|
|
assert_eq!(
|
|
cs,
|
|
vec![
|
|
"text".to_string(),
|
|
"int".to_string(),
|
|
"real".to_string(),
|
|
"decimal".to_string(),
|
|
"bool".to_string(),
|
|
"date".to_string(),
|
|
"datetime".to_string(),
|
|
"blob".to_string(),
|
|
"serial".to_string(),
|
|
"shortid".to_string(),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn type_slot_narrows_to_prefix_matches() {
|
|
// `de` matches only `decimal` (despite the surface
|
|
// resemblance to `date`/`datetime`, those start with
|
|
// `da`). The user-reported case from real testing
|
|
// round 4.
|
|
let cs = cands("add column to T: Name (de", 25);
|
|
assert_eq!(cs, vec!["decimal".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn type_slot_narrows_to_da_for_date_family() {
|
|
// `da` correctly returns date and datetime — in
|
|
// Type::all() declaration order (date before datetime,
|
|
// matching ADR-0005's grouping).
|
|
let cs = cands("add column to T: Name (da", 25);
|
|
assert_eq!(cs, vec!["date".to_string(), "datetime".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn type_slot_single_match_for_unique_prefix() {
|
|
// `sh` uniquely identifies `shortid`.
|
|
let cs = cands("add column to T: Name (sh", 25);
|
|
assert_eq!(cs, vec!["shortid".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn type_slot_no_match_for_invalid_prefix() {
|
|
// `var` matches nothing — Tab is a no-op; the parser's
|
|
// unknown-type custom error still fires on submit.
|
|
let cs = cands("add column to T: Name (var", 26);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn keywords_come_before_identifiers_in_grammar_order() {
|
|
// "add column " has both keyword candidates and
|
|
// schema-identifier candidates. Per the user's stage-8
|
|
// feedback round 2: keywords first in *grammar order*
|
|
// (so `to` before `table` because the canonical shape
|
|
// is `add column [to] [table] <Table>:…`), identifiers
|
|
// after, alphabetised. The grammar order falls out of
|
|
// chumsky's source-order expected-set traversal — we
|
|
// preserve that order through `describe_expected`.
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string(), "Orders".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
let kinds = cand_kinds_with("add column ", 11, &cache);
|
|
assert_eq!(
|
|
kinds,
|
|
vec![
|
|
("to".to_string(), CandidateKind::Keyword),
|
|
("table".to_string(), CandidateKind::Keyword),
|
|
("Customers".to_string(), CandidateKind::Identifier),
|
|
("Orders".to_string(), CandidateKind::Identifier),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keyword_wins_when_keyword_text_collides_with_schema_name() {
|
|
// Pathological: a table named "table". Keywords
|
|
// dominate the slot — the user can still reference
|
|
// their table via different syntax.
|
|
let cache = SchemaCache {
|
|
tables: vec!["table".to_string(), "Customers".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
let kinds = cand_kinds_with("add column ", 11, &cache);
|
|
// `table` appears once, as a keyword (not duplicated
|
|
// as identifier).
|
|
let table_entries: Vec<_> = kinds.iter().filter(|(t, _)| t == "table").collect();
|
|
assert_eq!(table_entries.len(), 1);
|
|
assert_eq!(table_entries[0].1, CandidateKind::Keyword);
|
|
}
|
|
|
|
// ---- SchemaCache + identifier completion (stage 8c) ----
|
|
|
|
#[test]
|
|
fn schema_cache_offers_table_names_at_table_slot() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string(), "Orders".to_string()],
|
|
columns: vec![],
|
|
relationships: vec![],
|
|
..SchemaCache::default()
|
|
};
|
|
// After `show data ` the parser expects a table name.
|
|
let cs = cands_with("show data ", 10, &cache);
|
|
assert_eq!(cs, vec!["Customers".to_string(), "Orders".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_cache_offers_column_names_at_column_slot() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string()],
|
|
columns: vec!["Email".to_string(), "Name".to_string()],
|
|
relationships: vec![],
|
|
..SchemaCache::default()
|
|
};
|
|
// After `drop column from Customers: ` the parser
|
|
// expects a column name (existing).
|
|
let cs = cands_with("drop column from Customers: ", 28, &cache);
|
|
assert_eq!(cs, vec!["Email".to_string(), "Name".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn schema_cache_offers_relationship_names_at_relationship_slot() {
|
|
let cache = SchemaCache {
|
|
tables: vec![],
|
|
columns: vec![],
|
|
relationships: vec!["cust_orders".to_string(), "ord_items".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
// After `drop relationship ` the parser expects either
|
|
// an identifier (relationship name) or `from`. Schema
|
|
// candidates plus the `from` keyword.
|
|
let cs = cands_with("drop relationship ", 18, &cache);
|
|
assert!(cs.contains(&"cust_orders".to_string()));
|
|
assert!(cs.contains(&"ord_items".to_string()));
|
|
assert!(cs.contains(&"from".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn schema_candidates_filtered_by_partial_prefix() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string(), "Orders".to_string()],
|
|
columns: vec![],
|
|
relationships: vec![],
|
|
..SchemaCache::default()
|
|
};
|
|
// Typed `Cu` after `show data ` — only `Customers`
|
|
// matches.
|
|
let cs = cands_with("show data Cu", 12, &cache);
|
|
assert_eq!(cs, vec!["Customers".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_cache_at_table_slot_returns_no_candidates() {
|
|
// Pre-population (or NewName-only positions) yield no
|
|
// identifier candidates.
|
|
let cache = SchemaCache::default();
|
|
let cs = cands_with("show data ", 10, &cache);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
// ---- typing_name_at_cursor (round-3 follow-up) ----
|
|
|
|
#[test]
|
|
fn typing_name_fires_at_new_column_slot_with_next_token() {
|
|
// After `add column to table T: ` the parser expects
|
|
// an identifier (NewName slot) followed by `(`. The
|
|
// probe substitutes a placeholder name and reads back
|
|
// that the next token is `(`.
|
|
let t = typing_name_at_cursor("add column to table T: ", 23)
|
|
.expect("should fire at NewName slot");
|
|
assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_fires_when_partial_name_already_typed() {
|
|
// Mid-typing the column name. typing_name_at_cursor
|
|
// walks back over the partial to find the slot, then
|
|
// probes forward as if the partial were a complete name.
|
|
let t = typing_name_at_cursor("add column to table T: Na", 25)
|
|
.expect("should fire at NewName slot with partial");
|
|
assert_eq!(t.next_after_name.as_deref(), Some("`(`"));
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_does_not_fire_at_table_name_slot() {
|
|
// `show data ` — the slot is TableName, not NewName.
|
|
// The candidates path (or invalid-ident) handles it;
|
|
// typing_name should not fire.
|
|
assert!(typing_name_at_cursor("show data ", 10).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_does_not_fire_at_keyword_slot() {
|
|
// `cr` at position 2 is a keyword slot.
|
|
assert!(typing_name_at_cursor("cr", 2).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn typing_name_yields_no_next_when_probe_succeeds() {
|
|
// `add column to table T: Name (text)` — the user is
|
|
// inside `Name`, and substituting any name there
|
|
// produces a complete command. No useful "next after
|
|
// name" hint.
|
|
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
|
|
.expect("should fire");
|
|
assert_eq!(t.next_after_name, None);
|
|
}
|
|
|
|
// ---- invalid_ident_at_cursor (stage 8e) ----
|
|
|
|
#[test]
|
|
fn invalid_ident_fires_for_unknown_table_prefix() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
// `show data Cust` matches → no invalid.
|
|
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
|
|
// `show data Cust` plus a typo: `show data Custp`. No
|
|
// table starts with "Custp" → invalid.
|
|
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
|
|
.expect("should be invalid");
|
|
assert_eq!(invalid.range, (10, 15));
|
|
assert_eq!(invalid.found, "Custp");
|
|
assert_eq!(invalid.source, IdentSource::Tables);
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_when_partial_matches_some_schema_entry() {
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string(), "Orders".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
// "C" matches Customers (prefix), so not invalid.
|
|
assert!(invalid_ident_at_cursor("show data C", 11, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_in_new_name_slot() {
|
|
// `create table Cust` — Cust is a NewName slot. Even
|
|
// if no schema entry matches, the user invents the
|
|
// name; not invalid.
|
|
let cache = SchemaCache {
|
|
tables: vec!["Existing".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
assert!(invalid_ident_at_cursor("create table Cust", 17, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_when_cursor_not_at_partial_token() {
|
|
let cache = SchemaCache::default();
|
|
// Cursor at a whitespace position — no partial token.
|
|
assert!(invalid_ident_at_cursor("show data ", 10, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_ident_does_not_fire_at_keyword_slot() {
|
|
// `cra` at the entry-keyword position — no keyword
|
|
// starts with "cra", but the slot is keyword (not a
|
|
// known-schema slot), so invalid_ident doesn't fire.
|
|
// The render path's regular parse-error overlay handles
|
|
// this case.
|
|
let cache = SchemaCache::default();
|
|
assert!(invalid_ident_at_cursor("cra", 3, &cache).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn new_name_slot_offers_no_candidates_even_with_populated_cache() {
|
|
// `create table ` — the table-name slot is NewName.
|
|
// Even if the cache has table/column entries, no
|
|
// candidates are offered (the user invents the name).
|
|
let cache = SchemaCache {
|
|
tables: vec!["Existing".to_string()],
|
|
columns: vec!["AlsoExisting".to_string()],
|
|
relationships: vec![],
|
|
..SchemaCache::default()
|
|
};
|
|
let cs = cands_with("create table ", 13, &cache);
|
|
assert!(cs.is_empty(), "got {cs:?}");
|
|
}
|
|
|
|
fn keyword_cand(text: &str) -> Candidate {
|
|
Candidate {
|
|
text: text.to_string(),
|
|
kind: CandidateKind::Keyword,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn last_completion_next_idx_wraps_around() {
|
|
let mut memo = LastCompletion {
|
|
inserted_range: (0, 0),
|
|
original_text: String::new(),
|
|
candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")],
|
|
selection_idx: 0,
|
|
};
|
|
assert_eq!(memo.next_idx(), 1);
|
|
memo.selection_idx = 1;
|
|
assert_eq!(memo.next_idx(), 2);
|
|
memo.selection_idx = 2;
|
|
assert_eq!(memo.next_idx(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn last_completion_prev_idx_wraps_around() {
|
|
let mut memo = LastCompletion {
|
|
inserted_range: (0, 0),
|
|
original_text: String::new(),
|
|
candidates: vec![keyword_cand("a"), keyword_cand("b"), keyword_cand("c")],
|
|
selection_idx: 0,
|
|
};
|
|
assert_eq!(memo.prev_idx(), 2);
|
|
memo.selection_idx = 2;
|
|
assert_eq!(memo.prev_idx(), 1);
|
|
memo.selection_idx = 1;
|
|
assert_eq!(memo.prev_idx(), 0);
|
|
}
|
|
|
|
// ---- Ranker hook (ADR-0024 §ranker-layer) ----
|
|
|
|
#[test]
|
|
fn identity_ranker_preserves_input_order() {
|
|
let input = vec![
|
|
Candidate {
|
|
text: "b".to_string(),
|
|
kind: CandidateKind::Keyword,
|
|
},
|
|
Candidate {
|
|
text: "a".to_string(),
|
|
kind: CandidateKind::Keyword,
|
|
},
|
|
Candidate {
|
|
text: "c".to_string(),
|
|
kind: CandidateKind::Identifier,
|
|
},
|
|
];
|
|
let out = identity_ranker(input.clone());
|
|
assert_eq!(out, input);
|
|
}
|
|
|
|
#[test]
|
|
fn ranker_can_reorder_candidates() {
|
|
// Hooks like frequency-based ranking or content-aware
|
|
// priors plug in through `Ranker` without touching the
|
|
// grammar. Smoke-test the call site with a sorter.
|
|
fn alphabetic_ranker(mut c: Vec<Candidate>) -> Vec<Candidate> {
|
|
c.sort_by(|a, b| a.text.cmp(&b.text));
|
|
c
|
|
}
|
|
// `add ` exposes `column` and `1:n` — alphabetic ranker
|
|
// flips them.
|
|
let cache = SchemaCache::default();
|
|
let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker)
|
|
.expect("some completion");
|
|
let texts: Vec<String> = comp.candidates.into_iter().map(|c| c.text).collect();
|
|
assert_eq!(texts, vec!["1:n".to_string(), "column".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn ranker_can_filter_to_empty() {
|
|
// A ranker that returns an empty list collapses the
|
|
// completion to `None`.
|
|
fn empty_ranker(_: Vec<Candidate>) -> Vec<Candidate> {
|
|
Vec::new()
|
|
}
|
|
let cache = SchemaCache::default();
|
|
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
|
|
}
|
|
}
|