From 7e5d954cecdf1f0d396246b317cd9bbc491b3ad3 Mon Sep 17 00:00:00 2001 From: Ramses de Norre Date: Mon, 20 Apr 2026 16:19:02 +0000 Subject: [PATCH 1/3] cc: first backend for cmake/meson stdenv units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-grain: a drv opts in via a single `bobCcSrc = ""` attr (helper in lib/cc.nix). That attr is the unit marker, the live source for change detection, and the cmake/meson SOURCE_DIR — no separate manifest. Incrementality comes from a persistent out-of-tree build dir at `cache.incremental_dir(drv_path)`: source edits keep the same dir so ninja's .d-file tracking rebuilds only changed TUs; any non-source input change moves drv_path → fresh dir → clean reconfigure. unpack/patch are skipped so cmake/meson record the live worktree path (stable across runs) instead of the wiped-per-run NIX_BUILD_TOP. scheduler: per-node backend dispatch so cc and rust units coexist in one graph. Edge classification stays dep-side; cc returns pipeline()=None so cc edges are done-gated. The early-signal path (header staging + cc-wrap link-gate + edge-aware policy for cc→rust) is documented in crates/cc/src/lib.rs as the follow-up. --- Cargo.lock | 9 ++ Cargo.toml | 1 + README.md | 30 ++++- crates/cc/Cargo.toml | 12 ++ crates/cc/src/hooks.rs | 190 +++++++++++++++++++++++++++++++ crates/cc/src/lib.rs | 124 ++++++++++++++++++++ crates/cc/src/workspace.rs | 212 +++++++++++++++++++++++++++++++++++ crates/cli/Cargo.toml | 1 + crates/cli/src/main.rs | 31 +++-- crates/core/src/cache.rs | 5 +- crates/core/src/scheduler.rs | 52 ++++++++- lib/cc.nix | 40 +++++++ 12 files changed, 687 insertions(+), 20 deletions(-) create mode 100644 crates/cc/Cargo.toml create mode 100644 crates/cc/src/hooks.rs create mode 100644 crates/cc/src/lib.rs create mode 100644 crates/cc/src/workspace.rs create mode 100644 lib/cc.nix diff --git a/Cargo.lock b/Cargo.lock index a9c6f15..9c6bd0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,11 +82,20 @@ dependencies = [ name = "bob" version = "0.1.0" dependencies = [ + "bob-cc", "bob-core", "bob-rust", "clap", ] +[[package]] +name = "bob-cc" +version = "0.1.0" +dependencies = [ + "blake3", + "bob-core", +] + [[package]] name = "bob-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 49b1f2a..666b86a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ toml = { version = "1", default-features = false, features = ["parse", "serde", libc = "0.2" bob-core = { path = "crates/core" } bob-rust = { path = "crates/rust" } +bob-cc = { path = "crates/cc" } diff --git a/README.md b/README.md index 7fd80b4..9071962 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Fast incremental builds on top of Nix. Replays fine-grained `buildRustCrate` derivations outside the Nix sandbox with a content-addressed artifact cache, persistent stdenv workers, and rustc incremental compilation. -**Status: experimental.** Currently targets Rust workspaces built via [cargo-nix-plugin] / `buildRustCrate`. The core (drv parser, scheduler, cache, path rewriter) is language-agnostic; other backends (Go via go2nix) are planned. +**Status: experimental.** Currently targets Rust workspaces built via [cargo-nix-plugin] / `buildRustCrate`, and C/C++ projects built with cmake or meson. The core (drv parser, scheduler, cache, path rewriter) is language-agnostic; other backends (Go via go2nix) are planned. [cargo-nix-plugin]: https://github.com/Mic92/cargo-nix-plugin @@ -91,6 +91,9 @@ crates/ ├── rust/ bob-rust — Rust backend: buildRustCrate/cargo-nix-plugin drvs, │ rmeta pipelining via the __rustc-wrap shim, │ -C incremental injection, Cargo workspace introspection +├── cc/ bob-cc — C/C++ backend: cmake/meson stdenv drvs marked via +│ lib/cc.nix, persistent out-of-tree build dir for +│ ninja-level per-TU incrementality (no pipelining yet) └── cli/ bob — the binary; registers backends and wires the CLI ``` @@ -111,6 +114,31 @@ an early-artifact analogue (Go) get correct done-gated scheduling for free. A `core-leakage` flake check enforces that `bob-core` stays free of backend-specific identifiers. +## C/C++ backend + +A cc unit is a plain `stdenv.mkDerivation` (cmake or meson, out-of-tree) +tagged with `bobCcSrc`: + +```nix +# bob.nix +let bobCc = import "${bob}/lib/cc.nix"; in +{ + workspaceMembers = …; # rust + cc = bobCc.units { + libfoo = { drv = pkgs.libfoo; src = "path/to/libfoo"; }; + }; +} +``` + +`bob build libfoo` then keeps a drv-path-keyed build directory under +`~/.cache/bob/incremental/` so reconfigure is warm and `ninja` rebuilds only +the TUs whose `.d` depfiles changed. The marked drv still `nix build`s +normally — `dontUnpack`/`cmakeBuildDir` are injected only at replay time. + +Caveats: unpack/patch are skipped (the build runs against the live worktree), +so patched derivations are not supported; cc edges are done-gated (no early +signal yet — see `crates/cc/src/lib.rs` for what's needed). + ## Limitations - Outputs are not registered in the Nix store — downstream Nix consumers can't use them. Use `nix-build` for that. diff --git a/crates/cc/Cargo.toml b/crates/cc/Cargo.toml new file mode 100644 index 0000000..20eac9c --- /dev/null +++ b/crates/cc/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bob-cc" +description = "C/C++ (cmake/meson via stdenv) backend for bob" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +bob-core.workspace = true +blake3.workspace = true diff --git a/crates/cc/src/hooks.rs b/crates/cc/src/hooks.rs new file mode 100644 index 0000000..a0b8b15 --- /dev/null +++ b/crates/cc/src/hooks.rs @@ -0,0 +1,190 @@ +//! cc-specific `builder.sh` injection: persistent out-of-tree build dir, +//! live-source `cmakeDir`/meson cwd, and warm-reconfigure handling. +//! +//! Runs after `source $stdenv/setup`, so the cmake/meson setup-hooks have +//! already registered themselves as `configurePhase` and `$CC`/`$CXX` are the +//! cc-wrapper store paths (stable across runs — important, since cmake treats +//! a compiler-path change as a full-rebuild trigger). + +use std::fmt::Write; +use std::path::Path; + +use bob_core::{BuildContext, Derivation}; + +pub fn build_script_hooks(ctx: &BuildContext<'_>) -> Result { + let mut s = String::new(); + + // Persistent build dir, drv-path-keyed (NOT effective-key-keyed): source + // edits keep the same dir so ninja's depfile graph survives, but any + // change to flags/compiler/buildInputs moves drv_path → fresh dir → clean + // reconfigure. Same lifecycle as Rust's `-C incremental` dir. + let inc = ctx.cache.incremental_dir(ctx.drv_path); + std::fs::create_dir_all(&inc).map_err(|e| format!("creating cc build dir: {e}"))?; + let inc_s = inc.display(); + + // Build directly against the live worktree. `$src` has already been + // overridden to the `OwnHash::src_dir` by core (executor.rs / attrs.rs), + // so cmake/meson record a stable absolute SOURCE_DIR and ninja's stored + // header paths keep resolving across runs. unpack/patch would copy into + // the (wiped-per-run) NIX_BUILD_TOP and break that stability. + // + // Note `$src` for a unit without an override is the original store path — + // also stable, just never changes, so the persistent dir is a one-shot + // cache. That's fine: only `bobCcSrc`-marked units reach this hook, and + // those are exactly the ones with a live override. + s.push_str("dontUnpack=1\n"); + s.push_str("dontPatch=1\n"); + + // cmake hook reads these as overridable defaults (`: ${cmakeBuildDir:=…}`). + // Absolute build dir → `cmakeDir` can't stay `..`, so point it at $src. + // The hook then `mkdir -p && cd $cmakeBuildDir && cmake $cmakeDir …`. + writeln!(s, "export cmakeBuildDir='{inc_s}'").unwrap(); + s.push_str("export cmakeDir=\"$src\"\n"); + + // The cmake hook prepends `-DCMAKE_INSTALL_PREFIX`/`_BINDIR`/… each run + // from `$out`/`$dev`/…, which DO move per effective-key. cmake handles a + // changed install prefix without recompiling (only install rules touch + // it). `-DCMAKE_C_COMPILER=$CC` is the dangerous one, but `$CC` is the + // cc-wrapper store path and that's drv-path-stable. + + // meson hook runs `meson setup $mesonBuildDir …` from cwd = source. With + // dontUnpack genericBuild never cds, so do it here. Re-setup on a warm + // dir needs `--reconfigure` (otherwise "Directory already configured"). + // `mesonFlags` may be a bash array under structuredAttrs; appending via + // `+=(…)` works for both array and unset-scalar. + writeln!(s, "export mesonBuildDir='{inc_s}'").unwrap(); + writeln!( + s, + r#"preConfigureHooks+=(_bobCcPreConfigure) +_bobCcPreConfigure() {{ + cd "$src" + if [[ -e '{inc_s}/meson-private' ]]; then + mesonFlags+=(--reconfigure) + fi +}}"# + ) + .unwrap(); + + // ninja install wants the build dir writable (it stamps `.ninja_log`); a + // previous run may have left it owned by a different effective uid via + // the worker's homeless-shelter dance. Belt-and-braces. + writeln!(s, "chmod -R u+w '{inc_s}' 2>/dev/null || true").unwrap(); + + Ok(s) +} + +/// installPhase produced something usable? Any populated declared output's +/// `lib/` or `bin/` counts. cc units don't always have a `lib` *output* (vs +/// a `lib/` subdir of `$out`), so check every declared output. +pub fn output_populated(tmp: &Path, drv: &Derivation) -> bool { + drv.outputs.keys().any(|o| { + let base = tmp.join(o); + dir_nonempty(&base.join("lib")) || dir_nonempty(&base.join("bin")) + }) +} + +fn dir_nonempty(p: &Path) -> bool { + std::fs::read_dir(p) + .map(|mut d| d.next().is_some()) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use bob_core::{ArtifactCache, Derivation}; + use std::collections::BTreeMap; + + fn fake_drv() -> Derivation { + Derivation { + outputs: { + let mut m = BTreeMap::new(); + m.insert( + "out".into(), + bob_core::drv::Output { + path: "/nix/store/x-foo".into(), + hash_algo: String::new(), + hash: String::new(), + }, + ); + m + }, + input_derivations: BTreeMap::new(), + input_sources: vec![], + platform: "x86_64-linux".into(), + builder: "/bin/sh".into(), + args: vec![], + env: { + let mut m = BTreeMap::new(); + m.insert("pname".into(), "libfoo".into()); + m.insert("bobCcSrc".into(), "path/to/foo".into()); + m + }, + } + } + + /// The injected shell fragment must be syntactically valid bash *and* + /// must not leak the effective-key tmp path into compiler-facing + /// variables (cmake treats CMAKE_C_COMPILER changes as full rebuilds, + /// so anything keyed on the effective key would cold-start every edit). + #[test] + fn hooks_are_valid_bash_and_drv_keyed() { + let cache_root = std::env::temp_dir().join(format!("bob-cc-hooks-{}", std::process::id())); + let cache = ArtifactCache::from_path(cache_root.clone()); + let tmp = cache_root.join("tmp").join("effkey"); + std::fs::create_dir_all(&tmp).unwrap(); + let drv = fake_drv(); + let ctx = BuildContext { + drv_path: "/nix/store/aaaa-libfoo.drv", + drv: &drv, + tmp: &tmp, + cache: &cache, + is_root: true, + self_exe: Path::new("/bin/false"), + }; + let s = build_script_hooks(&ctx).unwrap(); + + // bash -n: parse without executing. + let st = std::process::Command::new("bash") + .args(["-n", "-c", &s]) + .status() + .expect("running bash -n"); + assert!(st.success(), "hook output is not valid bash:\n{s}"); + + // Build dir is the drv-keyed incremental dir, never the effective- + // key tmp dir; nothing in the fragment should reference tmp/effkey. + let inc = cache.incremental_dir("/nix/store/aaaa-libfoo.drv"); + assert!(s.contains(&inc.display().to_string())); + assert!( + !s.contains("effkey"), + "hook leaked effective-key path into shell:\n{s}" + ); + assert!(s.contains("dontUnpack=1")); + assert!(s.contains("cmakeDir=\"$src\"")); + + let _ = std::fs::remove_dir_all(&cache_root); + } + + #[test] + fn output_populated_checks_all_outputs() { + let d = std::env::temp_dir().join(format!("bob-cc-out-{}", std::process::id())); + let mut drv = fake_drv(); + drv.outputs.insert( + "dev".into(), + bob_core::drv::Output { + path: "/nix/store/x-foo-dev".into(), + hash_algo: String::new(), + hash: String::new(), + }, + ); + // Empty outputs → not populated. + std::fs::create_dir_all(d.join("out")).unwrap(); + std::fs::create_dir_all(d.join("dev")).unwrap(); + assert!(!output_populated(&d, &drv)); + // A lib in `out` (not in a separate `lib` output) counts. + std::fs::create_dir_all(d.join("out/lib")).unwrap(); + std::fs::write(d.join("out/lib/libfoo.so"), b"").unwrap(); + assert!(output_populated(&d, &drv)); + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/cc/src/lib.rs b/crates/cc/src/lib.rs new file mode 100644 index 0000000..beb62d8 --- /dev/null +++ b/crates/cc/src/lib.rs @@ -0,0 +1,124 @@ +//! C/C++ language backend: stdenv `mkDerivation` units built with cmake or +//! meson (out-of-tree), replayed with a persistent build directory so ninja's +//! own `.d`-file dep tracking gives per-TU incrementality. +//! +//! ## Unit model +//! +//! Project-grain: one drv = one cmake/meson project. A drv opts in by carrying +//! `bobCcSrc = ""` in its env (see `lib/cc.nix`). +//! That single attr is the unit marker, the display name's source-of-truth +//! lookup, *and* the live source dir for change detection — no separate +//! manifest. +//! +//! ## Incrementality +//! +//! `cache.incremental_dir(drv_path)` is repurposed as the persistent +//! out-of-tree build dir (cmake's `-B`, meson's builddir). It is keyed on +//! `drv_path`, not the effective key, so source edits land in the same dir and +//! ninja rebuilds only changed TUs. Any non-source input change (compiler, +//! flags, buildInputs) yields a new `drv_path` → fresh dir → full reconfigure, +//! which is the correct invalidation boundary. +//! +//! Unpack/patch are skipped: the build is pointed directly at the live +//! worktree (`$src`, already overridden by core to `OwnHash::src_dir`), so +//! cmake/meson record a stable absolute `SOURCE_DIR` and ninja's recorded +//! header paths stay valid across runs. This means **patched derivations are +//! not supported** — the canonical bob-cc unit is first-party source you're +//! editing in place. +//! +//! ## Pipelining (not yet) +//! +//! `pipeline()` returns `None`: every cc edge is done-gated. Unlike Rust's +//! rmeta, a cc unit has no cheap interface artifact that downstream +//! `find_package`/setup-hooks can consume before `installPhase` populates +//! `$out`/`$dev`. A correct early-signal needs (a) header staging into +//! `$dev/include` before `buildPhase`, (b) a `__cc-wrap` link-gate that polls +//! in-flight cc deps' `done` (mirroring `rustc_wrap`), and (c) an edge-aware +//! `PipelinePolicy` so cc→Rust edges stay done-gated (rustc-wrap doesn't know +//! to wait on non-`completeDeps` inputs). All three are mechanical once +//! there's a real cc→cc graph to test against; the per-TU incrementality here +//! is the order-of-magnitude win on its own. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::Path; + +use bob_core::{Backend, BuildContext, BuildGraph, Derivation, OwnHash}; + +mod hooks; +mod workspace; + +pub struct CcBackend; + +/// Env-var marker set by `lib/cc.nix`'s `unit`/`units`. Value is the source +/// dir relative to repo root. +pub(crate) const MARK: &str = "bobCcSrc"; + +impl Backend for CcBackend { + fn id(&self) -> &'static str { + "cc" + } + + fn is_unit(&self, drv: &Derivation) -> bool { + drv.env.contains_key(MARK) + } + + fn unit_name<'a>(&self, drv: &'a Derivation) -> Cow<'a, str> { + drv.env + .get("pname") + .or_else(|| drv.env.get("name")) + .map(String::as_str) + .unwrap_or("?") + .into() + } + + fn resolve_attr(&self, target: &str, _repo_root: &Path) -> Option { + // The bob.nix `cc.` attr is the contract; CMakeLists + // `project()` names often differ (e.g. attr `ndl` vs project + // `neuron_kmdlib`), so don't gate on the discovered-project index. + // This backend is tried last, after Rust's definitive Cargo.toml + // lookup has declined, so claiming optimistically just turns a typo + // into nix-instantiate's "attribute 'cc.' missing" — clear + // enough, and `list_targets` still offers project-name suggestions. + // Reject obvious non-idents so paths/flags don't become attr paths. + target + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + .then(|| format!("cc.{target}")) + } + + fn lock_hash(&self, _repo_root: &Path) -> Result { + // No lockfile analogue. The drv graph for a cc target is fully + // determined by `bob.nix` and whatever it imports — both already + // mixed into the eval-cache key by core's `resolve::eval_key` (via + // `bob.nix` itself + `bob.toml` `eval-inputs`). Returning a fixed + // string here means cc contributes nothing extra, which is correct. + Ok(String::new()) + } + + fn detect_from_cwd(&self) -> Option { + workspace::detect_from_cwd() + } + + fn list_targets(&self, repo_root: &Path) -> Vec { + workspace::cc_targets(repo_root).keys().cloned().collect() + } + + fn workspace_unit_hashes( + &self, + repo_root: &Path, + graph: &BuildGraph, + ) -> HashMap { + workspace::unit_hashes(repo_root, graph) + } + + fn build_script_hooks(&self, ctx: &BuildContext<'_>) -> Result { + hooks::build_script_hooks(ctx) + } + + fn output_populated(&self, tmp: &Path, drv: &Derivation) -> bool { + hooks::output_populated(tmp, drv) + } + + // pipeline() defaults to None — see module doc. +} diff --git a/crates/cc/src/workspace.rs b/crates/cc/src/workspace.rs new file mode 100644 index 0000000..714e267 --- /dev/null +++ b/crates/cc/src/workspace.rs @@ -0,0 +1,212 @@ +//! cc target discovery and source-change tracking. +//! +//! There is no `Cargo.toml`-equivalent manifest. The drv itself carries +//! `bobCcSrc` (set by `lib/cc.nix`), so the build graph is the source of +//! truth for `unit_hashes`. For `resolve_attr`/`list_targets`/ +//! `detect_from_cwd` — which run *before* the graph exists — we walk the repo +//! once for `CMakeLists.txt`/`meson.build` files and index their `project()` +//! names. That's a heuristic (the `bob.nix` `cc.` attr is what actually +//! gets evaluated), but it lets `bob build .` and typo-suggestions work +//! without a separate config file. + +use std::collections::{BTreeMap, HashMap}; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use bob_core::resolve::EvalCache; +use bob_core::{BuildGraph, OwnHash}; + +use super::MARK; + +/// Directories whose contents must not contribute to a unit's source hash. +/// `build/` is the conventional cmake/meson out-of-tree dir when developers +/// build by hand inside the worktree; `target/` shows up when a Rust crate +/// shares the directory. +fn skip_dir(name: &OsStr) -> bool { + matches!(name.to_str(), Some("build" | "target")) +} + +/// `drv_path → (own-source hash, live src dir)` for every cc unit in the +/// graph. The src dir comes straight from the `bobCcSrc` env attr, so this +/// needs no manifest and stays in lock-step with whatever `bob.nix` marked. +pub fn unit_hashes(repo_root: &Path, g: &BuildGraph) -> HashMap { + let mut own = HashMap::new(); + for (drv_path, node) in &g.nodes { + let Some(rel) = node.drv.env.get(MARK) else { + continue; + }; + match EvalCache::source_hash(repo_root, Path::new(rel), &skip_dir) { + Ok(hash) => { + own.insert( + drv_path.clone(), + OwnHash { + hash, + src_dir: repo_root.join(rel), + }, + ); + } + Err(e) => eprintln!(" warn: hashing cc unit {rel}: {e}"), + } + } + own +} + +/// `project()` name → directory, discovered by a one-shot walk of the repo. +/// Memoized per process — same justification as the Rust backend's +/// `workspace_members`: this is hit from `resolve_attr`, `list_targets`, and +/// `detect_from_cwd` on every `bob build`. +pub fn cc_targets(repo_root: &Path) -> &'static BTreeMap { + static CACHE: OnceLock<(PathBuf, BTreeMap)> = OnceLock::new(); + let (cached_root, map) = CACHE.get_or_init(|| (repo_root.to_path_buf(), discover(repo_root))); + debug_assert_eq!( + cached_root, repo_root, + "cc_targets memo keyed on first root" + ); + map +} + +fn discover(repo_root: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + // Cap the walk: a monorepo can have hundreds of thousands of dirs. We + // only need top-level project files, and nested `CMakeLists.txt` under a + // root one are `add_subdirectory` children, not standalone projects. + walk(repo_root, repo_root, 6, &mut out); + out +} + +fn walk(root: &Path, dir: &Path, depth: u8, out: &mut BTreeMap) { + if depth == 0 { + return; + } + // A directory with its own project() is a leaf for our purposes — don't + // descend, its subdirs' CMakeLists are part of *this* project. + if let Some(name) = project_name(dir) { + let rel = dir.strip_prefix(root).unwrap_or(dir).to_path_buf(); + out.entry(name).or_insert(rel); + return; + } + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + if !e.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + let n = e.file_name(); + let ns = n.to_string_lossy(); + if ns.starts_with('.') || skip_dir(&n) || ns == "node_modules" { + continue; + } + walk(root, &e.path(), depth - 1, out); + } +} + +/// Walk up from cwd; first dir whose `CMakeLists.txt`/`meson.build` declares +/// a `project()` wins. +pub fn detect_from_cwd() -> Option { + let cwd = std::env::current_dir().ok()?; + let mut dir = cwd.as_path(); + loop { + if let Some(name) = project_name(dir) { + return Some(name); + } + dir = dir.parent()?; + } +} + +/// Extract `project( …)` from `CMakeLists.txt` or `meson.build`. Cheap +/// line-scan, not a real parser — both formats put the call on its own line +/// in practice, and we only need the first positional arg. +pub(crate) fn project_name(dir: &Path) -> Option { + for f in ["CMakeLists.txt", "meson.build"] { + let p = dir.join(f); + let Ok(s) = std::fs::read_to_string(&p) else { + continue; + }; + for line in s.lines() { + let line = line.trim_start(); + // Both: `project(` is the keyword; cmake is case-insensitive. + let rest = line + .strip_prefix("project(") + .or_else(|| line.strip_prefix("project (")) + .or_else(|| line.strip_prefix("PROJECT(")) + .or_else(|| line.strip_prefix("Project(")); + let Some(rest) = rest else { continue }; + // First token up to `,` `)` or whitespace, with optional quotes. + let tok: String = rest + .trim_start() + .trim_start_matches(['\'', '"']) + .chars() + .take_while(|c| !matches!(c, ',' | ')' | '\'' | '"') && !c.is_whitespace()) + .collect(); + // cmake `project(${VAR})` / meson run-time names — can't resolve. + if tok.is_empty() || tok.contains(['$', '@']) { + continue; + } + return Some(tok); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn tmpdir() -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let d = std::env::temp_dir().join(format!("bob-cc-ws-{}-{nanos}", std::process::id())); + fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn project_name_variants() { + let d = tmpdir(); + // cmake: case-insensitive keyword, optional whitespace, VERSION etc. + fs::write( + d.join("CMakeLists.txt"), + "cmake_minimum_required(VERSION 3.20)\nProject( libfoo VERSION 1.0 LANGUAGES C CXX)\n", + ) + .unwrap(); + assert_eq!(project_name(&d).as_deref(), Some("libfoo")); + + // meson: quoted first arg, kwargs after. + fs::write( + d.join("meson.build"), + "project('libbar', 'c', version: '1.0')\n", + ) + .unwrap(); + // CMakeLists.txt is checked first, so remove it. + fs::remove_file(d.join("CMakeLists.txt")).unwrap(); + assert_eq!(project_name(&d).as_deref(), Some("libbar")); + + // Variable interpolation → unresolvable. + fs::write(d.join("meson.build"), "project(@NAME@, 'c')\n").unwrap(); + assert_eq!(project_name(&d), None); + + let _ = fs::remove_dir_all(&d); + } + + #[test] + fn discover_stops_at_project_root() { + let d = tmpdir(); + fs::create_dir_all(d.join("a/sub")).unwrap(); + fs::create_dir_all(d.join("b")).unwrap(); + fs::write(d.join("a/CMakeLists.txt"), "project(a)\n").unwrap(); + // sub is add_subdirectory fodder, not a standalone target. + fs::write(d.join("a/sub/CMakeLists.txt"), "project(a_sub)\n").unwrap(); + fs::write(d.join("b/meson.build"), "project('b', 'c')\n").unwrap(); + + let m = discover(&d); + assert_eq!(m.get("a"), Some(&PathBuf::from("a"))); + assert_eq!(m.get("b"), Some(&PathBuf::from("b"))); + assert!(!m.contains_key("a_sub")); + let _ = fs::remove_dir_all(&d); + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3745a76..11963a5 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -14,4 +14,5 @@ path = "src/main.rs" [dependencies] bob-core.workspace = true bob-rust.workspace = true +bob-cc.workspace = true clap.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 3541c5e..da8a0be 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -6,16 +6,14 @@ use clap::{Args, Parser, Subcommand}; /// Registered language backends, tried in order for `resolve_attr` / /// `detect_from_cwd` / `dispatch_internal`. `is_unit` and -/// `workspace_unit_hashes` are unioned across all of them. +/// `workspace_unit_hashes` are unioned across all of them, and the +/// scheduler dispatches `build_script_hooks` / `output_populated` / +/// `pipeline` per-node by re-running `is_unit` to find each unit's owner. /// -/// `scheduler::run_parallel` still takes a single backend (the one that -/// resolved the root target); per-node backend dispatch in mixed-language -/// graphs is a follow-up once a second backend exists to test against. -static BACKENDS: &[&dyn Backend] = &[&bob_rust::RustBackend]; - -fn backend() -> &'static dyn Backend { - BACKENDS[0] -} +/// Order matters for `resolve_attr`: Rust consults `Cargo.toml` (definitive +/// member list) so it goes first; cc's `project()`-name walk is a heuristic +/// and only claims targets Rust declined. +static BACKENDS: &[&dyn Backend] = &[&bob_rust::RustBackend, &bob_cc::CcBackend]; #[derive(Parser)] #[command( @@ -274,7 +272,12 @@ fn cmd_build(args: BuildArgs) { Some(ov) => ArtifactCache::cache_key_with_source(drv, &ov.source_hash), None => ArtifactCache::cache_key(drv), }; - println!("{key} {} {drv}", backend().unit_name(&node.drv)); + let name = BACKENDS + .iter() + .find(|b| b.is_unit(&node.drv)) + .map(|b| b.unit_name(&node.drv)) + .unwrap_or("?".into()); + println!("{key} {name} {drv}"); } return; } @@ -285,7 +288,7 @@ fn cmd_build(args: BuildArgs) { jobs ); - let result = scheduler::run_parallel(&g, &cache, jobs, backend(), &overrides, &drv_paths); + let result = scheduler::run_parallel(&g, &cache, jobs, BACKENDS, &overrides, &drv_paths); // Result symlinks + --print-out-paths, one per (root, output) following // nix-build's naming: [-][-], with `-` omitted for @@ -492,7 +495,11 @@ fn cmd_graph(roots: &[String]) { println!("topological order:"); for (i, drv_path) in g.topo_order.iter().enumerate() { let node = &g.nodes[drv_path]; - let name = backend().unit_name(&node.drv); + let name = BACKENDS + .iter() + .find(|b| b.is_unit(&node.drv)) + .map(|b| b.unit_name(&node.drv)) + .unwrap_or("?".into()); let ndeps = node.unit_deps.len(); println!(" {i:3}. {name} ({ndeps} deps)"); } diff --git a/crates/core/src/cache.rs b/crates/core/src/cache.rs index 418063e..fe026b1 100644 --- a/crates/core/src/cache.rs +++ b/crates/core/src/cache.rs @@ -40,7 +40,10 @@ impl ArtifactCache { } } - #[cfg(test)] + /// Test/bench constructor: point the cache at an arbitrary root. Used + /// by backend crates' tests, so it can't be `#[cfg(test)]` (that only + /// applies within `bob-core`'s own test build). + #[doc(hidden)] pub fn from_path(root: PathBuf) -> Self { Self { root } } diff --git a/crates/core/src/scheduler.rs b/crates/core/src/scheduler.rs index cb6b5f6..4b7c1f3 100644 --- a/crates/core/src/scheduler.rs +++ b/crates/core/src/scheduler.rs @@ -24,6 +24,7 @@ use std::thread; use crate::backend::{Backend, BuildContext}; use crate::cache::ArtifactCache; +use crate::drv::Derivation; use crate::executor::{self, SourceOverride}; use crate::graph::{BuildGraph, UnitNode}; use crate::progress::Progress; @@ -89,20 +90,44 @@ impl SharedState { } } +/// Per-node backend dispatch. Each unit was admitted to the graph by exactly +/// one backend's `is_unit` (the cli unions them); rediscover which one here so +/// `unit_name` / `build_script_hooks` / `output_populated` / `pipeline` come +/// from the right place. Precomputed once — `is_unit` is cheap but called +/// per-edge for `pipelineable` below. +fn backend_for<'a>(backends: &'a [&'a dyn Backend], drv: &Derivation) -> &'a dyn Backend { + backends + .iter() + .copied() + .find(|b| b.is_unit(drv)) + // from_roots() only admits units some backend claimed, so this is + // unreachable for graph nodes. Fall back to the first backend rather + // than panic so a future caller passing a non-unit drv degrades. + .unwrap_or(backends[0]) +} + pub fn run_parallel( graph: &BuildGraph, cache: &ArtifactCache, jobs: usize, - backend: &dyn Backend, + backends: &[&dyn Backend], overrides: &HashMap, roots: &[String], ) -> SchedulerResult { let roots: HashSet<&str> = roots.iter().map(String::as_str).collect(); let start = std::time::Instant::now(); - let pl = backend.pipeline(); let self_exe = std::env::current_exe().expect("resolving self exe"); + // drv_path → owning backend. See `backend_for`. + let backend_of: HashMap<&str, &dyn Backend> = graph + .nodes + .iter() + .map(|(k, n)| (k.as_str(), backend_for(backends, &n.drv))) + .collect(); + // Worker pool config from any unit's drv — they all share stdenv/builder. + // Mixed-backend graphs share stdenv too (it's nixpkgs', not the + // language's), so any node will do. let Some(first_drv) = graph.nodes.values().next() else { // from_roots() rejects missing/non-unit roots, so this only triggers // when called with no roots at all. @@ -135,7 +160,7 @@ pub fn run_parallel( // deps (e.g. Rust cdylib roots need the .so; a prior non-root run may // have committed rlib-only). Absence is just "rebuild", not an error. if roots.contains(drv) { - return pl.is_none_or(|p| { + return backend_of[drv].pipeline().is_none_or(|p| { p.cached_artifact_sufficient_as_root(&node.drv, &artifact_dir(drv)) }); } @@ -143,10 +168,23 @@ pub fn run_parallel( }; let tmp_dir = |drv: &str| cache.root().join("tmp").join(key_for(drv)); + // Edge classification is decided by the *dep's* backend's policy: that's + // the side that knows whether it emits a usable early artifact. A backend + // with `pipeline() == None` (cc today) is done-gated for all dependents. + // Cross-backend caveat: a pipelineable Rust dep will early-unblock a cc + // dependent, which is harmless because cc units consume Rust deps (if at + // all) as boundary `buildInputs`, not as in-graph unit deps — so the edge + // doesn't exist in practice. If it ever does, make `is_pipelineable` + // edge-aware (dep, dependent) and gate on dependent's backend too. let pipelineable: HashMap = graph .nodes .iter() - .map(|(k, n)| (k.clone(), pl.is_some_and(|p| p.is_pipelineable(&n.drv)))) + .map(|(k, n)| { + let p = backend_of[k.as_str()] + .pipeline() + .is_some_and(|p| p.is_pipelineable(&n.drv)); + (k.clone(), p) + }) .collect(); let mut pending_early: HashMap = HashMap::new(); @@ -232,6 +270,7 @@ pub fn run_parallel( let bash = &bash; let stdenv_path = &stdenv_path; let self_exe = &self_exe; + let backend_of = &backend_of; s.spawn(move || { let mut worker = crate::worker::Worker::spawn(bash, stdenv_path).expect("spawning worker"); @@ -239,7 +278,7 @@ pub fn run_parallel( &state, &graph.nodes, cache, - backend, + backend_of, self_exe, &mut worker, &progress, @@ -263,7 +302,7 @@ fn worker_loop( state: &(Mutex, Condvar), nodes: &BTreeMap, cache: &ArtifactCache, - backend: &dyn Backend, + backend_of: &HashMap<&str, &dyn Backend>, self_exe: &std::path::Path, worker: &mut crate::worker::Worker, progress: &Progress, @@ -331,6 +370,7 @@ fn worker_loop( }; let node = &nodes[&drv_path]; + let backend = backend_of[drv_path.as_str()]; let unit_name = backend.unit_name(&node.drv).into_owned(); progress.start(&unit_name); diff --git a/lib/cc.nix b/lib/cc.nix new file mode 100644 index 0000000..896d862 --- /dev/null +++ b/lib/cc.nix @@ -0,0 +1,40 @@ +# Mark a stdenv derivation as a bob cc unit. +# +# `bobCcSrc` is the *only* contract between bob.nix and the cc backend: +# - presence makes `is_unit` true (the drv is replayed, not realised), +# - the value is the live source dir (relative to repo root) that bob hashes +# for change detection and overrides `$src` to at build time. +# +# `__structuredAttrs` is forced on so the attr survives into `.attrs.json` +# (the executor's structured-attrs path is also the only one that rewrites +# `src` to the live dir generically). +# +# Usage in a repo's bob.nix: +# +# let bobCc = import "${bob}/lib/cc.nix"; in +# { +# workspaceMembers = …; # rust backend +# cc = bobCc.units { +# libnrt = { drv = neuron.libnrt; src = "extra-code/b16/aws-neuron-runtime"; }; +# }; +# } +# +# `bob build libnrt` then resolves to `cc.libnrt`. +let + unit = + src: drv: + drv.overrideAttrs (old: { + __structuredAttrs = true; + bobCcSrc = src; + # Replayed builds skip unpack/patch and point cmake/meson at the live + # tree; in-sandbox `nix build` of the same drv must still work, so leave + # the original phases intact — bob's hook sets dontUnpack/dontPatch at + # replay time only. + }); +in +{ + inherit unit; + + # Convenience: `{ name = { drv, src }; … }` → `{ name = unit src drv; … }`. + units = builtins.mapAttrs (_: v: unit v.src v.drv); +} From c05e68f87daba1c61b1c3cd807d747e2cafd8664 Mon Sep 17 00:00:00 2001 From: Ramses de Norre Date: Mon, 20 Apr 2026 20:24:52 +0000 Subject: [PATCH 2/3] rust: accept `.` targets Maps to `.workspaceMembers..build` so a repo's bob.nix can expose multiple cargoNix instances (e.g. prod-tuned vs dev-loop rustc flags) under different prefixes. The crate part is still gated on Cargo.toml membership; the prefix is opaque to bob. --- crates/rust/src/lib.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/rust/src/lib.rs b/crates/rust/src/lib.rs index 76c1fa3..2fbe51b 100644 --- a/crates/rust/src/lib.rs +++ b/crates/rust/src/lib.rs @@ -33,10 +33,23 @@ impl Backend for RustBackend { } fn resolve_attr(&self, target: &str, repo_root: &Path) -> Option { + // `` → `workspaceMembers..build` + // `.` → `.workspaceMembers..build` + // + // The profile prefix lets a repo's bob.nix expose several cargoNix + // instances (e.g. prod-tuned vs dev-loop flags) without bob needing + // to know what "profile" means — it's just an attr-path prefix. The + // crate part is still gated on Cargo.toml membership so unknown + // names fall through to other backends. + let (prefix, crate_name) = match target.rsplit_once('.') { + Some((p, c)) => (Some(p), c), + None => (None, target), + }; let members = workspace::workspace_members(repo_root).ok()?; - members - .contains_key(target) - .then(|| format!("workspaceMembers.{target}.build")) + members.contains_key(crate_name).then(|| match prefix { + Some(p) => format!("{p}.workspaceMembers.{crate_name}.build"), + None => format!("workspaceMembers.{crate_name}.build"), + }) } fn lock_hash(&self, repo_root: &Path) -> Result { From faccfb12f173045e8b56fd9b66721f7a43390636 Mon Sep 17 00:00:00 2001 From: Ramses de Norre Date: Mon, 20 Apr 2026 21:20:04 +0000 Subject: [PATCH 3/3] cc: identify units by drvPath via a sidecar map (no overrideAttrs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/cc.nix now attaches bobCcSrc as a Nix-level attr (drv // { … }), so drvPath is unchanged and the same drv that appears in a Rust crate's buildInputs is the one under cc.. The cc backend evaluates (import bob.nix {}).cc once to get the drvPath→src map and uses it for is_unit and source-change tracking; nothing reaches the drv env. This makes the C-edit cascade work without overlays: bob build finds the cc unit in its closure, overrides::cascade propagates the eff-hash, and only the cc unit + the linking cdylib rebuild. Adding/removing a cc unit doesn't ripple through the Rust drv graph. core: Backend::is_unit gains drv_path and repo_root; threaded through from_roots/scheduler. cli: predicate_key folds in bob.nix content so the graph cache invalidates when the cc-unit set changes (the root drv path doesn't move, so the cache key wouldn't otherwise). --- Cargo.lock | 1 + README.md | 15 +- crates/cc/src/hooks.rs | 1 - crates/cc/src/lib.rs | 46 +++-- crates/cc/src/workspace.rs | 352 ++++++++++++++++++++++------------- crates/cli/Cargo.toml | 1 + crates/cli/src/main.rs | 50 +++-- crates/core/src/backend.rs | 7 +- crates/core/src/graph.rs | 6 +- crates/core/src/scheduler.rs | 14 +- crates/rust/src/lib.rs | 2 +- lib/cc.nix | 41 ++-- 12 files changed, 331 insertions(+), 205 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c6bd0d..e7044ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ dependencies = [ name = "bob" version = "0.1.0" dependencies = [ + "blake3", "bob-cc", "bob-core", "bob-rust", diff --git a/README.md b/README.md index 9071962..c188181 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ backend-specific identifiers. ## C/C++ backend A cc unit is a plain `stdenv.mkDerivation` (cmake or meson, out-of-tree) -tagged with `bobCcSrc`: +declared in `bob.nix`: ```nix # bob.nix @@ -130,10 +130,17 @@ let bobCc = import "${bob}/lib/cc.nix"; in } ``` -`bob build libfoo` then keeps a drv-path-keyed build directory under +`bobCc.unit` attaches `bobCcSrc` as a Nix-level attribute (`drv // { … }`), +so `drvPath` is **unchanged** — if `pkgs.libfoo` also appears in some Rust +crate's `buildInputs`, bob's graph walk from a Rust root finds the same drv +as a unit and a C edit cascades through to the `.so`. The cc backend +evaluates `(import bob.nix {}).cc` once to get the drvPath→src map; nothing +is written into the drv env. + +`bob build libfoo` keeps a drv-path-keyed build directory under `~/.cache/bob/incremental/` so reconfigure is warm and `ninja` rebuilds only -the TUs whose `.d` depfiles changed. The marked drv still `nix build`s -normally — `dontUnpack`/`cmakeBuildDir` are injected only at replay time. +the TUs whose `.d` depfiles changed. The drv still `nix build`s normally — +`dontUnpack`/`cmakeBuildDir` are injected only at replay time. Caveats: unpack/patch are skipped (the build runs against the live worktree), so patched derivations are not supported; cc edges are done-gated (no early diff --git a/crates/cc/src/hooks.rs b/crates/cc/src/hooks.rs index a0b8b15..29d1084 100644 --- a/crates/cc/src/hooks.rs +++ b/crates/cc/src/hooks.rs @@ -117,7 +117,6 @@ mod tests { env: { let mut m = BTreeMap::new(); m.insert("pname".into(), "libfoo".into()); - m.insert("bobCcSrc".into(), "path/to/foo".into()); m }, } diff --git a/crates/cc/src/lib.rs b/crates/cc/src/lib.rs index beb62d8..bc1f634 100644 --- a/crates/cc/src/lib.rs +++ b/crates/cc/src/lib.rs @@ -4,11 +4,14 @@ //! //! ## Unit model //! -//! Project-grain: one drv = one cmake/meson project. A drv opts in by carrying -//! `bobCcSrc = ""` in its env (see `lib/cc.nix`). -//! That single attr is the unit marker, the display name's source-of-truth -//! lookup, *and* the live source dir for change detection — no separate -//! manifest. +//! Project-grain: one drv = one cmake/meson project. Units are declared in +//! `bob.nix` under `cc.` via `lib/cc.nix`, which attaches `bobCcSrc` +//! as a *Nix-level* attribute (`drv // { … }`) so `drvPath` is unchanged. +//! That's load-bearing: the same drv path appears in any Rust crate's +//! `buildInputs` closure, so `bob build ` finds the cc unit in +//! its graph and a C edit cascades through to the `.so` without overlays. +//! [`workspace::cc_units`] evaluates the `cc` attrset once to get the +//! drvPath→src map; nothing is read from the drv env. //! //! ## Incrementality //! @@ -50,17 +53,13 @@ mod workspace; pub struct CcBackend; -/// Env-var marker set by `lib/cc.nix`'s `unit`/`units`. Value is the source -/// dir relative to repo root. -pub(crate) const MARK: &str = "bobCcSrc"; - impl Backend for CcBackend { fn id(&self) -> &'static str { "cc" } - fn is_unit(&self, drv: &Derivation) -> bool { - drv.env.contains_key(MARK) + fn is_unit(&self, drv_path: &str, _drv: &Derivation, repo_root: &Path) -> bool { + workspace::cc_units(repo_root).contains_key(drv_path) } fn unit_name<'a>(&self, drv: &'a Derivation) -> Cow<'a, str> { @@ -72,18 +71,14 @@ impl Backend for CcBackend { .into() } - fn resolve_attr(&self, target: &str, _repo_root: &Path) -> Option { - // The bob.nix `cc.` attr is the contract; CMakeLists - // `project()` names often differ (e.g. attr `ndl` vs project - // `neuron_kmdlib`), so don't gate on the discovered-project index. - // This backend is tried last, after Rust's definitive Cargo.toml - // lookup has declined, so claiming optimistically just turns a typo - // into nix-instantiate's "attribute 'cc.' missing" — clear - // enough, and `list_targets` still offers project-name suggestions. - // Reject obvious non-idents so paths/flags don't become attr paths. - target - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + fn resolve_attr(&self, target: &str, repo_root: &Path) -> Option { + // The bob.nix `cc.` attr is the contract. The map is already + // loaded (or loads now, cached), so gate on declared names — unlike + // the earlier optimistic claim, this is exact, so a typo falls + // through to the cli's "unknown target" suggestion path instead of + // a nix-instantiate error. + workspace::cc_target_names(repo_root) + .contains_key(target) .then(|| format!("cc.{target}")) } @@ -101,7 +96,10 @@ impl Backend for CcBackend { } fn list_targets(&self, repo_root: &Path) -> Vec { - workspace::cc_targets(repo_root).keys().cloned().collect() + workspace::cc_target_names(repo_root) + .keys() + .cloned() + .collect() } fn workspace_unit_hashes( diff --git a/crates/cc/src/workspace.rs b/crates/cc/src/workspace.rs index 714e267..5bb8912 100644 --- a/crates/cc/src/workspace.rs +++ b/crates/cc/src/workspace.rs @@ -1,109 +1,229 @@ -//! cc target discovery and source-change tracking. +//! cc unit discovery via `(import bob.nix {}).cc`. //! -//! There is no `Cargo.toml`-equivalent manifest. The drv itself carries -//! `bobCcSrc` (set by `lib/cc.nix`), so the build graph is the source of -//! truth for `unit_hashes`. For `resolve_attr`/`list_targets`/ -//! `detect_from_cwd` — which run *before* the graph exists — we walk the repo -//! once for `CMakeLists.txt`/`meson.build` files and index their `project()` -//! names. That's a heuristic (the `bob.nix` `cc.` attr is what actually -//! gets evaluated), but it lets `bob build .` and typo-suggestions work -//! without a separate config file. +//! Units are identified by **drv path**, not by a marker in the drv env. +//! `lib/cc.nix`'s `unit` attaches `bobCcSrc` as a Nix-level attribute +//! (`drv // { bobCcSrc = …; }`), which leaves `drvPath` unchanged — so the +//! drv referenced by some Rust crate's `buildInputs` and the one under +//! `cc.` are the same store path. We evaluate the `cc` attrset once +//! to get `{ = { drvPath, src }; … }` and use that map for both +//! `is_unit` (drvPath ∈ map) and source-change tracking. +//! +//! The eval is cached on `blake3(bob.nix)`: the unit set is fully determined +//! by `bob.nix` plus whatever it imports, and core's `resolve::eval_key` +//! already covers the imports via `bob.toml` `eval-inputs`. A stale cache +//! (bob.nix unchanged but an imported file moved a drvPath) degrades to +//! "unit not recognised → boundary input" — safe, just loses incrementality +//! for that unit until bob.nix is touched or the cache is cleared. use std::collections::{BTreeMap, HashMap}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; +use std::process::Command; use std::sync::OnceLock; use bob_core::resolve::EvalCache; use bob_core::{BuildGraph, OwnHash}; -use super::MARK; +/// One declared cc unit: its bob.nix attr name and live source dir. +#[derive(Debug)] +pub struct CcUnit { + pub name: String, + pub src: PathBuf, +} /// Directories whose contents must not contribute to a unit's source hash. -/// `build/` is the conventional cmake/meson out-of-tree dir when developers -/// build by hand inside the worktree; `target/` shows up when a Rust crate -/// shares the directory. fn skip_dir(name: &OsStr) -> bool { matches!(name.to_str(), Some("build" | "target")) } +/// drvPath → { name, src }, evaluated once from `(import bob.nix {}).cc`. +/// Memoized per process (same justification as the rust backend's +/// `workspace_members`); the on-disk cache survives across runs. +pub fn cc_units(repo_root: &Path) -> &'static HashMap { + static CACHE: OnceLock<(PathBuf, HashMap)> = OnceLock::new(); + let (cached_root, map) = CACHE.get_or_init(|| { + let m = load(repo_root).unwrap_or_else(|e| { + // Loud: a silent empty map here means cc edits never invalidate + // anything and the user has no idea why. Keep going (rust-only + // repos legitimately have no `cc` attr) but make it visible. + eprintln!("\x1b[1;31m cc backend disabled\x1b[0m: {e}"); + HashMap::new() + }); + (repo_root.to_path_buf(), m) + }); + debug_assert_eq!(cached_root, repo_root, "cc_units memo keyed on first root"); + map +} + +fn load(repo_root: &Path) -> Result, String> { + let bob_nix = repo_root.join("bob.nix"); + let key = blake3::hash( + &std::fs::read(&bob_nix).map_err(|e| format!("reading {}: {e}", bob_nix.display()))?, + ) + .to_hex()[..16] + .to_string(); + + let cache_home = std::env::var("XDG_CACHE_HOME") + .unwrap_or_else(|_| format!("{}/.cache", std::env::var("HOME").unwrap_or_default())); + let cache_path = PathBuf::from(cache_home) + .join("bob") + .join("eval") + .join(format!("ccunits.{key}.json")); + + if let Ok(s) = std::fs::read_to_string(&cache_path) { + if let Ok(m) = parse(&s) { + return Ok(m); + } + } + + // The expression must tolerate `cc` being absent (pure-Rust repos) and + // entries missing `bobCcSrc` (a bare drv someone put under `cc.` + // without going through `lib/cc.nix`). `--strict` forces the inner attrs + // so `--json` doesn't emit thunks. `nix-instantiate` matches the + // resolver path (BOB_NIX_INSTANTIATE) so any extra builtins bob.nix + // needs are present. + let nix_instantiate = + std::env::var("BOB_NIX_INSTANTIATE").unwrap_or_else(|_| "nix-instantiate".into()); + let expr = format!( + r#"builtins.mapAttrs + (_: v: {{ drv = v.drvPath; src = v.bobCcSrc or null; }}) + ((import {root}/bob.nix {{}}).cc or {{}})"#, + root = repo_root.display() + ); + let out = Command::new(&nix_instantiate) + .args(["--eval", "--json", "--strict", "--expr", &expr]) + .output() + .map_err(|e| format!("running {nix_instantiate}: {e}"))?; + if !out.status.success() { + return Err(format!( + "evaluating cc units: {}", + String::from_utf8_lossy(&out.stderr) + )); + } + let json = String::from_utf8_lossy(&out.stdout).into_owned(); + let m = parse(&json)?; + + let _ = std::fs::create_dir_all(cache_path.parent().unwrap()); + let _ = std::fs::write(&cache_path, &json); + Ok(m) +} + +/// Parse `{ "": { "drv": "", "src": "" | null }, … }` into +/// drvPath → CcUnit. Hand-rolled to keep bob-cc free of a serde_json dep +/// (bob-core already pulls it, but the trait bound here is simple enough). +fn parse(s: &str) -> Result, String> { + // The JSON is one flat object of objects with two known string fields. + // We pull it apart with the same minimal scanner shape rustc_wrap uses, + // rather than adding serde_json to this crate for one call site. + let mut m = HashMap::new(); + let mut i = 0usize; + let b = s.as_bytes(); + let str_at = |i: &mut usize| -> Option { + while *i < b.len() && b[*i] != b'"' { + *i += 1; + } + if *i >= b.len() { + return None; + } + *i += 1; + let start = *i; + while *i < b.len() && b[*i] != b'"' { + // nix-instantiate --json never emits escapes in drv paths or our + // src rels (no quotes/backslashes by construction), so a naive + // scan to the closing quote is sufficient. + *i += 1; + } + let v = std::str::from_utf8(&b[start..*i]).ok()?.to_string(); + *i += 1; + Some(v) + }; + // Outer { "name": { "drv": "…", "src": "…" }, … } + while i < b.len() { + let Some(name) = str_at(&mut i) else { break }; + let mut drv = None; + let mut src = None; + // Inner object: exactly two keys, order from nix is alphabetical + // (drv, src) but don't rely on it. + while i < b.len() && b[i] != b'}' { + let Some(k) = str_at(&mut i) else { break }; + // value: either a string or `null` + while i < b.len() && b[i] != b'"' && b[i] != b'n' && b[i] != b'}' { + i += 1; + } + let v = if i < b.len() && b[i] == b'"' { + str_at(&mut i) + } else { + // null + while i < b.len() && b[i].is_ascii_alphabetic() { + i += 1; + } + None + }; + match k.as_str() { + "drv" => drv = v, + "src" => src = v, + _ => {} + } + } + if let (Some(drv), Some(src)) = (drv, src) { + m.insert( + drv, + CcUnit { + name, + src: PathBuf::from(src), + }, + ); + } + while i < b.len() && b[i] != b',' && b[i] != b'}' { + i += 1; + } + if i < b.len() && b[i] == b'}' { + // close of inner or outer — advance past inner, stop on outer. + i += 1; + } + } + Ok(m) +} + /// `drv_path → (own-source hash, live src dir)` for every cc unit in the -/// graph. The src dir comes straight from the `bobCcSrc` env attr, so this -/// needs no manifest and stays in lock-step with whatever `bob.nix` marked. +/// graph. The src dir comes from the drvPath→src map, so the same drv that +/// `cargoNix*` references is tracked — no marker needed in the drv env. pub fn unit_hashes(repo_root: &Path, g: &BuildGraph) -> HashMap { + let units = cc_units(repo_root); let mut own = HashMap::new(); - for (drv_path, node) in &g.nodes { - let Some(rel) = node.drv.env.get(MARK) else { + for drv_path in g.nodes.keys() { + let Some(u) = units.get(drv_path) else { continue; }; - match EvalCache::source_hash(repo_root, Path::new(rel), &skip_dir) { + match EvalCache::source_hash(repo_root, &u.src, &skip_dir) { Ok(hash) => { own.insert( drv_path.clone(), OwnHash { hash, - src_dir: repo_root.join(rel), + src_dir: repo_root.join(&u.src), }, ); } - Err(e) => eprintln!(" warn: hashing cc unit {rel}: {e}"), + Err(e) => eprintln!(" warn: hashing cc unit {}: {e}", u.name), } } own } -/// `project()` name → directory, discovered by a one-shot walk of the repo. -/// Memoized per process — same justification as the Rust backend's -/// `workspace_members`: this is hit from `resolve_attr`, `list_targets`, and -/// `detect_from_cwd` on every `bob build`. -pub fn cc_targets(repo_root: &Path) -> &'static BTreeMap { - static CACHE: OnceLock<(PathBuf, BTreeMap)> = OnceLock::new(); - let (cached_root, map) = CACHE.get_or_init(|| (repo_root.to_path_buf(), discover(repo_root))); - debug_assert_eq!( - cached_root, repo_root, - "cc_targets memo keyed on first root" - ); - map -} - -fn discover(repo_root: &Path) -> BTreeMap { - let mut out = BTreeMap::new(); - // Cap the walk: a monorepo can have hundreds of thousands of dirs. We - // only need top-level project files, and nested `CMakeLists.txt` under a - // root one are `add_subdirectory` children, not standalone projects. - walk(repo_root, repo_root, 6, &mut out); - out -} - -fn walk(root: &Path, dir: &Path, depth: u8, out: &mut BTreeMap) { - if depth == 0 { - return; - } - // A directory with its own project() is a leaf for our purposes — don't - // descend, its subdirs' CMakeLists are part of *this* project. - if let Some(name) = project_name(dir) { - let rel = dir.strip_prefix(root).unwrap_or(dir).to_path_buf(); - out.entry(name).or_insert(rel); - return; - } - let Ok(rd) = std::fs::read_dir(dir) else { - return; - }; - for e in rd.flatten() { - if !e.file_type().map(|t| t.is_dir()).unwrap_or(false) { - continue; - } - let n = e.file_name(); - let ns = n.to_string_lossy(); - if ns.starts_with('.') || skip_dir(&n) || ns == "node_modules" { - continue; - } - walk(root, &e.path(), depth - 1, out); - } +/// Declared cc target names — for `list_targets` (typo suggestions) and +/// `resolve_attr` gating. +pub fn cc_target_names(repo_root: &Path) -> BTreeMap { + cc_units(repo_root) + .values() + .map(|u| (u.name.clone(), ())) + .collect() } /// Walk up from cwd; first dir whose `CMakeLists.txt`/`meson.build` declares -/// a `project()` wins. +/// a `project()` wins. This is best-effort — the returned name only resolves +/// if the user named the `cc.` after the project, which `lib/cc.nix` +/// doesn't enforce. pub fn detect_from_cwd() -> Option { let cwd = std::env::current_dir().ok()?; let mut dir = cwd.as_path(); @@ -115,32 +235,25 @@ pub fn detect_from_cwd() -> Option { } } -/// Extract `project( …)` from `CMakeLists.txt` or `meson.build`. Cheap -/// line-scan, not a real parser — both formats put the call on its own line -/// in practice, and we only need the first positional arg. -pub(crate) fn project_name(dir: &Path) -> Option { +fn project_name(dir: &Path) -> Option { for f in ["CMakeLists.txt", "meson.build"] { - let p = dir.join(f); - let Ok(s) = std::fs::read_to_string(&p) else { + let Ok(s) = std::fs::read_to_string(dir.join(f)) else { continue; }; for line in s.lines() { - let line = line.trim_start(); - // Both: `project(` is the keyword; cmake is case-insensitive. let rest = line + .trim_start() .strip_prefix("project(") - .or_else(|| line.strip_prefix("project (")) - .or_else(|| line.strip_prefix("PROJECT(")) - .or_else(|| line.strip_prefix("Project(")); + .or_else(|| line.trim_start().strip_prefix("project (")) + .or_else(|| line.trim_start().strip_prefix("PROJECT(")) + .or_else(|| line.trim_start().strip_prefix("Project(")); let Some(rest) = rest else { continue }; - // First token up to `,` `)` or whitespace, with optional quotes. let tok: String = rest .trim_start() .trim_start_matches(['\'', '"']) .chars() .take_while(|c| !matches!(c, ',' | ')' | '\'' | '"') && !c.is_whitespace()) .collect(); - // cmake `project(${VAR})` / meson run-time names — can't resolve. if tok.is_empty() || tok.contains(['$', '@']) { continue; } @@ -153,60 +266,45 @@ pub(crate) fn project_name(dir: &Path) -> Option { #[cfg(test)] mod tests { use super::*; - use std::fs; - fn tmpdir() -> PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let d = std::env::temp_dir().join(format!("bob-cc-ws-{}-{nanos}", std::process::id())); - fs::create_dir_all(&d).unwrap(); - d + #[test] + fn parse_cc_units_json() { + let json = r#"{"ndl":{"drv":"/nix/store/aaa-kmdlib.drv","src":"extra-code/b16/aws-neuron-kmdlib"},"pjrt":{"drv":"/nix/store/bbb-pjrt.drv","src":"extra-code/b16/pjrt"}}"#; + let m = parse(json).unwrap(); + assert_eq!(m.len(), 2); + assert_eq!(m["/nix/store/aaa-kmdlib.drv"].name, "ndl"); + assert_eq!( + m["/nix/store/aaa-kmdlib.drv"].src, + PathBuf::from("extra-code/b16/aws-neuron-kmdlib") + ); + assert_eq!(m["/nix/store/bbb-pjrt.drv"].name, "pjrt"); + } + + #[test] + fn parse_tolerates_null_src_and_empty() { + // A bare drv under cc. without bobCcSrc → src: null → skipped. + let json = r#"{"bare":{"drv":"/nix/store/x.drv","src":null}}"#; + assert!(parse(json).unwrap().is_empty()); + assert!(parse("{}").unwrap().is_empty()); } #[test] fn project_name_variants() { - let d = tmpdir(); - // cmake: case-insensitive keyword, optional whitespace, VERSION etc. - fs::write( + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let d = std::env::temp_dir().join(format!("bob-cc-pn-{}-{nanos}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write( d.join("CMakeLists.txt"), - "cmake_minimum_required(VERSION 3.20)\nProject( libfoo VERSION 1.0 LANGUAGES C CXX)\n", + "Project( libfoo VERSION 1.0 LANGUAGES C CXX)\n", ) .unwrap(); assert_eq!(project_name(&d).as_deref(), Some("libfoo")); - - // meson: quoted first arg, kwargs after. - fs::write( - d.join("meson.build"), - "project('libbar', 'c', version: '1.0')\n", - ) - .unwrap(); - // CMakeLists.txt is checked first, so remove it. - fs::remove_file(d.join("CMakeLists.txt")).unwrap(); + std::fs::remove_file(d.join("CMakeLists.txt")).unwrap(); + std::fs::write(d.join("meson.build"), "project('libbar', 'c')\n").unwrap(); assert_eq!(project_name(&d).as_deref(), Some("libbar")); - - // Variable interpolation → unresolvable. - fs::write(d.join("meson.build"), "project(@NAME@, 'c')\n").unwrap(); - assert_eq!(project_name(&d), None); - - let _ = fs::remove_dir_all(&d); - } - - #[test] - fn discover_stops_at_project_root() { - let d = tmpdir(); - fs::create_dir_all(d.join("a/sub")).unwrap(); - fs::create_dir_all(d.join("b")).unwrap(); - fs::write(d.join("a/CMakeLists.txt"), "project(a)\n").unwrap(); - // sub is add_subdirectory fodder, not a standalone target. - fs::write(d.join("a/sub/CMakeLists.txt"), "project(a_sub)\n").unwrap(); - fs::write(d.join("b/meson.build"), "project('b', 'c')\n").unwrap(); - - let m = discover(&d); - assert_eq!(m.get("a"), Some(&PathBuf::from("a"))); - assert_eq!(m.get("b"), Some(&PathBuf::from("b"))); - assert!(!m.contains_key("a_sub")); - let _ = fs::remove_dir_all(&d); + let _ = std::fs::remove_dir_all(&d); } } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 11963a5..cd9293e 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -15,4 +15,5 @@ path = "src/main.rs" bob-core.workspace = true bob-rust.workspace = true bob-cc.workspace = true +blake3.workspace = true clap.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index da8a0be..4fb339a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -193,18 +193,26 @@ fn resolve_target( eval_cache.resolve_one(repo_root, &name, &attr, &lock_hash) } -fn is_unit(d: &bob_core::Derivation) -> bool { - BACKENDS.iter().any(|b| b.is_unit(d)) +fn is_unit(repo_root: &Path) -> impl Fn(&str, &bob_core::Derivation) -> bool + '_ { + move |path, d| BACKENDS.iter().any(|b| b.is_unit(path, d, repo_root)) } /// Stable identifier for the `is_unit` predicate, mixed into the graph-cache -/// key so adding/removing a backend invalidates cached graphs. -fn predicate_key() -> String { - BACKENDS - .iter() - .map(|b| b.id()) - .collect::>() - .join(",") +/// key so adding/removing a backend invalidates cached graphs. Also folds in +/// `bob.nix` content: cc's `is_unit` keys on the drvPath→src map declared +/// there, and adding a cc unit doesn't move any root drv path, so without +/// this a cached graph from before the addition would be served and the new +/// unit would silently stay a boundary input. +fn predicate_key(repo_root: &Path) -> String { + let mut h = blake3::Hasher::new(); + for b in BACKENDS { + h.update(b.id().as_bytes()); + h.update(b"\0"); + } + if let Ok(b) = std::fs::read(repo_root.join("bob.nix")) { + h.update(&b); + } + h.finalize().to_hex()[..16].to_string() } fn cmd_build(args: BuildArgs) { @@ -246,9 +254,13 @@ fn cmd_build(args: BuildArgs) { } let drv_paths: Vec = resolve_results.iter().map(|r| r.drv_path.clone()).collect(); - let g = - graph::BuildGraph::from_roots_cached(&drv_paths, cache.root(), &predicate_key(), is_unit) - .expect("building graph"); + let g = graph::BuildGraph::from_roots_cached( + &drv_paths, + cache.root(), + &predicate_key(&repo_root), + is_unit(&repo_root), + ) + .expect("building graph"); // Realize any missing source tarballs / build inputs g.realize_inputs().expect("realizing inputs"); @@ -274,7 +286,7 @@ fn cmd_build(args: BuildArgs) { }; let name = BACKENDS .iter() - .find(|b| b.is_unit(&node.drv)) + .find(|b| b.is_unit(drv, &node.drv, &repo_root)) .map(|b| b.unit_name(&node.drv)) .unwrap_or("?".into()); println!("{key} {name} {drv}"); @@ -288,7 +300,9 @@ fn cmd_build(args: BuildArgs) { jobs ); - let result = scheduler::run_parallel(&g, &cache, jobs, BACKENDS, &overrides, &drv_paths); + let result = scheduler::run_parallel( + &g, &cache, jobs, BACKENDS, &repo_root, &overrides, &drv_paths, + ); // Result symlinks + --print-out-paths, one per (root, output) following // nix-build's naming: [-][-], with `-` omitted for @@ -489,7 +503,11 @@ fn cmd_parse_drv(path: &Path) { } fn cmd_graph(roots: &[String]) { - match graph::BuildGraph::from_roots(roots, is_unit) { + // `bob graph` is a debugging aid; if there's no bob.nix above cwd, fall + // back to an empty repo_root so backends that don't need it (rust) still + // classify, and cc units simply won't be recognised. + let repo_root = find_repo_root().unwrap_or_default(); + match graph::BuildGraph::from_roots(roots, is_unit(&repo_root)) { Ok(g) => { println!("units in graph: {}", g.unit_count()); println!("topological order:"); @@ -497,7 +515,7 @@ fn cmd_graph(roots: &[String]) { let node = &g.nodes[drv_path]; let name = BACKENDS .iter() - .find(|b| b.is_unit(&node.drv)) + .find(|b| b.is_unit(drv_path, &node.drv, &repo_root)) .map(|b| b.unit_name(&node.drv)) .unwrap_or("?".into()); let ndeps = node.unit_deps.len(); diff --git a/crates/core/src/backend.rs b/crates/core/src/backend.rs index 4248ea9..6479253 100644 --- a/crates/core/src/backend.rs +++ b/crates/core/src/backend.rs @@ -58,7 +58,12 @@ pub trait Backend: Send + Sync { // ── graph ────────────────────────────────────────────────────────────── /// Is this drv a unit we replay? Everything else becomes a boundary input. - fn is_unit(&self, drv: &Derivation) -> bool; + /// + /// `drv_path` and `repo_root` are provided for backends whose unit set is + /// declared out-of-band (e.g. cc's drvPath→src map in `bob.nix`) rather + /// than via a marker in the drv env. Backends that key purely on + /// `drv.env` ignore both. + fn is_unit(&self, drv_path: &str, drv: &Derivation, repo_root: &Path) -> bool; /// Human-readable name for progress output and error messages. fn unit_name<'a>(&self, drv: &'a Derivation) -> Cow<'a, str>; diff --git a/crates/core/src/graph.rs b/crates/core/src/graph.rs index 6b76613..800b434 100644 --- a/crates/core/src/graph.rs +++ b/crates/core/src/graph.rs @@ -45,7 +45,7 @@ impl BuildGraph { root_drv_paths: &[String], cache_dir: &Path, predicate_key: &str, - is_unit: impl Fn(&Derivation) -> bool, + is_unit: impl Fn(&str, &Derivation) -> bool, ) -> Result { let mut hasher = blake3::Hasher::new(); // bob-core's own package version (Cargo-the-build-system, not the @@ -180,7 +180,7 @@ impl BuildGraph { /// per the supplied predicate; everything else becomes a boundary input. pub fn from_roots( root_drv_paths: &[String], - is_unit: impl Fn(&Derivation) -> bool, + is_unit: impl Fn(&str, &Derivation) -> bool, ) -> Result { let roots: HashSet<&str> = root_drv_paths.iter().map(String::as_str).collect(); let mut nodes: BTreeMap = BTreeMap::new(); @@ -207,7 +207,7 @@ impl BuildGraph { let drv = Derivation::parse(&contents).map_err(|e| format!("parsing {drv_path}: {e}"))?; - if !is_unit(&drv) { + if !is_unit(&drv_path, &drv) { if is_root { return Err(format!("no backend recognises {drv_path} as a build unit")); } diff --git a/crates/core/src/scheduler.rs b/crates/core/src/scheduler.rs index 4b7c1f3..b642df1 100644 --- a/crates/core/src/scheduler.rs +++ b/crates/core/src/scheduler.rs @@ -16,7 +16,7 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::Condvar; use std::sync::Mutex; @@ -95,11 +95,16 @@ impl SharedState { /// `unit_name` / `build_script_hooks` / `output_populated` / `pipeline` come /// from the right place. Precomputed once — `is_unit` is cheap but called /// per-edge for `pipelineable` below. -fn backend_for<'a>(backends: &'a [&'a dyn Backend], drv: &Derivation) -> &'a dyn Backend { +fn backend_for<'a>( + backends: &'a [&'a dyn Backend], + drv_path: &str, + drv: &Derivation, + repo_root: &Path, +) -> &'a dyn Backend { backends .iter() .copied() - .find(|b| b.is_unit(drv)) + .find(|b| b.is_unit(drv_path, drv, repo_root)) // from_roots() only admits units some backend claimed, so this is // unreachable for graph nodes. Fall back to the first backend rather // than panic so a future caller passing a non-unit drv degrades. @@ -111,6 +116,7 @@ pub fn run_parallel( cache: &ArtifactCache, jobs: usize, backends: &[&dyn Backend], + repo_root: &Path, overrides: &HashMap, roots: &[String], ) -> SchedulerResult { @@ -122,7 +128,7 @@ pub fn run_parallel( let backend_of: HashMap<&str, &dyn Backend> = graph .nodes .iter() - .map(|(k, n)| (k.as_str(), backend_for(backends, &n.drv))) + .map(|(k, n)| (k.as_str(), backend_for(backends, k, &n.drv, repo_root))) .collect(); // Worker pool config from any unit's drv — they all share stdenv/builder. diff --git a/crates/rust/src/lib.rs b/crates/rust/src/lib.rs index 2fbe51b..60a5c7e 100644 --- a/crates/rust/src/lib.rs +++ b/crates/rust/src/lib.rs @@ -20,7 +20,7 @@ impl Backend for RustBackend { "rust" } - fn is_unit(&self, drv: &Derivation) -> bool { + fn is_unit(&self, _drv_path: &str, drv: &Derivation, _repo_root: &Path) -> bool { drv.env.contains_key("crateName") } diff --git a/lib/cc.nix b/lib/cc.nix index 896d862..2007dd9 100644 --- a/lib/cc.nix +++ b/lib/cc.nix @@ -1,40 +1,33 @@ -# Mark a stdenv derivation as a bob cc unit. +# Declare bob cc units without perturbing their drv hash. # -# `bobCcSrc` is the *only* contract between bob.nix and the cc backend: -# - presence makes `is_unit` true (the drv is replayed, not realised), -# - the value is the live source dir (relative to repo root) that bob hashes -# for change detection and overrides `$src` to at build time. +# `unit` attaches `bobCcSrc` as a *Nix-level* attribute (`drv // { … }`), not +# via `overrideAttrs`, so `drvPath` is unchanged. That's the whole point: the +# drv referenced by other consumers (e.g. a Rust cdylib's `buildInputs`) and +# the one under `cc.` are the same store path, so bob's graph walk +# from a Rust root finds it as a unit and the C-edit cascade reaches the +# `.so` without any overlay plumbing. # -# `__structuredAttrs` is forced on so the attr survives into `.attrs.json` -# (the executor's structured-attrs path is also the only one that rewrites -# `src` to the live dir generically). +# bob's cc backend evaluates +# +# builtins.mapAttrs (_: v: { drv = v.drvPath; src = v.bobCcSrc; }) +# ((import bob.nix {}).cc or {}) +# +# once (cached on bob.nix content) and uses the resulting drvPath→src map +# for `is_unit` and source-change tracking. Nothing reaches the drv env. # # Usage in a repo's bob.nix: # # let bobCc = import "${bob}/lib/cc.nix"; in # { -# workspaceMembers = …; # rust backend +# workspaceMembers = …; # cc = bobCc.units { -# libnrt = { drv = neuron.libnrt; src = "extra-code/b16/aws-neuron-runtime"; }; +# ndl = { drv = neuron.ndl; src = "extra-code/b16/aws-neuron-kmdlib"; }; # }; # } -# -# `bob build libnrt` then resolves to `cc.libnrt`. let - unit = - src: drv: - drv.overrideAttrs (old: { - __structuredAttrs = true; - bobCcSrc = src; - # Replayed builds skip unpack/patch and point cmake/meson at the live - # tree; in-sandbox `nix build` of the same drv must still work, so leave - # the original phases intact — bob's hook sets dontUnpack/dontPatch at - # replay time only. - }); + unit = src: drv: drv // { bobCcSrc = src; }; in { inherit unit; - - # Convenience: `{ name = { drv, src }; … }` → `{ name = unit src drv; … }`. units = builtins.mapAttrs (_: v: unit v.src v.drv); }