//! 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_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 { 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 { // Identity feedback view — highlight/overlay the whole input. render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0) } /// [`render_input_runs_in_mode`] with a separate **feedback view** for /// the walker-driven highlighting and overlays. /// /// Under the `:` one-shot escape (ADR-0003) the buffer carries a leading /// `:` that is not advanced SQL; `view` is the stripped SQL (and /// `view_cursor` the cursor within it) so the walker highlights and /// diagnoses the SQL itself, while the `:` prefix renders as plain text. /// `offset` is the byte length stripped from the front — base runs and /// overlay positions are shifted by it back into `input` coordinates. /// Callers without a one-shot escape pass `(input, cursor, 0)` (what /// [`render_input_runs_in_mode`] does). #[must_use] #[allow(clippy::too_many_arguments)] pub fn render_input_runs_feedback( input: &str, cursor_byte: usize, theme: &Theme, cache: &crate::completion::SchemaCache, mode: Mode, view: &str, view_cursor: usize, offset: usize, ) -> Vec { // Base highlighting runs over the SQL view, shifted into buffer // coordinates; the stripped prefix (the `:` + space) renders as // plain foreground text. let mut runs: Vec = if offset == 0 { lex_to_runs_in_mode(input, theme, mode) } else { let mut r = vec![StyledRun { byte_range: (0, offset), style: ratatui::style::Style::default().fg(theme.fg), }]; r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| { StyledRun { byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset), ..run } })); r }; if let InputState::DefiniteErrorAt(pos) = classify_parse_result(parse_command_with_schema_in_mode(view, cache, mode)) { overlay_error(&mut runs, pos + offset, theme); } if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(view, view_cursor, cache, mode) { overlay_error(&mut runs, inv.range.0 + offset, 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(view, 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.0 + offset, diag.span.1 + offset), 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, ) -> 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, /// 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, }, } /// 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 { 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 { 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 would be **valid** in advanced /// mode (ADR-0033 Amendment 5). /// /// "Valid" is the ADR-0027 sense: the advanced-mode input-validity /// verdict comes back as `None` (no Warning, no Error) after every /// static check — parse, schema existence, type slots, INSERT arity, /// predicate warnings, and any future addition. The pointer fires only /// when switching modes is **actually** the next step the user can /// take to make the line valid: a `Severity::Warning` or /// `Severity::Error` from the advanced-mode pipeline means switching /// wouldn't resolve the line, so the pointer would mislead. /// /// Issue #1 origin: previously the gate was a syntactic "would parse" /// check, which fired for the user's reported case (4-col table, /// 3 positional values) where the line parses but fails at the engine. /// Now the gate reads `input_verdict_in_mode` so any static /// rejection — including the Form B arity diagnostic added in this /// same change (issue #1) — suppresses the pointer automatically. #[must_use] pub fn advanced_alternative_note( input: &str, cache: &crate::completion::SchemaCache, ) -> Option { // The line must be *definitely* invalid in simple mode — a definite // parse error, or (issue #17) a parse that succeeds structurally but // carries a blocking ERROR diagnostic such as a value-count // mismatch. Incomplete input (still being typed) and empty input are // excluded so the pointer doesn't flicker mid-keystroke. let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) { InputState::DefiniteErrorAt(_) => true, InputState::Valid => { crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple) == Some(crate::dsl::walker::outcome::Severity::Error) } InputState::Empty | InputState::IncompleteAtEof => false, }; if !definite_dsl_error { return None; } // The validity-verdict-driven gate (ADR-0033 Amendment 5): the // line must be fully valid (verdict `None`) in advanced mode. if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() { return None; } Some(crate::t!("advanced_mode.also_valid_sql")) } /// Education note for the simple-mode INSERT Form B count-mismatch /// case (issue #1 sub-task 2). /// /// When a simple-mode `insert into values (…)` (Form B) supplies /// more values than the non-auto-generated columns of `` — but no /// more than every column — the bare "expected `)`" parse error /// doesn't explain the contract that excluded the serial / shortid /// columns. This helper returns a note that names the columns Form B /// expects values for, names the auto-generated ones it skips, and /// shows the column-list (Form A) override. /// /// Returns `None` when the input doesn't match the pattern (e.g. the /// advanced parse fails, the schema isn't known, the table has no /// auto-generated columns, the value count is at or below the non-auto /// count, or the value count exceeds the total column count — which is /// a different error class the engine surfaces directly). /// /// Also `None` when the cross-mode `advanced_alternative_note` pointer /// would fire for the same input: those two pieces of advice overlap /// (both are escape hatches from the Form-B mismatch — one to advanced /// mode, one to Form A) and showing both would clutter the error /// without adding pedagogy. The cross-mode pointer wins because it /// only fires when switching modes actually works (issue #1 sub-task /// 1's gate); when it doesn't fire, this note steps in. /// Submit-time pre-flight for a simple-mode (DSL) `Command::Insert` /// whose positional value count doesn't match the expected count /// (issue #17). Returns the advice line(s) to display when there is a /// mismatch — the caller (`dispatch_dsl`) blocks dispatch whenever this /// is `Some`, so a wrong-count insert never reaches the worker. `None` /// when the command isn't an insert, the table is unknown, or the count /// already matches. /// /// This is the simple-mode counterpart of the advanced Ok-arm pre-flight /// (`form_b_positional_count_mismatch_note`). Both modes now parse a /// wrong-count insert as `Ok` (so the typing-time arity diagnostic can /// fire — issue #17), so dispatch is gated here, uniformly, rather than /// by a parse error. /// /// Expected count: Form A (explicit `(col, …)`) → the listed count; /// Form B/C (no list) → the user-fillable (non-auto-generated) count, /// since the dispatch auto-fills serial/shortid (ADR-0018 §3). /// /// Advice selection mirrors the previous Err-arm logic: the cross-mode /// pointer wins when the same text is valid in advanced mode; otherwise /// Form B/C shows the rich teaching note (names the fillable + auto /// columns and the Form-A override) and Form A shows the column-list /// arity message. #[must_use] pub fn dsl_insert_count_mismatch_notes( input: &str, cmd: &crate::dsl::command::Command, cache: &crate::completion::SchemaCache, ) -> Option> { use crate::dsl::command::Command; use crate::dsl::types::Type; let Command::Insert { table, columns, values, } = cmd else { return None; }; let table_cols = cache.table_columns.get(table)?; let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId); let expected = columns.as_ref().map_or_else( || table_cols.iter().filter(|c| !is_auto(c.user_type)).count(), Vec::len, ); if values.len() == expected { return None; // counts match — nothing to flag, dispatch proceeds } // Count mismatch → the caller blocks dispatch. Build the advice. // The cross-mode pointer is the single most useful line when the // same text is valid in advanced mode, so it suppresses the rest. if let Some(pointer) = advanced_alternative_note(input, cache) { return Some(vec![pointer]); } let note = if columns.is_some() { // Form A: the column-list arity message. crate::t!( "diagnostic.insert_arity_mismatch", expected = expected, actual = values.len() ) } else { // Form B/C: the rich teaching note. Falls back to the all-auto // explanation for a table whose columns are all auto-generated // (the override note doesn't apply there). form_b_extra_values_note(input, cache).unwrap_or_else(|| { crate::t!( "diagnostic.insert_arity_mismatch_all_auto", table = table, actual = values.len() ) }) }; Some(vec![note]) } #[must_use] pub fn form_b_extra_values_note( input: &str, cache: &crate::completion::SchemaCache, ) -> Option { use crate::dsl::command::Command; use crate::dsl::types::Type; if advanced_alternative_note(input, cache).is_some() { return None; } let parsed = parse_command_with_schema_in_mode(input, cache, Mode::Advanced).ok()?; let Command::SqlInsert { target_table, listed_columns, literal_rows, .. } = parsed else { return None; }; if !listed_columns.is_empty() || literal_rows.is_empty() { return None; } let table_cols = cache.table_columns.get(&target_table)?; let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId); let auto: Vec<&str> = table_cols .iter() .filter(|c| is_auto(c.user_type)) .map(|c| c.name.as_str()) .collect(); if auto.is_empty() { return None; } let non_auto: Vec<&str> = table_cols .iter() .filter(|c| !is_auto(c.user_type)) .map(|c| c.name.as_str()) .collect(); // Defence in depth: a table that is all-auto has no non-auto // column to talk about. The cross-mode-pointer gate above would // normally shield us (the line works in advanced — the pointer // fires and we return early), but the helper is `pub` and should // not produce "expects 0 values for " on its own. if non_auto.is_empty() { return None; } // Fire whenever the supplied count differs from Form B's expected // count — the teaching message is forward-looking (it states what // Form B expects + the override path) so it works for under-supply, // over-supply within the column range, and over-supply past the // total column count alike (issue #1 siblings task — over- / // under-supply previously fell through to the bare parse error). let value_count = literal_rows[0].len(); if value_count == non_auto.len() { return None; } let expected_phrase = if non_auto.len() == 1 { format!("1 value for {}", quote_join_and(&non_auto)) } else { format!( "{count} values for {names}", count = non_auto.len(), names = quote_join_and(&non_auto), ) }; let auto_phrase = if auto.len() == 1 { format!( "{name} is auto-generated and filled automatically", name = quote_join_and(&auto), ) } else { format!( "{names} are auto-generated and filled automatically", names = quote_join_and(&auto), ) }; let all_cols = table_cols .iter() .map(|c| c.name.as_str()) .collect::>() .join(", "); Some(crate::t!( "insert.form_b_extra_values_note", table = target_table, expected_phrase = expected_phrase, auto_phrase = auto_phrase, all_cols = all_cols, )) } /// Pre-flight note for advanced-mode `insert into values (…)` /// (positional, no column list) when the value count doesn't match /// the table's column count (issue #1 sub-task 3). /// /// The advanced-mode SQL grammar accepts any positional value count; /// the engine then rejects the line with a NOT-NULL / type-mismatch /// error that doesn't tell the user about the column-list override. /// This helper detects the case at dispatch time and returns a note /// that names the rule, lists the target table's columns, and shows /// the column-list (Form A) override. /// /// Returns `None` when the input doesn't match the pattern (the parse /// isn't a Form B `SqlInsert`, the schema isn't known, the table has /// no auto-generated columns or no non-auto columns, the literal-row /// shape is empty, or every row's value count already matches the /// column count). /// /// Conservative on multi-row VALUES: fires only when **every** row's /// value count is the same wrong number. Mixed-length rows fall /// through to the engine error — they're a different shape of mistake. #[must_use] pub fn form_b_positional_count_mismatch_note( parsed: &crate::dsl::command::Command, cache: &crate::completion::SchemaCache, ) -> Option { use crate::dsl::command::Command; use crate::dsl::types::Type; let Command::SqlInsert { target_table, listed_columns, literal_rows, .. } = parsed else { return None; }; if !listed_columns.is_empty() || literal_rows.is_empty() { return None; } let table_cols = cache.table_columns.get(target_table)?; let col_count = table_cols.len(); let row_lens: Vec = literal_rows.iter().map(Vec::len).collect(); // Only fire when every row is the same (wrong) length. let first_len = row_lens[0]; if !row_lens.iter().all(|n| *n == first_len) { return None; } if first_len == col_count { return None; } let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId); // The override only makes sense when (a) there's something to // skip and (b) something to list. A table that is all-auto can't // omit anything via the column list; a table with no autos // doesn't benefit from the column-list form for this purpose. let has_auto = table_cols.iter().any(|c| is_auto(c.user_type)); if !has_auto { return None; } let non_auto: Vec<&str> = table_cols .iter() .filter(|c| !is_auto(c.user_type)) .map(|c| c.name.as_str()) .collect(); if non_auto.is_empty() { return None; } let all_cols_list = quote_join_and( &table_cols .iter() .map(|c| c.name.as_str()) .collect::>(), ); let non_auto_csv = non_auto.join(", "); Some(crate::t!( "insert.form_b_positional_count_mismatch_note", table = target_table, col_count = col_count, col_list = all_cols_list, supplied = first_len, non_auto_csv = non_auto_csv, )) } /// Format `items` as an English-prose list with backtick-quoted /// identifiers: `["a"]` → `` `a` ``, `["a","b"]` → `` `a` and `b` ``, /// `["a","b","c"]` → `` `a`, `b`, and `c` `` (Oxford comma). fn quote_join_and(items: &[&str]) -> String { match items { [] => String::new(), [only] => format!("`{only}`"), [a, b] => format!("`{a}` and `{b}`"), rest => { let head: Vec = rest[..rest.len() - 1] .iter() .map(|s| format!("`{s}`")) .collect(); format!("{}, and `{}`", head.join(", "), rest[rest.len() - 1]) } } } fn ambient_hint_core_in_mode( input: &str, cursor: usize, memo: Option<&crate::completion::LastCompletion>, cache: &crate::completion::SchemaCache, mode: Mode, ) -> Option { 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::>() .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 ]". // 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::IntroProse(key)) => { // Slot entry: surface the catalog prose so an // invisible-by-default ident slot (the column-name // `NewName` at the CREATE TABLE element position, // issue #4) reads as the dominant first move with // the keyword alternatives folded into the prose. // Tab candidates remain available via the parallel // completion surface; the user still cycles the // keyword set. return Some(AmbientHint::Prose(crate::friendly::translate(key, &[]))); } 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", // The `seed … set as ` curated vocabulary // (ADR-0048 D9) flags an unknown name here. crate::dsl::grammar::IdentSource::Generators => "generator", // `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. // // Issue #2: parse *with the schema* so the expected-token prose // reflects the schema-aware grammar. Between two values of an // `insert … values (…)` tuple, the type-blind (schemaless) grammar // closes the tuple after one value and points at `)`; the // schema-aware walk knows the remaining columns and correctly // points at `,`. The other hint paths above already use the cache, // so this keeps the whole ladder schema-consistent. match parse_command_with_schema_in_mode(input, cache, 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. // Mode-aware (ADR-0042 G3): advanced-mode shared // entry words show their SQL form, not the DSL one. let usage = crate::dsl::grammar::usage_key_for_input_in_mode(input, mode) .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 { 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 { base_runs(input, theme, mode) } fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec { // 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, 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 = 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 one_shot_colon_highlights_the_sql_and_overlays_no_error() { // ADR-0003 `:` one-shot: the SQL after the `:` must highlight and // diagnose like real advanced mode — the `:` prefix renders as // plain text and a valid query carries no error overlay (the old // path let the walker choke on the `:` and mark it red). use crate::completion::{SchemaCache, TableColumn}; use crate::dsl::types::Type; let theme = dark(); let mut cache = SchemaCache::default(); cache.tables.push("Customers".into()); cache.columns.push("name".into()); cache .table_columns .insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]); let input = ": select name from Customers"; let view = "select name from Customers"; let offset = 2; // ": " let runs = render_input_runs_feedback( input, input.len(), &theme, &cache, Mode::Advanced, view, view.len(), offset, ); assert!( runs.iter().all(|r| r.style.fg != Some(theme.tok_error)), "a valid one-shot query must carry no error overlay: {runs:?}", ); assert!( runs.iter() .any(|r| r.byte_range.0 == offset && r.style.fg == Some(theme.tok_keyword)), "the `select` keyword (past the `: ` prefix) is keyword-coloured: {runs:?}", ); assert_eq!( runs.first().unwrap().byte_range.0, 0, "the `:` prefix is rendered from byte 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 { match ambient_hint(input, cursor, None, &empty_cache()) { Some(AmbientHint::Prose(s)) => Some(s), _ => None, } } fn cands_hint(input: &str, cursor: usize) -> Option> { 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_create_table_element_position_introduces_column_name() { // Issue #4: at `create table T (`, the user is at the // ELEMENT slot of the column-def list. The current candidate // list shows only table-level constraint keywords (`primary`, // `unique`, `check`, `constraint`, `foreign`); a new column // is the dominant first move and is currently invisible // because the COLUMN_DEF branch starts with an `Ident::NewName` // slot which produces no concrete candidate. // // The fix wraps the ELEMENT choice in a `Hinted::IntroProse` // that surfaces a prose hint mentioning the column name first, // with the constraint keywords as the alternative. Tab // candidates remain available. let cache = crate::completion::SchemaCache::default(); let input = "create table Orders ("; match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { Some(AmbientHint::Prose(p)) => { assert!( p.to_lowercase().contains("column name"), "prose must mention `column name`; got: {p:?}", ); // Constraint alternatives should still be mentioned. assert!( p.contains("primary") && p.contains("unique"), "prose should mention constraint alternatives; got: {p:?}", ); } other => panic!("expected Prose hint at ELEMENT slot; got: {other:?}"), } // Tab candidates should remain available (the keywords still cycle). let comp = crate::completion::candidates_at_cursor_in_mode( input, input.len(), &cache, Mode::Advanced, ) .expect("completion must remain available"); let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); for kw in &["primary", "unique"] { assert!( texts.contains(kw), "Tab candidate `{kw}` must remain; got {texts:?}", ); } } fn seed_cache() -> crate::completion::SchemaCache { use crate::completion::TableColumn; use crate::dsl::types::Type; let mut cache = crate::completion::SchemaCache::default(); cache.tables.push("users".to_string()); cache.columns.push("email".to_string()); cache .table_columns .insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]); cache } #[test] fn seed_count_is_advertised_at_the_optional_position() { // Issue #26: `seed users ▮` is a complete command, so the hint // ladder shows only the `set` / `--seed` continuation chips — // the optional row count (a bare number with no candidate) was // invisible. An IntroProse hint that survives the trailing // optionals now advertises it; Tab still cycles the keywords. let cache = seed_cache(); let input = "seed users "; match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) { Some(AmbientHint::Prose(p)) => { assert!( p.contains("row count") && p.contains("20"), "prose must mention the row count and the default; got: {p:?}", ); assert!( p.contains("set") && p.contains("--seed") && p.contains(".column"), "prose should fold in the keyword + column-fill options; got: {p:?}", ); } other => panic!("expected a Prose count hint; got: {other:?}"), } // Tab candidates remain available (completion is independent). let comp = crate::completion::candidates_at_cursor_in_mode( input, input.len(), &cache, Mode::Simple, ) .expect("completion remains available"); let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); assert!( texts.contains(&"set") && texts.contains(&"--seed"), "Tab must still cycle `set` / `--seed`; got {texts:?}", ); // `seed` runs in both modes (ADR-0048), so the hint must fire in // advanced mode too — not only simple. match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { Some(AmbientHint::Prose(p)) => assert!( p.contains("row count"), "count hint must also fire in advanced mode; got: {p:?}", ), other => panic!("expected the count hint in advanced mode; got: {other:?}"), } } #[test] fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() { // Position guard: the hint shows only while the cursor sits at // the count slot. Once the count is supplied — or a later clause // consumes input past it — it must not reappear. let cache = seed_cache(); for input in ["seed users 50 ", "seed users set email = 'x' "] { let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple); let is_count_prose = matches!( &hint, Some(AmbientHint::Prose(p)) if p.contains("row count") ); assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}"); } } #[test] fn seed_count_hint_also_fires_after_a_column_fill_target() { // The count is valid after `seed users.email` too, so the hint // fires there — `.email` is a real column (no diagnostic). let cache = seed_cache(); let input = "seed users.email "; match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) { Some(AmbientHint::Prose(p)) => assert!( p.contains("row count"), "count hint expected after a column-fill target; got: {p:?}", ), other => panic!("expected a Prose count hint; got: {other:?}"), } } #[test] fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() { // Issue #6 trade-off lockdown: dropping the typing-time // `invalid_ident_at_cursor` flag at `sql_expr_ident` positions // (to avoid the false positive on function names like `sum`) // must not silently kill the typo signal for *genuine* column // typos. Once the SELECT is structurally complete (FROM is in // scope), the schema-existence pass fires `unknown_column` // and the ambient hint surfaces that diagnostic via // `pick_hint_diagnostic`. The user still gets the typing-time // warning, just through a different path. 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); for input in [ "select Agx from Customers", "select * from Customers where Agx = 5", "select * from Customers where Agx", ] { match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { Some(AmbientHint::Prose(p)) => assert!( p.contains("no such column") && p.contains("Agx"), "complete SELECT with column typo must surface the diagnostic hint for {input:?}; got: {p:?}", ), other => panic!( "complete SELECT with column typo must produce a Prose hint for {input:?}; got: {other:?}", ), } } } #[test] fn advanced_select_partial_function_name_not_flagged_as_invalid_column() { // Issue #6 follow-on: while the user is typing // `select sum` (no `(` yet), the ambient hint must not // pre-emptively show "No such column: `sum`". At a SQL // expression position the partial could resolve to either a // column reference *or* a function-call name; the typing-time // `invalid_ident_at_cursor` check would otherwise mislead // because it only knows about schema columns. Submit-time // validation still flags a genuine column typo (the // `unknown_column` diagnostic only skips function-call names, // which require a trailing `(` — so a bare unknown ident // still trips at submit). 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 sum"; let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced); if let Some(AmbientHint::Prose(p)) = &hint { assert!( !p.contains("No such column"), "`select sum` mid-typing must not pre-emptively flag `sum` as an invalid column; got: {p:?}", ); } } #[test] fn advanced_select_genuine_column_typo_before_from_warns_at_typing_time() { // Issue #16: the gap the issue-#6 trade-off opened. While the // user types `select Agx` (no FROM yet, so the schema-existence // diagnostic stays silent), a genuine column typo must warn at // typing time via the restored `invalid_ident` path — `Agx` // matches neither a schema column nor a known function name. use crate::completion::{SchemaCache, TableColumn}; use crate::dsl::types::Type; let mut cache = SchemaCache::default(); cache.tables.push("Customers".to_string()); let tc = vec![TableColumn { name: "Age".to_string(), user_type: Type::Int, not_null: false, has_default: false, }]; for c in &tc { cache.columns.push(c.name.clone()); } cache.table_columns.insert("Customers".to_string(), tc); let input = "select Agx"; match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { Some(AmbientHint::Prose(p)) => assert!( p.contains("No such") && p.contains("Agx"), "a genuine column typo before FROM must warn at typing time; got: {p:?}", ), other => panic!( "`select Agx` must surface a typing-time typo hint; got: {other:?}", ), } } #[test] fn advanced_partial_typing_does_not_leak_bare_double_in_prose() { // Issue #5 (prose half): at `create table Orders (count` (no // trailing space), the user is mid-typing what's // grammatically a column name (`count` could be the start of // `counterparty`). The bare `Word("double")` from the // DOUBLE_PRECISION_NODES branch must not appear in the // ambient hint at this position — the new IntroProse hint // from issue #4 already covers this position by introducing // the element slot ("Type a column name, or a table-level // constraint: …"), and the user discovers the type list // (with `double precision` as a single composite, not bare // `double`) when they advance to the SQL_TYPE slot. let cache = crate::completion::SchemaCache::default(); let input = "create table Orders (count"; match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { Some(AmbientHint::Prose(p)) => { assert!( !p.contains("`double`"), "bare `double` must not appear in the prose; got: {p:?}", ); } other => panic!("expected Prose hint at partial column name; got: {other:?}"), } } #[test] fn advanced_type_position_offers_double_precision_not_bare_double() { // Issue #5: at the SQL_TYPE position (`create table Orders // (count `), the candidate list previously surfaced `double` // as a peer of the playground's regular types — the user // sees a leading "double" alongside int/text/etc. and has // to know it's the start of the two-word `double precision` // alias. The fix surfaces `double precision` as a single // composite candidate and suppresses the bare `double`. let cache = crate::completion::SchemaCache::default(); let input = "create table Orders (count "; let comp = crate::completion::candidates_at_cursor_in_mode( input, input.len(), &cache, Mode::Advanced, ) .expect("completion expected at the SQL_TYPE position"); let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); assert!( !texts.contains(&"double"), "bare `double` must NOT appear as a type candidate; got {texts:?}", ); assert!( texts.contains(&"double precision"), "`double precision` should appear as a composite type candidate; got {texts:?}", ); // The regular type vocabulary still appears. for t in &["int", "text", "real", "serial"] { assert!( texts.iter().any(|x| x == t), "regular type `{t}` must remain a candidate; got {texts:?}", ); } } #[test] fn advanced_create_table_offers_open_paren_after_name() { // Issue #3: typing `create table Orders ` in advanced mode // should offer both `with` (DSL form, ADR-0009) and `(` (SQL // form, ADR-0035 §4) as the next-step continuation. Today // only `with` surfaces — the shared-entry-word completion // merge only fires at the entry-word boundary, so deeper // positions show only the committed node's continuations. let cache = crate::completion::SchemaCache::default(); let input = "create table Orders "; let comp = crate::completion::candidates_at_cursor_in_mode( input, input.len(), &cache, Mode::Advanced, ) .expect("completion expected for advanced create-table after name"); let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); assert!( texts.contains(&"("), "advanced mode must offer `(` for the SQL column-def list; got {texts:?}", ); assert!( texts.contains(&"with"), "advanced mode must keep `with` for the DSL form; got {texts:?}", ); } #[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 = 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 = 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 } fn issue31_join_cache() -> crate::completion::SchemaCache { use crate::completion::{SchemaCache, TableColumn}; use crate::dsl::types::Type; let mut cache = SchemaCache::default(); let tables: &[(&str, &[(&str, Type)])] = &[ ("Customers", &[("id", Type::Serial), ("name", Type::Text)]), ( "Products", &[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)], ), ( "OrderLines", &[ ("id", Type::Serial), ("order_id", Type::Int), ("product_id", Type::Int), ("count", Type::Int), ], ), ( "Orders", &[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)], ), ]; for (t, cols) in tables { cache.tables.push((*t).to_string()); let tc: Vec = cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect(); for c in &tc { cache.columns.push(c.name.clone()); } cache.table_columns.insert((*t).to_string(), tc); } cache } #[test] fn issue31_group_by_partial_alias_shows_alias_hint() { // Issue #31 end-to-end: the manual-testing query ended in // `… group by o`, where `o` aliases `Orders`. The ambient // hint must guide the learner to `o.`, not claim // `o` is an unknown column. let cache = issue31_join_cache(); let input = "select c.name as customer_name, o.id as order_id, o.date, sum(ol.count*p.price) as total from Orders o join OrderLines ol on o.id=ol.order_id join Products p on p.id=ol.product_id join Customers c on c.id=o.customer_id group by o"; match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { Some(AmbientHint::Prose(p)) => { assert!( p.contains("`o` is a table alias") && p.contains("o."), "expected the alias hint; got: {p:?}", ); assert!( !p.contains("no such column"), "must not show the misleading unknown-column message; got: {p:?}", ); } other => panic!("expected a Prose alias hint; got: {other:?}"), } } #[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. // Substring "mode advanced" is the actionable fragment // (the switch command) that survives wording revisions. assert!( p.contains("mode advanced"), "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("mode advanced"), "a valid DSL command must not carry the advanced pointer, got: {p:?}", ); } } #[test] fn ambient_hint_omits_advanced_pointer_when_form_b_value_count_wouldnt_match() { // Issue #1: simple-mode rejects `insert into Customers values // ('Oli', 52, 3)` because Form B skips both serials and expects // 2 values for `Name`, `Age`. The same line ALSO fails in // advanced mode: positional VALUES requires every column (4 // total) but only 3 were supplied. The cross-mode pointer would // be misleading — switching modes wouldn't help — so it must // not be appended. use crate::dsl::types::Type; let cache = schema_with_columns( "Customers", &[ ("id", Type::Serial), ("Name", Type::Text), ("Age", Type::Int), ("SerNo", Type::Serial), ], ); let input = "insert into Customers values ('Oli', 52, 3)"; let note = advanced_alternative_note(input, &cache); assert!( note.is_none(), "Form B mismatch where advanced mode would also reject — \ the cross-mode pointer must be suppressed; got: {note:?}", ); } #[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() 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_between_values_points_to_comma_not_close_paren() { // Issue #2: at the cursor just after a completed first value // (no comma yet) the between-values fallback hint must reflect // the SCHEMA-AWARE grammar — there is a second column still to // fill, so the meaningful next token is `,`, not `)`. The bug // was that this fallback parsed schemalessly, so the type-blind // grammar closed the tuple after one value and pointed at `)`. // Not literal-kind-specific: each case below uses a value that // is type-correct for the first column, so the only difference // from the report's string case is the literal shape. use crate::dsl::types::Type; let cases: &[(&[(&str, Type)], &str)] = &[ // string first value (the report's case): first col text. (&[("Name", Type::Text), ("Age", Type::Int)], "insert into Customers values ('Oli'"), // integer first value: first col int. (&[("Age", Type::Int), ("Name", Type::Text)], "insert into Customers values (42"), // real first value: first col real. (&[("Score", Type::Real), ("Name", Type::Text)], "insert into Customers values (3.5"), ]; for (cols, input) in cases { let cache = schema_with_columns("Customers", cols); match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { assert!( p.contains(','), "expected a comma next-token hint for {input:?}, got: {p:?}", ); assert!( !p.contains(')'), "must not point at the closing paren mid-tuple for \ {input:?}, got: {p:?}", ); } other => panic!("expected Prose for {input:?}, got {other:?}"), } } } #[test] fn advanced_mode_wrong_arity_insert_keeps_friendly_diagnostic_over_fallback() { // Issue #2 no-masking guard. In advanced mode a wrong-arity // insert tuple structurally matches via the type-blind path and // the walker emits the friendly `insert_arity_mismatch_form_b` // diagnostic (ADR-0033 §8.1 / Amendment 5). That diagnostic is // checked EARLY in the hint ladder, so the issue-#2 schema-aware // fallback (which would otherwise say "expected `)`") must NOT // shadow it. Locks ladder ordering so the fix can't regress the // richer message. use crate::dsl::types::Type; let cache = schema_with_columns( "Customers", &[ ("id", Type::Serial), ("Name", Type::Text), ("Age", Type::Int), ("SerNo", Type::Serial), ], ); let input = "insert into Customers values ('Oli', 52, 3)"; match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { Some(AmbientHint::Prose(p)) => { assert!( p.contains("value(s) are given"), "expected the friendly arity diagnostic, got: {p:?}", ); } other => panic!("expected Prose, got {other:?}"), } } #[test] fn ambient_hint_after_last_value_points_to_close_paren() { // Counterpart to the issue #2 fix: once every column has a // value, the schema-aware fallback SHOULD point at `)` — there // is nothing left to fill. Guards against over-correcting the // fix into never suggesting the close paren. use crate::dsl::types::Type; let cache = schema_with_columns( "Customers", &[("Name", Type::Text), ("Age", Type::Int)], ); let input = "insert into Customers values ('Oli', 52"; match ambient_hint(input, input.len(), None, &cache) { Some(AmbientHint::Prose(p)) => { assert!( p.contains(')'), "expected a close-paren next-token hint, 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_all_subcommands() { // data / table plus the V5 list-all forms, grammar order. let cs = cands_hint("show ", 5).expect("candidate hint"); assert_eq!( cs, vec![ "data".to_string(), "table".to_string(), "tables".to_string(), "relationships".to_string(), "indexes".to_string(), "relationship".to_string(), "index".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 "), "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, mode: crate::completion::ModeClass::Both }, Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, ], 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, mode: crate::completion::ModeClass::Both }, Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both }, ], 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)); } } }