diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index db296a9..268fadd 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -332,6 +332,34 @@ pub fn input_verdict( outcome_severity.into_iter().chain(diag_severity).max() } +/// The schema-aware diagnostics for `source` (ADR-0027 §2). +/// +/// Schema-existence ERRORs (unknown table / column) and +/// expression WARNINGs. The highlight overlay and the hint +/// panel both read these for *where* and *why*; the indicator +/// ([`input_verdict`]) is the severity summary over them. +/// +/// Empty for empty input, an unrecognised command, or a parse +/// that never reached a structural `Match` — a parse failure +/// carries its own ERROR through the outcome, not through a +/// `Diagnostic`, and is highlighted by the existing +/// definite-error path. +#[must_use] +pub fn input_diagnostics( + source: &str, + schema: Option<&crate::completion::SchemaCache>, +) -> Vec { + if source.trim().is_empty() { + return Vec::new(); + } + let mut ctx = schema.map_or_else( + context::WalkContext::new, + context::WalkContext::with_schema, + ); + let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); + result.map_or_else(Vec::new, |r| r.diagnostics) +} + /// Schema-existence diagnostics (ADR-0027 §2). /// /// A matched `IdentSource::Tables` token whose name is not in diff --git a/src/input_render.rs b/src/input_render.rs index fcc653b..5f4c438 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -22,7 +22,7 @@ //! The error overlay (stage 4) and hint panel (stage 5) //! compose with these runs without fighting them. -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use crate::dsl::parser::parse_command_with_schema; use crate::dsl::walker; @@ -73,6 +73,19 @@ pub fn render_input_runs( 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): an unknown table + // or column (ERROR), or a dubious comparison (WARNING), is + // overlaid wherever it sits in the input — not only under + // the cursor, so a problem the user has typed past stays + // visible. `input_diagnostics` is empty on a parse failure, + // so this never fights the definite-error overlay above. + for diag in walker::input_diagnostics(input, Some(cache)) { + 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 } @@ -417,6 +430,21 @@ fn overlay_error(runs: &mut [StyledRun], error_byte: usize, theme: &Theme) { // 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 @@ -1108,13 +1136,17 @@ mod tests { #[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. + // 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, &empty_cache()); + 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 @@ -1126,4 +1158,87 @@ mod tests { 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)); + } + } }