ADR-0022 follow-up r4: column-type completion

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.
This commit is contained in:
claude@clouddev1
2026-05-12 07:23:17 +00:00
parent 22119d6a4e
commit c247f55094
+92 -2
View File
@@ -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<String> = 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<String> = 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<Candidate> = Vec::with_capacity(keywords.len() + identifiers.len());
// then type names (closed-set grammar — coloured as
// keywords), then schema identifiers.
let mut candidates: Vec<Candidate> =
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