1use std::path::Path;
30
31use crate::manifest::YogToml;
32
33pub const BENCH_DIR: &str = "benches";
35pub const GENERATED_DIR: &str = ".yog-build/generated/benches";
37const CRITERION: &str = r#"criterion = { version = "0.8", default-features = false, features = ["cargo_bench_support"] }"#;
39const ATTRIBUTE: &str = "#[yog_api::bench]";
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Takes {
45 Bencher,
47 Criterion,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct BenchFile {
54 pub name: String,
55 pub functions: Vec<(String, Takes)>,
56}
57
58pub fn discover(root: &Path) -> Result<Vec<BenchFile>, String> {
60 let dir = root.join(BENCH_DIR);
61 let Ok(entries) = std::fs::read_dir(&dir) else {
62 return Ok(Vec::new());
63 };
64 let mut files = Vec::new();
65 for entry in entries.flatten() {
66 let path = entry.path();
67 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
68 continue;
69 }
70 let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
71 continue;
72 };
73 let source =
74 std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
75 let functions = functions(&source).map_err(|e| format!("{BENCH_DIR}/{name}.rs: {e}"))?;
76 if !functions.is_empty() {
77 files.push(BenchFile {
78 name: name.to_string(),
79 functions,
80 });
81 }
82 }
83 files.sort_by(|a, b| a.name.cmp(&b.name));
84 Ok(files)
85}
86
87pub fn functions(source: &str) -> Result<Vec<(String, Takes)>, String> {
89 let mut out = Vec::new();
90 let mut rest = source;
91 while let Some(at) = rest.find(ATTRIBUTE) {
92 let line = source[..source.len() - rest.len() + at]
93 .matches('\n')
94 .count()
95 + 1;
96 rest = &rest[at + ATTRIBUTE.len()..];
97 let Some(fn_at) = rest.find("fn ") else {
98 return Err(format!("line {line}: {ATTRIBUTE} must be on a function"));
99 };
100 let after = &rest[fn_at + 3..];
101 let name: String = after
102 .trim_start()
103 .chars()
104 .take_while(|c| c.is_alphanumeric() || *c == '_')
105 .collect();
106 let params = after
107 .split_once('(')
108 .and_then(|(_, p)| p.split_once(')'))
109 .map(|(p, _)| p)
110 .unwrap_or_default();
111 let takes = if params.contains("Criterion") {
112 Takes::Criterion
113 } else if params.contains("Bencher") {
114 Takes::Bencher
115 } else {
116 return Err(format!(
117 "line {line}: `{name}` must take `&mut Bencher` or `&mut Criterion`"
118 ));
119 };
120 out.push((name, takes));
121 }
122 Ok(out)
123}
124
125pub fn main_source(file: &BenchFile) -> String {
127 let mut calls = String::new();
128 for (function, takes) in &file.functions {
129 calls.push_str(&match takes {
130 Takes::Bencher => {
131 format!(
132 " c.bench_function(\"{}/{function}\", bench::{function});\n",
133 file.name
134 )
135 }
136 Takes::Criterion => format!(" bench::{function}(&mut c);\n"),
137 });
138 }
139 format!(
140 "// Generated by yog from {BENCH_DIR}/{name}.rs — do not edit.\n\
141 #[path = \"../../../{BENCH_DIR}/{name}.rs\"]\n\
142 mod bench;\n\n\
143 fn main() {{\n\
144 \x20 let mut c = ::criterion::Criterion::default().configure_from_args();\n\
145 {calls}\
146 \x20 c.final_summary();\n\
147 }}\n",
148 name = file.name,
149 )
150}
151
152pub fn write_sources(root: &Path, files: &[BenchFile]) -> Result<(), String> {
154 let dir = root.join(GENERATED_DIR);
155 if let Ok(entries) = std::fs::read_dir(&dir) {
156 for entry in entries.flatten() {
157 let path = entry.path();
158 let stale = path
159 .file_stem()
160 .and_then(|s| s.to_str())
161 .is_none_or(|stem| !files.iter().any(|f| f.name == stem));
162 if stale {
163 let _ = std::fs::remove_file(path);
164 }
165 }
166 }
167 for file in files {
168 crate::write_if_changed(&dir.join(format!("{}.rs", file.name)), main_source(file))?;
169 }
170 Ok(())
171}
172
173pub fn cargo_sections(meta: &YogToml, files: &[BenchFile], test_deps: &[String]) -> String {
176 if files.is_empty() && test_deps.is_empty() {
177 return String::new();
178 }
179 let mut out = String::from("\n[dev-dependencies]\n");
180 for dep in test_deps {
181 out.push_str(dep);
182 out.push('\n');
183 }
184 if files.is_empty() {
185 return out;
186 }
187 for (name, spec) in &meta.dev_dependencies {
188 out.push_str(&format!("{name} = {spec}\n"));
189 }
190 if !meta
191 .dev_dependencies
192 .iter()
193 .any(|(name, _)| name == "criterion")
194 {
195 out.push_str(CRITERION);
196 out.push('\n');
197 }
198 for file in files {
199 out.push_str(&format!(
200 "\n[[bench]]\nname = \"{name}\"\npath = \"generated/benches/{name}.rs\"\nharness = false\n",
201 name = file.name
202 ));
203 }
204 out
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn finds_marked_functions_in_order() {
213 let src = "use criterion::{Bencher, Criterion};\n\
214 fn helper() {}\n\
215 #[yog_api::bench]\nfn parse(b: &mut Bencher) { b.iter(|| 1) }\n\
216 #[yog_api::bench]\npub fn groups(c: &mut Criterion) {}\n";
217 assert_eq!(
218 functions(src).unwrap(),
219 [
220 ("parse".to_string(), Takes::Bencher),
221 ("groups".to_string(), Takes::Criterion)
222 ]
223 );
224 }
225
226 #[test]
227 fn rejects_a_function_without_a_bencher() {
228 let e = functions("\n#[yog_api::bench]\nfn nope(x: u32) {}\n").unwrap_err();
229 assert!(e.starts_with("line 2:"), "{e}");
230 }
231
232 #[test]
233 fn main_runs_each_function() {
234 let file = BenchFile {
235 name: "casting".into(),
236 functions: vec![
237 ("parse".into(), Takes::Bencher),
238 ("vm".into(), Takes::Criterion),
239 ],
240 };
241 let main = main_source(&file);
242 assert!(main.contains("#[path = \"../../../benches/casting.rs\"]"));
243 assert!(main.contains("c.bench_function(\"casting/parse\", bench::parse);"));
244 assert!(main.contains("bench::vm(&mut c);"));
245 }
246}