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:
@@ -686,6 +686,92 @@ can be revisited if hints routinely need more.
|
|||||||
full-screen snapshots (the empty-state placeholder and the `insert`
|
full-screen snapshots (the empty-state placeholder and the `insert`
|
||||||
usage hint now wrap to their full text instead of being clipped).
|
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
|
## Out of scope
|
||||||
|
|
||||||
Deliberately deferred to keep this ADR shippable as a single
|
Deliberately deferred to keep this ADR shippable as a single
|
||||||
|
|||||||
@@ -409,3 +409,24 @@ Later phases extend the same fragment:
|
|||||||
set the engine-neutrality posture and the no-allowlist rule.
|
set the engine-neutrality posture and the no-allowlist rule.
|
||||||
- `docs/simple-mode-limitations.md` — the DSL limits this grammar
|
- `docs/simple-mode-limitations.md` — the DSL limits this grammar
|
||||||
lifts for advanced mode (§1, §4).
|
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
File diff suppressed because one or more lines are too long
+24
-14
@@ -26,18 +26,21 @@ repo is pushed).
|
|||||||
|
|
||||||
## Test baseline
|
## Test baseline
|
||||||
|
|
||||||
After the ADR-0027 highlight / hint follow-up (precise WARNING
|
After ADR-0022 Amendment 6 (the curated SQL function-name list —
|
||||||
spans, the diagnostic overlay + hint wiring, the
|
issues #15 tab-completion + #16 typing-time typo hint):
|
||||||
`LIKE`-on-numeric WARNING, the debounce state machine) plus
|
**1538 lib unit tests passing, 0 failing, 1 ignored**
|
||||||
two manual-testing bug fixes (optional trailing-flag
|
(`cargo test --lib`; the full `cargo test` across every binary is
|
||||||
completion; the `--resume` temp-project pointer):
|
2107 passing, 0 failing, 1 ignored — the one ignored is a
|
||||||
**1131 passing, 0 failing, 1 ignored** (`cargo test` — the one
|
long-standing `` ```ignore `` doc-test in `src/friendly/mod.rs`).
|
||||||
ignored test is a long-standing `` ```ignore `` doc-test in
|
Clippy clean with the nursery lint group enabled. (Earlier
|
||||||
`src/friendly/mod.rs`). Clippy clean with the nursery lint
|
reference points — lib counts: 1131 after the ADR-0027 highlight /
|
||||||
group enabled. (Earlier reference points: 1100 after ADR-0027's
|
hint follow-up + the optional-trailing-flag / `--resume`
|
||||||
initial ship; 1079 after ADR-0026 (complex WHERE expressions);
|
manual-testing fixes; 1100 after ADR-0027's initial ship; 1079
|
||||||
1039 after ADR-0025 (indexes); 1006 after ADR-0024 + the
|
after ADR-0026 (complex WHERE expressions); 1039 after ADR-0025
|
||||||
handoff-14 cleanup; 449 after B2/C2.)
|
(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.)*
|
rolling history is out of scope per OOS-6 / N4.)*
|
||||||
- [ ] **I3** Tab completion for app commands, DSL keywords, table
|
- [ ] **I3** Tab completion for app commands, DSL keywords, table
|
||||||
names, column names, and SQL keywords.
|
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.
|
- [ ] **I4** Syntax highlighting for both the DSL and SQL.
|
||||||
*(Refinement 2026-05-29, issue #8: column data types now carry a
|
*(Refinement 2026-05-29, issue #8: column data types now carry a
|
||||||
dedicated `HighlightClass::Type` / `tok_type` colour, distinct from
|
dedicated `HighlightClass::Type` / `tok_type` colour, distinct from
|
||||||
identifiers and clause keywords — ADR-0022 Amendment 4. The broad
|
identifiers and clause keywords — ADR-0022 Amendment 4; a further
|
||||||
highlighting goal stays open.)*
|
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
|
- [ ] **I5** In-flight query/command cancellation (Ctrl-C in the
|
||||||
output area or input field).
|
output area or input field).
|
||||||
|
|
||||||
|
|||||||
+228
-9
@@ -228,6 +228,12 @@ pub enum CandidateKind {
|
|||||||
/// expects either `values` or `(`, and surfacing both makes
|
/// expects either `values` or `(`, and surfacing both makes
|
||||||
/// the Form A path discoverable.
|
/// the Form A path discoverable.
|
||||||
Punct,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -682,6 +688,27 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
Vec::new()
|
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
|
// Source 2: schema identifiers — accumulated across every
|
||||||
// matching schema-listable `Ident { source }` expectation.
|
// matching schema-listable `Ident { source }` expectation.
|
||||||
// `NewName` / `Types` / `Free` sources don't query the
|
// `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
|
// resolving collisions in the user's favour would create
|
||||||
// ambiguity in the live render.
|
// ambiguity in the live render.
|
||||||
identifiers.retain(|name| !keywords.contains(name));
|
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
|
// Schema identifiers first: a column / table name the user
|
||||||
// would otherwise have to look up is the highest-value
|
// would otherwise have to look up is the highest-value
|
||||||
@@ -738,6 +775,7 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
identifiers.len()
|
identifiers.len()
|
||||||
+ keywords.len()
|
+ keywords.len()
|
||||||
+ type_names.len()
|
+ type_names.len()
|
||||||
|
+ functions.len()
|
||||||
+ composites.len()
|
+ composites.len()
|
||||||
+ punct_candidates.len()
|
+ punct_candidates.len()
|
||||||
+ flags.len(),
|
+ flags.len(),
|
||||||
@@ -762,6 +800,14 @@ pub fn candidates_at_cursor_with_in_mode(
|
|||||||
kind: CandidateKind::Keyword,
|
kind: CandidateKind::Keyword,
|
||||||
mode: ModeClass::Both,
|
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 {
|
candidates.extend(composites.into_iter().map(|text| Candidate {
|
||||||
text,
|
text,
|
||||||
kind: CandidateKind::Keyword,
|
kind: CandidateKind::Keyword,
|
||||||
@@ -1134,19 +1180,23 @@ pub fn invalid_ident_at_cursor_in_mode(
|
|||||||
if expected.is_empty() {
|
if expected.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Issue #6 follow-on: at a SQL expression position the partial
|
// Issue #6 / #16: at a SQL expression position the partial could
|
||||||
// could resolve to either a column reference *or* a function-call
|
// resolve to either a column reference *or* a function-call name
|
||||||
// name (the grammar admits the call shape structurally — see
|
// (the grammar admits the call shape structurally — see
|
||||||
// sql_expr.rs's `CALL_ARGS` comment, "it does not know which
|
// sql_expr.rs's `CALL_ARGS` comment, "it does not know which
|
||||||
// names are aggregates"). Without lookahead for a trailing `(`,
|
// names are aggregates"). Issue #6 dropped the flag here wholesale
|
||||||
// we can't tell here, so we don't flag — submit-time validation
|
// to stop the false positive on names like `sum`; issue #16
|
||||||
// still catches a genuine column typo via the `unknown_column`
|
// restores it for genuine typos using the curated known-function
|
||||||
// diagnostic, which only skips when the ident *is* followed by
|
// list. When the partial prefix-matches a known function name we
|
||||||
// `(` (i.e. it really is a function call).
|
// 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| {
|
let has_sql_expr_slot = expected.iter().any(|e| {
|
||||||
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
// Find every schema-listable source in the expected list.
|
// Find every schema-listable source in the expected list.
|
||||||
@@ -2365,6 +2415,175 @@ mod tests {
|
|||||||
|
|
||||||
// ---- ADR-0032 §10.5 qualified-prefix completion ----
|
// ---- 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 {
|
fn two_table_schema() -> SchemaCache {
|
||||||
use crate::dsl::types::Type;
|
use crate::dsl::types::Type;
|
||||||
let mut s = SchemaCache::default();
|
let mut s = SchemaCache::default();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod command;
|
|||||||
pub mod grammar;
|
pub mod grammar;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod shortid;
|
pub mod shortid;
|
||||||
|
pub mod sql_functions;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod value;
|
pub mod value;
|
||||||
pub mod walker;
|
pub mod walker;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
#[test]
|
||||||
fn advanced_partial_typing_does_not_leak_bare_double_in_prose() {
|
fn advanced_partial_typing_does_not_leak_bare_double_in_prose() {
|
||||||
// Issue #5 (prose half): at `create table Orders (count` (no
|
// Issue #5 (prose half): at `create table Orders (count` (no
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ pub struct Theme {
|
|||||||
pub tok_punct: Color,
|
pub tok_punct: Color,
|
||||||
pub tok_flag: Color,
|
pub tok_flag: Color,
|
||||||
pub tok_error: 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 {
|
impl Theme {
|
||||||
@@ -95,6 +102,7 @@ impl Theme {
|
|||||||
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
|
tok_punct: Color::Rgb(0x8B, 0x90, 0x9A), // == muted
|
||||||
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
|
tok_flag: Color::Rgb(0xFF, 0xCB, 0x6B), // amber
|
||||||
tok_error: Color::Rgb(0xFF, 0x6B, 0x6B), // == error
|
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_punct: Color::Rgb(0x60, 0x66, 0x73), // == muted
|
||||||
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
|
tok_flag: Color::Rgb(0xB0, 0x88, 0x00), // mustard
|
||||||
tok_error: Color::Rgb(0xC0, 0x39, 0x2B), // == error
|
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_string", t.tok_string),
|
||||||
("tok_flag", t.tok_flag),
|
("tok_flag", t.tok_flag),
|
||||||
("tok_error", t.tok_error),
|
("tok_error", t.tok_error),
|
||||||
|
("tok_function", t.tok_function),
|
||||||
("warning", t.warning),
|
("warning", t.warning),
|
||||||
] {
|
] {
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
@@ -188,6 +198,7 @@ mod tests {
|
|||||||
("tok_string", t.tok_string),
|
("tok_string", t.tok_string),
|
||||||
("tok_flag", t.tok_flag),
|
("tok_flag", t.tok_flag),
|
||||||
("tok_error", t.tok_error),
|
("tok_error", t.tok_error),
|
||||||
|
("tok_function", t.tok_function),
|
||||||
("warning", t.warning),
|
("warning", t.warning),
|
||||||
] {
|
] {
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
@@ -220,4 +231,16 @@ mod tests {
|
|||||||
assert_ne!(t.tok_type, t.tok_identifier);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1136,6 +1136,7 @@ fn render_candidate_line(
|
|||||||
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
|
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
|
||||||
crate::completion::CandidateKind::Flag => theme.tok_flag,
|
crate::completion::CandidateKind::Flag => theme.tok_flag,
|
||||||
crate::completion::CandidateKind::Punct => theme.tok_punct,
|
crate::completion::CandidateKind::Punct => theme.tok_punct,
|
||||||
|
crate::completion::CandidateKind::Function => theme.tok_function,
|
||||||
};
|
};
|
||||||
let base_fg = if mixed {
|
let base_fg = if mixed {
|
||||||
match items[i].mode {
|
match items[i].mode {
|
||||||
|
|||||||
Reference in New Issue
Block a user