use serde::{Deserialize, Serialize};
use chumsky::input::{Stream, ValueInput};
use chumsky::prelude::*;
use logos::Logos;
use crate::query::model::{QueryColumn, QueryValue};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum VizKind {
#[default]
Table,
Histogram,
Piechart,
Treemap,
}
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
pub struct VizSpec {
pub kind: VizKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label_col: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_col: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cap: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
pub fn split_directive(text: &str) -> (String, Option<VizSpec>, Option<String>) {
let mut directive_line: Option<usize> = None;
for (i, line) in text.lines().enumerate() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("--") {
let rest = rest.trim_start();
if rest
.split_whitespace()
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("@viz"))
{
directive_line = Some(i);
break;
}
}
}
let Some(dir_idx) = directive_line else {
return (text.to_string(), None, None);
};
let cleaned: String = text
.lines()
.enumerate()
.filter(|(i, _)| *i != dir_idx)
.map(|(_, l)| l)
.collect::<Vec<_>>()
.join("\n");
let directive = text.lines().nth(dir_idx).unwrap().trim_start();
let after_dashes = directive.strip_prefix("--").unwrap().trim_start();
let body = after_dashes
.split_whitespace()
.next()
.map(|kw| after_dashes[kw.len()..].trim_start())
.unwrap_or("");
match parse_directive_body(body) {
Ok(spec) => (cleaned, Some(spec), None),
Err(reason) => (cleaned, None, Some(reason)),
}
}
#[derive(Logos, Debug, Clone, PartialEq)]
#[logos(skip r"[ \t\r\n]+")]
enum VizToken {
#[token("=")]
Eq,
#[regex(r#""[^"]*""#, |lex| { let s = lex.slice(); s[1..s.len() - 1].to_string() })]
Str(String),
#[regex(r"@[A-Za-z_][A-Za-z0-9_.$]*", |lex| lex.slice()[1..].to_string())]
At(String),
#[regex(r"[0-9]+", |lex| lex.slice().parse::<i64>().ok())]
Int(i64),
#[regex(r"[A-Za-z_][A-Za-z0-9_.$]*", |lex| lex.slice().to_string())]
Ident(String),
}
#[derive(Debug, Clone, PartialEq)]
enum VizArgVal {
Word(String),
Number(i64),
}
fn viz_parser<'a, I>()
-> impl Parser<'a, I, (String, Vec<(String, VizArgVal)>), extra::Err<Rich<'a, VizToken>>>
where
I: ValueInput<'a, Token = VizToken, Span = SimpleSpan>,
{
let word = select! { VizToken::Ident(s) => s };
let value = select! {
VizToken::Ident(s) => VizArgVal::Word(s),
VizToken::At(s) => VizArgVal::Word(s),
VizToken::Str(s) => VizArgVal::Word(s),
VizToken::Int(n) => VizArgVal::Number(n),
};
let arg = word
.then_ignore(just(VizToken::Eq))
.then(value)
.map(|(k, v)| (k, v));
let kind = word;
kind.then(arg.repeated().collect::<Vec<_>>())
.then_ignore(end())
}
fn parse_directive_body(body: &str) -> Result<VizSpec, String> {
if body.trim().is_empty() {
return Err(
"ignored @viz directive: missing chart kind (expected one of \
table, histogram, piechart, treemap)"
.to_string(),
);
}
let mut toks: Vec<(VizToken, SimpleSpan)> = Vec::new();
let mut lex = VizToken::lexer(body);
while let Some(res) = lex.next() {
let span = lex.span();
match res {
Ok(t) => toks.push((t, (span.start..span.end).into())),
Err(()) => {
return Err(format!(
"ignored @viz directive: unexpected character(s) at offset {} ({:?})",
span.start,
&body[span.clone()]
));
}
}
}
let eoi: SimpleSpan = (body.len()..body.len()).into();
let stream = Stream::from_iter(toks).map(eoi, |(t, s)| (t, s));
let (kind_word, args) = viz_parser().parse(stream).into_result().map_err(|_errs| {
"ignored @viz argument: expected key=value (label=, value=, cap=, title=, or name=)"
.to_string()
})?;
let kind = match kind_word.to_ascii_lowercase().as_str() {
"table" => VizKind::Table,
"histogram" => VizKind::Histogram,
"piechart" => VizKind::Piechart,
"treemap" => VizKind::Treemap,
other => {
return Err(format!(
"ignored @viz directive: unknown chart kind `{other}` \
(expected table, histogram, piechart, or treemap)"
));
}
};
let mut label_col = None;
let mut value_col = None;
let mut cap = None;
let mut title = None;
let mut name = None;
for (key, val) in args {
match key.to_ascii_lowercase().as_str() {
"label" => label_col = Some(arg_word(&key, val)?),
"value" => value_col = Some(arg_word(&key, val)?),
"title" => title = Some(arg_word(&key, val)?),
"name" => name = Some(arg_word(&key, val)?),
"cap" => match val {
VizArgVal::Number(n) if n > 0 => cap = Some(n as usize),
_ => {
return Err("ignored @viz cap: cap must be a positive integer".to_string());
}
},
other => {
return Err(format!(
"ignored @viz argument `{other}=`: unknown key \
(expected label=, value=, cap=, title=, or name=)"
));
}
}
}
Ok(VizSpec {
kind,
label_col,
value_col,
cap,
title,
name,
})
}
fn arg_word(key: &str, val: VizArgVal) -> Result<String, String> {
match val {
VizArgVal::Word(s) => Ok(s),
VizArgVal::Number(n) => Err(format!(
"ignored @viz `{key}={n}`: expected a column name, not a number"
)),
}
}
pub fn resolve_columns(
spec: &VizSpec,
columns: &[QueryColumn],
rows: &[Vec<QueryValue>],
) -> Result<(usize, usize), String> {
if spec.kind == VizKind::Table {
return Ok((0, 0));
}
if columns.is_empty() {
return Err("cannot chart a query with no columns; showing table".to_string());
}
let value_idx = match &spec.value_col {
Some(name) => find_column(columns, name).ok_or_else(|| {
format!("@viz value column `{name}` not found in query result; showing table")
})?,
None => first_numeric_column(columns, rows).ok_or_else(|| {
"no numeric column found for the chart value axis; showing table".to_string()
})?,
};
if !column_is_numeric(value_idx, rows) {
return Err(format!(
"@viz value column `{}` is not numeric; showing table",
columns[value_idx].name
));
}
let label_idx = match &spec.label_col {
Some(name) => find_column(columns, name).ok_or_else(|| {
format!("@viz label column `{name}` not found in query result; showing table")
})?,
None => (0..columns.len())
.find(|&i| i != value_idx)
.ok_or_else(|| {
"chart needs a label column distinct from the value column; showing table"
.to_string()
})?,
};
Ok((label_idx, value_idx))
}
fn find_column(columns: &[QueryColumn], name: &str) -> Option<usize> {
let want = name.strip_prefix('@').unwrap_or(name);
columns.iter().position(|c| {
let have = c.name.strip_prefix('@').unwrap_or(&c.name);
have.eq_ignore_ascii_case(want)
})
}
fn first_numeric_column(columns: &[QueryColumn], rows: &[Vec<QueryValue>]) -> Option<usize> {
(0..columns.len()).find(|&i| column_is_numeric(i, rows))
}
fn column_is_numeric(idx: usize, rows: &[Vec<QueryValue>]) -> bool {
let mut saw_number = false;
for row in rows {
match row.get(idx) {
Some(QueryValue::Int(_) | QueryValue::Float(_)) => saw_number = true,
Some(QueryValue::Null) | None => {}
Some(_) => return false,
}
}
saw_number
}
pub fn cell_as_f64(v: &QueryValue) -> Option<f64> {
match v {
QueryValue::Int(i) => Some(*i as f64),
QueryValue::Float(f) => Some(*f),
_ => None,
}
}
pub fn cell_as_label(v: &QueryValue) -> String {
match v {
QueryValue::Null => "(null)".to_string(),
QueryValue::Bool(b) => b.to_string(),
QueryValue::Int(i) => i.to_string(),
QueryValue::Float(f) => f.to_string(),
QueryValue::Str(s) => s.clone(),
QueryValue::ObjRef { index, class, .. } => format!("{class}@{index}"),
}
}
pub fn default_view_name(q: &crate::query::ast::Query) -> Option<String> {
use crate::query::ast::FromSource;
if !q.union_branches.is_empty() {
return None; }
match &q.from {
FromSource::Class(c) => Some(if c.instanceof {
format!("INSTANCEOF {}", c.class_name)
} else {
c.class_name.clone()
}),
FromSource::Object(addr) => Some(format!("object 0x{addr:x}")),
FromSource::Subquery(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_view_name_from_target() {
use crate::query::parse::parse;
assert_eq!(
default_view_name(&parse("SELECT * FROM java.lang.String").unwrap()).as_deref(),
Some("java.lang.String")
);
assert_eq!(
default_view_name(&parse("SELECT * FROM INSTANCEOF java.lang.Thread").unwrap())
.as_deref(),
Some("INSTANCEOF java.lang.Thread")
);
assert_eq!(
default_view_name(&parse("SELECT * FROM OBJECTS 0x10").unwrap()).as_deref(),
Some("object 0x10")
);
assert_eq!(
default_view_name(&parse("SELECT * FROM (SELECT * FROM java.lang.String)").unwrap()),
None
);
assert_eq!(
default_view_name(
&parse("SELECT * FROM java.lang.String UNION SELECT * FROM java.lang.Thread")
.unwrap()
),
None
);
}
fn cols(names: &[&str]) -> Vec<QueryColumn> {
names
.iter()
.map(|n| QueryColumn {
name: n.to_string(),
})
.collect()
}
#[test]
fn no_directive_returns_text_unchanged() {
let (oql, spec, warn) = split_directive("SELECT * FROM C");
assert_eq!(oql, "SELECT * FROM C");
assert!(spec.is_none());
assert!(warn.is_none());
}
#[test]
fn well_formed_histogram_directive() {
let (oql, spec, warn) = split_directive(
"-- @viz histogram label=c value=n\nSELECT @clazz AS c, COUNT(*) AS n FROM C",
);
assert_eq!(oql.trim(), "SELECT @clazz AS c, COUNT(*) AS n FROM C");
assert!(warn.is_none());
let spec = spec.unwrap();
assert_eq!(spec.kind, VizKind::Histogram);
assert_eq!(spec.label_col.as_deref(), Some("c"));
assert_eq!(spec.value_col.as_deref(), Some("n"));
assert_eq!(spec.cap, None);
}
#[test]
fn all_kinds_parse() {
for (tok, kind) in [
("table", VizKind::Table),
("histogram", VizKind::Histogram),
("piechart", VizKind::Piechart),
("treemap", VizKind::Treemap),
] {
let (_, spec, warn) = split_directive(&format!("-- @viz {tok}\nSELECT * FROM C"));
assert!(warn.is_none(), "{tok} should be well-formed");
assert_eq!(spec.unwrap().kind, kind);
}
}
#[test]
fn kind_is_case_insensitive() {
let (_, spec, warn) = split_directive("-- @VIZ HISTOGRAM\nSELECT * FROM C");
assert!(warn.is_none());
assert_eq!(spec.unwrap().kind, VizKind::Histogram);
}
#[test]
fn at_prefix_stripped_in_column_args() {
let (_, spec, _) = split_directive(
"-- @viz treemap value=@retainedHeapSize label=@displayName\nSELECT * FROM C",
);
let spec = spec.unwrap();
assert_eq!(spec.value_col.as_deref(), Some("retainedHeapSize"));
assert_eq!(spec.label_col.as_deref(), Some("displayName"));
}
#[test]
fn cap_parses_positive_integer() {
let (_, spec, warn) = split_directive("-- @viz piechart cap=10\nSELECT * FROM C");
assert!(warn.is_none());
assert_eq!(spec.unwrap().cap, Some(10));
}
#[test]
fn cap_zero_is_malformed() {
let (oql, spec, warn) = split_directive("-- @viz piechart cap=0\nSELECT * FROM C");
assert_eq!(
oql.trim(),
"SELECT * FROM C",
"directive line still removed"
);
assert!(spec.is_none());
assert!(warn.unwrap().contains("cap"));
}
#[test]
fn unknown_kind_is_malformed_but_removes_line() {
let (oql, spec, warn) = split_directive("-- @viz pie\nSELECT * FROM C");
assert_eq!(oql.trim(), "SELECT * FROM C");
assert!(spec.is_none());
assert!(warn.unwrap().contains("unknown chart kind"));
}
#[test]
fn unknown_arg_key_is_malformed() {
let (_, spec, warn) = split_directive("-- @viz histogram color=red\nSELECT * FROM C");
assert!(spec.is_none());
assert!(warn.unwrap().contains("unknown key"));
}
#[test]
fn arg_without_equals_is_malformed() {
let (_, spec, warn) = split_directive("-- @viz histogram foo\nSELECT * FROM C");
assert!(spec.is_none());
assert!(warn.unwrap().contains("key=value"));
}
#[test]
fn extra_whitespace_between_args_is_tolerated() {
let (_, spec, warn) =
split_directive("-- @viz histogram label=c value=n\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
let spec = spec.unwrap();
assert_eq!(spec.label_col.as_deref(), Some("c"));
assert_eq!(spec.value_col.as_deref(), Some("n"));
}
#[test]
fn args_in_any_order() {
let (_, spec, _) =
split_directive("-- @viz piechart cap=5 value=n label=c\nSELECT * FROM C");
let spec = spec.unwrap();
assert_eq!(spec.cap, Some(5));
assert_eq!(spec.label_col.as_deref(), Some("c"));
assert_eq!(spec.value_col.as_deref(), Some("n"));
}
#[test]
fn dotted_column_name_in_value_arg() {
let (_, spec, warn) = split_directive("-- @viz histogram value=obj.size\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
assert_eq!(spec.unwrap().value_col.as_deref(), Some("obj.size"));
}
#[test]
fn cap_with_column_syntax_is_rejected() {
let (_, spec, warn) = split_directive("-- @viz piechart cap=@foo\nSELECT * FROM C");
assert!(spec.is_none());
assert!(warn.unwrap().contains("cap"));
}
#[test]
fn label_with_number_value_is_rejected() {
let (_, spec, warn) = split_directive("-- @viz histogram label=42\nSELECT * FROM C");
assert!(spec.is_none());
let w = warn.unwrap();
assert!(w.contains("column name") || w.contains("label"), "got: {w}");
}
#[test]
fn bad_byte_in_directive_is_malformed_not_panic() {
let (oql, spec, warn) = split_directive("-- @viz histogram label=a;b\nSELECT * FROM C");
assert_eq!(
oql.trim(),
"SELECT * FROM C",
"directive line still removed"
);
assert!(spec.is_none());
assert!(warn.is_some());
}
#[test]
fn stray_equals_only_is_malformed() {
let (_, spec, warn) = split_directive("-- @viz histogram =x\nSELECT * FROM C");
assert!(spec.is_none());
assert!(warn.is_some());
}
#[test]
fn kind_only_no_args_is_well_formed() {
let (_, spec, warn) = split_directive("-- @viz treemap\nSELECT * FROM C");
assert!(warn.is_none());
let spec = spec.unwrap();
assert_eq!(spec.kind, VizKind::Treemap);
assert_eq!(spec.label_col, None);
assert_eq!(spec.value_col, None);
assert_eq!(spec.cap, None);
}
#[test]
fn arg_key_is_case_insensitive() {
let (_, spec, warn) =
split_directive("-- @viz histogram LABEL=c VALUE=n CAP=3\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
let spec = spec.unwrap();
assert_eq!(spec.label_col.as_deref(), Some("c"));
assert_eq!(spec.value_col.as_deref(), Some("n"));
assert_eq!(spec.cap, Some(3));
}
#[test]
fn title_single_word_parses() {
let (_, spec, warn) = split_directive("-- @viz histogram title=Sizes\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
assert_eq!(spec.unwrap().title.as_deref(), Some("Sizes"));
}
#[test]
fn title_quoted_multiword_parses() {
let (_, spec, warn) =
split_directive("-- @viz histogram title=\"Top classes by size\"\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
assert_eq!(spec.unwrap().title.as_deref(), Some("Top classes by size"));
}
#[test]
fn name_quoted_multiword_parses() {
let (_, spec, warn) =
split_directive("-- @viz table name=\"big classes\"\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
assert_eq!(spec.unwrap().name.as_deref(), Some("big classes"));
}
#[test]
fn title_and_name_together_with_other_args() {
let (_, spec, warn) = split_directive(
"-- @viz piechart title=\"By retained\" name=ret value=n label=c cap=5\nSELECT * FROM C",
);
assert!(warn.is_none(), "warn: {warn:?}");
let spec = spec.unwrap();
assert_eq!(spec.title.as_deref(), Some("By retained"));
assert_eq!(spec.name.as_deref(), Some("ret"));
assert_eq!(spec.value_col.as_deref(), Some("n"));
assert_eq!(spec.label_col.as_deref(), Some("c"));
assert_eq!(spec.cap, Some(5));
}
#[test]
fn empty_quoted_title_is_empty_string() {
let (_, spec, warn) = split_directive("-- @viz histogram title=\"\"\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
assert_eq!(spec.unwrap().title.as_deref(), Some(""));
}
#[test]
fn quoted_value_for_label_column_parses() {
let (_, spec, warn) =
split_directive("-- @viz histogram label=\"my col\"\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
assert_eq!(spec.unwrap().label_col.as_deref(), Some("my col"));
}
#[test]
fn title_without_value_is_malformed() {
let (_, spec, warn) = split_directive("-- @viz histogram title=\nSELECT * FROM C");
assert!(spec.is_none());
assert!(warn.is_some());
}
#[test]
fn unterminated_quote_is_malformed_not_panic() {
let (oql, spec, warn) =
split_directive("-- @viz histogram title=\"unclosed\nSELECT * FROM C");
assert_eq!(
oql.trim(),
"SELECT * FROM C",
"directive line still removed"
);
assert!(spec.is_none());
assert!(warn.is_some());
}
#[test]
fn title_is_case_insensitive_key() {
let (_, spec, warn) =
split_directive("-- @viz histogram TITLE=Foo NAME=bar\nSELECT * FROM C");
assert!(warn.is_none(), "warn: {warn:?}");
let spec = spec.unwrap();
assert_eq!(spec.title.as_deref(), Some("Foo"));
assert_eq!(spec.name.as_deref(), Some("bar"));
}
#[test]
fn non_viz_comment_line_is_left_untouched() {
let (oql, spec, warn) = split_directive("-- just a note\nSELECT * FROM C");
assert_eq!(oql, "-- just a note\nSELECT * FROM C");
assert!(spec.is_none());
assert!(warn.is_none());
}
#[test]
fn resolve_named_columns() {
let spec = VizSpec {
kind: VizKind::Histogram,
label_col: Some("c".into()),
value_col: Some("n".into()),
cap: None,
..Default::default()
};
let columns = cols(&["c", "n"]);
let rows = vec![
vec![QueryValue::Str("a".into()), QueryValue::Int(3)],
vec![QueryValue::Str("b".into()), QueryValue::Int(5)],
];
assert_eq!(resolve_columns(&spec, &columns, &rows).unwrap(), (0, 1));
}
#[test]
fn resolve_positional_fallback_picks_first_numeric() {
let spec = VizSpec {
kind: VizKind::Piechart,
label_col: None,
value_col: None,
cap: None,
..Default::default()
};
let columns = cols(&["name", "count"]);
let rows = vec![vec![QueryValue::Str("a".into()), QueryValue::Int(3)]];
assert_eq!(resolve_columns(&spec, &columns, &rows).unwrap(), (0, 1));
}
#[test]
fn resolve_at_prefix_named_column() {
let spec = VizSpec {
kind: VizKind::Treemap,
label_col: Some("displayName".into()),
value_col: Some("retainedHeapSize".into()),
cap: None,
..Default::default()
};
let columns = cols(&["@displayName", "@retainedHeapSize"]);
let rows = vec![vec![QueryValue::Str("x".into()), QueryValue::Int(9)]];
assert_eq!(resolve_columns(&spec, &columns, &rows).unwrap(), (0, 1));
}
#[test]
fn resolve_unknown_value_column_errors() {
let spec = VizSpec {
kind: VizKind::Histogram,
label_col: None,
value_col: Some("missing".into()),
cap: None,
..Default::default()
};
let columns = cols(&["a", "b"]);
let rows = vec![vec![QueryValue::Int(1), QueryValue::Int(2)]];
assert!(
resolve_columns(&spec, &columns, &rows)
.unwrap_err()
.contains("not found")
);
}
#[test]
fn resolve_non_numeric_value_errors() {
let spec = VizSpec {
kind: VizKind::Histogram,
label_col: Some("a".into()),
value_col: Some("b".into()),
cap: None,
..Default::default()
};
let columns = cols(&["a", "b"]);
let rows = vec![vec![
QueryValue::Str("x".into()),
QueryValue::Str("y".into()),
]];
assert!(
resolve_columns(&spec, &columns, &rows)
.unwrap_err()
.contains("not numeric")
);
}
#[test]
fn resolve_no_numeric_column_positional_errors() {
let spec = VizSpec {
kind: VizKind::Histogram,
label_col: None,
value_col: None,
cap: None,
..Default::default()
};
let columns = cols(&["a", "b"]);
let rows = vec![vec![
QueryValue::Str("x".into()),
QueryValue::Str("y".into()),
]];
assert!(
resolve_columns(&spec, &columns, &rows)
.unwrap_err()
.contains("no numeric column")
);
}
#[test]
fn resolve_table_kind_is_noop() {
let spec = VizSpec {
kind: VizKind::Table,
label_col: None,
value_col: None,
cap: None,
..Default::default()
};
let columns = cols(&["a"]);
let rows = vec![vec![QueryValue::Str("x".into())]];
assert_eq!(resolve_columns(&spec, &columns, &rows).unwrap(), (0, 0));
}
}