Skip to content

Commit 2263b00

Browse files
committed
feat: discover benchmarks in go project
1 parent 770198c commit 2263b00

File tree

2 files changed

+1126
-0
lines changed

2 files changed

+1126
-0
lines changed

go-runner/src/builder/discovery.rs

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
//! Finds all the benchmarks and packages in a given Go project.
2+
3+
use std::{
4+
ops::Deref,
5+
path::{Path, PathBuf},
6+
process::Command,
7+
sync::atomic::{AtomicU32, Ordering},
8+
};
9+
10+
use crate::builder::verifier;
11+
use crate::prelude::*;
12+
use serde::{Deserialize, Serialize};
13+
14+
/// Represents a Go package, deserialized from `go list -json` output.
15+
#[derive(Debug, Clone, Serialize, Deserialize)]
16+
pub struct GoPackage {
17+
/// The path to the package (e.g., "github.com/user/project/pkg/foo").
18+
#[serde(rename = "Dir")]
19+
pub dir: PathBuf,
20+
21+
/// The name of the package (e.g., "foo").
22+
#[serde(rename = "Name")]
23+
pub name: String,
24+
25+
/// The import path of the package with a package identifier (e.g., "local.dev/example-complex/internal/config [local.dev/example-complex/internal/config.test]").
26+
#[serde(rename = "ImportPath")]
27+
pub import_path: String,
28+
29+
#[serde(rename = "TestGoFiles")]
30+
pub test_go_files: Option<Vec<String>>,
31+
32+
#[serde(rename = "TestImports")]
33+
pub test_imports: Option<Vec<String>>,
34+
35+
#[serde(rename = "CompiledGoFiles")]
36+
pub compiled_go_files: Option<Vec<String>>,
37+
38+
#[serde(rename = "Module")]
39+
pub module: GoModule,
40+
}
41+
42+
/// Contains information about the Go module, which contains one or more Go packages.
43+
#[derive(Debug, Clone, Serialize, Deserialize)]
44+
pub struct GoModule {
45+
/// The module path (e.g., "local.dev/example-complex").
46+
#[serde(rename = "Path")]
47+
pub path: String,
48+
49+
/// The module directory (e.g., "/home/user/go/src/local.dev/example-complex").
50+
#[serde(rename = "Dir")]
51+
pub dir: PathBuf,
52+
53+
/// The module go.mod file (e.g., "/home/user/go/src/local.dev/example-complex/go.mod").
54+
#[serde(rename = "GoMod")]
55+
pub go_mod: PathBuf,
56+
57+
/// The module version (e.g., "v1.0.0").
58+
#[serde(rename = "GoVersion")]
59+
pub version: String,
60+
61+
/// Whether this is the main module.
62+
#[serde(rename = "Main")]
63+
pub main: bool,
64+
}
65+
66+
impl GoPackage {
67+
pub fn from_go_list_output(output: &str) -> anyhow::Result<Vec<Self>> {
68+
// Replace all \n, then find '}{' and replace with '},{' to convert the output into a valid JSON array
69+
let output = output.replace("\n", "");
70+
let output = output.replace("}{", "},{");
71+
72+
serde_json::from_str(&format!("[{output}]")).context("Failed to parse Go list output")
73+
}
74+
75+
fn benchmarks(&self) -> anyhow::Result<Vec<GoBenchmark>> {
76+
let Some(test_go_files) = &self.test_go_files else {
77+
bail!("No test files found for package: {}", self.name);
78+
};
79+
80+
let mut benchmarks = Vec::new();
81+
'file_loop: for file in test_go_files {
82+
assert!(file.ends_with("_test.go"));
83+
84+
let file_path = self.dir.join(file);
85+
let content = std::fs::read_to_string(&file_path)
86+
.context(format!("Failed to read test file: {file_path:?}"))?;
87+
88+
let file = match gosyn::parse_source(&content) {
89+
Ok(ast) => ast,
90+
Err(e) => {
91+
warn!("Failed to parse Go file {file_path:?}: {e}");
92+
continue;
93+
}
94+
};
95+
96+
// Check for unsupported imports
97+
const UNSUPPORTED_IMPORTS: &[(&str, &str)] = &[
98+
("github.com/frankban/quicktest", "quicktest"),
99+
("github.com/stretchr/testify", "testify"),
100+
];
101+
for (import_path, import_name) in UNSUPPORTED_IMPORTS {
102+
if file
103+
.imports
104+
.iter()
105+
.any(|import| import.path.value.contains(import_path))
106+
{
107+
warn!("Skipping file with {import_name} import: {file_path:?}");
108+
continue 'file_loop;
109+
}
110+
}
111+
112+
// We can't import packages that are declared as `main`
113+
if file.pkg_name.name == "main" {
114+
warn!("Skipping file with main package: {file_path:?}");
115+
continue;
116+
}
117+
118+
// First, collect all benchmark function names from this file
119+
let mut found_benchmarks = Vec::new();
120+
for decl in &file.decl {
121+
let gosyn::ast::Declaration::Function(func_decl) = decl else {
122+
continue;
123+
};
124+
125+
let func_name = &func_decl.name.name;
126+
127+
// Check if function name starts with "Benchmark"
128+
if !func_name.starts_with("Benchmark") {
129+
continue;
130+
}
131+
132+
found_benchmarks.push(func_name.clone());
133+
}
134+
135+
// Extract the actual package import path from the full import_path
136+
// The import_path format is like "local.dev/example-complex/pkg/auth [local.dev/example-complex/pkg/auth.test]"
137+
let package_import_path = self
138+
.import_path
139+
.split_whitespace()
140+
.next()
141+
.unwrap_or(&self.import_path)
142+
.to_string();
143+
144+
// Remove the module dir parent from the file path
145+
let root_relative_file_path = file_path.strip_prefix(&self.module.dir).context(
146+
format!("Couldn't strip the module dir from file path: {file_path:?}"),
147+
)?;
148+
149+
let valid_benchmarks =
150+
verifier::FuncVisitor::verify_source_code(&content, &found_benchmarks)?;
151+
if valid_benchmarks.len() != found_benchmarks.len() {
152+
warn!(
153+
"Only {} out of {} are valid, skipping file",
154+
valid_benchmarks.len(),
155+
found_benchmarks.len()
156+
);
157+
warn!("Valid benchmarks: {valid_benchmarks:?}");
158+
warn!(
159+
"Invalid benchmarks: {:?}",
160+
found_benchmarks
161+
.iter()
162+
.filter(|f| !valid_benchmarks.contains(f))
163+
.collect::<Vec<_>>()
164+
);
165+
166+
continue;
167+
}
168+
169+
for func in valid_benchmarks {
170+
benchmarks.push(GoBenchmark::new(
171+
package_import_path.clone(),
172+
func,
173+
root_relative_file_path.to_path_buf(),
174+
));
175+
}
176+
}
177+
178+
Ok(benchmarks)
179+
}
180+
}
181+
182+
#[derive(Debug, Clone, Serialize, Deserialize)]
183+
pub struct GoBenchmark {
184+
/// The name of the benchmark (e.g. `BenchmarkFoo`).
185+
pub name: String,
186+
187+
/// The path to the module (e.g. `github.com/user/foo`).
188+
module_path: String,
189+
190+
/// The import alias (e.g. `foo_test_49212941`).
191+
import_alias: String,
192+
193+
/// The name with the package (e.g. `foo_test.BenchmarkFoo`).
194+
qualified_name: String,
195+
196+
/// The file path relative to the module directory (e.g. `pkg/foo/foo_test.go`).
197+
pub file_path: PathBuf,
198+
}
199+
200+
impl GoBenchmark {
201+
pub fn new(package_import_path: String, name: String, file_path: PathBuf) -> Self {
202+
static COUNTER: AtomicU32 = AtomicU32::new(0);
203+
204+
let import_alias = format!(
205+
"{}_{}",
206+
name.to_lowercase(),
207+
COUNTER.fetch_add(1, Ordering::Relaxed)
208+
);
209+
let qualified_name = format!("{}.{}", import_alias, &name);
210+
Self {
211+
module_path: package_import_path,
212+
import_alias,
213+
name,
214+
qualified_name,
215+
file_path,
216+
}
217+
}
218+
}
219+
220+
/// Represents a package with its benchmarks.
221+
#[derive(Debug, Clone, Serialize)]
222+
pub struct BenchmarkPackage {
223+
raw_package: GoPackage,
224+
pub benchmarks: Vec<GoBenchmark>,
225+
}
226+
227+
impl BenchmarkPackage {
228+
fn new(package: GoPackage, benchmarks: Vec<GoBenchmark>) -> Self {
229+
Self {
230+
raw_package: package,
231+
benchmarks,
232+
}
233+
}
234+
235+
pub fn from_project(go_project_path: &Path) -> anyhow::Result<Vec<BenchmarkPackage>> {
236+
let raw_packages = Self::run_go_list(go_project_path)?;
237+
let has_test_files =
238+
|files: &Vec<String>| files.iter().any(|name| name.ends_with("_test.go"));
239+
let has_test_imports = |imports: &Vec<String>| {
240+
imports.iter().any(|import| {
241+
// import "testing"
242+
import.contains("testing")
243+
})
244+
};
245+
246+
let mut packages = Vec::new();
247+
for package in raw_packages {
248+
// Skip packages without test files
249+
let has_tests = package
250+
.test_go_files
251+
.as_ref()
252+
.map(has_test_files)
253+
.unwrap_or_default();
254+
if !has_tests {
255+
debug!("Skipping package without test files: {}", package.name);
256+
continue;
257+
}
258+
259+
// Skip packages without test imports
260+
let has_test_imports = package
261+
.test_imports
262+
.as_ref()
263+
.map(has_test_imports)
264+
.unwrap_or_default();
265+
if !has_test_imports {
266+
debug!("Skipping package without test imports: {}", package.name);
267+
continue;
268+
}
269+
270+
// Only include test executables, since we want to generate them manually.
271+
// Example format: `local.dev/example-complex [local.dev/example-complex.test]`
272+
if !package.import_path.ends_with(".test]") {
273+
debug!("Skipping package without test executable: {}", package.name);
274+
continue;
275+
}
276+
277+
// Skip packages that don't have benchmarks
278+
let benchmarks = match package.benchmarks() {
279+
Ok(benchmarks) => benchmarks,
280+
Err(e) => {
281+
warn!(
282+
"Failed to get benchmarks for package {}: {}",
283+
package.name, e
284+
);
285+
continue;
286+
}
287+
};
288+
if benchmarks.is_empty() {
289+
debug!("Skipping package without benchmarks: {}", package.name);
290+
continue;
291+
}
292+
293+
packages.push(BenchmarkPackage::new(package, benchmarks));
294+
}
295+
296+
Ok(packages)
297+
}
298+
299+
fn run_go_list(go_project_path: &Path) -> anyhow::Result<Vec<GoPackage>> {
300+
// Execute 'go list -test -compiled -json ./...' to get package information
301+
let output = Command::new("go")
302+
.args(["list", "-test", "-compiled", "-json", "./..."])
303+
.current_dir(go_project_path)
304+
.output()?;
305+
306+
if !output.status.success() {
307+
bail!(
308+
"Failed to execute 'go list': {}",
309+
String::from_utf8_lossy(&output.stderr)
310+
);
311+
}
312+
313+
// Wrap it in '[{output}]' and parse it with serde_json
314+
let output_str = String::from_utf8(output.stdout)?;
315+
trace!("Go list output: {output_str}");
316+
317+
GoPackage::from_go_list_output(&output_str)
318+
}
319+
}
320+
321+
impl Deref for BenchmarkPackage {
322+
type Target = GoPackage;
323+
324+
fn deref(&self) -> &Self::Target {
325+
&self.raw_package
326+
}
327+
}
328+
329+
#[cfg(test)]
330+
mod tests {
331+
use super::*;
332+
333+
#[test]
334+
fn test_discover_benchmarks() {
335+
let packages =
336+
BenchmarkPackage::from_project(Path::new("testdata/projects/golang-benchmarks"))
337+
.unwrap();
338+
339+
insta::assert_json_snapshot!(packages, {
340+
".**[\"Dir\"]" => "[package_dir]",
341+
".**[\"Module\"][\"Dir\"]" => "[module_dir]",
342+
".**[\"Module\"][\"GoMod\"]" => "[go_mod_path]"
343+
});
344+
}
345+
}

0 commit comments

Comments
 (0)