From c247f550942cc69386fa2d6d034492dc9ff9383b Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 12 May 2026 07:23:17 +0000 Subject: [PATCH] ADR-0022 follow-up r4: column-type completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 user finding: typing `(de` at a column-type slot showed the parser's "unknown type 'de'" error and Tab did nothing — completion was blind to the type vocabulary entirely. Root cause: type names are NOT in the Keyword enum (ADR-0020 §2 — they remain identifiers, validated by Type::from_str), so the keyword-iter path in candidates_at_cursor missed them. The schema-identifier path also missed them (they're not in the schema cache). Fix: when the parser's expected-set contains the `"type"` label (from `ident_inner().labelled("type")` inside `type_keyword`), produce candidates from `Type::all()` filtered by the partial prefix. Centralised as `TYPE_SLOT_LABEL` constant so the parser and the completion engine agree on the magic string. Candidates appear in `Type::all()` declaration order (text/int/real/decimal/bool/date/datetime/blob/serial/ shortid) — matching ADR-0005's pedagogical grouping. Coloured as Keyword (purple) since type names are closed-set grammar, not user content. Verified end-to-end: - `(de` → ["decimal"] (single match → Tab inserts with space) - `(da` → ["date", "datetime"] (multi → cycles) - `(sh` → ["shortid"] - `(` → all 10 types in declaration order - `(var` → [] (no Tab candidates; parser custom error fires on submit) Tests: 760 passing, 0 failing, 1 ignored (755 baseline +5 new type-slot cases). Clippy clean. --- src/completion.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/src/completion.rs b/src/completion.rs index ca986ef..29a4605 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -16,9 +16,16 @@ use crate::dsl::ident_slot::IdentSlot; use crate::dsl::keyword::Keyword; +use crate::dsl::types::Type; use crate::dsl::usage; use crate::dsl::{ParseError, parse_command}; +/// Label emitted by `type_keyword` (in `dsl::parser`) when it +/// expects a column-type token. Matches the `.labelled("type")` +/// applied on the inner `select_ref!`. Centralised here so the +/// completion engine and the parser agree on the magic string. +const TYPE_SLOT_LABEL: &str = "type"; + /// Per-project schema lookup cache (ADR-0022 §9). /// /// Held by `App::schema_cache` and consulted by the completion @@ -139,6 +146,24 @@ pub fn candidates_at_cursor( let mut seen_kw = std::collections::HashSet::new(); keywords.retain(|k| seen_kw.insert(k.clone())); + // Source 1.5: type-name candidates when the parser expects + // a column-type slot. Type names live outside the Keyword + // enum (ADR-0020 §2 — type names stay as identifiers, + // validated by Type::from_str), so they need their own + // completion path. Preserve `Type::all()` declaration + // order — that's text/int/real/decimal/bool/date/datetime/ + // blob/serial/shortid, the order a learner reads them in + // ADR-0005. + let type_names: Vec = if expected.iter().any(|s| s == TYPE_SLOT_LABEL) { + Type::all() + .iter() + .map(|t| t.keyword().to_string()) + .filter(|s| matches_prefix(s)) + .collect() + } else { + Vec::new() + }; + // Source 2: schema identifiers — accumulated across every // matching known-set slot. `NewName` slots return `&[]`. let mut identifiers: Vec = expected @@ -157,12 +182,18 @@ pub fn candidates_at_cursor( identifiers.retain(|name| !keywords.contains(name)); // Keywords first (grammar parts read before content), - // identifiers after. Within each group, alphabetical. - let mut candidates: Vec = Vec::with_capacity(keywords.len() + identifiers.len()); + // then type names (closed-set grammar — coloured as + // keywords), then schema identifiers. + let mut candidates: Vec = + Vec::with_capacity(keywords.len() + type_names.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(identifiers.into_iter().map(|text| Candidate { text, kind: CandidateKind::Identifier, @@ -529,6 +560,65 @@ mod tests { 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