//! 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 { 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 { 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(input, cursor_byte, cache) { 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)) } 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 ambient hint for the input panel /// (ADR-0022 §6). /// /// 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 { 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), }); } // 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*. let diagnostics = crate::dsl::walker::input_diagnostics(input, Some(cache)); if let Some(diag) = pick_hint_diagnostic(&diagnostics, cursor.min(input.len())) { 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(leading, Some(cache)); 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::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. // `ambient_hint` is only called for `EffectiveMode::Simple` // (ui.rs gates it), so completion runs through the // simple-mode walker view — `select` does not surface here // (ADR-0030 §2). if let Some(comp) = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, Mode::Simple) { 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(input, cursor, cache) { 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. // ADR-0030 §2: simple-mode hint uses the simple-mode walker // view so a SQL form surfaces with the "this is SQL" hint. match parse_command_in_mode(input, Mode::Simple) { 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 { 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 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()); } // ---- 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, }) .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_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_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 "), "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)); } } }