explain: styled plan tree + annotation taxonomy (ADR-0028 step 4)

`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.
This commit is contained in:
claude@clouddev1
2026-05-19 12:44:21 +00:00
parent d17addddd7
commit a7d459f8f2
+211 -17
View File
@@ -22,7 +22,7 @@
use std::collections::HashSet; 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::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
use crate::dsl::Type; use crate::dsl::Type;
use crate::mode::Mode; use crate::mode::Mode;
@@ -152,13 +152,45 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
out 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 /// 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 /// The standard-SQL display form of the explained statement is
/// shown first; the plan tree follows, built from each row's /// shown first; the plan tree follows, built from each row's
/// `id` / `parent` links. Node text is the engine's `detail` /// `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<String>`-returning renderers above this /// Unlike the `Vec<String>`-returning renderers above this
/// returns ready-built `OutputLine`s, because a plan line /// returns ready-built `OutputLine`s, because a plan line
@@ -167,7 +199,8 @@ pub fn render_structure(desc: &TableDescription) -> Vec<String> {
#[must_use] #[must_use]
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> { pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
let mut out: Vec<OutputLine> = Vec::with_capacity(plan.rows.len() + 1); let mut out: Vec<OutputLine> = 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 // `emitted` guards against a malformed plan with a cyclic
// or self-referential `parent` link — every node is drawn // or self-referential `parent` link — every node is drawn
// at most once. // at most once.
@@ -196,25 +229,73 @@ fn render_plan_subtree(
} }
let is_last = idx == last_idx; let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " }; let connector = if is_last { "└─ " } else { "├─ " };
out.push(plain_plan_line( out.push(plan_node_line(prefix, connector, &row.detail, mode));
format!("{prefix}{connector}{}", row.detail),
mode,
));
let child_prefix = let child_prefix =
format!("{prefix}{}", if is_last { " " } else { "" }); format!("{prefix}{}", if is_last { " " } else { "" });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode); render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
} }
} }
/// A plain (unstyled) plan output line. ADR-0028 step 4 swaps /// Classify `detail` against the taxonomy, returning the byte
/// the tree lines for span-styled ones; the display-SQL line /// offset + length of the matched marker and its colour class.
/// stays plain. /// `None` when no marker matches (a neutral node).
const fn plain_plan_line(text: String, mode: Mode) -> OutputLine { fn classify_detail(detail: &str) -> Option<(usize, usize, OutputStyleClass)> {
OutputLine { PLAN_TAXONOMY
text, .iter()
kind: OutputKind::System, .find_map(|(marker, class)| detail.find(marker).map(|off| (off, marker.len(), *class)))
mode_at_submission: mode, }
styled_runs: None,
/// 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<OutputSpan> = 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<OutputSpan>, 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")); 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] #[test]
fn render_explain_plan_survives_a_self_referential_node() { fn render_explain_plan_survives_a_self_referential_node() {
// A malformed plan node that is both a root (`parent == // A malformed plan node that is both a root (`parent ==