use std::path::Path;
use crate::manifest::YogToml;
pub const BENCH_DIR: &str = "benches";
pub const GENERATED_DIR: &str = ".yog-build/generated/benches";
const CRITERION: &str = r#"criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] }"#;
const ATTRIBUTE: &str = "#[yog_api::bench]";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Takes {
Bencher,
Criterion,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BenchFile {
pub name: String,
pub functions: Vec<(String, Takes)>,
}
pub fn discover(root: &Path) -> Result<Vec<BenchFile>, String> {
let dir = root.join(BENCH_DIR);
let Ok(entries) = std::fs::read_dir(&dir) else {
return Ok(Vec::new());
};
let mut files = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let source =
std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let functions = functions(&source).map_err(|e| format!("{BENCH_DIR}/{name}.rs: {e}"))?;
if !functions.is_empty() {
files.push(BenchFile {
name: name.to_string(),
functions,
});
}
}
files.sort_by(|a, b| a.name.cmp(&b.name));
Ok(files)
}
pub fn functions(source: &str) -> Result<Vec<(String, Takes)>, String> {
let mut out = Vec::new();
let mut rest = source;
while let Some(at) = rest.find(ATTRIBUTE) {
let line = source[..source.len() - rest.len() + at]
.matches('\n')
.count()
+ 1;
rest = &rest[at + ATTRIBUTE.len()..];
let Some(fn_at) = rest.find("fn ") else {
return Err(format!("line {line}: {ATTRIBUTE} must be on a function"));
};
let after = &rest[fn_at + 3..];
let name: String = after
.trim_start()
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
let params = after
.split_once('(')
.and_then(|(_, p)| p.split_once(')'))
.map(|(p, _)| p)
.unwrap_or_default();
let takes = if params.contains("Criterion") {
Takes::Criterion
} else if params.contains("Bencher") {
Takes::Bencher
} else {
return Err(format!(
"line {line}: `{name}` must take `&mut Bencher` or `&mut Criterion`"
));
};
out.push((name, takes));
}
Ok(out)
}
pub fn main_source(file: &BenchFile) -> String {
let mut calls = String::new();
for (function, takes) in &file.functions {
calls.push_str(&match takes {
Takes::Bencher => {
format!(
" c.bench_function(\"{}/{function}\", bench::{function});\n",
file.name
)
}
Takes::Criterion => format!(" bench::{function}(&mut c);\n"),
});
}
format!(
"// Generated by yog from {BENCH_DIR}/{name}.rs — do not edit.\n\
#[path = \"../../../{BENCH_DIR}/{name}.rs\"]\n\
mod bench;\n\n\
fn main() {{\n\
\x20 let mut c = ::criterion::Criterion::default().configure_from_args();\n\
{calls}\
\x20 c.final_summary();\n\
}}\n",
name = file.name,
)
}
pub fn write_sources(root: &Path, files: &[BenchFile]) -> Result<(), String> {
let dir = root.join(GENERATED_DIR);
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
let stale = path
.file_stem()
.and_then(|s| s.to_str())
.is_none_or(|stem| !files.iter().any(|f| f.name == stem));
if stale {
let _ = std::fs::remove_file(path);
}
}
}
for file in files {
crate::write_if_changed(&dir.join(format!("{}.rs", file.name)), &main_source(file))?;
}
Ok(())
}
pub fn cargo_sections(meta: &YogToml, files: &[BenchFile]) -> String {
if files.is_empty() {
return String::new();
}
let mut out = String::from("\n[dev-dependencies]\n");
for (name, spec) in &meta.dev_dependencies {
out.push_str(&format!("{name} = {spec}\n"));
}
if !meta
.dev_dependencies
.iter()
.any(|(name, _)| name == "criterion")
{
out.push_str(CRITERION);
out.push('\n');
}
for file in files {
out.push_str(&format!(
"\n[[bench]]\nname = \"{name}\"\npath = \"generated/benches/{name}.rs\"\nharness = false\n",
name = file.name
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_marked_functions_in_order() {
let src = "use criterion::{Bencher, Criterion};\n\
fn helper() {}\n\
#[yog_api::bench]\nfn parse(b: &mut Bencher) { b.iter(|| 1) }\n\
#[yog_api::bench]\npub fn groups(c: &mut Criterion) {}\n";
assert_eq!(
functions(src).unwrap(),
[
("parse".to_string(), Takes::Bencher),
("groups".to_string(), Takes::Criterion)
]
);
}
#[test]
fn rejects_a_function_without_a_bencher() {
let e = functions("\n#[yog_api::bench]\nfn nope(x: u32) {}\n").unwrap_err();
assert!(e.starts_with("line 2:"), "{e}");
}
#[test]
fn main_runs_each_function() {
let file = BenchFile {
name: "casting".into(),
functions: vec![
("parse".into(), Takes::Bencher),
("vm".into(), Takes::Criterion),
],
};
let main = main_source(&file);
assert!(main.contains("#[path = \"../../../benches/casting.rs\"]"));
assert!(main.contains("c.bench_function(\"casting/parse\", bench::parse);"));
assert!(main.contains("bench::vm(&mut c);"));
}
}