ADR-0022 stage 8c: IdentSlot propagation + SchemaCache API
`IdentSlot` gains `expected_label()` and the round-trip
`from_expected_label()`. The four slot kinds map to the
user-facing labels "identifier" (NewName), "table name",
"column name", "relationship name".
`ident_ctx(slot)` now actually applies `slot.expected_label()`
as the chumsky label (was documentation-only after stage 6).
Parser errors and the hint panel's "expected: …" prose now
read with the slot-specific name: "expected table name"
instead of the generic "expected identifier". One parser
test updated accordingly; the four catalog `parse.token.*`
keys are unaffected (the slot labels are a parallel surface).
New `completion::SchemaCache { tables, columns,
relationships }` struct + `for_slot(slot) -> &[String]`
accessor. Empty by default; runtime wiring lands in a
follow-on substage. NewName slots return `&[]`
unconditionally.
`candidates_at_cursor` extended to accept `&SchemaCache`:
when the parser's expected-set includes a slot label,
schema candidates from the cache are added alongside the
keyword candidates. Both sources are then prefix-filtered,
combined, sorted, deduplicated. App::schema_cache field
threaded into both the App-side completion paths and the
ambient_hint computation in ui.
Tests: 738 passing, 0 failing, 1 ignored (730 baseline →
+8: 2 IdentSlot label round-trip tests, 6 completion-with-cache
cases covering table/column/relationship slots, prefix
filtering, empty cache, and NewName-no-candidates).
Clippy clean.
User-visible: identifier completion infrastructure is in
place but the cache is always empty — runtime wiring (the
next substage) will populate it on project load and after
successful DDL, at which point Tab on identifier slots
starts offering schema names.
This commit is contained in:
+152
-15
@@ -14,10 +14,38 @@
|
||||
//! The cycling memo (`LastCompletion` on `App`) lives in
|
||||
//! `app.rs`; this module owns the candidate computation.
|
||||
|
||||
use crate::dsl::ident_slot::IdentSlot;
|
||||
use crate::dsl::keyword::Keyword;
|
||||
use crate::dsl::usage;
|
||||
use crate::dsl::{ParseError, parse_command};
|
||||
|
||||
/// Per-project schema lookup cache (ADR-0022 §9).
|
||||
///
|
||||
/// Held by `App::schema_cache` and consulted by the completion
|
||||
/// engine for identifier slots. 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>,
|
||||
}
|
||||
|
||||
impl SchemaCache {
|
||||
/// Lookup the candidate list for an identifier slot.
|
||||
/// `NewName` always returns `&[]` — the user invents
|
||||
/// these names.
|
||||
#[must_use]
|
||||
pub fn for_slot(&self, slot: IdentSlot) -> &[String] {
|
||||
match slot {
|
||||
IdentSlot::NewName => &[],
|
||||
IdentSlot::TableName => &self.tables,
|
||||
IdentSlot::Column => &self.columns,
|
||||
IdentSlot::RelationshipName => &self.relationships,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Completion {
|
||||
/// Byte range in `input` to be replaced when a candidate
|
||||
@@ -34,10 +62,28 @@ pub struct Completion {
|
||||
}
|
||||
|
||||
/// Compute what would happen if the user pressed Tab right
|
||||
/// now. `None` means there is nothing to complete (no
|
||||
/// candidates fit / cursor at end of complete input).
|
||||
/// 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 `IdentSlot::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) -> Option<Completion> {
|
||||
pub fn candidates_at_cursor(
|
||||
input: &str,
|
||||
cursor: usize,
|
||||
cache: &SchemaCache,
|
||||
) -> Option<Completion> {
|
||||
let cursor = cursor.min(input.len());
|
||||
|
||||
// Walk backward from the cursor over identifier-shaped
|
||||
@@ -60,16 +106,23 @@ pub fn candidates_at_cursor(input: &str, cursor: usize) -> Option<Completion> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Filter to bare keyword candidates only — the parser may
|
||||
// surface descriptive labels ("identifier", "string literal",
|
||||
// "where clause or --all-rows") or punctuation; neither is
|
||||
// useful as Tab-insertable text (ADR-0022 §10). Then
|
||||
// narrow by the typed partial prefix (case-insensitive).
|
||||
let lowered_prefix = partial_prefix.to_lowercase();
|
||||
let mut candidates: Vec<String> = expected
|
||||
|
||||
// Source 1: keyword candidates.
|
||||
let keyword_iter = expected
|
||||
.iter()
|
||||
.filter_map(|item| strip_backticks(item))
|
||||
.filter_map(|name| Keyword::from_word(name).map(|_| name.to_string()))
|
||||
.filter_map(|name| Keyword::from_word(name).map(|_| name.to_string()));
|
||||
|
||||
// Source 2: schema identifiers (TableName / Column /
|
||||
// RelationshipName slots). We collect across all matching
|
||||
// slots. `NewName` slots return empty lists from the cache.
|
||||
let schema_iter = expected.iter().filter_map(|item| {
|
||||
IdentSlot::from_expected_label(item).map(|slot| cache.for_slot(slot))
|
||||
});
|
||||
|
||||
let mut candidates: Vec<String> = keyword_iter
|
||||
.chain(schema_iter.flat_map(|names| names.iter().cloned()))
|
||||
.filter(|name| name.to_lowercase().starts_with(&lowered_prefix))
|
||||
.collect();
|
||||
candidates.sort();
|
||||
@@ -145,7 +198,12 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn cands(input: &str, cursor: usize) -> Vec<String> {
|
||||
candidates_at_cursor(input, cursor)
|
||||
candidates_at_cursor(input, cursor, &SchemaCache::default())
|
||||
.map_or_else(Vec::new, |c| c.candidates)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -234,9 +292,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
|
||||
// Typed `cre`, cursor at byte 3. The completion
|
||||
// replaces (0,3) with `create`.
|
||||
let comp = candidates_at_cursor("cre", 3).expect("some completion");
|
||||
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, vec!["create".to_string()]);
|
||||
@@ -244,11 +301,91 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cursor_at_word_boundary_has_empty_partial_prefix() {
|
||||
let comp = candidates_at_cursor("create ", 7).expect("some completion");
|
||||
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
|
||||
.expect("some completion");
|
||||
assert_eq!(comp.replaced_range, (7, 7));
|
||||
assert_eq!(comp.partial_prefix, "");
|
||||
}
|
||||
|
||||
// ---- 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![],
|
||||
};
|
||||
// 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![],
|
||||
};
|
||||
// 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()],
|
||||
};
|
||||
// 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![],
|
||||
};
|
||||
// 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:?}");
|
||||
}
|
||||
|
||||
#[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![],
|
||||
};
|
||||
let cs = cands_with("create table ", 13, &cache);
|
||||
assert!(cs.is_empty(), "got {cs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_completion_next_idx_wraps_around() {
|
||||
let mut memo = LastCompletion {
|
||||
|
||||
Reference in New Issue
Block a user