380c4238ef
Sub-phase 3k of ADR-0033. Adds the Tier-3 end-to-end DML suite (tests/sql_dml_e2e.rs) and the cross-cut gap-fill tests, fills the verification matrix (every row a verified file::function), and produces the phase-exit report. - tests/sql_dml_e2e.rs: INSERT…SELECT cross-table, all-ten-type multi-row INSERT + RETURNING type recovery, UPDATE-with-subquery-in-SET, cascade DELETE, UPSERT round-trip, RETURNING x3, history.log replay, OOS rejections (full §13 table), validity-indicator-from-SQL-DML. - walker/mod.rs, highlight.rs, completion.rs, input_render.rs: inherited-diagnostic, DML-keyword highlight, INSERT INTO completion, and advanced-mode DML hint-panel cross-cuts. - Matrix correction (user-confirmed): predicate warnings fire on row-scoped DML slots; INSERT VALUES has no row scope (ADR-0033 §8.4). - Auto-snapshot row marked N/A (user-confirmed): ADR-0006 unimplemented for both paths; deferred. /runda round: added an advanced-mode DML hint-panel test (A6 was attributed to simple-mode prose under the §8 advanced heading); extended OOS coverage to the full ADR-0033 §13 table (OOS-5 INDEXED BY / OOS-6 multi-statement) + a trailing-semicolon guard. 1645 passing / 0 failing / 0 skipped / 1 ignored. Clippy clean.
1758 lines
67 KiB
Rust
1758 lines
67 KiB
Rust
//! Render-side helpers for the input panel
|
|
//! (ADR-0022 stage 2 — token-class colouring of the live
|
|
//! input field with cursor injection).
|
|
//!
|
|
//! The functions here are pure: given an input string, a
|
|
//! cursor byte position, and a theme, they return a sequence
|
|
//! of `StyledRun`s describing each contiguous span with its
|
|
//! ratatui style. `ui::render_input_panel` converts these to
|
|
//! `Span<'_>`s at render time.
|
|
//!
|
|
//! Cursor handling:
|
|
//! - Cursor inside a token splits that token's run into
|
|
//! before/under/after, with `under` carrying the token's
|
|
//! colour plus `Modifier::REVERSED`.
|
|
//! - Cursor on a whitespace gap between tokens splits the
|
|
//! gap the same way.
|
|
//! - Cursor at end-of-input is represented as a trailing
|
|
//! run with empty byte range; the renderer treats that as
|
|
//! "inverted space".
|
|
//!
|
|
//! Per ADR-0022 §2/§3, this is the silent always-on layer.
|
|
//! The error overlay (stage 4) and hint panel (stage 5)
|
|
//! compose with these runs without fighting them.
|
|
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
|
|
use crate::dsl::parser::{parse_command_in_mode, parse_command_with_schema, parse_command_with_schema_in_mode};
|
|
use crate::mode::Mode;
|
|
use crate::dsl::walker;
|
|
use crate::dsl::{ParseError, parse_command};
|
|
use crate::theme::Theme;
|
|
|
|
/// A run of text with its byte range in the source and the
|
|
/// ratatui style it should render with. The text itself is
|
|
/// not stored — callers slice `source[byte_range.0..byte_range.1]`.
|
|
///
|
|
/// An empty byte range (`(n, n)`) represents the end-of-input
|
|
/// cursor and is rendered as an inverted space.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct StyledRun {
|
|
pub byte_range: (usize, usize),
|
|
pub style: Style,
|
|
}
|
|
|
|
impl StyledRun {
|
|
/// The text this run covers in `source`. Empty for the
|
|
/// end-of-input cursor sentinel.
|
|
#[must_use]
|
|
pub fn text<'a>(&self, source: &'a str) -> &'a str {
|
|
&source[self.byte_range.0..self.byte_range.1]
|
|
}
|
|
}
|
|
|
|
/// Build the run sequence for the input panel.
|
|
///
|
|
/// Lexes `input`, assigns each token its `theme.token_color`,
|
|
/// applies the parse-error overlay if the input is in the
|
|
/// definite-error state (ADR-0022 §1, §4), applies the
|
|
/// invalid-identifier overlay if the cursor is in a known-set
|
|
/// slot with no schema match (stage 8e), preserves whitespace
|
|
/// gaps as `theme.fg` runs, then injects the cursor at
|
|
/// `cursor_byte` (clamped to `input.len()`).
|
|
#[must_use]
|
|
pub fn render_input_runs(
|
|
input: &str,
|
|
cursor_byte: usize,
|
|
theme: &Theme,
|
|
cache: &crate::completion::SchemaCache,
|
|
) -> Vec<StyledRun> {
|
|
render_input_runs_in_mode(input, cursor_byte, theme, cache, Mode::Simple)
|
|
}
|
|
|
|
/// Mode-aware [`render_input_runs`] (ADR-0030 §8).
|
|
///
|
|
/// Advanced mode runs the highlight walker with `Mode::Advanced`
|
|
/// so SQL keywords get matched and coloured, and the
|
|
/// definite-error / schema-existence overlays use the
|
|
/// advanced-mode parse view.
|
|
#[must_use]
|
|
pub fn render_input_runs_in_mode(
|
|
input: &str,
|
|
cursor_byte: usize,
|
|
theme: &Theme,
|
|
cache: &crate::completion::SchemaCache,
|
|
mode: Mode,
|
|
) -> Vec<StyledRun> {
|
|
let mut runs = lex_to_runs_in_mode(input, theme, mode);
|
|
if let InputState::DefiniteErrorAt(pos) =
|
|
classify_parse_result(parse_command_with_schema_in_mode(input, cache, mode))
|
|
{
|
|
overlay_error(&mut runs, pos, theme);
|
|
}
|
|
if let Some(inv) =
|
|
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor_byte, cache, mode)
|
|
{
|
|
overlay_error(&mut runs, inv.range.0, theme);
|
|
}
|
|
// Schema-aware diagnostics (ADR-0027 §2): unknown table /
|
|
// column (ERROR), or a dubious comparison (WARNING), is
|
|
// overlaid wherever it sits — not only under the cursor —
|
|
// so a problem the user has typed past stays visible. The
|
|
// mode-aware walk picks up the SQL-specific diagnostics from
|
|
// ADR-0032 in advanced mode.
|
|
for diag in walker::input_diagnostics_in_mode(input, Some(cache), mode) {
|
|
let colour = match diag.severity {
|
|
walker::Severity::Error => theme.tok_error,
|
|
walker::Severity::Warning => theme.warning,
|
|
};
|
|
overlay_span(&mut runs, diag.span, colour);
|
|
}
|
|
inject_cursor(&mut runs, input, cursor_byte, theme);
|
|
runs
|
|
}
|
|
|
|
/// One of three mid-typing classifications (ADR-0022 §1).
|
|
///
|
|
/// Distinguishes "the user isn't done yet" from "this token
|
|
/// can never fit". Drives error overlay (this stage) and the
|
|
/// hint panel ambient mode (stage 5).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum InputState {
|
|
/// No tokens at all (empty / whitespace-only input).
|
|
Empty,
|
|
/// Parses to a complete `Command`. The user can submit.
|
|
Valid,
|
|
/// Parse failed because more input was expected — every
|
|
/// consumed token fits a known command, just not all of
|
|
/// it is here yet.
|
|
IncompleteAtEof,
|
|
/// Parse failed at a token strictly inside the input —
|
|
/// no continuation can recover. The byte offset is the
|
|
/// failing token's start.
|
|
DefiniteErrorAt(usize),
|
|
}
|
|
|
|
/// Classify `input` into one of the three mid-typing states.
|
|
///
|
|
/// Schemaless. Wrong-count / wrong-type value-list cases that
|
|
/// only the schema-aware parser would catch surface as `Valid`
|
|
/// here. For UX-correct classification at typing time prefer
|
|
/// [`classify_input_with_schema`] — `render_input_runs` always
|
|
/// does. Kept public because handoff-11/12 regression tests use
|
|
/// it for schema-independent assertions (cheap, predictable).
|
|
///
|
|
/// ADR-0022 §13: cheap (lex + parse).
|
|
#[must_use]
|
|
pub fn classify_input(input: &str) -> InputState {
|
|
if input.trim().is_empty() {
|
|
return InputState::Empty;
|
|
}
|
|
classify_parse_result(parse_command(input))
|
|
}
|
|
|
|
/// Schema-aware variant of [`classify_input`].
|
|
///
|
|
/// Threads the `SchemaCache` through `parse_command_with_schema`
|
|
/// so that typed-slot rejections (Phase D — wrong-count Form B
|
|
/// value lists, wrong-type column values, etc.) surface as
|
|
/// `DefiniteErrorAt`/`IncompleteAtEof` at typing time, before
|
|
/// the user submits.
|
|
#[must_use]
|
|
pub fn classify_input_with_schema(
|
|
input: &str,
|
|
cache: &crate::completion::SchemaCache,
|
|
) -> InputState {
|
|
if input.trim().is_empty() {
|
|
return InputState::Empty;
|
|
}
|
|
classify_parse_result(parse_command_with_schema(input, cache))
|
|
}
|
|
|
|
/// Mode-aware [`classify_input_with_schema`].
|
|
///
|
|
/// Walks the input in `mode` so the simple-mode DSL surface is
|
|
/// classified against the DSL grammar rather than the advanced SQL
|
|
/// grammar — relevant for the shared `insert`/`update`/`delete` entry
|
|
/// words (ADR-0033 Amendment 3). The mode-less entry point keeps its
|
|
/// advanced-mode behaviour.
|
|
#[must_use]
|
|
pub fn classify_input_with_schema_in_mode(
|
|
input: &str,
|
|
cache: &crate::completion::SchemaCache,
|
|
mode: Mode,
|
|
) -> InputState {
|
|
if input.trim().is_empty() {
|
|
return InputState::Empty;
|
|
}
|
|
classify_parse_result(crate::dsl::parser::parse_command_with_schema_in_mode(
|
|
input, cache, mode,
|
|
))
|
|
}
|
|
|
|
fn classify_parse_result(
|
|
result: Result<crate::dsl::Command, ParseError>,
|
|
) -> InputState {
|
|
match result {
|
|
Ok(_) => InputState::Valid,
|
|
Err(ParseError::Empty) => InputState::Empty,
|
|
Err(err @ ParseError::Invalid { position, .. }) => {
|
|
// `at_eof` is the parser's own classification: true
|
|
// when more input would (potentially) help, false
|
|
// when a specific token is in the wrong place.
|
|
// Custom-error inputs (try_map failures) currently
|
|
// map to `at_eof = true` — see the field docstring
|
|
// on `ParseError::Invalid::at_eof`.
|
|
if err.at_eof() {
|
|
InputState::IncompleteAtEof
|
|
} else {
|
|
InputState::DefiniteErrorAt(position)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ambient hint-panel content for the user's current input
|
|
/// (ADR-0022 §6, stage 8b). The renderer dispatches on the
|
|
/// returned variant.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum AmbientHint {
|
|
/// Single-line prose hint — used for "submit with Enter",
|
|
/// IncompleteAtEof with no keyword candidates (i.e. an
|
|
/// identifier or punctuation slot), and definite-error
|
|
/// states with optional usage template.
|
|
Prose(String),
|
|
/// Multi-candidate (or single-candidate) completion at
|
|
/// the cursor. Each item carries its kind so the
|
|
/// renderer can colour keywords differently from
|
|
/// schema-identifiers (post-stage-8 user feedback).
|
|
/// The selected item — if any — gets bold + brighter
|
|
/// colour; `<` / `>` markers appear at the edges when
|
|
/// items overflow the panel width.
|
|
Candidates {
|
|
items: Vec<crate::completion::Candidate>,
|
|
/// Index into `items` of the currently-inserted Tab
|
|
/// candidate (per the live `LastCompletion` memo), or
|
|
/// `None` if the user hasn't pressed Tab yet.
|
|
selected: Option<usize>,
|
|
},
|
|
}
|
|
|
|
/// Compute the simple-mode ambient hint for the input panel
|
|
/// (ADR-0022 §6). Thin wrapper over [`ambient_hint_in_mode`];
|
|
/// advanced-mode callers pass the active mode instead.
|
|
///
|
|
/// Returns `None` for empty input — caller falls back to
|
|
/// `panel.hint_empty`.
|
|
#[must_use]
|
|
pub fn ambient_hint(
|
|
input: &str,
|
|
cursor: usize,
|
|
memo: Option<&crate::completion::LastCompletion>,
|
|
cache: &crate::completion::SchemaCache,
|
|
) -> Option<AmbientHint> {
|
|
ambient_hint_in_mode(input, cursor, memo, cache, Mode::Simple)
|
|
}
|
|
|
|
/// Mode-aware ambient hint for the input panel (ADR-0022
|
|
/// Amendment 1).
|
|
///
|
|
/// Walks the input in `mode` so advanced-mode SQL surfaces slot
|
|
/// hints + completion candidates instead of the simple-mode
|
|
/// "this is SQL" gate. The simple-mode entry point [`ambient_hint`]
|
|
/// forwards here with `Mode::Simple`.
|
|
///
|
|
/// In simple mode, when the line is a *definite* DSL error but the
|
|
/// same line would parse in advanced mode, the DSL error prose is
|
|
/// suffixed with the `advanced_mode.also_valid_sql` pointer — so the
|
|
/// user keeps the actionable DSL fix *and* learns it would run as SQL
|
|
/// in advanced mode (ADR-0033 Amendment 3). Mid-typing (incomplete)
|
|
/// input is not suffixed, to avoid noise during normal DSL entry.
|
|
///
|
|
/// Returns `None` for empty input — caller falls back to
|
|
/// `panel.hint_empty`.
|
|
#[must_use]
|
|
pub fn ambient_hint_in_mode(
|
|
input: &str,
|
|
cursor: usize,
|
|
memo: Option<&crate::completion::LastCompletion>,
|
|
cache: &crate::completion::SchemaCache,
|
|
mode: Mode,
|
|
) -> Option<AmbientHint> {
|
|
let core = ambient_hint_core_in_mode(input, cursor, memo, cache, mode);
|
|
// Combine: a simple-mode *definite* DSL error that would run in
|
|
// advanced mode keeps its DSL prose and gains the mode pointer.
|
|
// Skip the "command complete" prose — appending the pointer to a
|
|
// "submit this" hint would be contradictory (and that prose can
|
|
// come from the hint's schemaless fallback even when the
|
|
// schema-aware classify is a definite error — a pre-existing
|
|
// quirk this combine step deliberately does not amplify).
|
|
if mode == Mode::Simple
|
|
&& let Some(AmbientHint::Prose(message)) = &core
|
|
&& *message != crate::t!("hint.ambient_complete")
|
|
&& let Some(suffix) = advanced_alternative_note(input, cache)
|
|
{
|
|
return Some(AmbientHint::Prose(format!("{message} {suffix}")));
|
|
}
|
|
core
|
|
}
|
|
|
|
/// The `advanced_mode.also_valid_sql` pointer string, or `None`.
|
|
///
|
|
/// Returns the pointer when a simple-mode line is a *definite* DSL
|
|
/// error (not merely incomplete) yet parses in advanced mode. Used to
|
|
/// combine the DSL fix with the mode hint — both while typing
|
|
/// (`ambient_hint_in_mode`) and on submit (`App::dispatch_dsl`) — so
|
|
/// the pointer reaches SQL constructs that surface only on submit,
|
|
/// e.g. `delete … returning` (ADR-0033 Amendment 3).
|
|
#[must_use]
|
|
pub fn advanced_alternative_note(
|
|
input: &str,
|
|
cache: &crate::completion::SchemaCache,
|
|
) -> Option<String> {
|
|
let definite_dsl_error = matches!(
|
|
classify_input_with_schema_in_mode(input, cache, Mode::Simple),
|
|
InputState::DefiniteErrorAt(_)
|
|
);
|
|
if !definite_dsl_error {
|
|
return None;
|
|
}
|
|
if parse_command_with_schema_in_mode(input, cache, Mode::Advanced).is_ok() {
|
|
Some(crate::t!("advanced_mode.also_valid_sql"))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn ambient_hint_core_in_mode(
|
|
input: &str,
|
|
cursor: usize,
|
|
memo: Option<&crate::completion::LastCompletion>,
|
|
cache: &crate::completion::SchemaCache,
|
|
mode: Mode,
|
|
) -> Option<AmbientHint> {
|
|
if input.trim().is_empty() {
|
|
return None;
|
|
}
|
|
// Mid-cycle through Tab candidates: the memo carries the
|
|
// candidate list captured when Tab was first pressed, plus
|
|
// the current selection_idx. While the memo is alive the
|
|
// hint shows that exact list — recomputing at the
|
|
// post-insert cursor would whiplash the panel through "what
|
|
// comes next at the new cursor" between cycles. Closes
|
|
// the user-reported #4 in stage-8 testing.
|
|
if let Some(m) = memo {
|
|
return Some(AmbientHint::Candidates {
|
|
items: m.candidates.clone(),
|
|
selected: Some(m.selection_idx),
|
|
});
|
|
}
|
|
// Completion candidates at the cursor, computed once: both the
|
|
// diagnostic-shadow check below and the candidate ladder
|
|
// further down consume them. `candidates_at_cursor_in_mode`
|
|
// narrows column candidates to the active table and runs the
|
|
// §10.6 look-ahead, so it is the authoritative "what can go
|
|
// here" set.
|
|
let completion =
|
|
crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
|
|
|
|
// Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
|
|
// is non-empty only for a command that *structurally parses*
|
|
// — so a non-empty result means "this command is complete
|
|
// and submittable, but wrong or dubious". That is the single
|
|
// most important thing to tell the user, ahead of slot hints
|
|
// and completions, so it is checked early — right after the
|
|
// Tab-cycle memo. An unknown name is an ERROR, a dubious
|
|
// comparison a WARNING; the diagnostic under the cursor wins
|
|
// (the panel explains where the user is looking), else the
|
|
// most severe one. The error overlay still marks every
|
|
// flagged token; this panel carries the *why*.
|
|
//
|
|
// F1 (ADR-0022): the hint panel *is* the completion UI, so a
|
|
// premature "unknown table/column" ERROR on the very token the
|
|
// user is still typing must not shadow its completion. When an
|
|
// under-cursor ERROR overlaps the (non-empty) partial a
|
|
// candidate would replace, prefer the candidates.
|
|
let diagnostics = crate::dsl::walker::input_diagnostics_in_mode(input, Some(cache), mode);
|
|
if let Some(diag) = pick_hint_diagnostic(&diagnostics, cursor.min(input.len())) {
|
|
let typing_over_diag = diag.severity == crate::dsl::walker::Severity::Error
|
|
&& completion.as_ref().is_some_and(|c| {
|
|
let (replace_start, replace_end) = c.replaced_range;
|
|
let (diag_start, diag_end) = diag.span;
|
|
replace_end > replace_start
|
|
&& !c.candidates.is_empty()
|
|
&& diag_start < replace_end
|
|
&& replace_start < diag_end
|
|
});
|
|
if !typing_over_diag {
|
|
return Some(AmbientHint::Prose(diag.message.clone()));
|
|
}
|
|
}
|
|
// Resolve the walker-side `HintMode` at the cursor. This
|
|
// detects value-literal-slot and NewName-slot positions
|
|
// declaratively (ADR-0024 §HintMode-per-node) — the
|
|
// ambient-hint ladder dispatches on the returned variant
|
|
// before falling through to the generic candidates / prose
|
|
// framings.
|
|
//
|
|
// We pass the `leading` slice (input up to the start of any
|
|
// partial identifier the user is mid-typing) so the hint
|
|
// mode reflects the slot expected at the token boundary,
|
|
// not whatever the partial would resolve to.
|
|
let leading = hint_leading_slice(input, cursor);
|
|
// ADR-0024 §Phase D §typed-value-slots: pass the schema so
|
|
// the resolver can narrow value-slot prose per column type
|
|
// (Date → "Type a date as 'YYYY-MM-DD'", etc.) and surface
|
|
// the column name when the walker has it bound.
|
|
let resolution =
|
|
crate::dsl::walker::hint_resolution_at_input_in_mode(leading, Some(cache), mode);
|
|
match resolution.as_ref().map(|r| r.mode) {
|
|
Some(crate::dsl::grammar::HintMode::ProseOnly(key)) => {
|
|
// The cursor sits at a slot where Tab candidates
|
|
// would be actively misleading. Surface the catalog
|
|
// prose for the slot. Only fires at empty prefix —
|
|
// once the user starts typing a partial, normal
|
|
// candidate completion (e.g. `n` → `null`) applies.
|
|
if cursor_partial_is_empty(input, cursor) {
|
|
let detail = crate::friendly::translate(key, &[]);
|
|
let resolution = resolution.expect("matched on resolution.mode");
|
|
let mut composed = match resolution.column {
|
|
Some(column) => crate::t!(
|
|
"hint.value_slot_for_column",
|
|
column = column,
|
|
detail = detail
|
|
),
|
|
None => detail,
|
|
};
|
|
// Form B pedagogical note: when the first value
|
|
// slot of `insert into T values (…)` is reached
|
|
// and T has auto-generated columns the value list
|
|
// skips, point the user at the explicit-column
|
|
// form so the skipped column is discoverable
|
|
// (handoff-12 §2.2).
|
|
if !resolution.form_b_autogen_skipped.is_empty() {
|
|
let columns = resolution
|
|
.form_b_autogen_skipped
|
|
.iter()
|
|
.map(|c| format!("`{c}`"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
composed.push(' ');
|
|
composed.push_str(&crate::t!(
|
|
"hint.value_slot_autogen_skipped",
|
|
columns = columns
|
|
));
|
|
}
|
|
return Some(AmbientHint::Prose(composed));
|
|
}
|
|
}
|
|
Some(crate::dsl::grammar::HintMode::ForceProse(_key)) => {
|
|
// NewName slot: show "Type a name [then <next>]".
|
|
// The probe in `typing_name_at_cursor` reads what
|
|
// would come *after* the name, so we still consult
|
|
// it to populate the optional `then` clause. The
|
|
// walker-side `ForceProse` annotation tells us
|
|
// *that* we're in this mode; the probe tells us
|
|
// *what comes next*.
|
|
if let Some(t) = crate::completion::typing_name_at_cursor(input, cursor) {
|
|
let text = t.next_after_name.map_or_else(
|
|
|| crate::t!("hint.ambient_typing_name"),
|
|
|next| crate::t!("hint.ambient_typing_name_then", next = next),
|
|
);
|
|
return Some(AmbientHint::Prose(text));
|
|
}
|
|
}
|
|
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
|
|
| None => {}
|
|
}
|
|
|
|
// No HintMode override: candidate-or-prose ladder applies.
|
|
// Candidates win when any exist — the panel surfaces them
|
|
// directly because they're more actionable than prose
|
|
// framings.
|
|
// Candidate completion (computed once above) runs through the
|
|
// `mode`-aware walker view (ADR-0022 Amendment 1): in advanced
|
|
// mode SQL keywords and schema candidates surface; in simple
|
|
// mode `select` is gated as "this is SQL" (ADR-0030 §2).
|
|
if let Some(comp) = completion {
|
|
return Some(AmbientHint::Candidates {
|
|
items: comp.candidates,
|
|
selected: None,
|
|
});
|
|
}
|
|
// Invalid identifier: cursor sits in a known-set slot but
|
|
// the typed prefix matches nothing in the schema. (Stage
|
|
// 8e / the user's #5.)
|
|
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
|
|
{
|
|
let kind = match inv.source {
|
|
crate::dsl::grammar::IdentSource::Tables => "table",
|
|
crate::dsl::grammar::IdentSource::Columns => "column",
|
|
crate::dsl::grammar::IdentSource::Relationships => "relationship",
|
|
// `NewName`, `Types`, `Free` are filtered out by
|
|
// `invalid_ident_at_cursor` (it only fires for
|
|
// known-set sources via `completes_from_schema`), so
|
|
// these arms are unreachable in practice — render a
|
|
// neutral fallback rather than panic.
|
|
_ => "identifier",
|
|
};
|
|
return Some(AmbientHint::Prose(crate::t!(
|
|
"hint.ambient_invalid_ident",
|
|
kind = kind,
|
|
found = inv.found,
|
|
)));
|
|
}
|
|
// Otherwise fall back to the prose framings from stage 5,
|
|
// parsed in the active `mode` (ADR-0022 Amendment 1). In
|
|
// simple mode a SQL form still surfaces the "this is SQL"
|
|
// hint (ADR-0030 §2); in advanced mode it parses as SQL.
|
|
match parse_command_in_mode(input, mode) {
|
|
Ok(_) => Some(AmbientHint::Prose(crate::t!("hint.ambient_complete"))),
|
|
Err(ParseError::Empty) => None,
|
|
Err(ParseError::Invalid {
|
|
message,
|
|
position,
|
|
at_eof,
|
|
expected,
|
|
}) => {
|
|
if at_eof {
|
|
if expected.is_empty() {
|
|
Some(AmbientHint::Prose(message))
|
|
} else {
|
|
let joined = oxford_or(&expected);
|
|
Some(AmbientHint::Prose(crate::t!(
|
|
"hint.ambient_expected",
|
|
expected = joined
|
|
)))
|
|
}
|
|
} else {
|
|
let _ = position;
|
|
// The form the user has committed to drives the
|
|
// usage template — `add index …` shows the
|
|
// `add index` usage, not the first `add` form.
|
|
let usage = crate::dsl::grammar::usage_key_for_input(input)
|
|
.map(|key| crate::friendly::translate(key, &[]));
|
|
Some(AmbientHint::Prose(match usage {
|
|
Some(u) => crate::t!(
|
|
"hint.ambient_error_with_usage",
|
|
message = message,
|
|
usage = u,
|
|
),
|
|
None => message,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Slice of `input` ending at the start of the partial
|
|
/// identifier-shape token at `cursor` (if any). Mirrors the
|
|
/// look-back used by `completion::candidates_at_cursor` so the
|
|
/// hint resolver sees the same "what was expected at the token
|
|
/// boundary" view of the world.
|
|
fn hint_leading_slice(input: &str, cursor: usize) -> &str {
|
|
let cursor = cursor.min(input.len());
|
|
let bytes = input.as_bytes();
|
|
let mut start = cursor;
|
|
while start > 0 {
|
|
let prev = bytes[start - 1];
|
|
if prev.is_ascii_alphanumeric() || prev == b'_' {
|
|
start -= 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
&input[..start]
|
|
}
|
|
|
|
/// True when the cursor is at a token boundary — no partial
|
|
/// identifier-shape token in progress.
|
|
fn cursor_partial_is_empty(input: &str, cursor: usize) -> bool {
|
|
let cursor = cursor.min(input.len());
|
|
let bytes = input.as_bytes();
|
|
if cursor == 0 {
|
|
return true;
|
|
}
|
|
let prev = bytes[cursor - 1];
|
|
!(prev.is_ascii_alphanumeric() || prev == b'_')
|
|
}
|
|
|
|
/// "A, B, or C" / "A or B" / "A". Local copy because the
|
|
/// parser's identical helper is private.
|
|
fn oxford_or(items: &[String]) -> String {
|
|
match items {
|
|
[] => String::new(),
|
|
[a] => a.clone(),
|
|
[a, b] => format!("{a} or {b}"),
|
|
rest => {
|
|
let (last, head) = rest.split_last().expect("len >= 3");
|
|
format!("{}, or {last}", head.join(", "))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Choose which diagnostic the hint panel surfaces: the one
|
|
/// whose span contains the cursor (the panel explains where the
|
|
/// user is looking), else the most severe — ERROR over WARNING,
|
|
/// ties broken leftmost.
|
|
fn pick_hint_diagnostic(
|
|
diagnostics: &[crate::dsl::walker::Diagnostic],
|
|
cursor: usize,
|
|
) -> Option<&crate::dsl::walker::Diagnostic> {
|
|
diagnostics
|
|
.iter()
|
|
.find(|d| d.span.0 <= cursor && cursor <= d.span.1)
|
|
.or_else(|| {
|
|
diagnostics.iter().max_by(|a, b| {
|
|
a.severity
|
|
.cmp(&b.severity)
|
|
.then_with(|| b.span.0.cmp(&a.span.0))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn overlay_error(runs: &mut [StyledRun], error_byte: usize, theme: &Theme) {
|
|
// Failing tokens have their byte_range starting exactly at
|
|
// `error_byte`. Override the fg colour while preserving any
|
|
// other style bits the base run carried.
|
|
if let Some(run) = runs.iter_mut().find(|r| r.byte_range.0 == error_byte) {
|
|
run.style = run.style.fg(theme.tok_error);
|
|
}
|
|
// If no run starts at error_byte, the failure is past the
|
|
// last token (an EOF failure misclassified as definite —
|
|
// shouldn't happen given classify_input's contract). No-op.
|
|
}
|
|
|
|
/// Recolour every run wholly inside the byte range `span` to
|
|
/// `colour`, preserving other style bits. Unlike `overlay_error`
|
|
/// — which targets the single run *starting* at a byte — this
|
|
/// covers a whole span: a diagnostic (ADR-0027) is anchored to
|
|
/// a token's exact byte range (an identifier, a literal), and
|
|
/// the matching base run sits exactly inside it.
|
|
fn overlay_span(runs: &mut [StyledRun], span: (usize, usize), colour: Color) {
|
|
let (start, end) = span;
|
|
for run in runs.iter_mut() {
|
|
if run.byte_range.0 >= start && run.byte_range.1 <= end {
|
|
run.style = run.style.fg(colour);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cursor-less variant: tokenises `input` into styled runs
|
|
/// covering the full byte range, with no inverted cursor.
|
|
/// Used by the echo-line renderer (ADR-0022 §5) where there's
|
|
/// no cursor to show.
|
|
#[must_use]
|
|
pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
|
|
lex_to_runs_in_mode(input, theme, Mode::Simple)
|
|
}
|
|
|
|
/// Mode-aware [`lex_to_runs`]. Advanced mode runs the walker
|
|
/// with `Mode::Advanced` so SQL keywords past the entry word
|
|
/// match and get highlighted (ADR-0030 §8).
|
|
#[must_use]
|
|
pub fn lex_to_runs_in_mode(
|
|
input: &str,
|
|
theme: &Theme,
|
|
mode: Mode,
|
|
) -> Vec<StyledRun> {
|
|
base_runs(input, theme, mode)
|
|
}
|
|
|
|
fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
|
|
// Walker-driven highlighting (ADR-0024 §architecture, Phase F).
|
|
// `walker::highlight_runs_in_mode` returns per-byte classes for
|
|
// every token shape in the source; whitespace gaps are not
|
|
// represented and we fill them with the default foreground
|
|
// colour below.
|
|
let classes = walker::highlight_runs_in_mode(input, mode);
|
|
let mut runs = Vec::with_capacity(classes.len() * 2);
|
|
let mut pos = 0;
|
|
for class in classes {
|
|
let (start, end) = (class.start, class.end);
|
|
if pos < start {
|
|
runs.push(StyledRun {
|
|
byte_range: (pos, start),
|
|
style: Style::default().fg(theme.fg),
|
|
});
|
|
}
|
|
runs.push(StyledRun {
|
|
byte_range: (start, end),
|
|
style: Style::default().fg(theme.highlight_class_color(class.class)),
|
|
});
|
|
pos = end;
|
|
}
|
|
if pos < input.len() {
|
|
runs.push(StyledRun {
|
|
byte_range: (pos, input.len()),
|
|
style: Style::default().fg(theme.fg),
|
|
});
|
|
}
|
|
runs
|
|
}
|
|
|
|
fn inject_cursor(
|
|
runs: &mut Vec<StyledRun>,
|
|
input: &str,
|
|
cursor_byte: usize,
|
|
theme: &Theme,
|
|
) {
|
|
let cursor_byte = cursor_byte.min(input.len());
|
|
|
|
// End-of-input cursor: append the empty-range sentinel.
|
|
if cursor_byte == input.len() {
|
|
runs.push(StyledRun {
|
|
byte_range: (input.len(), input.len()),
|
|
style: Style::default()
|
|
.fg(theme.fg)
|
|
.add_modifier(Modifier::REVERSED),
|
|
});
|
|
return;
|
|
}
|
|
|
|
let idx = runs
|
|
.iter()
|
|
.position(|r| r.byte_range.0 <= cursor_byte && cursor_byte < r.byte_range.1)
|
|
.expect("cursor_byte < input.len() ⇒ some run contains it");
|
|
let target = runs[idx].clone();
|
|
let (start, end) = target.byte_range;
|
|
|
|
// Walk to the next char boundary so a multi-byte UTF-8
|
|
// codepoint is treated as a single visual unit at the
|
|
// cursor.
|
|
let mut char_end = cursor_byte + 1;
|
|
while char_end < input.len() && !input.is_char_boundary(char_end) {
|
|
char_end += 1;
|
|
}
|
|
|
|
let mut replacement: Vec<StyledRun> = Vec::with_capacity(3);
|
|
if start < cursor_byte {
|
|
replacement.push(StyledRun {
|
|
byte_range: (start, cursor_byte),
|
|
style: target.style,
|
|
});
|
|
}
|
|
replacement.push(StyledRun {
|
|
byte_range: (cursor_byte, char_end),
|
|
style: target.style.add_modifier(Modifier::REVERSED),
|
|
});
|
|
if char_end < end {
|
|
replacement.push(StyledRun {
|
|
byte_range: (char_end, end),
|
|
style: target.style,
|
|
});
|
|
}
|
|
|
|
runs.splice(idx..=idx, replacement);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
fn dark() -> Theme {
|
|
Theme::dark()
|
|
}
|
|
|
|
fn reversed(r: &StyledRun) -> bool {
|
|
r.style.add_modifier.contains(Modifier::REVERSED)
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_renders_only_the_end_of_input_cursor() {
|
|
let runs = render_input_runs("", 0, &dark(), &empty_cache());
|
|
assert_eq!(runs.len(), 1);
|
|
assert_eq!(runs[0].byte_range, (0, 0));
|
|
assert!(reversed(&runs[0]));
|
|
}
|
|
|
|
#[test]
|
|
fn keyword_token_takes_keyword_colour() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create", 6, &theme, &empty_cache());
|
|
// Token + end-of-input cursor.
|
|
assert_eq!(runs.len(), 2);
|
|
assert_eq!(runs[0].byte_range, (0, 6));
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_keyword));
|
|
assert!(!reversed(&runs[0]));
|
|
assert!(reversed(&runs[1]));
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_inside_token_splits_into_three_runs_keeping_colour() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create", 3, &theme, &empty_cache());
|
|
assert_eq!(runs.len(), 3);
|
|
assert_eq!(runs[0].byte_range, (0, 3));
|
|
assert_eq!(runs[1].byte_range, (3, 4));
|
|
assert_eq!(runs[2].byte_range, (4, 6));
|
|
// All three keep the keyword colour.
|
|
for r in &runs {
|
|
assert_eq!(r.style.fg, Some(theme.tok_keyword));
|
|
}
|
|
assert!(!reversed(&runs[0]));
|
|
assert!(reversed(&runs[1]));
|
|
assert!(!reversed(&runs[2]));
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_on_whitespace_inverts_a_single_space() {
|
|
let theme = dark();
|
|
// "create table" has whitespace at byte 6.
|
|
let runs = render_input_runs("create table", 6, &theme, &empty_cache());
|
|
// base: keyword, ws(6,7), keyword. After cursor injection
|
|
// at the start of ws: under=(6,7) REVERSED. The
|
|
// before/after slices are empty so we get 3 runs total.
|
|
assert_eq!(runs.len(), 3);
|
|
let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect();
|
|
assert_eq!(r_under.len(), 1);
|
|
assert_eq!(r_under[0].byte_range, (6, 7));
|
|
assert_eq!(r_under[0].style.fg, Some(theme.fg));
|
|
}
|
|
|
|
#[test]
|
|
fn lex_error_token_renders_in_error_colour() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("$", 1, &theme, &empty_cache());
|
|
// Error token (0,1), then end-of-input cursor (1,1).
|
|
assert_eq!(runs.len(), 2);
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_error));
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_between_tokens_takes_default_fg() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create table", 12, &theme, &empty_cache());
|
|
// base: keyword(0,6), ws(6,7), keyword(7,12). Plus
|
|
// end-of-input cursor (12,12) = 4 runs.
|
|
assert_eq!(runs.len(), 4);
|
|
assert_eq!(runs[1].byte_range, (6, 7));
|
|
assert_eq!(runs[1].style.fg, Some(theme.fg));
|
|
assert_eq!(runs[3].byte_range, (12, 12));
|
|
assert!(reversed(&runs[3]));
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_inside_multi_byte_string_literal_advances_to_char_boundary() {
|
|
let theme = dark();
|
|
// 'café' = ['(0)', c(1), a(2), f(3), é(4-5), '(6)] — é is 2 bytes.
|
|
// Cursor at byte 4: inside é. char_end advances to 6.
|
|
let runs = render_input_runs("'café'", 4, &theme, &empty_cache());
|
|
let r_under: Vec<_> = runs.iter().filter(|r| reversed(r)).collect();
|
|
assert_eq!(r_under.len(), 1);
|
|
assert_eq!(r_under[0].byte_range, (4, 6));
|
|
}
|
|
|
|
#[test]
|
|
fn end_of_input_cursor_is_an_empty_range() {
|
|
let runs = render_input_runs("create", 6, &dark(), &empty_cache());
|
|
let last = runs.last().expect("non-empty");
|
|
assert_eq!(last.byte_range, (6, 6));
|
|
assert!(reversed(last));
|
|
}
|
|
|
|
// ---- ambient_hint (stage 5 + stage 8b) ----
|
|
|
|
fn empty_cache() -> crate::completion::SchemaCache {
|
|
crate::completion::SchemaCache::default()
|
|
}
|
|
|
|
fn prose(input: &str, cursor: usize) -> Option<String> {
|
|
match ambient_hint(input, cursor, None, &empty_cache()) {
|
|
Some(AmbientHint::Prose(s)) => Some(s),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn cands_hint(input: &str, cursor: usize) -> Option<Vec<String>> {
|
|
match ambient_hint(input, cursor, None, &empty_cache()) {
|
|
Some(AmbientHint::Candidates { items, .. }) => {
|
|
Some(items.into_iter().map(|c| c.text).collect())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_is_none_for_empty_input() {
|
|
assert!(ambient_hint("", 0, None, &empty_cache()).is_none());
|
|
assert!(ambient_hint(" ", 3, None, &empty_cache()).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn advanced_mode_ambient_offers_sql_from_slot_candidate() {
|
|
// ADR-0022 Amendment 1: advanced-mode ambient assistance
|
|
// surfaces SQL completion candidates (here the FROM-slot
|
|
// table) instead of the simple-mode "this is SQL" gate.
|
|
let cache =
|
|
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
|
let input = "select * from ";
|
|
match ambient_hint_in_mode(
|
|
input,
|
|
input.len(),
|
|
None,
|
|
&cache,
|
|
crate::mode::Mode::Advanced,
|
|
) {
|
|
Some(AmbientHint::Candidates { items, .. }) => assert!(
|
|
items.iter().any(|c| c.text == "Customers"),
|
|
"FROM slot should offer table `Customers`; got {items:?}",
|
|
),
|
|
other => panic!("expected candidates in advanced mode, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn advanced_mode_ambient_offers_dml_slot_candidates() {
|
|
// 3k cross-cut (matrix A6, advanced surface): the ambient hint
|
|
// panel surfaces SQL DML slot assistance in Advanced mode —
|
|
// column candidates at an `UPDATE … SET` LHS slot and inside an
|
|
// `INSERT … (` column list. (The simple-mode DSL value-slot
|
|
// prose is a separate surface; this pins the §8 advanced claim.)
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Name", Type::Text)],
|
|
);
|
|
|
|
let set_slot = "update Customers set ";
|
|
match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) {
|
|
Some(AmbientHint::Candidates { items, .. }) => assert!(
|
|
items.iter().any(|c| c.text == "Name" || c.text == "id"),
|
|
"UPDATE SET slot should offer column candidates; got {items:?}",
|
|
),
|
|
other => panic!("expected candidates at the UPDATE SET slot, got {other:?}"),
|
|
}
|
|
|
|
let col_list = "insert into Customers (";
|
|
match ambient_hint_in_mode(col_list, col_list.len(), None, &cache, Mode::Advanced) {
|
|
Some(AmbientHint::Candidates { items, .. }) => assert!(
|
|
items.iter().any(|c| c.text == "Name" || c.text == "id"),
|
|
"INSERT column-list slot should offer column candidates; got {items:?}",
|
|
),
|
|
other => panic!("expected candidates in the INSERT column list, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn simple_mode_ambient_does_not_surface_sql_candidates() {
|
|
// The simple-mode entry point keeps gating SQL — advanced
|
|
// assistance is opt-in via mode, never leaked into simple.
|
|
let cache =
|
|
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
|
let input = "select * from ";
|
|
let hint = ambient_hint_in_mode(
|
|
input,
|
|
input.len(),
|
|
None,
|
|
&cache,
|
|
crate::mode::Mode::Simple,
|
|
);
|
|
let offers_table = matches!(
|
|
&hint,
|
|
Some(AmbientHint::Candidates { items, .. })
|
|
if items.iter().any(|c| c.text == "Customers"),
|
|
);
|
|
assert!(
|
|
!offers_table,
|
|
"simple mode must not surface SQL FROM candidates: {hint:?}",
|
|
);
|
|
}
|
|
|
|
// ---- F1: mid-typed token completes, not flagged (both modes) ----
|
|
|
|
#[test]
|
|
fn f1_mid_typed_table_prefix_shows_completion_not_error() {
|
|
// "select * from c" — `c` prefix-matches `Customers`. The
|
|
// hint must offer the completion, not "no such table c".
|
|
let cache =
|
|
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
|
match ambient_hint_in_mode(
|
|
"select * from c",
|
|
"select * from c".len(),
|
|
None,
|
|
&cache,
|
|
crate::mode::Mode::Advanced,
|
|
) {
|
|
Some(AmbientHint::Candidates { items, .. }) => assert!(
|
|
items.iter().any(|c| c.text == "Customers"),
|
|
"expected Customers completion, got {items:?}",
|
|
),
|
|
other => panic!("F1: expected completion candidates, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn f1_genuinely_unknown_table_still_shows_error() {
|
|
// "zzz" matches no table prefix — the error must still show.
|
|
let cache =
|
|
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
|
match ambient_hint_in_mode(
|
|
"select * from zzz",
|
|
"select * from zzz".len(),
|
|
None,
|
|
&cache,
|
|
crate::mode::Mode::Advanced,
|
|
) {
|
|
Some(AmbientHint::Prose(s)) => {
|
|
assert!(s.contains("zzz"), "expected unknown-table error, got {s:?}");
|
|
}
|
|
other => panic!("F1: expected unknown-table error prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn f1_simple_mode_dsl_mid_typed_table_completes() {
|
|
// The same shadowing affects DSL commands in simple mode:
|
|
// "show data c" must offer Customers, not "no such table c".
|
|
let cache =
|
|
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
|
|
match ambient_hint_in_mode(
|
|
"show data c",
|
|
"show data c".len(),
|
|
None,
|
|
&cache,
|
|
crate::mode::Mode::Simple,
|
|
) {
|
|
Some(AmbientHint::Candidates { items, .. }) => assert!(
|
|
items.iter().any(|c| c.text == "Customers"),
|
|
"expected Customers completion, got {items:?}",
|
|
),
|
|
other => panic!("F1 (simple): expected completion candidates, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ---- F2: projection-before-FROM narrows to the FROM table ----
|
|
|
|
#[test]
|
|
fn f2_empty_projection_narrows_to_from_table() {
|
|
use crate::completion::TableColumn;
|
|
use crate::dsl::types::Type;
|
|
// Two tables; cursor in the EMPTY projection of
|
|
// "select from Orders" must offer Orders' column, NOT
|
|
// Customers' column.
|
|
let mut cache = schema_with_columns("Customers", &[("cust_col", Type::Text)]);
|
|
cache.tables.push("Orders".to_string());
|
|
cache.columns.push("order_col".to_string());
|
|
cache.table_columns.insert(
|
|
"Orders".to_string(),
|
|
vec![TableColumn { name: "order_col".to_string(), user_type: Type::Int, not_null: false, has_default: false }],
|
|
);
|
|
|
|
let comp = crate::completion::candidates_at_cursor_in_mode(
|
|
"select from Orders",
|
|
7,
|
|
&cache,
|
|
crate::mode::Mode::Advanced,
|
|
)
|
|
.expect("candidates at projection cursor");
|
|
let texts: Vec<String> = comp.candidates.iter().map(|c| c.text.clone()).collect();
|
|
assert!(
|
|
texts.iter().any(|t| t == "order_col"),
|
|
"F2: should offer the FROM table's column; got {texts:?}",
|
|
);
|
|
assert!(
|
|
!texts.iter().any(|t| t == "cust_col"),
|
|
"F2: must NOT offer the other table's column; got {texts:?}",
|
|
);
|
|
}
|
|
|
|
// ---- Phase D typed-slot hints (end-to-end) -------
|
|
|
|
fn schema_with_columns(
|
|
table: &str,
|
|
cols: &[(&str, crate::dsl::types::Type)],
|
|
) -> crate::completion::SchemaCache {
|
|
use crate::completion::{SchemaCache, TableColumn};
|
|
let mut cache = SchemaCache::default();
|
|
cache.tables.push(table.to_string());
|
|
let columns: Vec<TableColumn> = cols
|
|
.iter()
|
|
.map(|(n, t)| TableColumn {
|
|
name: (*n).to_string(),
|
|
user_type: *t,
|
|
not_null: false,
|
|
has_default: false,
|
|
})
|
|
.collect();
|
|
for c in &columns {
|
|
cache.columns.push(c.name.clone());
|
|
}
|
|
cache
|
|
.table_columns
|
|
.insert(table.to_string(), columns);
|
|
cache
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_insert_first_value_shows_int_prose() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Name", Type::Text)],
|
|
);
|
|
let input = "insert into Customers values (";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(
|
|
p.contains("integer"),
|
|
"expected int-slot prose, got: {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_combines_dsl_error_with_advanced_sql_pointer() {
|
|
// ADR-0033 Amendment 3: in simple mode, a *definite* DSL
|
|
// error whose line would run as SQL in advanced mode keeps
|
|
// its actionable DSL prose AND gains the
|
|
// `advanced_mode.also_valid_sql` pointer. `Customers(id
|
|
// serial, Name, Email)`: DSL Form B auto-skips the serial
|
|
// `id`, so three values is a definite DSL error — but the same
|
|
// line is a valid SQL insert (all three columns).
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
|
|
);
|
|
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
// The DSL detail survives …
|
|
assert!(p.contains("Name"), "expected DSL slot detail, got: {p:?}");
|
|
// … and the advanced-mode pointer is appended.
|
|
assert!(
|
|
p.contains("advanced mode"),
|
|
"expected the advanced-mode pointer, got: {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_does_not_add_pointer_for_a_valid_dsl_command() {
|
|
// A valid simple-mode DSL command gets no advanced pointer —
|
|
// it isn't an error, and there is nothing to switch modes for.
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Serial), ("Name", Type::Text)],
|
|
);
|
|
let input = "insert into Customers values ('Alice')";
|
|
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
|
|
assert!(
|
|
!p.contains("advanced mode"),
|
|
"a valid DSL command must not carry the advanced pointer, got: {p:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_insert_second_value_shows_text_prose() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Name", Type::Text)],
|
|
);
|
|
let input = "insert into Customers values (1, ";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(
|
|
p.contains("quoted string"),
|
|
"expected text-slot prose, got: {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_update_set_shows_per_column_prose() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Birthday", Type::Date)],
|
|
);
|
|
let input = "update Customers set Birthday=";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(
|
|
p.contains("YYYY-MM-DD"),
|
|
"expected date-slot prose, got: {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn in_progress_form_a_values_list_classifies_as_incomplete_not_definite_error() {
|
|
// Regression: typing `insert into T (a, b, c) values
|
|
// (1, 2, 3` (no closing paren yet) used to classify as
|
|
// DefiniteErrorAt(<values position>) because the
|
|
// walker's Optional rolled back the partial values
|
|
// list, leaving the rest of the input as trailing junk
|
|
// — the renderer then overlaid `values` in red. After
|
|
// the walk_optional fix (only roll back when the inner
|
|
// hasn't committed), the Optional propagates Incomplete
|
|
// and the user sees no error overlay until they submit.
|
|
assert_eq!(
|
|
classify_input(
|
|
"insert into Orders (id, CustId, Total) values (42, 89, 17.59"
|
|
),
|
|
InputState::IncompleteAtEof,
|
|
);
|
|
assert_eq!(
|
|
classify_input("insert into Orders (id, CustId, Total) values (42, 89"),
|
|
InputState::IncompleteAtEof,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_insert_first_value_mentions_column_name() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Name", Type::Text)],
|
|
);
|
|
let input = "insert into Customers values (";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(p.contains("id"), "expected column name `id`, got {p:?}");
|
|
assert!(
|
|
p.contains("integer"),
|
|
"expected int prose, got {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_update_set_mentions_column_name() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text)],
|
|
);
|
|
let input = "update Customers set Email=";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(
|
|
p.contains("Email"),
|
|
"expected column name `Email`, got {p:?}",
|
|
);
|
|
assert!(
|
|
p.contains("quoted string"),
|
|
"expected text prose, got {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_where_mentions_column_name() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns("Events", &[("ts", Type::DateTime)]);
|
|
let input = "delete from Events where ts=";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(p.contains("ts"), "expected column name `ts`, got {p:?}");
|
|
assert!(
|
|
p.contains("YYYY-MM-DD"),
|
|
"expected datetime prose, got {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_second_insert_value_mentions_second_column() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Name", Type::Text)],
|
|
);
|
|
let input = "insert into Customers values (1, ";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(
|
|
p.contains("Name"),
|
|
"expected second column `Name`, got {p:?}",
|
|
);
|
|
assert!(
|
|
p.contains("quoted string"),
|
|
"expected text prose, got {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_value_slot_falls_back_to_generic_without_schema() {
|
|
// Empty cache: the walker can't resolve the column type
|
|
// → falls back to the generic value-literal prose.
|
|
let cache = empty_cache();
|
|
let input = "insert into T values (";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
// Generic prose lists all forms.
|
|
assert!(p.contains("number"), "got: {p:?}");
|
|
assert!(p.contains("true/false") || p.contains("true"), "got: {p:?}");
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_valid_input_invites_submit() {
|
|
let h = prose("create table T with pk", 22).expect("prose hint");
|
|
assert!(h.contains("Enter"), "got {h:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_partial_keyword_position_returns_candidates() {
|
|
// `show` mid-keyword: candidates_at_cursor returns
|
|
// {data, table} filtered by prefix "show" — but
|
|
// "show" doesn't match any keyword's prefix. The
|
|
// partial prefix walk finds `show`; expected set at
|
|
// start-of-input is the entry keywords; none start
|
|
// with "show" except `show` itself. Hmm — let me
|
|
// check the actual semantics: at "show" cursor 4,
|
|
// start = 0, partial = "show", expected = entry
|
|
// keywords. Filter by "show" → just `show`. Single
|
|
// candidate.
|
|
let cs = cands_hint("show", 4).expect("candidate hint");
|
|
assert_eq!(cs, vec!["show".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_at_word_boundary_after_show_returns_data_table() {
|
|
let cs = cands_hint("show ", 5).expect("candidate hint");
|
|
assert_eq!(cs, vec!["data".to_string(), "table".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_usage_matches_the_add_form_typed() {
|
|
// A trailing-junk error after `add index …` must show
|
|
// the `add index` usage — `add` is a multi-form
|
|
// command and the hint used to always show the first
|
|
// form (`add column`).
|
|
let input = "add index on Customers (barg):";
|
|
let h = prose(input, input.len()).expect("prose hint");
|
|
assert!(h.contains("usage:"), "got {h:?}");
|
|
assert!(
|
|
h.contains("add index"),
|
|
"should show the `add index` usage, got {h:?}",
|
|
);
|
|
assert!(
|
|
!h.contains("add column"),
|
|
"should not show the `add column` usage, got {h:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_definite_error_includes_usage_template() {
|
|
let h = prose("insert into T extra", 19).expect("prose hint");
|
|
assert!(
|
|
h.contains("usage:"),
|
|
"definite-error hint should include usage template, got {h:?}",
|
|
);
|
|
assert!(
|
|
h.contains("insert into <Table>"),
|
|
"should reference the insert usage template, got {h:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_unknown_command_falls_back_to_message() {
|
|
// `frobulate widgets` cursor at start: candidates are
|
|
// computed first; "frobulate" doesn't match any
|
|
// keyword, so candidates = empty → falls back to
|
|
// prose error message.
|
|
let h = prose("frobulate widgets", 17).expect("prose hint");
|
|
assert!(
|
|
!h.contains("usage:"),
|
|
"no entry keyword consumed → no usage template; got {h:?}",
|
|
);
|
|
assert!(
|
|
h.contains("frobulate"),
|
|
"message should mention the unknown word; got {h:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_for_invalid_identifier_says_no_such() {
|
|
use crate::completion::SchemaCache;
|
|
// `show data Custp` is a complete command naming a table
|
|
// that does not exist — surfaced by the ADR-0027
|
|
// diagnostic branch (the schema-existence ERROR).
|
|
let cache = SchemaCache {
|
|
tables: vec!["Customers".to_string()],
|
|
..SchemaCache::default()
|
|
};
|
|
match ambient_hint("show data Custp", 15, None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(
|
|
p.to_lowercase().contains("no such table"),
|
|
"expected 'no such table' wording, got {p:?}",
|
|
);
|
|
assert!(p.contains("Custp"), "should name the bad ident, got {p:?}");
|
|
}
|
|
other => panic!("expected Prose for invalid-ident, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ---- diagnostic hints (ADR-0027 hint wiring) ----
|
|
|
|
#[test]
|
|
fn ambient_hint_surfaces_unknown_table_diagnostic() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns("Customers", &[("id", Type::Int)]);
|
|
match ambient_hint("show data Missing", 17, None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(p.contains("Missing"), "got {p:?}");
|
|
assert!(
|
|
p.to_lowercase().contains("no such table"),
|
|
"got {p:?}",
|
|
);
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_surfaces_type_mismatch_over_submit_prose() {
|
|
use crate::dsl::types::Type;
|
|
// The command parses cleanly — without the diagnostic
|
|
// branch this shows the misleading "press Enter" prose.
|
|
let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
|
|
let input = "delete from Events where Count = 'oops'";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(!p.contains("Enter"), "should not invite submit: {p:?}");
|
|
assert!(p.contains("Count"), "should name the column: {p:?}");
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_surfaces_like_numeric_warning() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
|
|
let input = "delete from Events where Count like '9%'";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(p.contains("LIKE"), "should mention LIKE: {p:?}");
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_clean_command_still_invites_submit() {
|
|
use crate::dsl::types::Type;
|
|
let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
|
|
let input = "delete from Events where Count = 7";
|
|
match ambient_hint(input, input.len(), None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => {
|
|
assert!(p.contains("Enter"), "clean command invites submit: {p:?}");
|
|
}
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_diagnostic_follows_the_cursor() {
|
|
use crate::dsl::types::Type;
|
|
// Two type-mismatch WARNINGs; the hint names the column
|
|
// whose offending literal the cursor sits in.
|
|
let cache =
|
|
schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
|
|
let input = "delete from Events where a = 'x' or b = 'y'";
|
|
let on_x = input.find("'x'").expect("'x' literal") + 1;
|
|
let on_y = input.find("'y'").expect("'y' literal") + 1;
|
|
let prose_at = |cursor| match ambient_hint(input, cursor, None, &cache) {
|
|
Some(AmbientHint::Prose(p)) => p,
|
|
other => panic!("expected Prose, got {other:?}"),
|
|
};
|
|
assert!(prose_at(on_x).contains("`a`"), "cursor on 'x' → column a");
|
|
assert!(prose_at(on_y).contains("`b`"), "cursor on 'y' → column b");
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_with_memo_carries_selected_index() {
|
|
use crate::completion::{Candidate, CandidateKind, LastCompletion};
|
|
let memo = LastCompletion {
|
|
inserted_range: (5, 5),
|
|
original_text: String::new(),
|
|
candidates: vec![
|
|
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword },
|
|
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword },
|
|
],
|
|
selection_idx: 1,
|
|
};
|
|
match ambient_hint("show ", 5, Some(&memo), &empty_cache()) {
|
|
Some(AmbientHint::Candidates { items, selected }) => {
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].text, "data");
|
|
assert_eq!(items[1].text, "table");
|
|
assert_eq!(selected, Some(1));
|
|
}
|
|
other => panic!("expected Candidates, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ambient_hint_during_cycling_shows_memo_list_not_recomputed() {
|
|
// Stage-8 user-reported #4: cycling through candidates
|
|
// moves the cursor; the panel should NOT shift to "what
|
|
// comes next at the new cursor position" — it should
|
|
// keep showing the memo's candidate list with the
|
|
// updated selection. Without the memo short-circuit,
|
|
// ambient_hint would recompute candidates_at_cursor
|
|
// post-Tab and produce a different list.
|
|
use crate::completion::{Candidate, CandidateKind, LastCompletion};
|
|
let memo = LastCompletion {
|
|
inserted_range: (5, 11),
|
|
original_text: String::new(),
|
|
// Include candidates whose order would NOT match
|
|
// what candidates_at_cursor("show table ", 11) would
|
|
// produce — proves the memo's list is being used,
|
|
// not a recomputed one.
|
|
candidates: vec![
|
|
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword },
|
|
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword },
|
|
],
|
|
selection_idx: 1,
|
|
};
|
|
match ambient_hint("show table ", 11, Some(&memo), &empty_cache()) {
|
|
Some(AmbientHint::Candidates { items, selected }) => {
|
|
assert_eq!(items.len(), 2);
|
|
assert_eq!(items[0].text, "data");
|
|
assert_eq!(items[1].text, "table");
|
|
assert_eq!(selected, Some(1));
|
|
}
|
|
other => panic!("expected Candidates from memo, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ---- classify_input + error overlay (stage 4) ----
|
|
|
|
#[test]
|
|
fn classify_empty_input() {
|
|
assert_eq!(classify_input(""), InputState::Empty);
|
|
assert_eq!(classify_input(" "), InputState::Empty);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_complete_command_is_valid() {
|
|
assert_eq!(
|
|
classify_input("create table Customers with pk"),
|
|
InputState::Valid,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_partial_keyword_only_is_incomplete() {
|
|
// `create` alone — parser fails at EOF expecting `table`.
|
|
assert_eq!(classify_input("create"), InputState::IncompleteAtEof);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_partial_command_mid_clause_is_incomplete() {
|
|
assert_eq!(
|
|
classify_input("create table Customers"),
|
|
InputState::IncompleteAtEof,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_unknown_command_is_definite_error_at_zero() {
|
|
assert_eq!(
|
|
classify_input("frobulate widgets"),
|
|
InputState::DefiniteErrorAt(0),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn classify_wrong_token_mid_command_is_definite_error_at_token_position() {
|
|
// `create table` consumed (12 bytes inc. trailing space
|
|
// skipped by lexer); `1Bad` lexes as Number(13)+Identifier(14).
|
|
// Parser expects ident at position 13, finds Number — fails.
|
|
let state = classify_input("create table 1Bad");
|
|
match state {
|
|
InputState::DefiniteErrorAt(pos) => assert_eq!(pos, 13),
|
|
other => panic!("expected DefiniteErrorAt(13), got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn classify_trailing_whitespace_does_not_create_definite_error() {
|
|
// Trailing whitespace alone shouldn't promote an
|
|
// incomplete-at-EOF state into a definite error.
|
|
assert_eq!(
|
|
classify_input("create "),
|
|
InputState::IncompleteAtEof,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_input_runs_overlays_error_on_failing_token() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("frobulate widgets", 17, &theme, &empty_cache());
|
|
// First run is `frobulate` at (0,9). Should be tok_error
|
|
// colour (definite error overlay).
|
|
assert_eq!(runs[0].byte_range, (0, 9));
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_error));
|
|
// Second run is whitespace, third is `widgets` — these
|
|
// don't get the overlay (only the failing token).
|
|
let widgets = runs.iter().find(|r| r.byte_range == (10, 17));
|
|
assert!(widgets.is_some());
|
|
assert_eq!(
|
|
widgets.unwrap().style.fg,
|
|
Some(theme.tok_identifier),
|
|
"tokens after the error stay in their lex-class colour",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_input_runs_does_not_overlay_for_incomplete_input() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create", 6, &theme, &empty_cache());
|
|
// No error overlay — `create` keeps tok_keyword.
|
|
assert_eq!(runs[0].byte_range, (0, 6));
|
|
assert_eq!(runs[0].style.fg, Some(theme.tok_keyword));
|
|
}
|
|
|
|
#[test]
|
|
fn render_input_runs_does_not_overlay_for_valid_input() {
|
|
let theme = dark();
|
|
let runs = render_input_runs("create table T with pk", 22, &theme, &empty_cache());
|
|
// None of the tokens should be tok_error.
|
|
for r in &runs {
|
|
assert_ne!(
|
|
r.style.fg,
|
|
Some(theme.tok_error),
|
|
"no error overlay for valid input: {r:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn full_valid_command_lexes_to_each_token_class() {
|
|
// Use a valid command — `update ... --all-rows` — with
|
|
// a schema that actually has the table and column, so
|
|
// no overlay (parse-error or ADR-0027 diagnostic)
|
|
// replaces a class colour with tok_error. Tokens:
|
|
// keyword(s), identifier(s), string literal, punct (=),
|
|
// flag.
|
|
use crate::dsl::types::Type;
|
|
let theme = dark();
|
|
let cache = schema_with_columns("T", &[("Name", Type::Text)]);
|
|
let input = "update T set Name='hi' --all-rows";
|
|
let runs = render_input_runs(input, input.len(), &theme, &cache);
|
|
let fgs: Vec<_> = runs.iter().filter_map(|r| r.style.fg).collect();
|
|
assert!(fgs.contains(&theme.tok_keyword)); // update / set
|
|
assert!(fgs.contains(&theme.tok_identifier)); // T / Name
|
|
assert!(fgs.contains(&theme.tok_string)); // 'hi'
|
|
assert!(fgs.contains(&theme.tok_punct)); // =
|
|
assert!(fgs.contains(&theme.tok_flag)); // --all-rows
|
|
// The valid command must not have any error overlay.
|
|
for r in &runs {
|
|
assert_ne!(r.style.fg, Some(theme.tok_error));
|
|
}
|
|
}
|
|
|
|
// ---- diagnostic overlays (ADR-0027 highlight wiring) ----
|
|
|
|
#[test]
|
|
fn unknown_table_is_overlaid_in_error_colour() {
|
|
use crate::dsl::types::Type;
|
|
let theme = dark();
|
|
let cache = schema_with_columns("Customers", &[("id", Type::Int)]);
|
|
let input = "show data NoSuchTable";
|
|
// Cursor at the start — the overlay must fire even
|
|
// though the cursor is nowhere near the bad name.
|
|
let runs = render_input_runs(input, 0, &theme, &cache);
|
|
let bad = runs
|
|
.iter()
|
|
.find(|r| r.text(input) == "NoSuchTable")
|
|
.expect("a run for the table name");
|
|
assert_eq!(
|
|
bad.style.fg,
|
|
Some(theme.tok_error),
|
|
"diagnostic highlight is global, not cursor-local",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_column_is_overlaid_in_error_colour() {
|
|
use crate::dsl::types::Type;
|
|
let theme = dark();
|
|
let cache = schema_with_columns("Customers", &[("id", Type::Int)]);
|
|
let input = "delete from Customers where Nope = 1";
|
|
let runs = render_input_runs(input, 0, &theme, &cache);
|
|
let bad = runs
|
|
.iter()
|
|
.find(|r| r.text(input) == "Nope")
|
|
.expect("a run for the column name");
|
|
assert_eq!(bad.style.fg, Some(theme.tok_error));
|
|
}
|
|
|
|
#[test]
|
|
fn type_mismatch_literal_is_overlaid_in_warning_colour() {
|
|
use crate::dsl::types::Type;
|
|
let theme = dark();
|
|
let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
|
|
let input = "delete from Events where Count = 'oops'";
|
|
let runs = render_input_runs(input, 0, &theme, &cache);
|
|
let lit = runs
|
|
.iter()
|
|
.find(|r| r.text(input) == "'oops'")
|
|
.expect("a run for the literal");
|
|
assert_eq!(lit.style.fg, Some(theme.warning));
|
|
// Precise span: the column name is not warning-coloured.
|
|
let col = runs
|
|
.iter()
|
|
.find(|r| r.text(input) == "Count")
|
|
.expect("a run for the column");
|
|
assert_ne!(col.style.fg, Some(theme.warning));
|
|
}
|
|
|
|
#[test]
|
|
fn like_on_numeric_column_is_overlaid_in_warning_colour() {
|
|
use crate::dsl::types::Type;
|
|
let theme = dark();
|
|
let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
|
|
let input = "delete from Events where Count like '9%'";
|
|
let runs = render_input_runs(input, 0, &theme, &cache);
|
|
let col = runs
|
|
.iter()
|
|
.find(|r| r.text(input) == "Count")
|
|
.expect("a run for the column");
|
|
assert_eq!(col.style.fg, Some(theme.warning));
|
|
}
|
|
|
|
#[test]
|
|
fn clean_schema_aware_command_has_no_diagnostic_overlay() {
|
|
use crate::dsl::types::Type;
|
|
let theme = dark();
|
|
let cache = schema_with_columns("Events", &[("Count", Type::Int)]);
|
|
let input = "delete from Events where Count = 7";
|
|
let runs = render_input_runs(input, input.len(), &theme, &cache);
|
|
for r in &runs {
|
|
assert_ne!(r.style.fg, Some(theme.tok_error));
|
|
assert_ne!(r.style.fg, Some(theme.warning));
|
|
}
|
|
}
|
|
}
|