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:
+16
-2
@@ -135,6 +135,11 @@ pub struct App {
|
|||||||
/// keystroke — no completion mode, just a transient
|
/// keystroke — no completion mode, just a transient
|
||||||
/// memory of "the last thing Tab did."
|
/// memory of "the last thing Tab did."
|
||||||
pub last_completion: Option<crate::completion::LastCompletion>,
|
pub last_completion: Option<crate::completion::LastCompletion>,
|
||||||
|
/// 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.
|
/// Dialogs that take over keyboard input when active.
|
||||||
@@ -245,6 +250,7 @@ impl App {
|
|||||||
fatal_message: None,
|
fatal_message: None,
|
||||||
modal: None,
|
modal: None,
|
||||||
last_completion: None,
|
last_completion: None,
|
||||||
|
schema_cache: crate::completion::SchemaCache::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,7 +689,11 @@ impl App {
|
|||||||
/// candidates to insert.
|
/// candidates to insert.
|
||||||
fn start_completion(&mut self, start_idx: usize) -> Option<crate::completion::LastCompletion> {
|
fn start_completion(&mut self, start_idx: usize) -> Option<crate::completion::LastCompletion> {
|
||||||
let cursor = self.input_cursor.min(self.input.len());
|
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 idx = start_idx % comp.candidates.len();
|
||||||
let inserted = format!("{} ", comp.candidates[idx]);
|
let inserted = format!("{} ", comp.candidates[idx]);
|
||||||
let original_text =
|
let original_text =
|
||||||
@@ -708,7 +718,11 @@ impl App {
|
|||||||
// before we can pick "last". Compute the completion
|
// before we can pick "last". Compute the completion
|
||||||
// first, then call start_completion with that index.
|
// first, then call start_completion with that index.
|
||||||
let cursor = self.input_cursor.min(self.input.len());
|
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;
|
let last = comp.candidates.len() - 1;
|
||||||
// Fall through to the same path so the inserted text
|
// Fall through to the same path so the inserted text
|
||||||
// and memo construction stay in one place.
|
// and memo construction stay in one place.
|
||||||
|
|||||||
+152
-15
@@ -14,10 +14,38 @@
|
|||||||
//! The cycling memo (`LastCompletion` on `App`) lives in
|
//! The cycling memo (`LastCompletion` on `App`) lives in
|
||||||
//! `app.rs`; this module owns the candidate computation.
|
//! `app.rs`; this module owns the candidate computation.
|
||||||
|
|
||||||
|
use crate::dsl::ident_slot::IdentSlot;
|
||||||
use crate::dsl::keyword::Keyword;
|
use crate::dsl::keyword::Keyword;
|
||||||
use crate::dsl::usage;
|
use crate::dsl::usage;
|
||||||
use crate::dsl::{ParseError, parse_command};
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Completion {
|
pub struct Completion {
|
||||||
/// Byte range in `input` to be replaced when a candidate
|
/// 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
|
/// Compute what would happen if the user pressed Tab right
|
||||||
/// now. `None` means there is nothing to complete (no
|
/// now (ADR-0022 stage 8). `None` means there is nothing to
|
||||||
/// candidates fit / cursor at end of complete input).
|
/// 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]
|
#[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());
|
let cursor = cursor.min(input.len());
|
||||||
|
|
||||||
// Walk backward from the cursor over identifier-shaped
|
// 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;
|
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 lowered_prefix = partial_prefix.to_lowercase();
|
||||||
let mut candidates: Vec<String> = expected
|
|
||||||
|
// Source 1: keyword candidates.
|
||||||
|
let keyword_iter = expected
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| strip_backticks(item))
|
.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))
|
.filter(|name| name.to_lowercase().starts_with(&lowered_prefix))
|
||||||
.collect();
|
.collect();
|
||||||
candidates.sort();
|
candidates.sort();
|
||||||
@@ -145,7 +198,12 @@ mod tests {
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
fn cands(input: &str, cursor: usize) -> Vec<String> {
|
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)
|
.map_or_else(Vec::new, |c| c.candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +292,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
|
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
|
||||||
// Typed `cre`, cursor at byte 3. The completion
|
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
|
||||||
// replaces (0,3) with `create`.
|
.expect("some completion");
|
||||||
let comp = candidates_at_cursor("cre", 3).expect("some completion");
|
|
||||||
assert_eq!(comp.replaced_range, (0, 3));
|
assert_eq!(comp.replaced_range, (0, 3));
|
||||||
assert_eq!(comp.partial_prefix, "cre");
|
assert_eq!(comp.partial_prefix, "cre");
|
||||||
assert_eq!(comp.candidates, vec!["create".to_string()]);
|
assert_eq!(comp.candidates, vec!["create".to_string()]);
|
||||||
@@ -244,11 +301,91 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cursor_at_word_boundary_has_empty_partial_prefix() {
|
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.replaced_range, (7, 7));
|
||||||
assert_eq!(comp.partial_prefix, "");
|
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]
|
#[test]
|
||||||
fn last_completion_next_idx_wraps_around() {
|
fn last_completion_next_idx_wraps_around() {
|
||||||
let mut memo = LastCompletion {
|
let mut memo = LastCompletion {
|
||||||
|
|||||||
@@ -60,6 +60,37 @@ impl IdentSlot {
|
|||||||
Self::TableName | Self::Column | Self::RelationshipName => true,
|
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<Self> {
|
||||||
|
match label {
|
||||||
|
"identifier" => Some(Self::NewName),
|
||||||
|
"table name" => Some(Self::TableName),
|
||||||
|
"column name" => Some(Self::Column),
|
||||||
|
"relationship name" => Some(Self::RelationshipName),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-13
@@ -180,27 +180,29 @@ fn punct<'a>(
|
|||||||
/// command parsers must use `ident_ctx(slot)` so the
|
/// command parsers must use `ident_ctx(slot)` so the
|
||||||
/// completion engine knows what kind of identifier each
|
/// completion engine knows what kind of identifier each
|
||||||
/// position expects (ADR-0022 §8). Bare `ident_inner()` calls
|
/// 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>()
|
fn ident_inner<'a>()
|
||||||
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
select_ref! {
|
select_ref! {
|
||||||
Token { kind: TokenKind::Identifier(s), .. } => s.clone()
|
Token { kind: TokenKind::Identifier(s), .. } => s.clone()
|
||||||
}
|
}
|
||||||
.labelled("identifier")
|
|
||||||
.as_context()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tag-and-parse an identifier slot. `slot` is currently
|
/// Tag-and-parse an identifier slot. The slot's user-facing
|
||||||
/// documentation-only — the parser does not propagate it to
|
/// label (`IdentSlot::expected_label`) replaces the generic
|
||||||
/// chumsky's `extra` data — but the call-site annotation
|
/// "identifier" in the parser's expected-set machinery, so
|
||||||
/// forces every parser author to think about the slot at the
|
/// the error message reads "expected table name" /
|
||||||
/// moment of writing the combinator. The `no_bare_ident_inner_calls`
|
/// "expected column name" / "expected relationship name" /
|
||||||
/// unit test enforces that no command parser calls `ident_inner`
|
/// "expected identifier" depending on the call site
|
||||||
/// directly (only via this wrapper).
|
/// (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>(
|
fn ident_ctx<'a>(
|
||||||
_slot: crate::dsl::ident_slot::IdentSlot,
|
slot: crate::dsl::ident_slot::IdentSlot,
|
||||||
) -> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
) -> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
|
||||||
ident_inner()
|
ident_inner().labelled(slot.expected_label()).as_context()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Match a number-literal token, returning a `Value::Number`.
|
/// Match a number-literal token, returning a `Value::Number`.
|
||||||
@@ -912,9 +914,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn structural_error_for_show_data_without_arg() {
|
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");
|
let msg = err_message("show data");
|
||||||
assert!(msg.contains("after `show data`"), "{msg}");
|
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}");
|
assert!(msg.contains("found end of input"), "{msg}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
-11
@@ -147,16 +147,17 @@ pub fn ambient_hint(
|
|||||||
input: &str,
|
input: &str,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
memo: Option<&crate::completion::LastCompletion>,
|
memo: Option<&crate::completion::LastCompletion>,
|
||||||
|
cache: &crate::completion::SchemaCache,
|
||||||
) -> Option<AmbientHint> {
|
) -> Option<AmbientHint> {
|
||||||
if input.trim().is_empty() {
|
if input.trim().is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// First check for keyword candidates at the cursor.
|
// First check for candidates at the cursor (keywords +
|
||||||
// When candidates exist, the user can Tab to insert one,
|
// schema identifiers). When any exist the user can Tab to
|
||||||
// and the panel surfaces them directly. This wins over
|
// insert one, and the panel surfaces them directly — this
|
||||||
// the prose IncompleteAtEof framing because the candidate
|
// wins over the prose IncompleteAtEof framing because the
|
||||||
// list is more actionable.
|
// candidate list is more actionable.
|
||||||
if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor) {
|
if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) {
|
||||||
let selected = memo.map(|m| m.selection_idx);
|
let selected = memo.map(|m| m.selection_idx);
|
||||||
return Some(AmbientHint::Candidates {
|
return Some(AmbientHint::Candidates {
|
||||||
items: comp.candidates,
|
items: comp.candidates,
|
||||||
@@ -427,15 +428,19 @@ mod tests {
|
|||||||
|
|
||||||
// ---- ambient_hint (stage 5 + stage 8b) ----
|
// ---- ambient_hint (stage 5 + stage 8b) ----
|
||||||
|
|
||||||
|
fn empty_cache() -> crate::completion::SchemaCache {
|
||||||
|
crate::completion::SchemaCache::default()
|
||||||
|
}
|
||||||
|
|
||||||
fn prose(input: &str, cursor: usize) -> Option<String> {
|
fn prose(input: &str, cursor: usize) -> Option<String> {
|
||||||
match ambient_hint(input, cursor, None) {
|
match ambient_hint(input, cursor, None, &empty_cache()) {
|
||||||
Some(AmbientHint::Prose(s)) => Some(s),
|
Some(AmbientHint::Prose(s)) => Some(s),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cands_hint(input: &str, cursor: usize) -> Option<Vec<String>> {
|
fn cands_hint(input: &str, cursor: usize) -> Option<Vec<String>> {
|
||||||
match ambient_hint(input, cursor, None) {
|
match ambient_hint(input, cursor, None, &empty_cache()) {
|
||||||
Some(AmbientHint::Candidates { items, .. }) => Some(items),
|
Some(AmbientHint::Candidates { items, .. }) => Some(items),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -443,8 +448,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambient_hint_is_none_for_empty_input() {
|
fn ambient_hint_is_none_for_empty_input() {
|
||||||
assert!(ambient_hint("", 0, None).is_none());
|
assert!(ambient_hint("", 0, None, &empty_cache()).is_none());
|
||||||
assert!(ambient_hint(" ", 3, None).is_none());
|
assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -519,7 +524,7 @@ mod tests {
|
|||||||
candidates: vec!["data".to_string(), "table".to_string()],
|
candidates: vec!["data".to_string(), "table".to_string()],
|
||||||
selection_idx: 1,
|
selection_idx: 1,
|
||||||
};
|
};
|
||||||
match ambient_hint("show ", 5, Some(&memo)) {
|
match ambient_hint("show ", 5, Some(&memo), &empty_cache()) {
|
||||||
Some(AmbientHint::Candidates { items, selected }) => {
|
Some(AmbientHint::Candidates { items, selected }) => {
|
||||||
assert_eq!(items, vec!["data".to_string(), "table".to_string()]);
|
assert_eq!(items, vec!["data".to_string(), "table".to_string()]);
|
||||||
assert_eq!(selected, Some(1));
|
assert_eq!(selected, Some(1));
|
||||||
|
|||||||
@@ -696,6 +696,7 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
|
|||||||
&app.input,
|
&app.input,
|
||||||
app.input_cursor,
|
app.input_cursor,
|
||||||
app.last_completion.as_ref(),
|
app.last_completion.as_ref(),
|
||||||
|
&app.schema_cache,
|
||||||
),
|
),
|
||||||
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => None,
|
EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => None,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user