From 7cdbb8da7e011855ae7688f44f62487b1ff5ff03 Mon Sep 17 00:00:00 2001 From: David Elner Date: Thu, 13 Aug 2026 23:05:21 +0000 Subject: [PATCH] Added: Go eval support --- README.md | 21 ++- src/eval.rs | 481 ++++++++++++++++++++++++++++++++++++++++++++--- src/go_runner.rs | 133 +++++++++++++ src/main.rs | 2 + 4 files changed, 610 insertions(+), 27 deletions(-) create mode 100644 src/go_runner.rs diff --git a/README.md b/README.md index 3a80617e..40edcd9c 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ Remove-Item -Recurse -Force (Join-Path $env:APPDATA "bt") -ErrorAction SilentlyC - `bt eval "tests/**/*.eval.ts"` — glob pattern - `bt eval a.eval.ts b.eval.ts` — one or more explicit files -Files inside `node_modules`, `.venv`, `venv`, `site-packages`, `dist-packages`, and `__pycache__` are excluded from automatic discovery. Explicit paths and globs bypass these exclusions. +Files inside `node_modules`, `.venv`, `venv`, `site-packages`, `dist-packages`, `__pycache__`, and `vendor` are excluded from automatic discovery. Explicit paths and globs bypass these exclusions. **Runners:** @@ -173,6 +173,25 @@ Files inside `node_modules`, `.venv`, `venv`, `site-packages`, `dist-packages`, - If eval execution fails with ESM/top-level-await related errors, retry with: - `bt eval --runner vite-node tutorial.eval.ts` +**Go evals:** + +Go works differently from JavaScript and Python. There is no runtime loading in Go, so `bt` does not +supply the program — your compiled package _is_ the eval runner. Write it with the +[Go SDK](https://github.com/braintrustdata/braintrust-sdk-go)'s `evalrunner` package, then: + +- `bt eval --language go ./cmd/evals` — point `bt` at the **package directory**. Go's compilation + unit is a directory, not a file, so all inputs must resolve to a single package. +- `bt eval ./cmd/evals` — works without `--language` when the package contains a conventionally + named `*_eval.go` file, which `bt` uses as a discovery marker. +- `bt eval --language go --runner ./bin/evals` — run a prebuilt binary instead of compiling. The + binary is spawned with no arguments; the Go runner takes all its input from the environment. +- `BT_EVAL_GO_BIN` / `BT_EVAL_GO` override which `go` toolchain is used; otherwise `bt` looks at + `GOROOT` and then `PATH`. + +Two caveats. The first `go run` of a package with real dependencies pays compile time before +anything is reported. And `--watch` re-runs on changes to `*.go` files in the package directory +only — `bt` cannot follow Go imports into other packages. + **Passing arguments to the eval file:** Use `--` to forward extra arguments to the eval file via `process.argv`: diff --git a/src/eval.rs b/src/eval.rs index 38d73e27..88c6ab5b 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -27,6 +27,7 @@ use tokio::sync::mpsc; use crate::args::BaseArgs; use crate::auth::resolved_runner_env; +use crate::go_runner; use crate::js_runner; use crate::python_runner; use crate::runner_sse; @@ -241,6 +242,7 @@ const PY_RUNNER_SOURCE: &str = include_str!("../scripts/eval-runner.py"); const JS_RUNNER_FIRE_AND_FORGET_ENTRY: &str = "\nmain().catch((err) => {\n console.error(err);\n process.exit(1);\n});\n"; const PYTHON_INTERPRETER_ENV_OVERRIDES: &[&str] = &["BT_EVAL_PYTHON_RUNNER", "BT_EVAL_PYTHON"]; +const GO_TOOLCHAIN_ENV_OVERRIDES: &[&str] = &["BT_EVAL_GO_BIN", "BT_EVAL_GO"]; #[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] pub enum EvalLanguage { @@ -248,6 +250,8 @@ pub enum EvalLanguage { JavaScript, #[value(alias = "py")] Python, + #[value(alias = "golang")] + Go, } #[derive(Debug, Clone, Args)] @@ -256,6 +260,8 @@ Examples: bt eval my.eval.ts bt eval --no-send-logs --runner tsx my.eval.ts bt eval --language python my_eval.py + bt eval --language go ./cmd/evals + bt eval --language go --runner ./bin/evals ")] pub struct EvalArgs { /// Eval files, directories, or glob patterns to execute (e.g. foo.eval.ts, tests/, "**/*.eval.ts"). @@ -263,7 +269,7 @@ pub struct EvalArgs { #[arg(value_name = "FILE")] pub files: Vec, - /// Eval runner binary (e.g. tsx, bun, ts-node, deno, python). Defaults to tsx for JS files. + /// Eval runner binary (e.g. tsx, bun, ts-node, deno, python, or a prebuilt Go eval binary). Defaults to tsx for JS files. #[arg(long, short = 'r', env = "BT_EVAL_RUNNER", value_name = "RUNNER")] pub runner: Option, @@ -456,7 +462,7 @@ pub async fn run(base: BaseArgs, args: EvalArgs) -> Result<()> { } else { args.files.clone() }; - let mut files = expand_eval_file_globs(&inputs)?; + let mut files = expand_eval_file_globs_for_language(&inputs, args.language)?; if args.files.is_empty() { files.retain(|p| !is_excluded_by_default(p)); if files.is_empty() { @@ -707,9 +713,17 @@ async fn run_eval_files_once( let dependencies = if collect_dependencies { let mut dependencies = normalize_watch_paths(output.dependency_files.into_iter().map(PathBuf::from))?; - if plan.language == EvalLanguage::JavaScript { - let static_dependencies = collect_js_static_dependencies(files)?; - dependencies = merge_watch_paths(&dependencies, &static_dependencies); + match plan.language { + EvalLanguage::JavaScript => { + let static_dependencies = collect_js_static_dependencies(files)?; + dependencies = merge_watch_paths(&dependencies, &static_dependencies); + } + EvalLanguage::Go => { + let static_dependencies = collect_go_static_dependencies(files)?; + dependencies = merge_watch_paths(&dependencies, &static_dependencies); + } + // Python runners report their own dependencies over SSE. + EvalLanguage::Python => {} } dependencies } else { @@ -780,7 +794,8 @@ async fn spawn_eval_runner( if language != EvalLanguage::Python && options.num_workers.is_some() { anyhow::bail!("--num-workers is only supported for Python evals."); } - let (js_runner, py_runner) = prepare_eval_runners()?; + // The JS/Python runner scripts are materialized lazily, inside the language + // arms below: Go needs neither, and --dev spawns per HTTP request. let force_esm = matches!(js_mode, JsMode::ForceEsm); let (listener, sse_guard) = runner_sse::bind_sse_listener("bt-eval")?; @@ -810,11 +825,15 @@ async fn spawn_eval_runner( }); let (mut cmd, runner_kind) = match language { - EvalLanguage::Python => ( - build_python_command(runner_override, &py_runner, files)?, - RunnerKind::Other, - ), + EvalLanguage::Python => { + let (_, py_runner) = prepare_eval_runners()?; + ( + build_python_command(runner_override, &py_runner, files)?, + RunnerKind::Other, + ) + } EvalLanguage::JavaScript => { + let (js_runner, _) = prepare_eval_runners()?; if force_esm { ( build_vite_node_fallback_command(&js_runner, files)?, @@ -825,6 +844,9 @@ async fn spawn_eval_runner( (plan.cmd, plan.kind) } } + // Go has no bt-supplied runner script: the user's package or prebuilt + // binary is the runner, so nothing is materialized to disk. + EvalLanguage::Go => (build_go_command(runner_override, files)?, RunnerKind::Other), }; if language == EvalLanguage::JavaScript && should_set_node_heap_size(runner_kind) { set_node_heap_size_env(&mut cmd); @@ -2167,6 +2189,7 @@ fn detect_eval_language( let current = match ext.as_str() { "py" => EvalLanguage::Python, "ts" | "tsx" | "js" | "mjs" | "cjs" => EvalLanguage::JavaScript, + "go" => EvalLanguage::Go, _ => { anyhow::bail!("Unsupported eval file extension: {ext}"); } @@ -2192,6 +2215,10 @@ const DEFAULT_EVAL_GLOBS: &[&str] = &[ "**/*.eval.cjs", "**/*.eval.py", "**/eval_*.py", + // Go marks special files with a suffix (`_test.go`), so evals follow the + // same convention. The matched file is only a marker: `go run` compiles the + // whole package directory regardless. + "**/*_eval.go", ]; /// Directory name segments excluded during default discovery. @@ -2203,6 +2230,8 @@ const DEFAULT_EXCLUDE_DIRS: &[&str] = &[ "__pycache__", ".venv", "venv", + // Vendored Go dependencies can carry their own *_eval.go files. + "vendor", ]; fn is_excluded_by_default(path: &str) -> bool { @@ -2215,7 +2244,23 @@ fn is_excluded_by_default(path: &str) -> bool { }) } +/// Expands the user's inputs without a language hint. Kept for tests and for +/// callers that have no `--language` to pass. +#[cfg(test)] fn expand_eval_file_globs(inputs: &[String]) -> Result> { + expand_eval_file_globs_for_language(inputs, None) +} + +/// Expands eval inputs into a list of regular files. +/// +/// The language override matters here because it arrives before +/// `detect_eval_language` runs: Go's compilation unit is a package directory, +/// so `--language go ./cmd/evals` must expand to that package's .go files +/// rather than searching for conventionally-named eval files. +fn expand_eval_file_globs_for_language( + inputs: &[String], + language_override: Option, +) -> Result> { let mut expanded: Vec = Vec::new(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); @@ -2228,22 +2273,11 @@ fn expand_eval_file_globs(inputs: &[String]) -> Result> { for input in inputs { let path = Path::new(input); if path.is_dir() { - let mut dir_files: Vec = Vec::new(); - for glob_suffix in DEFAULT_EVAL_GLOBS { - let pattern = format!("{input}/{glob_suffix}"); - let matches: Vec = glob::glob(&pattern) - .with_context(|| format!("invalid glob pattern: {pattern}"))? - .collect::>() - .with_context(|| format!("error expanding glob: {pattern}"))?; - dir_files.extend( - matches - .into_iter() - .map(|p| p.to_string_lossy().into_owned()), - ); - } - if dir_files.is_empty() { - anyhow::bail!("no eval files found in directory: {input}"); - } + let dir_files = if language_override == Some(EvalLanguage::Go) { + expand_go_package_dir(input)? + } else { + expand_default_eval_globs_in_dir(input)? + }; dir_files.into_iter().for_each(&mut push); continue; } @@ -2272,6 +2306,60 @@ fn expand_eval_file_globs(inputs: &[String]) -> Result> { Ok(expanded) } +/// Expands the default eval globs inside a directory input. +fn expand_default_eval_globs_in_dir(input: &str) -> Result> { + let mut dir_files: Vec = Vec::new(); + for glob_suffix in DEFAULT_EVAL_GLOBS { + let pattern = format!("{input}/{glob_suffix}"); + let matches: Vec = glob::glob(&pattern) + .with_context(|| format!("invalid glob pattern: {pattern}"))? + .collect::>() + .with_context(|| format!("error expanding glob: {pattern}"))?; + dir_files.extend( + matches + .into_iter() + .map(|p| p.to_string_lossy().into_owned()), + ); + } + if dir_files.is_empty() { + anyhow::bail!("no eval files found in directory: {input}"); + } + Ok(dir_files) +} + +/// Expands a Go package directory to the files that make up that package. +/// +/// Non-recursive on purpose: a Go package is exactly one directory, and +/// sub-directories are separate packages that `go run` cannot build together. +/// Mirrors the go tool's own filtering, which skips `_`/`.`-prefixed files and +/// leaves `_test.go` files out of a normal build. +fn expand_go_package_dir(input: &str) -> Result> { + let pattern = format!("{input}/*.go"); + let mut matches: Vec = glob::glob(&pattern) + .with_context(|| format!("invalid glob pattern: {pattern}"))? + .collect::, _>>() + .with_context(|| format!("error expanding glob: {pattern}"))? + .into_iter() + .filter(|p| is_go_build_input(p)) + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + matches.sort(); + + if matches.is_empty() { + anyhow::bail!( + "no Go files found in package directory: {input}. Point bt at the directory containing your `package main` eval (e.g. `bt eval --language go ./cmd/evals`), or pass a prebuilt binary with --runner." + ); + } + Ok(matches) +} + +fn is_go_build_input(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + !name.starts_with('_') && !name.starts_with('.') && !name.ends_with("_test.go") +} + fn validate_eval_input_files(files: &[String]) -> Result<()> { let cwd = std::env::current_dir().context("failed to read current directory")?; validate_eval_input_files_in_cwd(files, &cwd) @@ -2441,6 +2529,104 @@ fn build_python_command( Ok(command) } +/// Builds the command for a Go eval. +/// +/// Two shapes, unlike the other languages. With `--runner`, the user hands us a +/// prebuilt eval binary that is already the complete program, so it is spawned +/// with **no arguments** -- the Go runner reads everything from `BT_EVAL_*` and +/// ignores argv, and appending our file list would break a `main` that parses +/// flags. Without `--runner`, `go run` compiles and runs the package directory. +fn build_go_command(runner_override: Option<&str>, files: &[String]) -> Result { + if let Some(explicit) = runner_override { + return Ok(Command::new(go_runner::resolve_prebuilt_runner(explicit)?)); + } + + let package_dir = go_package_dir(files)?; + let Some(go) = go_runner::resolve_go_toolchain(GO_TOOLCHAIN_ENV_OVERRIDES) else { + anyhow::bail!( + "No Go toolchain found. Install Go (https://go.dev/dl/), set BT_EVAL_GO_BIN, or pass a prebuilt eval binary with --runner." + ); + }; + + let mut command = Command::new(go); + command.arg("run").arg(&package_dir); + + // `go run ` only works from inside the main module. Leave the working + // directory alone when we are already inside it, so the eval's own relative + // paths behave as the user expects. + if let Some(module_root) = go_runner::module_root_for(&package_dir) { + let inside_module = std::env::current_dir() + .map(|cwd| cwd.starts_with(&module_root)) + .unwrap_or(false); + if !inside_module { + command.current_dir(module_root); + } + } + + Ok(command) +} + +/// Collapses the expanded file list back to the single package directory that +/// `go run` needs. Go compiles a directory, not a file list. +fn go_package_dir(files: &[String]) -> Result { + let cwd = std::env::current_dir().context("failed to read current directory")?; + let mut dirs: BTreeSet = BTreeSet::new(); + + for file in files { + let path = Path::new(file); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + let dir = if absolute.is_dir() { + absolute + } else { + absolute + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| cwd.clone()) + }; + // Canonicalize so the same directory reached by different paths counts + // once, and so the argument stays valid if we change the working dir. + dirs.insert(std::fs::canonicalize(&dir).unwrap_or(dir)); + } + + match dirs.len() { + 0 => anyhow::bail!("No Go eval package provided."), + 1 => Ok(dirs.into_iter().next().expect("exactly one entry")), + n => anyhow::bail!( + "Go evals must live in a single package directory, but the inputs span {n} directories: {}. Pass one package directory (e.g. `bt eval --language go ./cmd/evals`) or a prebuilt binary with --runner.", + dirs.iter() + .map(|d| d.display().to_string()) + .collect::>() + .join(", ") + ), + } +} + +/// Derives the watch set for a Go eval. +/// +/// A Go binary cannot report its own sources the way the JS and Python runners +/// do, so bt works it out statically: every buildable file in the package, plus +/// the directory itself, whose mtime changes when files are added or removed. +/// Imported packages elsewhere in the module are not followed. +fn collect_go_static_dependencies(files: &[String]) -> Result> { + let package_dir = go_package_dir(files)?; + let mut paths = vec![package_dir.clone()]; + + for entry in std::fs::read_dir(&package_dir) + .with_context(|| format!("failed to read {}", package_dir.display()))? + { + let path = entry?.path(); + if path.extension().and_then(|e| e.to_str()) == Some("go") && is_go_build_input(&path) { + paths.push(path); + } + } + + normalize_watch_paths(paths) +} + fn python_runner_search_roots(files: &[String]) -> Vec { let mut search_roots = Vec::new(); if let Ok(cwd) = std::env::current_dir() { @@ -3928,6 +4114,249 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn detect_eval_language_detects_go_extension() { + let files = vec!["evals/classifier_eval.go".to_string()]; + assert_eq!( + detect_eval_language(&files, None).expect("go should be detected"), + EvalLanguage::Go + ); + } + + #[test] + fn detect_eval_language_rejects_mixed_go_and_python() { + let files = vec!["a_eval.go".to_string(), "eval_b.py".to_string()]; + let err = detect_eval_language(&files, None).expect_err("mixed languages should fail"); + assert!(format!("{err:#}").contains("Mixed eval file types are not supported yet")); + } + + #[test] + fn detect_eval_language_override_wins_over_extension() { + let files = vec!["a.eval.ts".to_string()]; + assert_eq!( + detect_eval_language(&files, Some(EvalLanguage::Go)).expect("override should win"), + EvalLanguage::Go + ); + } + + #[test] + fn expand_eval_file_globs_directory_finds_go_eval_files() { + let dir = make_temp_dir("go-convention"); + fs::write(dir.join("classifier_eval.go"), "package main").expect("eval file written"); + fs::write(dir.join("main.go"), "package main").expect("main written"); + fs::write(dir.join("helper.go"), "package main").expect("helper written"); + + let result = expand_eval_file_globs(&[dir.to_string_lossy().into_owned()]) + .expect("directory should expand"); + + // Without --language go, only the conventionally-named marker matches. + assert_eq!(result.len(), 1, "unexpected matches: {result:?}"); + assert!(result[0].ends_with("classifier_eval.go")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn expand_eval_file_globs_go_language_expands_package_dir() { + let dir = make_temp_dir("go-package"); + fs::write(dir.join("classifier_eval.go"), "package main").expect("eval file written"); + fs::write(dir.join("main.go"), "package main").expect("main written"); + fs::write(dir.join("helper.go"), "package main").expect("helper written"); + + let result = expand_eval_file_globs_for_language( + &[dir.to_string_lossy().into_owned()], + Some(EvalLanguage::Go), + ) + .expect("package dir should expand"); + + // Go compiles the whole package, so every buildable file comes along. + assert_eq!(result.len(), 3, "unexpected matches: {result:?}"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn expand_eval_file_globs_go_language_skips_non_build_inputs() { + let dir = make_temp_dir("go-skips"); + fs::write(dir.join("main.go"), "package main").expect("main written"); + fs::write(dir.join("main_test.go"), "package main").expect("test written"); + fs::write(dir.join("_scratch.go"), "package main").expect("scratch written"); + fs::create_dir_all(dir.join("sub")).expect("sub dir created"); + fs::write(dir.join("sub").join("nested.go"), "package sub").expect("nested written"); + + let result = expand_eval_file_globs_for_language( + &[dir.to_string_lossy().into_owned()], + Some(EvalLanguage::Go), + ) + .expect("package dir should expand"); + + assert_eq!(result.len(), 1, "unexpected matches: {result:?}"); + assert!(result[0].ends_with("main.go")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn expand_eval_file_globs_go_language_errors_on_dir_without_go_files() { + let dir = make_temp_dir("go-empty"); + + let err = expand_eval_file_globs_for_language( + &[dir.to_string_lossy().into_owned()], + Some(EvalLanguage::Go), + ) + .expect_err("an empty package should fail"); + assert!(format!("{err:#}").contains("no Go files found in package directory")); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn go_package_dir_collapses_files_in_one_directory() { + let dir = make_temp_dir("go-collapse"); + let main = dir.join("main.go"); + let helper = dir.join("helper.go"); + fs::write(&main, "package main").expect("main written"); + fs::write(&helper, "package main").expect("helper written"); + + let resolved = go_package_dir(&[ + main.to_string_lossy().into_owned(), + helper.to_string_lossy().into_owned(), + ]) + .expect("files in one directory should collapse"); + + assert_eq!( + resolved, + fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone()) + ); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn go_package_dir_rejects_inputs_spanning_directories() { + let dir = make_temp_dir("go-span"); + let a = dir.join("a"); + let b = dir.join("b"); + fs::create_dir_all(&a).expect("a created"); + fs::create_dir_all(&b).expect("b created"); + fs::write(a.join("main.go"), "package main").expect("a main written"); + fs::write(b.join("main.go"), "package main").expect("b main written"); + + let err = go_package_dir(&[ + a.join("main.go").to_string_lossy().into_owned(), + b.join("main.go").to_string_lossy().into_owned(), + ]) + .expect_err("multiple package directories should fail"); + assert!(format!("{err:#}").contains("span 2 directories")); + + let _ = fs::remove_dir_all(&dir); + } + + // The Go runner takes everything from BT_EVAL_* and ignores argv, so a + // prebuilt binary must be spawned bare. Appending bt's file list would + // break any main that parses flags. + #[test] + fn build_go_command_with_runner_override_passes_no_args() { + let dir = make_temp_dir("go-prebuilt"); + let binary = dir.join("evals"); + fs::write(&binary, "#!/bin/sh\n").expect("binary written"); + + let cmd = build_go_command( + Some(binary.to_str().expect("utf-8 path")), + &["./cmd/evals".to_string()], + ) + .expect("prebuilt runner should resolve"); + + let std_cmd = cmd.as_std(); + assert_eq!(Path::new(std_cmd.get_program()), binary); + assert_eq!(std_cmd.get_args().count(), 0, "argv must stay empty"); + + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn build_go_command_rejects_a_missing_runner_binary() { + let err = build_go_command(Some("./definitely/not/here"), &[]) + .expect_err("a missing runner should fail"); + assert!(format!("{err:#}").contains("--runner binary not found")); + } + + #[test] + fn build_go_command_runs_the_package_directory() { + let _guard = env_test_lock().lock().expect("lock env test"); + let dir = make_temp_dir("go-run"); + fs::write(dir.join("main.go"), "package main").expect("main written"); + + let fake_go = dir.join("go"); + fs::write(&fake_go, "#!/bin/sh\n").expect("fake go written"); + let previous = set_env_var("BT_EVAL_GO_BIN", fake_go.to_str().expect("utf-8 path")); + + let cmd = build_go_command(None, &[dir.join("main.go").to_string_lossy().into_owned()]) + .expect("go run command should build"); + + let std_cmd = cmd.as_std(); + assert_eq!(Path::new(std_cmd.get_program()), fake_go); + let args: Vec = std_cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args.len(), 2); + assert_eq!(args[0], "run"); + assert!(Path::new(&args[1]).is_absolute(), "got {args:?}"); + + restore_env_var("BT_EVAL_GO_BIN", previous); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn build_go_command_errors_without_a_go_toolchain() { + let _guard = env_test_lock().lock().expect("lock env test"); + let dir = make_temp_dir("go-missing-toolchain"); + fs::write(dir.join("main.go"), "package main").expect("main written"); + + let go_bin = clear_env_var("BT_EVAL_GO_BIN"); + let go_alt = clear_env_var("BT_EVAL_GO"); + let goroot = clear_env_var("GOROOT"); + let path = clear_env_var("PATH"); + + let err = build_go_command(None, &[dir.join("main.go").to_string_lossy().into_owned()]) + .expect_err("no toolchain should fail"); + assert!(format!("{err:#}").contains("No Go toolchain found")); + + restore_env_var("PATH", path); + restore_env_var("GOROOT", goroot); + restore_env_var("BT_EVAL_GO", go_alt); + restore_env_var("BT_EVAL_GO_BIN", go_bin); + let _ = fs::remove_dir_all(&dir); + } + + // A Go binary cannot report its own sources, so bt watches the package + // directory (whose mtime changes when files come and go) plus its files. + #[test] + fn collect_go_static_dependencies_covers_the_package() { + let dir = make_temp_dir("go-watch"); + fs::write(dir.join("main.go"), "package main").expect("main written"); + fs::write(dir.join("helper.go"), "package main").expect("helper written"); + fs::write(dir.join("main_test.go"), "package main").expect("test written"); + + let deps = + collect_go_static_dependencies(&[dir.join("main.go").to_string_lossy().into_owned()]) + .expect("dependencies should be collected"); + + let names: Vec = deps + .iter() + .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().into_owned())) + .collect(); + assert!(names.iter().any(|n| n == "main.go"), "got {names:?}"); + assert!(names.iter().any(|n| n == "helper.go"), "got {names:?}"); + assert!( + !names.iter().any(|n| n == "main_test.go"), + "test files are not part of the build: {names:?}" + ); + + let _ = fs::remove_dir_all(&dir); + } + #[test] fn box_with_title_handles_ansi_content_without_panicking() { let content = "plain line\n\x1b[38;5;196mred text\x1b[0m"; diff --git a/src/go_runner.rs b/src/go_runner.rs new file mode 100644 index 00000000..ad0a9dc1 --- /dev/null +++ b/src/go_runner.rs @@ -0,0 +1,133 @@ +//! Locating the Go toolchain and a user's prebuilt eval binary. +//! +//! Unlike JavaScript and Python, bt does not supply the program for Go. There +//! is no runtime loading in Go, so the user's compiled package *is* the runner +//! and nothing is materialized to disk on our side. This module only answers +//! two questions: which `go` to invoke, and where the user's module root is. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +/// Resolves the Go toolchain to invoke. +/// +/// Explicit environment overrides win, then `GOROOT`, then `PATH`. The override +/// matters for version managers (mise, asdf, gvm) that keep the real toolchain +/// off the default `PATH`. +pub fn resolve_go_toolchain(env_overrides: &[&str]) -> Option { + for env_name in env_overrides { + if let Some(value) = std::env::var_os(env_name) { + if !value.is_empty() { + return Some(PathBuf::from(value)); + } + } + } + + if let Some(goroot) = std::env::var_os("GOROOT") { + let candidate = PathBuf::from(goroot).join("bin").join("go"); + if candidate.is_file() { + return Some(candidate); + } + } + + crate::python_runner::find_binary_in_path(&["go"]) +} + +/// Resolves `--runner` for Go, which names a prebuilt eval binary rather than +/// an interpreter. +/// +/// Unlike the Python interpreter override, this is validated: the Go runner +/// takes all its input from the environment and ignores argv, so a typo'd path +/// would otherwise surface only as an opaque spawn failure. +pub fn resolve_prebuilt_runner(explicit: &str) -> Result { + let path = Path::new(explicit); + let looks_like_path = path.is_absolute() + || explicit.contains('/') + || explicit.contains('\\') + || explicit.starts_with('.'); + + if looks_like_path { + if !path.is_file() { + anyhow::bail!("--runner binary not found: {explicit}"); + } + return Ok(path.to_path_buf()); + } + + // A bare name: prefer PATH, but fall through to the literal so the spawn + // error names it, matching how the JS runner override behaves. + Ok(crate::python_runner::find_binary_in_path(&[explicit]) + .unwrap_or_else(|| PathBuf::from(explicit))) +} + +/// Finds the nearest ancestor containing `go.mod`. +/// +/// `go run ` requires the process working directory to sit inside the main +/// module, so bt may need to move into this directory before spawning. +pub fn module_root_for(package_dir: &Path) -> Option { + let mut current = Some(package_dir); + while let Some(dir) = current { + if dir.join("go.mod").is_file() { + return Some(dir.to_path_buf()); + } + current = dir.parent(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_temp_dir(label: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("bt-go-runner-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir should be created"); + dir + } + + #[test] + fn module_root_finds_nearest_go_mod() { + let dir = make_temp_dir("module-root"); + let pkg = dir.join("cmd").join("evals"); + std::fs::create_dir_all(&pkg).expect("package dir should be created"); + std::fs::write(dir.join("go.mod"), "module example.test\n").expect("go.mod written"); + + assert_eq!(module_root_for(&pkg), Some(dir.clone())); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn module_root_is_none_without_go_mod() { + let dir = make_temp_dir("no-module"); + let pkg = dir.join("cmd"); + std::fs::create_dir_all(&pkg).expect("package dir should be created"); + + // A go.mod anywhere above the temp dir would make this flaky, so only + // assert that the temp tree itself contributes nothing. + let found = module_root_for(&pkg); + assert!(found.is_none() || !found.unwrap().starts_with(&dir)); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn prebuilt_runner_rejects_a_missing_path() { + let err = resolve_prebuilt_runner("./definitely/not/here") + .expect_err("a missing path should fail"); + assert!(format!("{err:#}").contains("--runner binary not found")); + } + + #[test] + fn prebuilt_runner_accepts_an_existing_path() { + let dir = make_temp_dir("prebuilt"); + let binary = dir.join("evals"); + std::fs::write(&binary, b"#!/bin/sh\n").expect("binary written"); + + let resolved = + resolve_prebuilt_runner(binary.to_str().expect("utf-8 path")).expect("should resolve"); + assert_eq!(resolved, binary); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/main.rs b/src/main.rs index e7e3eb28..bf663a87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ mod env; mod eval; mod experiments; mod functions; +#[cfg(unix)] +mod go_runner; mod http; mod init; mod js_runner;