From a7d459f8f205920cf22b93e4b6029e1a94616a2e Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 19 May 2026 12:44:21 +0000 Subject: [PATCH] explain: styled plan tree + annotation taxonomy (ADR-0028 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_explain_plan` now classifies each plan node and colours its category-bearing keywords through the styled-runs mechanism. - `PLAN_TAXONOMY`: a substring-pattern table mapping the engine's plan vocabulary to four semantic classes — full scan / temp B-tree -> Expensive, index search / covering index / PK lookup -> Efficient, automatic index -> AutomaticIndex. An unrecognised detail renders neutral, since the engine's plan vocabulary may grow. - Only the matched keyword run carries the category colour; connectors, prefixes and table / index names stay neutral (ADR-0028 §6). The display-SQL line is wholly neutral. - An automatic-index node also gets the distinct "← add an index?" advice tag, so it reads as guidance, not merely "this is slow". 1158 tests pass (+7); clippy clean. --- src/output_render.rs | 228 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 211 insertions(+), 17 deletions(-) diff --git a/src/output_render.rs b/src/output_render.rs index de069fc..2f266f8 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -22,7 +22,7 @@ use std::collections::HashSet; -use crate::app::{OutputKind, OutputLine}; +use crate::app::{OutputKind, OutputLine, OutputSpan, OutputStyleClass}; use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription}; use crate::dsl::Type; use crate::mode::Mode; @@ -152,13 +152,45 @@ pub fn render_structure(desc: &TableDescription) -> Vec { out } +/// The plan annotation taxonomy (ADR-0028 §4). +/// +/// A `detail` string is classified by the **first** entry +/// whose marker substring it contains; the marker's byte range +/// is also the run that carries the category colour (ADR-0028 +/// §6 — "only the category-bearing keywords"). Order matters: +/// the automatic-index markers come before the plain-index and +/// `SCAN` ones so a `SEARCH … USING AUTOMATIC … INDEX` is not +/// mis-read as a plain index search or a full scan; `SCAN` +/// comes last so a covering-index scan (`SCAN … USING INDEX …`) +/// classifies on its index, not on the `SCAN`. +/// +/// A `detail` matching no marker renders neutral — the engine's +/// plan vocabulary may grow (ADR-0028 §4). +const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[ + ("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex), + ("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex), + ("USING COVERING INDEX", OutputStyleClass::Efficient), + ("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient), + ("USING PRIMARY KEY", OutputStyleClass::Efficient), + ("USING INDEX", OutputStyleClass::Efficient), + ("USE TEMP B-TREE", OutputStyleClass::Expensive), + ("SCAN", OutputStyleClass::Expensive), +]; + +/// The short "you should add an index here" tag appended to an +/// automatic-index plan node (ADR-0028 §6 — the distinct +/// marker that makes it read as advice, not merely "slow"). +const AUTO_INDEX_TAG: &str = " ← add an index?"; + /// Render a captured query plan as a box-drawing tree -/// (ADR-0028 §3). +/// (ADR-0028 §3/§4). /// /// The standard-SQL display form of the explained statement is /// shown first; the plan tree follows, built from each row's /// `id` / `parent` links. Node text is the engine's `detail` -/// string **verbatim** — nothing is reworded. +/// string **verbatim** — nothing is reworded — but each +/// node's category-bearing keywords carry a semantic colour +/// from the annotation taxonomy. /// /// Unlike the `Vec`-returning renderers above this /// returns ready-built `OutputLine`s, because a plan line @@ -167,7 +199,8 @@ pub fn render_structure(desc: &TableDescription) -> Vec { #[must_use] pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec { let mut out: Vec = Vec::with_capacity(plan.rows.len() + 1); - out.push(plain_plan_line(plan.display_sql.clone(), mode)); + // The display SQL is wholly neutral — no category keywords. + out.push(neutral_plan_line(plan.display_sql.clone(), mode)); // `emitted` guards against a malformed plan with a cyclic // or self-referential `parent` link — every node is drawn // at most once. @@ -196,25 +229,73 @@ fn render_plan_subtree( } let is_last = idx == last_idx; let connector = if is_last { "└─ " } else { "├─ " }; - out.push(plain_plan_line( - format!("{prefix}{connector}{}", row.detail), - mode, - )); + out.push(plan_node_line(prefix, connector, &row.detail, mode)); let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " }); render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode); } } -/// A plain (unstyled) plan output line. ADR-0028 step 4 swaps -/// the tree lines for span-styled ones; the display-SQL line -/// stays plain. -const fn plain_plan_line(text: String, mode: Mode) -> OutputLine { - OutputLine { - text, - kind: OutputKind::System, - mode_at_submission: mode, - styled_runs: None, +/// Classify `detail` against the taxonomy, returning the byte +/// offset + length of the matched marker and its colour class. +/// `None` when no marker matches (a neutral node). +fn classify_detail(detail: &str) -> Option<(usize, usize, OutputStyleClass)> { + PLAN_TAXONOMY + .iter() + .find_map(|(marker, class)| detail.find(marker).map(|off| (off, marker.len(), *class))) +} + +/// Build one plan-tree node line: `{prefix}{connector}{detail}` +/// with the connectors / prefix / table & index names neutral +/// and only the category-bearing keyword run coloured +/// (ADR-0028 §6). An automatic-index node also gets the +/// "add an index?" advice tag. +fn plan_node_line(prefix: &str, connector: &str, detail: &str, mode: Mode) -> OutputLine { + let mut text = format!("{prefix}{connector}{detail}"); + let detail_start = prefix.len() + connector.len(); + let mut runs: Vec = Vec::new(); + match classify_detail(detail) { + Some((offset, len, class)) => { + let marker_start = detail_start + offset; + let marker_end = marker_start + len; + push_neutral(&mut runs, 0, marker_start); + runs.push(OutputSpan { + byte_range: (marker_start, marker_end), + class, + }); + push_neutral(&mut runs, marker_end, text.len()); + if class == OutputStyleClass::AutomaticIndex { + let tag_start = text.len(); + text.push_str(AUTO_INDEX_TAG); + runs.push(OutputSpan { + byte_range: (tag_start, text.len()), + class, + }); + } + } + None => push_neutral(&mut runs, 0, text.len()), + } + OutputLine::styled(text, OutputKind::System, mode, runs) +} + +/// A wholly-neutral plan line — used for the display-SQL line, +/// which carries no category keywords. Styled (rather than +/// plain) so it renders in the neutral foreground colour +/// instead of the whole-line `System` styling (ADR-0028 §6). +fn neutral_plan_line(text: String, mode: Mode) -> OutputLine { + let mut runs = Vec::new(); + push_neutral(&mut runs, 0, text.len()); + OutputLine::styled(text, OutputKind::System, mode, runs) +} + +/// Push a `Neutral` span covering `[start, end)`, skipping the +/// empty case. +fn push_neutral(runs: &mut Vec, start: usize, end: usize) { + if end > start { + runs.push(OutputSpan { + byte_range: (start, end), + class: OutputStyleClass::Neutral, + }); } } @@ -757,6 +838,119 @@ mod tests { assert!(lines[2].text.starts_with(" ├─ child-a")); } + /// The semantic class of the styled run covering the first + /// occurrence of `needle` in `line`'s text. + fn span_class_for(line: &OutputLine, needle: &str) -> OutputStyleClass { + let runs = line.styled_runs.as_ref().expect("line should be styled"); + let at = line.text.find(needle).expect("needle present in text"); + runs.iter() + .find(|s| s.byte_range.0 <= at && at < s.byte_range.1) + .map(|s| s.class) + .expect("a styled run covers the needle") + } + + fn one_node_plan(detail: &str) -> QueryPlan { + QueryPlan { + display_sql: "SELECT 1".to_string(), + rows: vec![ExplainRow { + id: 1, + parent: 0, + detail: detail.to_string(), + }], + } + } + + #[test] + fn render_explain_plan_colours_a_full_scan_expensive() { + let plan = one_node_plan("SCAN Customers"); + let lines = render_explain_plan(&plan, Mode::Simple); + assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive); + // The table name stays neutral (ADR-0028 §6). + assert_eq!( + span_class_for(&lines[1], "Customers"), + OutputStyleClass::Neutral, + ); + } + + #[test] + fn render_explain_plan_colours_an_index_search_efficient() { + let plan = one_node_plan("SEARCH Customers USING INDEX Customers_Email_idx (Email=?)"); + let lines = render_explain_plan(&plan, Mode::Simple); + assert_eq!( + span_class_for(&lines[1], "USING INDEX"), + OutputStyleClass::Efficient, + ); + // The index name and the connector stay neutral. + assert_eq!( + span_class_for(&lines[1], "Customers_Email_idx"), + OutputStyleClass::Neutral, + ); + assert_eq!(span_class_for(&lines[1], "└─"), OutputStyleClass::Neutral); + } + + #[test] + fn render_explain_plan_flags_an_automatic_index() { + let plan = + one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)"); + let lines = render_explain_plan(&plan, Mode::Simple); + assert_eq!( + span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"), + OutputStyleClass::AutomaticIndex, + ); + // ADR-0028 §6: the distinct "add an index" advice tag. + assert!( + lines[1].text.ends_with("← add an index?"), + "got {:?}", + lines[1].text, + ); + assert_eq!( + span_class_for(&lines[1], "add an index"), + OutputStyleClass::AutomaticIndex, + ); + } + + #[test] + fn render_explain_plan_temp_btree_is_expensive() { + let plan = one_node_plan("USE TEMP B-TREE FOR ORDER BY"); + let lines = render_explain_plan(&plan, Mode::Simple); + assert_eq!( + span_class_for(&lines[1], "USE TEMP B-TREE"), + OutputStyleClass::Expensive, + ); + } + + #[test] + fn render_explain_plan_covering_index_scan_classifies_on_the_index() { + // A `SCAN … USING INDEX …` is a covering-index scan — + // it must read as efficient, not as a full scan. + let plan = one_node_plan("SCAN Customers USING COVERING INDEX idx"); + let lines = render_explain_plan(&plan, Mode::Simple); + assert_eq!( + span_class_for(&lines[1], "USING COVERING INDEX"), + OutputStyleClass::Efficient, + ); + assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Neutral); + } + + #[test] + fn render_explain_plan_unrecognised_detail_is_neutral() { + let plan = one_node_plan("CO-ROUTINE 0x1"); + let lines = render_explain_plan(&plan, Mode::Simple); + let runs = lines[1].styled_runs.as_ref().unwrap(); + assert!( + runs.iter().all(|s| s.class == OutputStyleClass::Neutral), + "an unrecognised detail must render wholly neutral: {runs:?}", + ); + } + + #[test] + fn render_explain_plan_display_sql_line_is_neutral() { + let plan = one_node_plan("SCAN T"); + let lines = render_explain_plan(&plan, Mode::Simple); + let runs = lines[0].styled_runs.as_ref().unwrap(); + assert!(runs.iter().all(|s| s.class == OutputStyleClass::Neutral)); + } + #[test] fn render_explain_plan_survives_a_self_referential_node() { // A malformed plan node that is both a root (`parent ==