Files
rdbms-playground/src/input_render.rs
T
claude@clouddev1 39da399add ADR-0022 stage 3/8: simple-mode echo lines highlighted
Lift `dsl::ECHO_PREFIX = "running: "` as a public const,
with a unit test asserting `t!("dsl.running", input = "")`
matches it. The catalog template is now contracted to equal
`format!("{ECHO_PREFIX}{input}")` — a translator changing
the prefix breaks the test.

Add `input_render::lex_to_runs(input, theme)` — a
cursor-less variant of `render_input_runs` for use cases
(echo lines, future hint panel) that need token-class
colouring without an inverted cursor.

ui::render_output_line: when the line is an Echo submitted
in Simple mode, peel the prefix and re-tokenise the rest
through lex_to_runs, rendering each token at its class
colour. Advanced-mode echoes and any echo whose body
unexpectedly lacks the prefix fall through to the plain
rendering.

Tests: 683 passing, 0 failing, 1 ignored (682 baseline →
+1 echo_prefix_matches_catalog_template). Clippy clean
(uses let-chain to keep the if condition flat).

Stage 4 adds render-time parse + error overlay so the
failing token in mid-typed input lights up in the error
colour.
2026-05-10 17:32:11 +00:00

279 lines
9.3 KiB
Rust

//! Render-side helpers for the input panel
//! (ADR-0022 stage 2 — token-class colouring of the live
//! input field with cursor injection).
//!
//! The functions here are pure: given an input string, a
//! cursor byte position, and a theme, they return a sequence
//! of `StyledRun`s describing each contiguous span with its
//! ratatui style. `ui::render_input_panel` converts these to
//! `Span<'_>`s at render time.
//!
//! Cursor handling:
//! - Cursor inside a token splits that token's run into
//! before/under/after, with `under` carrying the token's
//! colour plus `Modifier::REVERSED`.
//! - Cursor on a whitespace gap between tokens splits the
//! gap the same way.
//! - Cursor at end-of-input is represented as a trailing
//! run with empty byte range; the renderer treats that as
//! "inverted space".
//!
//! Per ADR-0022 §2/§3, this is the silent always-on layer.
//! The error overlay (stage 4) and hint panel (stage 5)
//! compose with these runs without fighting them.
use ratatui::style::{Modifier, Style};
use crate::dsl::lexer::lex;
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`,
/// 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) -> Vec<StyledRun> {
let mut runs = lex_to_runs(input, theme);
inject_cursor(&mut runs, input, cursor_byte, theme);
runs
}
/// Cursor-less variant: tokenises `input` into styled runs
/// covering the full byte range, with no inverted cursor.
/// Used by the echo-line renderer (ADR-0022 §5) where there's
/// no cursor to show.
#[must_use]
pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
base_runs(input, theme)
}
fn base_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
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<StyledRun>,
input: &str,
cursor_byte: usize,
theme: &Theme,
) {
let cursor_byte = cursor_byte.min(input.len());
// End-of-input cursor: append the empty-range sentinel.
if cursor_byte == input.len() {
runs.push(StyledRun {
byte_range: (input.len(), input.len()),
style: Style::default()
.fg(theme.fg)
.add_modifier(Modifier::REVERSED),
});
return;
}
let idx = runs
.iter()
.position(|r| r.byte_range.0 <= cursor_byte && cursor_byte < r.byte_range.1)
.expect("cursor_byte < input.len() ⇒ some run contains it");
let target = runs[idx].clone();
let (start, end) = target.byte_range;
// Walk to the next char boundary so a multi-byte UTF-8
// codepoint is treated as a single visual unit at the
// cursor.
let mut char_end = cursor_byte + 1;
while char_end < input.len() && !input.is_char_boundary(char_end) {
char_end += 1;
}
let mut replacement: Vec<StyledRun> = Vec::with_capacity(3);
if start < cursor_byte {
replacement.push(StyledRun {
byte_range: (start, cursor_byte),
style: target.style,
});
}
replacement.push(StyledRun {
byte_range: (cursor_byte, char_end),
style: target.style.add_modifier(Modifier::REVERSED),
});
if char_end < end {
replacement.push(StyledRun {
byte_range: (char_end, end),
style: target.style,
});
}
runs.splice(idx..=idx, replacement);
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn dark() -> Theme {
Theme::dark()
}
fn reversed(r: &StyledRun) -> bool {
r.style.add_modifier.contains(Modifier::REVERSED)
}
#[test]
fn empty_input_renders_only_the_end_of_input_cursor() {
let runs = render_input_runs("", 0, &dark());
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);
// 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);
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);
// 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);
// 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);
// 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);
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());
let last = runs.last().expect("non-empty");
assert_eq!(last.byte_range, (6, 6));
assert!(reversed(last));
}
#[test]
fn full_command_lexes_to_each_token_class() {
let theme = dark();
let runs = render_input_runs(
"insert into T values (1, 'hi', null) --all-rows",
47,
&theme,
);
// Spot-check: there's at least one run of each token class.
let fgs: Vec<_> = runs.iter().filter_map(|r| r.style.fg).collect();
assert!(fgs.contains(&theme.tok_keyword)); // insert / into / values / null
assert!(fgs.contains(&theme.tok_identifier)); // T / hi (string is separate)
assert!(fgs.contains(&theme.tok_number)); // 1
assert!(fgs.contains(&theme.tok_string)); // 'hi'
assert!(fgs.contains(&theme.tok_punct)); // ( , )
assert!(fgs.contains(&theme.tok_flag)); // --all-rows
}
}