ui: overlay diagnostic spans on the input field (ADR-0027 §2)
render_input_runs now overlays the walker's schema-aware diagnostics: an unknown table/column ERROR is recoloured tok_error, an expression WARNING (type mismatch, = NULL, LIKE on a numeric column) recoloured theme.warning. New overlay_span covers a token's whole byte range (overlay_error only hits the run at a single byte). New walker::input_diagnostics is the shared entry point. The overlay is global — every flagged token is coloured wherever it sits, not only under the cursor — which is exactly ADR-0027's motivation. The existing cursor-local invalid-ident overlay is kept (it covers in-progress idents diagnostics do not); the two are additive and idempotent. 5 input_render tests (unknown table/column, type-mismatch literal precise, LIKE-on-numeric, clean command). 1113 passing, clippy clean.
This commit is contained in:
@@ -332,6 +332,34 @@ pub fn input_verdict(
|
|||||||
outcome_severity.into_iter().chain(diag_severity).max()
|
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<outcome::Diagnostic> {
|
||||||
|
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).
|
/// Schema-existence diagnostics (ADR-0027 §2).
|
||||||
///
|
///
|
||||||
/// A matched `IdentSource::Tables` token whose name is not in
|
/// A matched `IdentSource::Tables` token whose name is not in
|
||||||
|
|||||||
+121
-6
@@ -22,7 +22,7 @@
|
|||||||
//! The error overlay (stage 4) and hint panel (stage 5)
|
//! The error overlay (stage 4) and hint panel (stage 5)
|
||||||
//! compose with these runs without fighting them.
|
//! 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::parser::parse_command_with_schema;
|
||||||
use crate::dsl::walker;
|
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) {
|
if let Some(inv) = crate::completion::invalid_ident_at_cursor(input, cursor_byte, cache) {
|
||||||
overlay_error(&mut runs, inv.range.0, theme);
|
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);
|
inject_cursor(&mut runs, input, cursor_byte, theme);
|
||||||
runs
|
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.
|
// 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
|
/// Cursor-less variant: tokenises `input` into styled runs
|
||||||
/// covering the full byte range, with no inverted cursor.
|
/// covering the full byte range, with no inverted cursor.
|
||||||
/// Used by the echo-line renderer (ADR-0022 §5) where there's
|
/// Used by the echo-line renderer (ADR-0022 §5) where there's
|
||||||
@@ -1108,13 +1136,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_valid_command_lexes_to_each_token_class() {
|
fn full_valid_command_lexes_to_each_token_class() {
|
||||||
// Use a valid command — `update ... --all-rows` —
|
// Use a valid command — `update ... --all-rows` — with
|
||||||
// so the error overlay (stage 4) doesn't replace any
|
// a schema that actually has the table and column, so
|
||||||
// class colours with tok_error. Tokens: keyword(s),
|
// no overlay (parse-error or ADR-0027 diagnostic)
|
||||||
// identifier(s), string literal, punct (=), flag.
|
// 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 theme = dark();
|
||||||
|
let cache = schema_with_columns("T", &[("Name", Type::Text)]);
|
||||||
let input = "update T set Name='hi' --all-rows";
|
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();
|
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_keyword)); // update / set
|
||||||
assert!(fgs.contains(&theme.tok_identifier)); // T / Name
|
assert!(fgs.contains(&theme.tok_identifier)); // T / Name
|
||||||
@@ -1126,4 +1158,87 @@ mod tests {
|
|||||||
assert_ne!(r.style.fg, Some(theme.tok_error));
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user