diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c63d196..c21dc52 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,17 +54,17 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y libxml2-utils
- name: Validate SVG files
run: |
- shopt -s nullglob
- svg_files=(*.svg **/*.svg)
- if [ ${#svg_files[@]} -eq 0 ]; then
- echo "No SVG files found"
- exit 0
+ set -euo pipefail
+ # Validate every tracked SVG (the prior glob missed nested files such as
+ # the per-parser badges under web/static/badges/). Tracked-only, so build
+ # output and the dataset cache are never picked up.
+ mapfile -t svgs < <(git ls-files '*.svg')
+ if [ ${#svgs[@]} -eq 0 ]; then
+ echo "No tracked SVG files found"
+ exit 1
fi
- echo "Validating ${#svg_files[@]} SVG file(s)..."
- for svg in "${svg_files[@]}"; do
- echo " Checking $svg"
- xmllint --noout "$svg"
- done
+ echo "Validating ${#svgs[@]} SVG file(s)..."
+ xmllint --noout "${svgs[@]}"
echo "All SVG files are valid XML"
test:
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index b4c8bc8..38d2018 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -50,6 +50,7 @@ jobs:
cp web/static/robots.txt _site/robots.txt # crawler directives + sitemap pointer
cp web/static/sitemap.xml _site/sitemap.xml # sitemap for search engines
cp -r web/static/failures _site/failures # rejected-statement TSV downloads
+ cp -r web/static/badges _site/badges # per-parser README badge SVGs
- uses: actions/upload-pages-artifact@v3
with:
path: _site
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4ebaf19..2b65a95 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -34,6 +34,14 @@ cargo run --bin sqlbench -- export # read all of the above, write web/asse
The charts are rendered in the browser from the JSON by the shared `viz` crate (plotters, SVG backend), so no chart images are committed.
+The per-parser README badges under `web/static/badges/` are derived from the same scoring, so refresh them whenever the snapshot changes:
+
+```bash
+cargo run -p badgegen # rewrite web/static/badges/*.svg from the embedded snapshot
+```
+
+`badgegen` reads the snapshot the `web` crate embeds, so run it after `export`. The shields-style SVG renderer lives in `viz::badge` and is shared with the parser page, which previews the same badges and shows their copy-paste Markdown.
+
## Contentious constructs
A contentious construct is one the reference engine accepts but a parser may reasonably decline to support (a niche engine quirk, a non-standard extension, a lossy or deprecated form). The benchmark keeps strict, oracle-graded recall as the headline number and adds a secondary "recall excluding contentious" beside it, plus a per-statement badge on the failures view. The design is written up in [docs/contentious-constructs.md](docs/contentious-constructs.md).
diff --git a/Cargo.toml b/Cargo.toml
index 2d7ada6..6de4bc9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
[workspace]
-members = [".", "viz", "web", "membench", "oracle", "timemachine", "featurescan"]
+members = [".", "viz", "web", "badgegen", "membench", "oracle", "timemachine", "featurescan"]
default-members = ["."]
resolver = "2"
diff --git a/badgegen/Cargo.toml b/badgegen/Cargo.toml
new file mode 100644
index 0000000..1d91048
--- /dev/null
+++ b/badgegen/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "badgegen"
+version = "0.1.0"
+edition = "2021"
+description = "Writes the static per-parser README badges into web/static/badges"
+license = "MIT"
+publish = false
+
+[lints.clippy]
+all = "warn"
+
+[dependencies]
+sql_ast_benchmark_web = { path = "../web" }
diff --git a/badgegen/src/main.rs b/badgegen/src/main.rs
new file mode 100644
index 0000000..0c33e77
--- /dev/null
+++ b/badgegen/src/main.rs
@@ -0,0 +1,27 @@
+//! Writes the static per-parser README badges into `web/static/badges/`, one SVG
+//! per parser per variant, from the same scoring the website uses. Run after
+//! `sqlbench export` refreshes the snapshot the `web` crate embeds.
+
+use sql_ast_benchmark_web::badges;
+use std::{fs, path::Path};
+
+fn main() -> std::io::Result<()> {
+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../web/static/badges");
+ fs::create_dir_all(&dir)?;
+ for entry in fs::read_dir(&dir)? {
+ let path = entry?.path();
+ if path.extension().is_some_and(|e| e == "svg") {
+ fs::remove_file(path)?;
+ }
+ }
+
+ let mut written = 0;
+ for (_parser, variants) in badges::all() {
+ for v in variants {
+ fs::write(dir.join(&v.file), v.svg)?;
+ written += 1;
+ }
+ }
+ println!("wrote {written} badge SVGs to {}", dir.display());
+ Ok(())
+}
diff --git a/src/bin/sqlbench.rs b/src/bin/sqlbench.rs
index 3b24282..b83d945 100644
--- a/src/bin/sqlbench.rs
+++ b/src/bin/sqlbench.rs
@@ -259,7 +259,7 @@ fn run_regen() {
],
), // web/assets/history.json.zst
];
- let total = steps.len() + 1;
+ let total = steps.len() + 2;
for (i, (cmd, args)) in steps.iter().enumerate() {
eprintln!("\n[regen {}/{total}] {cmd} {}", i + 1, args.join(" "));
let status = std::process::Command::new(cmd)
@@ -274,11 +274,26 @@ fn run_regen() {
std::process::exit(1);
}
}
- eprintln!("\n[regen {total}/{total}] export");
+ eprintln!("\n[regen {}/{total}] export", total - 1);
if let Err(e) = export::run() {
eprintln!("ERROR: {e}");
std::process::exit(1);
}
+
+ // Badges read the snapshot the web crate embeds, so regenerate them last,
+ // after export has rewritten it.
+ eprintln!("\n[regen {total}/{total}] cargo run -p badgegen");
+ let status = std::process::Command::new("cargo")
+ .args(["run", "-p", "badgegen"])
+ .status()
+ .unwrap_or_else(|e| {
+ eprintln!("ERROR: could not launch `cargo run -p badgegen`: {e}");
+ std::process::exit(1);
+ });
+ if !status.success() {
+ eprintln!("ERROR: step failed: `cargo run -p badgegen`");
+ std::process::exit(1);
+ }
}
fn usage() -> ! {
diff --git a/timemachine/src/run.rs b/timemachine/src/run.rs
index 1219fbb..c1439ed 100644
--- a/timemachine/src/run.rs
+++ b/timemachine/src/run.rs
@@ -154,6 +154,10 @@ fn metrics_of(report: &report::DialectReport) -> ParserMetrics {
} else {
pct(s.accepted_valid, report.valid_total)
},
+ // The time machine does not classify contentious constructs, so it reports
+ // no excluding-contentious recall and counts none accepted.
+ accepted_valid_contentious: 0,
+ recall_excl_contentious_pct: None,
// The time machine does not measure the empirical panic rate (only the
// current build does, via BenchParser's panic-detecting parse_outcome), so
// it is left unmeasured rather than reported as a misleading zero.
diff --git a/viz/src/badge.rs b/viz/src/badge.rs
new file mode 100644
index 0000000..0d4642b
--- /dev/null
+++ b/viz/src/badge.rs
@@ -0,0 +1,94 @@
+//! Pure renderer for shields-style flat SVG badges, shared by the website and
+//! the static `/badges/*.svg` generator so both emit identical output. Text is
+//! sized from a Verdana width table and carries an SVG `textLength`, so the
+//! browser scales each string to the computed width regardless of the font.
+
+/// Approximate Verdana advance width (px at font-size 11) for one character.
+fn char_width(c: char) -> f64 {
+ match c {
+ ' ' | '!' | '\'' | '.' | ',' => 3.8,
+ '#' => 8.9,
+ '$' | '0'..='9' => 7.0,
+ '(' | ')' | '-' | '/' | ':' => 4.6,
+ 'i' | 'j' | 'l' => 3.0,
+ 'f' | 't' | 'r' => 4.4,
+ 'm' => 10.6,
+ 'w' => 9.0,
+ 'M' | 'W' => 10.6,
+ 'I' | 'J' => 4.6,
+ 'a'..='z' => 6.6,
+ 'A'..='Z' => 8.2,
+ _ => 6.8,
+ }
+}
+
+fn text_width(s: &str) -> f64 {
+ s.chars().map(char_width).sum()
+}
+
+fn esc(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
+/// A flat badge: dark grey `label`, then `message` filled with `color` (e.g.
+/// `#4c1`). Returns a self-contained 20px-tall SVG.
+#[must_use]
+pub fn render(label: &str, message: &str, color: &str) -> String {
+ let lw = (text_width(label) + 10.0).round() as i64;
+ let mw = (text_width(message) + 10.0).round() as i64;
+ let total = lw + mw;
+ let lcx = lw * 5;
+ let mcx = lw * 10 + mw * 5;
+ let ltl = (text_width(label) * 10.0).round() as i64;
+ let mtl = (text_width(message) * 10.0).round() as i64;
+ let (l, m, c) = (esc(label), esc(message), esc(color));
+ format!(
+ ""
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn render_contains_both_segments() {
+ let svg = render("sql ast benchmark", "#1 SQLite", "#4c1");
+ assert!(svg.starts_with("