feat: curated SQL function list — Tab completion (#15) + typing-time typo hint (#16)

Add src/dsl/sql_functions.rs (KNOWN_SQL_FUNCTIONS) as the shared source
of truth at sql_expr_ident slots:

- #15: offer the functions as Tab candidates under a new
  CandidateKind::Function + ninth Theme colour tok_function (blue,
  distinct from keyword/identifier/type).
- #16: restore the column-typo flag the #6 fix had dropped wholesale —
  invalid_ident_at_cursor now bails only when the partial prefix-matches
  a known function, else falls through to the schema-column check.

A column named like a function (e.g. `count`) is deduped (column wins).
`cast` is excluded — CAST(x AS type) is not a plain-call shape.
The no-validation-allowlist posture stands: the list drives completion +
the typo hint only, never parse-time acceptance.

Docs: ADR-0022 Amendment 6, ADR-0031 status note, README index,
requirements I3/I4 + refreshed test baseline.
This commit is contained in:
claude@clouddev1
2026-05-31 11:49:10 +00:00
parent 01ec926ec8
commit 6d8c9eea36
10 changed files with 570 additions and 25 deletions
@@ -686,6 +686,92 @@ can be revisited if hints routinely need more.
full-screen snapshots (the empty-state placeholder and the `insert`
usage hint now wrap to their full text instead of being clipped).
## Amendment 6 — Curated SQL function names: completion + typing-time typo hint (2026-05-30)
The advanced-mode SQL expression grammar (ADR-0031) accepts a
function-call *shape*`name(args)` — at its `sql_expr_ident` slot
but, by design, does **not** know which names are real functions
(ADR-0031 §1: the slot is `IdentSource::Columns`, optimised for the
common case of a column reference; the walker is a structural matcher,
not an evaluator). That left two gaps at this slot, raised as issues
#15 and #16:
- **#16 (regression).** The earlier issue-#6 function-call validator
fix dropped `invalid_ident_at_cursor`'s "no such column" flag at
every `sql_expr_ident` position — necessary to stop a false positive
on a function name like `sum`, but it also silenced the typing-time
signal for a *genuine* column typo in an incomplete expression
(`select Agx` before a `FROM` brings the schema-existence diagnostic
into scope). Typing-time became *less* eager than submit-time.
- **#15 (discovery).** Tab at a `sql_expr_ident` slot offered schema
columns + a few expression keywords (`null`, `distinct`, `case`, …)
but no function names, so a learner had to already know `sum` / `avg`
/ `upper` to type them.
Both want the same thing: a single source of truth for *"what SQL
function names does this playground recognise"*.
**Change:**
1. **Curated list.** New `src/dsl/sql_functions.rs` with
`KNOWN_SQL_FUNCTIONS` (sorted, lowercase — a pinned invariant) and
an `is_known_function_prefix()` helper. A deliberately *curated
pedagogical set*, not "every SQLite built-in": the aggregates a
learner meets first (`count`/`sum`/`avg`/`min`/`max`), the common
scalars (`length`/`upper`/`lower`/`trim`/`substr`/`coalesce`/`abs`/
`round`), and a broader scalar tier (`date`/`datetime`/`strftime`/
`hex`/`ifnull`/`nullif`/`replace`/`instr`/`typeof`/`random`).
**`cast` is deliberately excluded** — SQLite's `CAST(expr AS type)`
is not a plain-call shape the expression grammar parses, so
offering it would surface a candidate that does not complete; it
stays out until the grammar grows a dedicated `CAST` form.
2. **#16 — restore the typo flag, narrowly.** `invalid_ident_at_cursor`
no longer bails wholesale at a `sql_expr_ident` slot. It bails only
when the partial prefix-matches a known function name; otherwise it
falls through to the existing schema-column check, which flags "no
such column" unless the partial prefix-matches a real column. So
`select Agx` warns again at typing time while `select sum` does not.
The submit-time `unknown_column` diagnostic path is untouched; the
issue-#6 lockdown tests (`genuine_column_typo_in_complete_select_…`,
`advanced_select_partial_function_name_not_flagged_…`) still pass.
3. **#15 — offer functions as candidates.** A new completion source
(Source 1.8) contributes `KNOWN_SQL_FUNCTIONS` (prefix-filtered like
every other source) whenever the expected set contains a
`sql_expr_ident` slot, ordered after keywords/types (a learner
reads clause keywords first, then discovers callables).
4. **New `CandidateKind::Function` + `tok_function` colour.** Like
Amendment 4 gave types their own class, function candidates get a
dedicated kind and a ninth `Theme` colour field (`tok_function`,
a blue distinct from keyword purple, identifier teal, and type
pink/magenta in both `dark()` and `light()`) so a callable reads
apart from a clause keyword, a column reference, and a column type.
**No-validation-allowlist posture stands (ADR-0031 §6/§7).** The list
drives *completion* and the *typo hint* only — never parse-time
acceptance. An unknown or engine-specific function still parses (the
grammar admits the call shape generically) and surfaces an
engine-neutral *execution* error, exactly as before.
**Pedagogy:** the same dedicated-colour rationale as Amendment 4 — a
learner can tell *"this is a function"* at a glance, and Tab now
*teaches* the function vocabulary instead of assuming it.
**Coverage:** `sql_functions::{list_is_sorted_and_lowercase,
list_has_no_duplicates, cast_is_excluded, prefix_match_is_case_insensitive,
empty_prefix_matches_all, unknown_prefix_does_not_match}`;
`completion::{sql_expr_slot_offers_known_function_candidates,
projection_slot_offers_known_function_candidates,
sql_function_candidates_filter_by_prefix,
sql_function_candidates_carry_function_kind,
function_candidates_absent_at_non_expression_slots,
cast_is_not_offered_as_a_function_candidate,
invalid_ident_fires_for_genuine_typo_at_sql_expr_slot,
invalid_ident_does_not_fire_for_function_prefix_at_sql_expr_slot,
invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`;
`input_render::advanced_select_genuine_column_typo_before_from_warns_at_typing_time`;
`theme::function_colour_is_distinct_from_keyword_identifier_and_type`.
See ADR-0031's status note for the grammar-side anchor.
## Out of scope
Deliberately deferred to keep this ADR shippable as a single
+21
View File
@@ -409,3 +409,24 @@ Later phases extend the same fragment:
set the engine-neutrality posture and the no-allowlist rule.
- `docs/simple-mode-limitations.md` — the DSL limits this grammar
lifts for advanced mode (§1, §4).
## Status note — known-function list layered on the slot (2026-05-30)
The `sql_expr_ident` slot is `IdentSource::Columns` and, per §1 / §5,
does **not** itself know which identifiers are function names — it
optimises for the common case (a column reference) and admits the
function-call shape structurally; §5 explicitly noted "function names
are not completed … a typed function name simply is not a candidate".
**ADR-0022 Amendment 6** layers a curated known-function list
(`src/dsl/sql_functions.rs`) on top of this slot, consumed two ways:
as Tab-completion candidates so a learner can discover `sum` / `upper`
/ … (issue #15 — softening §5's "not completed" line to "completed
from a curated pedagogical list, not an allowlist for validation"),
and as the allow-list that lets the typing-time column-typo hint stay
strict at this slot — flag a partial as "no such column" only when it
matches neither a schema column nor a known function name (issue #16).
The grammar here is unchanged, and §6/§7's no-validation-allowlist
posture stands: the list drives completion + the typo hint, **not**
parse-time acceptance (an unknown function still parses and surfaces an
engine-neutral execution error). The list sits in the completion /
hint layer above the grammar.
+2 -2
View File
File diff suppressed because one or more lines are too long
+24 -14
View File
@@ -26,18 +26,21 @@ repo is pushed).
## Test baseline
After the ADR-0027 highlight / hint follow-up (precise WARNING
spans, the diagnostic overlay + hint wiring, the
`LIKE`-on-numeric WARNING, the debounce state machine) plus
two manual-testing bug fixes (optional trailing-flag
completion; the `--resume` temp-project pointer):
**1131 passing, 0 failing, 1 ignored** (`cargo test` — the one
ignored test is a long-standing `` ```ignore `` doc-test in
`src/friendly/mod.rs`). Clippy clean with the nursery lint
group enabled. (Earlier reference points: 1100 after ADR-0027's
initial ship; 1079 after ADR-0026 (complex WHERE expressions);
1039 after ADR-0025 (indexes); 1006 after ADR-0024 + the
handoff-14 cleanup; 449 after B2/C2.)
After ADR-0022 Amendment 6 (the curated SQL function-name list —
issues #15 tab-completion + #16 typing-time typo hint):
**1538 lib unit tests passing, 0 failing, 1 ignored**
(`cargo test --lib`; the full `cargo test` across every binary is
2107 passing, 0 failing, 1 ignored — the one ignored is a
long-standing `` ```ignore `` doc-test in `src/friendly/mod.rs`).
Clippy clean with the nursery lint group enabled. (Earlier
reference points — lib counts: 1131 after the ADR-0027 highlight /
hint follow-up + the optional-trailing-flag / `--resume`
manual-testing fixes; 1100 after ADR-0027's initial ship; 1079
after ADR-0026 (complex WHERE expressions); 1039 after ADR-0025
(indexes); 1006 after ADR-0024 + the handoff-14 cleanup; 449
after B2/C2. Note the intervening issue fixes #8/#13/#12/#7/#9
landed tests without a baseline bump; this is the first refresh
since ADR-0027.)
---
@@ -105,11 +108,18 @@ handoff-14 cleanup; 449 after B2/C2.)
rolling history is out of scope per OOS-6 / N4.)*
- [ ] **I3** Tab completion for app commands, DSL keywords, table
names, column names, and SQL keywords.
*(Refinement 2026-05-30, issue #15: SQL expression slots
(`sql_expr_ident`) now also offer a curated set of SQL function
names — `KNOWN_SQL_FUNCTIONS` in `src/dsl/sql_functions.rs`,
surfaced as a new `CandidateKind::Function` — ADR-0022 Amendment 6.
The broad tab-completion goal stays open.)*
- [ ] **I4** Syntax highlighting for both the DSL and SQL.
*(Refinement 2026-05-29, issue #8: column data types now carry a
dedicated `HighlightClass::Type` / `tok_type` colour, distinct from
identifiers and clause keywords — ADR-0022 Amendment 4. The broad
highlighting goal stays open.)*
identifiers and clause keywords — ADR-0022 Amendment 4; a further
refinement 2026-05-30, issue #15: SQL function-name candidates carry
a dedicated `tok_function` colour (the ninth `Theme` token colour,
ADR-0022 Amendment 6). The broad highlighting goal stays open.)*
- [ ] **I5** In-flight query/command cancellation (Ctrl-C in the
output area or input field).
+228 -9
View File
@@ -228,6 +228,12 @@ pub enum CandidateKind {
/// expects either `values` or `(`, and surfacing both makes
/// the Form A path discoverable.
Punct,
/// A curated SQL function name (ADR-0022 Amendment 6, issue
/// #15) offered at a `sql_expr_ident` slot. Coloured with
/// `tok_function` so a learner can tell `sum` / `upper` apart
/// from a column reference or a clause keyword. The set is
/// `crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS`.
Function,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -682,6 +688,27 @@ pub fn candidates_at_cursor_with_in_mode(
Vec::new()
};
// Source 1.8: SQL function-name candidates (ADR-0022 Amendment 6,
// issue #15). At a `sql_expr_ident` slot the grammar accepts a
// column reference *or* the leading name of a function call
// (ADR-0031 §1); the column candidates come from the schema
// (Source 2), and these surface the curated known-function set so
// a learner can discover `sum` / `upper` / … by Tab. Prefix-
// filtered like every other source; empty prefix offers the whole
// set. Tagged `CandidateKind::Function` for its own colour.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
});
let mut functions: Vec<String> = if has_sql_expr_slot {
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
.iter()
.filter(|f| matches_prefix(f))
.map(|f| (*f).to_string())
.collect()
} else {
Vec::new()
};
// Source 2: schema identifiers — accumulated across every
// matching schema-listable `Ident { source }` expectation.
// `NewName` / `Types` / `Free` sources don't query the
@@ -725,6 +752,16 @@ pub fn candidates_at_cursor_with_in_mode(
// resolving collisions in the user's favour would create
// ambiguity in the live render.
identifiers.retain(|name| !keywords.contains(name));
// If a known function name collides with one of the user's own
// columns (a column literally named `count` / `date` / …), the
// column wins and the function candidate is dropped — otherwise the
// same text would appear twice in the candidate line, distinguished
// only by colour. Same spirit as the keyword-wins rule above: a
// schema name is the user's content and the more relevant
// completion; a bare `count` accepted as the column still becomes a
// call the moment the user types `(`. Case-insensitive so `Count`
// (column) also suppresses `count` (function). (DA review, #15.)
functions.retain(|f| !identifiers.iter().any(|i| i.eq_ignore_ascii_case(f)));
// Schema identifiers first: a column / table name the user
// would otherwise have to look up is the highest-value
@@ -738,6 +775,7 @@ pub fn candidates_at_cursor_with_in_mode(
identifiers.len()
+ keywords.len()
+ type_names.len()
+ functions.len()
+ composites.len()
+ punct_candidates.len()
+ flags.len(),
@@ -762,6 +800,14 @@ pub fn candidates_at_cursor_with_in_mode(
kind: CandidateKind::Keyword,
mode: ModeClass::Both,
}));
// Function names sit after keywords/types: a learner reads the
// clause keywords first, then discovers callables. Their own
// `Function` kind drives the `tok_function` colour (issue #15).
candidates.extend(functions.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Function,
mode: ModeClass::Both,
}));
candidates.extend(composites.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Keyword,
@@ -1134,19 +1180,23 @@ pub fn invalid_ident_at_cursor_in_mode(
if expected.is_empty() {
return None;
}
// Issue #6 follow-on: at a SQL expression position the partial
// could resolve to either a column reference *or* a function-call
// name (the grammar admits the call shape structurally — see
// Issue #6 / #16: at a SQL expression position the partial could
// resolve to either a column reference *or* a function-call name
// (the grammar admits the call shape structurally — see
// sql_expr.rs's `CALL_ARGS` comment, "it does not know which
// names are aggregates"). Without lookahead for a trailing `(`,
// we can't tell here, so we don't flag — submit-time validation
// still catches a genuine column typo via the `unknown_column`
// diagnostic, which only skips when the ident *is* followed by
// `(` (i.e. it really is a function call).
// names are aggregates"). Issue #6 dropped the flag here wholesale
// to stop the false positive on names like `sum`; issue #16
// restores it for genuine typos using the curated known-function
// list. When the partial prefix-matches a known function name we
// still bail (it may yet become a function call); otherwise we
// fall through to the schema-column check below, which flags the
// partial as "no such column" unless it prefix-matches a real
// column. So `select Agx` warns at typing time again, while
// `select sum` does not.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
});
if has_sql_expr_slot {
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
return None;
}
// Find every schema-listable source in the expected list.
@@ -2365,6 +2415,175 @@ mod tests {
// ---- ADR-0032 §10.5 qualified-prefix completion ----
// ---- SQL function-name completion (issue #15) ----
#[test]
fn sql_expr_slot_offers_known_function_candidates() {
// Issue #15: at a `sql_expr_ident` slot Tab offers the curated
// function names so a learner can discover them.
let cache = two_table_schema();
let cs = cands_with("select * from a where ", 22, &cache);
for f in ["sum", "avg", "count", "upper", "coalesce"] {
assert!(
cs.contains(&f.to_string()),
"expected function `{f}` offered at WHERE expr slot; got {cs:?}",
);
}
}
#[test]
fn projection_slot_offers_known_function_candidates() {
// Issue #15's headline example: at `select ` (the projection
// slot, empty prefix) the functions must surface alongside
// columns — the projection slot is also `sql_expr_ident`, and
// it must not be swallowed by the value-literal suppression
// (it carries a schema-ident expectation, so it isn't).
let cache = two_table_schema();
let cs = cands_with("select ", 7, &cache);
for f in ["sum", "count", "upper"] {
assert!(
cs.contains(&f.to_string()),
"expected function `{f}` offered at the projection slot; got {cs:?}",
);
}
}
#[test]
fn sql_function_candidates_filter_by_prefix() {
// `su` narrows to the functions starting `su` — `sum`,
// `substr` — and excludes non-matches.
let cache = two_table_schema();
let input = "select * from a where su";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"sum".to_string()), "got {cs:?}");
assert!(cs.contains(&"substr".to_string()), "got {cs:?}");
assert!(
!cs.contains(&"avg".to_string()),
"`avg` does not prefix-match `su`; got {cs:?}",
);
}
#[test]
fn sql_function_candidates_carry_function_kind() {
// The hint panel colours functions via their own kind.
let cache = two_table_schema();
let input = "select * from a where su";
let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion")
.candidates;
let sum = cands
.iter()
.find(|c| c.text == "sum")
.expect("`sum` present");
assert_eq!(sum.kind, CandidateKind::Function);
}
#[test]
fn function_candidates_absent_at_non_expression_slots() {
// A non-`sql_expr_ident` slot (the table slot of `show data `)
// must not surface function names.
let cache = SchemaCache {
tables: vec!["Customers".to_string()],
..SchemaCache::default()
};
let cs = cands_with("show data ", 10, &cache);
for f in ["sum", "count", "upper"] {
assert!(
!cs.contains(&f.to_string()),
"function `{f}` must not appear at the table slot; got {cs:?}",
);
}
}
#[test]
fn function_candidate_deduped_against_a_like_named_column() {
// DA review (#15): a column literally named like a function
// (`count`) must appear exactly once in the candidate line —
// the column wins, the redundant function candidate is dropped.
let mut cache = SchemaCache {
tables: vec!["a".to_string()],
columns: vec!["count".to_string()],
..SchemaCache::default()
};
cache
.table_columns
.insert("a".to_string(), vec![TableColumn::new("count", Type::Int)]);
let input = "select * from a where co";
let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion")
.candidates;
let count_entries: Vec<_> =
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
assert_eq!(
count_entries.len(),
1,
"`count` must appear once, not duplicated as column + function; got {count_entries:?}",
);
assert_eq!(
count_entries[0].kind,
CandidateKind::Identifier,
"the surviving `count` candidate must be the user's column",
);
// A non-colliding function at the same slot is unaffected.
assert!(
cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
"non-colliding functions still surface; got {cands:?}",
);
}
#[test]
fn cast_is_not_offered_as_a_function_candidate() {
// `cast` uses `CAST(x AS type)`, which the call-shape grammar
// does not parse — it must never be offered (ADR-0031 call
// shape; sql_functions.rs exclusion).
let cache = two_table_schema();
let input = "select * from a where ca";
let cs = cands_with(input, input.len(), &cache);
assert!(
!cs.contains(&"cast".to_string()),
"`cast` must not be offered as a function candidate; got {cs:?}",
);
}
// ---- typing-time column-typo hint restored (issue #16) ----
#[test]
fn invalid_ident_fires_for_genuine_typo_at_sql_expr_slot() {
// Issue #16: a genuine column typo at a `sql_expr_ident` slot
// (before FROM is in scope) warns at typing time again — it
// matches neither a schema column nor a known function name.
let cache = two_table_schema();
let inv = invalid_ident_at_cursor("select Agx", 10, &cache)
.expect("genuine typo at an expr slot must flag");
assert_eq!(inv.found, "Agx");
assert_eq!(inv.source, IdentSource::Columns);
}
#[test]
fn invalid_ident_does_not_fire_for_function_prefix_at_sql_expr_slot() {
// The reason issue #6 dropped the flag: a known function name
// (or its prefix) must NOT be mis-flagged as an unknown column.
let cache = two_table_schema();
assert!(
invalid_ident_at_cursor("select su", 9, &cache).is_none(),
"`su` prefixes `sum`/`substr` — must not flag",
);
assert!(
invalid_ident_at_cursor("select sum", 10, &cache).is_none(),
"`sum` is a known function — must not flag",
);
}
#[test]
fn invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot() {
// A real column prefix is an in-progress lookup, not a typo.
let cache = two_table_schema();
assert!(
invalid_ident_at_cursor("select na", 9, &cache).is_none(),
"`na` prefixes the `name` column — must not flag",
);
}
fn two_table_schema() -> SchemaCache {
use crate::dsl::types::Type;
let mut s = SchemaCache::default();
+1
View File
@@ -14,6 +14,7 @@ pub mod command;
pub mod grammar;
pub mod parser;
pub mod shortid;
pub mod sql_functions;
pub mod types;
pub mod value;
pub mod walker;
+151
View File
@@ -0,0 +1,151 @@
//! Curated set of SQL function names the playground recognises
//! (ADR-0022 Amendment 6, issues #15 / #16).
//!
//! This is the single source of truth for "what SQL function names
//! does this playground know about", shared by two consumers:
//!
//! - **Tab completion** (issue #15): at a `sql_expr_ident` slot the
//! completion engine offers these as `CandidateKind::Function`
//! candidates, so a learner can discover `sum` / `upper` / … without
//! already knowing them.
//! - **The typing-time column-typo hint** (issue #16): at the same
//! slot, `invalid_ident_at_cursor` flags a partial as "no such
//! column" only when it matches neither a schema column *nor* a
//! known function name — so a genuine typo still warns early while
//! a real function name like `sum` does not get mis-flagged.
//!
//! Scope (ADR-0031 §1): the SQL expression grammar admits any
//! function-call *shape* without knowing which names are real — the
//! walker is a structural matcher, not an evaluator. This list is a
//! deliberately *curated pedagogical set*, not "every SQLite
//! built-in". It covers the aggregates a learner meets first plus the
//! common scalar functions, and is the allow-list / discovery source
//! layered on top of that shape-only grammar.
//!
//! Deliberately excluded: `cast`. SQLite's `CAST` uses the
//! `CAST(expr AS type)` syntax, which the expression grammar does
//! **not** parse as a function call (function args are expressions,
//! and `expr AS type` is not one). Offering `cast` as a completion
//! candidate would surface a name that does not parse in the call
//! shape, so it stays out of the set until the grammar grows a
//! dedicated `CAST` form.
/// The curated SQL function names, lowercase and sorted.
///
/// Sorted + lowercase is an invariant (pinned by a unit test): the
/// completion engine relies on a stable order, and the prefix match
/// is case-insensitive against these canonical lowercase spellings.
///
/// Grouping (all plain `name(args)` call shapes):
/// - **Aggregates:** `avg`, `count`, `max`, `min`, `sum`.
/// - **Common scalars:** `abs`, `coalesce`, `length`, `lower`,
/// `round`, `substr`, `trim`, `upper`.
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
"abs",
"avg",
"coalesce",
"count",
"date",
"datetime",
"hex",
"ifnull",
"instr",
"length",
"lower",
"max",
"min",
"nullif",
"random",
"replace",
"round",
"strftime",
"substr",
"sum",
"trim",
"typeof",
"upper",
];
/// Whether `partial` is a case-insensitive prefix of at least one
/// known function name.
///
/// An empty `partial` matches every function (it is a prefix of
/// all), mirroring the empty-prefix behaviour of the keyword /
/// identifier completion sources. Used by `invalid_ident_at_cursor`
/// to decide whether a partial at a `sql_expr_ident` slot might still
/// resolve to a function name (and so must not be flagged as an
/// unknown column).
#[must_use]
pub fn is_known_function_prefix(partial: &str) -> bool {
let lowered = partial.to_lowercase();
KNOWN_SQL_FUNCTIONS
.iter()
.any(|f| f.starts_with(&lowered))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_is_sorted_and_lowercase() {
// The completion engine and prefix matcher both rely on the
// canonical sorted-lowercase invariant.
for f in KNOWN_SQL_FUNCTIONS {
assert_eq!(
*f,
f.to_lowercase(),
"function name `{f}` must be lowercase",
);
}
let mut sorted = KNOWN_SQL_FUNCTIONS.to_vec();
sorted.sort_unstable();
assert_eq!(
sorted.as_slice(),
KNOWN_SQL_FUNCTIONS,
"KNOWN_SQL_FUNCTIONS must be declared in sorted order",
);
}
#[test]
fn list_has_no_duplicates() {
let mut seen = std::collections::HashSet::new();
for f in KNOWN_SQL_FUNCTIONS {
assert!(seen.insert(*f), "duplicate function name `{f}`");
}
}
#[test]
fn cast_is_excluded() {
// `CAST(expr AS type)` is not a plain call shape the grammar
// parses — it must not be offered as a candidate (faithfulness
// to ADR-0031's call-shape grammar).
assert!(
!KNOWN_SQL_FUNCTIONS.contains(&"cast"),
"`cast` is not a plain-call function and must stay out of the set",
);
}
#[test]
fn prefix_match_is_case_insensitive() {
assert!(is_known_function_prefix("su")); // sum, substr
assert!(is_known_function_prefix("SU")); // case-folded
assert!(is_known_function_prefix("sum")); // exact
assert!(is_known_function_prefix("UPP")); // upper
}
#[test]
fn empty_prefix_matches_all() {
assert!(is_known_function_prefix(""));
}
#[test]
fn unknown_prefix_does_not_match() {
assert!(!is_known_function_prefix("zzz"));
assert!(!is_known_function_prefix("xqz"));
// A genuine column-typo shape that prefixes no function.
assert!(!is_known_function_prefix("Agx"));
}
}
+33
View File
@@ -1344,6 +1344,39 @@ mod tests {
}
}
#[test]
fn advanced_select_genuine_column_typo_before_from_warns_at_typing_time() {
// Issue #16: the gap the issue-#6 trade-off opened. While the
// user types `select Agx` (no FROM yet, so the schema-existence
// diagnostic stays silent), a genuine column typo must warn at
// typing time via the restored `invalid_ident` path — `Agx`
// matches neither a schema column nor a known function name.
use crate::completion::{SchemaCache, TableColumn};
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string());
let tc = vec![TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}];
for c in &tc {
cache.columns.push(c.name.clone());
}
cache.table_columns.insert("Customers".to_string(), tc);
let input = "select Agx";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
Some(AmbientHint::Prose(p)) => assert!(
p.contains("No such") && p.contains("Agx"),
"a genuine column typo before FROM must warn at typing time; got: {p:?}",
),
other => panic!(
"`select Agx` must surface a typing-time typo hint; got: {other:?}",
),
}
}
#[test]
fn advanced_partial_typing_does_not_leak_bare_double_in_prose() {
// Issue #5 (prose half): at `create table Orders (count` (no
+23
View File
@@ -61,6 +61,13 @@ pub struct Theme {
pub tok_punct: Color,
pub tok_flag: Color,
pub tok_error: Color,
/// SQL function-name candidate colour (ADR-0022 Amendment 6,
/// issue #15) — a dedicated tone distinct from `tok_keyword`,
/// `tok_identifier`, and `tok_type` so a learner can tell a
/// callable (`sum`, `upper`) apart from a clause keyword, a
/// column reference, and a column type. Drives the
/// `CandidateKind::Function` colour in the hint panel.
pub tok_function: Color,
}
impl Theme {
@@ -95,6 +102,7 @@ impl Theme {
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
tok_function: Color::Rgb(0x82, 0xCF, 0xFD), // sky blue — cool like keyword but bluer, clearly apart from purple keyword + teal identifier + pink type
}
}
@@ -125,6 +133,7 @@ impl Theme {
tok_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
tok_function: Color::Rgb(0x1A, 0x5F, 0xB0), // strong blue — cool like keyword but bluer, apart from royal-purple keyword + teal identifier + magenta type
}
}
@@ -169,6 +178,7 @@ mod tests {
("tok_string", t.tok_string),
("tok_flag", t.tok_flag),
("tok_error", t.tok_error),
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
@@ -188,6 +198,7 @@ mod tests {
("tok_string", t.tok_string),
("tok_flag", t.tok_flag),
("tok_error", t.tok_error),
("tok_function", t.tok_function),
("warning", t.warning),
] {
assert_ne!(
@@ -220,4 +231,16 @@ mod tests {
assert_ne!(t.tok_type, t.tok_identifier);
}
}
#[test]
fn function_colour_is_distinct_from_keyword_identifier_and_type() {
// ADR-0022 Amendment 6 / issue #15: function-name candidates
// get their own tone so a callable reads apart from a clause
// keyword, a column reference, and a column type.
for t in [Theme::dark(), Theme::light()] {
assert_ne!(t.tok_function, t.tok_keyword);
assert_ne!(t.tok_function, t.tok_identifier);
assert_ne!(t.tok_function, t.tok_type);
}
}
}
+1
View File
@@ -1136,6 +1136,7 @@ fn render_candidate_line(
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
crate::completion::CandidateKind::Flag => theme.tok_flag,
crate::completion::CandidateKind::Punct => theme.tok_punct,
crate::completion::CandidateKind::Function => theme.tok_function,
};
let base_fg = if mixed {
match items[i].mode {