diff --git a/src/app.rs b/src/app.rs index 5c22183..14fb97f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -135,6 +135,11 @@ pub struct App { /// keystroke — no completion mode, just a transient /// memory of "the last thing Tab did." pub last_completion: Option, + /// Per-project schema lookup cache feeding Tab completion + /// for identifier slots (ADR-0022 §9 + stage 8c). Empty + /// by default; refreshed by the runtime on project load + /// and after successful DDL. + pub schema_cache: crate::completion::SchemaCache, } /// Dialogs that take over keyboard input when active. @@ -245,6 +250,7 @@ impl App { fatal_message: None, modal: None, last_completion: None, + schema_cache: crate::completion::SchemaCache::default(), } } @@ -683,7 +689,11 @@ impl App { /// candidates to insert. fn start_completion(&mut self, start_idx: usize) -> Option { let cursor = self.input_cursor.min(self.input.len()); - let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?; + let comp = crate::completion::candidates_at_cursor( + &self.input, + cursor, + &self.schema_cache, + )?; let idx = start_idx % comp.candidates.len(); let inserted = format!("{} ", comp.candidates[idx]); let original_text = @@ -708,7 +718,11 @@ impl App { // before we can pick "last". Compute the completion // first, then call start_completion with that index. let cursor = self.input_cursor.min(self.input.len()); - let comp = crate::completion::candidates_at_cursor(&self.input, cursor)?; + let comp = crate::completion::candidates_at_cursor( + &self.input, + cursor, + &self.schema_cache, + )?; let last = comp.candidates.len() - 1; // Fall through to the same path so the inserted text // and memo construction stay in one place. diff --git a/src/completion.rs b/src/completion.rs index 530753d..f0682fb 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -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, + pub columns: Vec, + pub relationships: Vec, +} + +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 { +pub fn candidates_at_cursor( + input: &str, + cursor: usize, + cache: &SchemaCache, +) -> Option { 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 { 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 = 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 = 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 { - 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 { + 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 { diff --git a/src/dsl/ident_slot.rs b/src/dsl/ident_slot.rs index 720f459..e3dddc2 100644 --- a/src/dsl/ident_slot.rs +++ b/src/dsl/ident_slot.rs @@ -60,6 +60,37 @@ impl IdentSlot { Self::TableName | Self::Column | Self::RelationshipName => true, } } + + /// Human-readable label for the parser's expected-set + /// machinery (ADR-0022 §8 + stage 8c). Carried through + /// chumsky labels by `ident_ctx(slot)` so error messages + /// say "expected table name" instead of the generic + /// "expected identifier", and so the completion engine + /// can recover the slot from the parser's expected set + /// via `from_expected_label`. + #[must_use] + pub const fn expected_label(self) -> &'static str { + match self { + Self::NewName => "identifier", + Self::TableName => "table name", + Self::Column => "column name", + Self::RelationshipName => "relationship name", + } + } + + /// Round-trip from the human label back to the slot kind. + /// `None` for any string that isn't one of the four + /// `expected_label()` outputs. + #[must_use] + pub fn from_expected_label(label: &str) -> Option { + match label { + "identifier" => Some(Self::NewName), + "table name" => Some(Self::TableName), + "column name" => Some(Self::Column), + "relationship name" => Some(Self::RelationshipName), + _ => None, + } + } } #[cfg(test)] @@ -84,4 +115,26 @@ mod tests { ); } } + + #[test] + fn expected_label_round_trips_for_every_variant() { + for slot in [ + IdentSlot::NewName, + IdentSlot::TableName, + IdentSlot::Column, + IdentSlot::RelationshipName, + ] { + assert_eq!( + IdentSlot::from_expected_label(slot.expected_label()), + Some(slot), + "round-trip failed for {slot:?}", + ); + } + } + + #[test] + fn unknown_expected_label_returns_none() { + assert_eq!(IdentSlot::from_expected_label("blob"), None); + assert_eq!(IdentSlot::from_expected_label("`create`"), None); + } } diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 973c7ee..33dee52 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -180,27 +180,29 @@ fn punct<'a>( /// command parsers must use `ident_ctx(slot)` so the /// completion engine knows what kind of identifier each /// position expects (ADR-0022 §8). Bare `ident_inner()` calls -/// outside this module would skip the slot annotation. +/// outside this module would skip the slot annotation. The +/// label is applied by `ident_ctx` (one per call site) — none +/// here. fn ident_inner<'a>() -> impl Parser<'a, &'a [Token], String, extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::Identifier(s), .. } => s.clone() } - .labelled("identifier") - .as_context() } -/// Tag-and-parse an identifier slot. `slot` is currently -/// documentation-only — the parser does not propagate it to -/// chumsky's `extra` data — but the call-site annotation -/// forces every parser author to think about the slot at the -/// moment of writing the combinator. The `no_bare_ident_inner_calls` -/// unit test enforces that no command parser calls `ident_inner` -/// directly (only via this wrapper). +/// Tag-and-parse an identifier slot. The slot's user-facing +/// label (`IdentSlot::expected_label`) replaces the generic +/// "identifier" in the parser's expected-set machinery, so +/// the error message reads "expected table name" / +/// "expected column name" / "expected relationship name" / +/// "expected identifier" depending on the call site +/// (ADR-0022 stage 8c). The completion engine reverses the +/// mapping via `IdentSlot::from_expected_label` to know what +/// schema list to consult. fn ident_ctx<'a>( - _slot: crate::dsl::ident_slot::IdentSlot, + slot: crate::dsl::ident_slot::IdentSlot, ) -> impl Parser<'a, &'a [Token], String, extra::Err>> + Clone { - ident_inner() + ident_inner().labelled(slot.expected_label()).as_context() } /// Match a number-literal token, returning a `Value::Number`. @@ -912,9 +914,13 @@ mod tests { #[test] fn structural_error_for_show_data_without_arg() { + // ADR-0022 stage 8c: `ident_ctx(IdentSlot::TableName)` + // labels the expected slot with "table name" so the + // error reads as the more specific "expected table + // name" rather than the generic "expected identifier". let msg = err_message("show data"); assert!(msg.contains("after `show data`"), "{msg}"); - assert!(msg.contains("expected identifier"), "{msg}"); + assert!(msg.contains("expected table name"), "{msg}"); assert!(msg.contains("found end of input"), "{msg}"); } diff --git a/src/input_render.rs b/src/input_render.rs index 9974a0c..7feb0c3 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -147,16 +147,17 @@ pub fn ambient_hint( input: &str, cursor: usize, memo: Option<&crate::completion::LastCompletion>, + cache: &crate::completion::SchemaCache, ) -> Option { if input.trim().is_empty() { return None; } - // First check for keyword candidates at the cursor. - // When candidates exist, the user can Tab to insert one, - // and the panel surfaces them directly. This wins over - // the prose IncompleteAtEof framing because the candidate - // list is more actionable. - if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor) { + // First check for candidates at the cursor (keywords + + // schema identifiers). When any exist the user can Tab to + // insert one, and the panel surfaces them directly — this + // wins over the prose IncompleteAtEof framing because the + // candidate list is more actionable. + if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) { let selected = memo.map(|m| m.selection_idx); return Some(AmbientHint::Candidates { items: comp.candidates, @@ -427,15 +428,19 @@ mod tests { // ---- ambient_hint (stage 5 + stage 8b) ---- + fn empty_cache() -> crate::completion::SchemaCache { + crate::completion::SchemaCache::default() + } + fn prose(input: &str, cursor: usize) -> Option { - match ambient_hint(input, cursor, None) { + match ambient_hint(input, cursor, None, &empty_cache()) { Some(AmbientHint::Prose(s)) => Some(s), _ => None, } } fn cands_hint(input: &str, cursor: usize) -> Option> { - match ambient_hint(input, cursor, None) { + match ambient_hint(input, cursor, None, &empty_cache()) { Some(AmbientHint::Candidates { items, .. }) => Some(items), _ => None, } @@ -443,8 +448,8 @@ mod tests { #[test] fn ambient_hint_is_none_for_empty_input() { - assert!(ambient_hint("", 0, None).is_none()); - assert!(ambient_hint(" ", 3, None).is_none()); + assert!(ambient_hint("", 0, None, &empty_cache()).is_none()); + assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none()); } #[test] @@ -519,7 +524,7 @@ mod tests { candidates: vec!["data".to_string(), "table".to_string()], selection_idx: 1, }; - match ambient_hint("show ", 5, Some(&memo)) { + match ambient_hint("show ", 5, Some(&memo), &empty_cache()) { Some(AmbientHint::Candidates { items, selected }) => { assert_eq!(items, vec!["data".to_string(), "table".to_string()]); assert_eq!(selected, Some(1)); diff --git a/src/ui.rs b/src/ui.rs index 96f5842..928d5e4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -696,6 +696,7 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect &app.input, app.input_cursor, app.last_completion.as_ref(), + &app.schema_cache, ), EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => None, };