//! 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::{Modifier, Style}; use crate::dsl::lexer::lex; 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 { let mut runs = lex_to_runs(input, theme); if let InputState::DefiniteErrorAt(pos) = classify_input(input) { 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); } 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. /// Cheap (lex + parse) per ADR-0022 §13. #[must_use] pub fn classify_input(input: &str) -> InputState { if input.trim().is_empty() { return InputState::Empty; } match parse_command(input) { 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 keyword) /// completion at the cursor. Stage 8b renders these as /// styled spans with the selected item highlighted (if /// any) and `<` / `>` scroll markers 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; } // First check for candidates at the cursor (keywords + // schema identifiers). When any exist the user can Tab to // insert one, and the panel surfaces them directly — this // wins over the prose IncompleteAtEof framing because the // candidate list is more actionable. if let Some(comp) = crate::completion::candidates_at_cursor(input, cursor, cache) { let selected = memo.map(|m| m.selection_idx); return Some(AmbientHint::Candidates { items: comp.candidates, selected, }); } // 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.slot { crate::dsl::ident_slot::IdentSlot::TableName => "table", crate::dsl::ident_slot::IdentSlot::Column => "column", crate::dsl::ident_slot::IdentSlot::RelationshipName => "relationship", // `NewName` is filtered out by `invalid_ident_at_cursor` // (it only fires for known-set slots), so this arm // is unreachable in practice; render a neutral // fallback rather than panic. crate::dsl::ident_slot::IdentSlot::NewName => "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. match parse_command(input) { 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 tokens = lex(input); let usage = crate::dsl::usage::matched_entry(&tokens, position) .and_then(|(_, keys)| keys.first().copied()) .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, })) } } } } /// "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(", ")) } } } 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. } /// 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 { base_runs(input, theme) } fn base_runs(input: &str, theme: &Theme) -> Vec { let tokens = lex(input); let mut runs = Vec::with_capacity(tokens.len() * 2); let mut pos = 0; for tok in tokens { let (start, end) = tok.span; if pos < start { // Whitespace gap before this token. 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.token_color(&tok.kind)), }); 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), _ => 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 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_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; // Schema knows "Customers"; user typed "Custp" — no match. 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.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:?}"), } } #[test] fn ambient_hint_with_memo_carries_selected_index() { use crate::completion::LastCompletion; // Simulate the post-Tab state at "show " — but with // the original word still pending (cursor placed // after `show ` to expose the multi-candidate slot). // The memo's selection_idx is what the renderer uses // to highlight one of the items. let memo = LastCompletion { inserted_range: (5, 5), original_text: String::new(), candidates: vec!["data".to_string(), "table".to_string()], selection_idx: 1, }; match ambient_hint("show ", 5, Some(&memo), &empty_cache()) { Some(AmbientHint::Candidates { items, selected }) => { assert_eq!(items, vec!["data".to_string(), "table".to_string()]); assert_eq!(selected, Some(1)); } other => panic!("expected Candidates, 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` — // so the error overlay (stage 4) doesn't replace any // class colours with tok_error. Tokens: keyword(s), // identifier(s), string literal, punct (=), flag. let theme = dark(); let input = "update T set Name='hi' --all-rows"; let runs = render_input_runs(input, input.len(), &theme, &empty_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)); } } }