Skip to content

Commit abf1f5b

Browse files
andrewgazelkacodex
andauthored
cargo-unit: split doctests into derivations (#143)
## Summary - expose Rust doctests from Cargo Unit as target-level Nix checks - enumerate doctests with nightly rustdoc JSON output and run each listed case in its own derivation - add fixture coverage that forces both aggregate and per-doctest derivations ## Checks - `cargo fmt -p nix-cargo-unit` - `cargo check -p nix-cargo-unit` - `nix build .#checks.x86_64-linux.eval --print-build-logs` - `nix run .#lint` Co-authored-by: Codex <codex@openai.com>
1 parent 2f71210 commit abf1f5b

5 files changed

Lines changed: 306 additions & 1 deletion

File tree

packages/nix-cargo-unit/src/model.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,13 @@ impl Unit {
569569
self.target.has_library_kind()
570570
}
571571

572+
pub fn has_doctests(&self) -> bool {
573+
self.target.doctest
574+
&& self.is_library()
575+
&& !self.is_external()
576+
&& self.mode != UnitMode::Test
577+
}
578+
572579
pub fn is_custom_build_compile(&self) -> bool {
573580
self.target.has_kind("custom-build") && !self.is_run_custom_build()
574581
}

packages/nix-cargo-unit/src/render.rs

Lines changed: 230 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,9 @@ struct UnitsNixTemplate {
263263
library_entries: String,
264264
benchmark_entries: String,
265265
test_entries: String,
266+
doctest_entries: String,
266267
test_target_entries: String,
268+
doctest_target_entries: String,
267269
benchmark_target_entries: String,
268270
target_set_entries: String,
269271
default_entry: String,
@@ -284,7 +286,9 @@ pub fn render_units_nix(graph: &UnitGraph, options: &RenderOptions) -> Result<St
284286
library_entries: render_root_entries(graph, &prepared, Unit::is_library),
285287
benchmark_entries: render_benchmark_entries(graph, &prepared),
286288
test_entries: render_test_entries(graph, &prepared),
289+
doctest_entries: render_doctest_entries(graph, &prepared),
287290
test_target_entries: render_test_target_entries(graph, &prepared),
291+
doctest_target_entries: render_doctest_target_entries(graph, &prepared)?,
288292
benchmark_target_entries: render_benchmark_target_entries(graph, &prepared),
289293
target_set_entries: render_target_sets(graph, &prepared),
290294
default_entry: render_default_entry(graph, &prepared),
@@ -1313,6 +1317,10 @@ fn nix_indented_string_fragment(value: &str) -> String {
13131317
value.replace("''", "'''").replace("${", "''${")
13141318
}
13151319

1320+
fn nix_indented_string(value: &str) -> String {
1321+
format!("''\n{value}''")
1322+
}
1323+
13161324
#[derive(Default)]
13171325
struct CargoManifestPackageMetadata {
13181326
authors: String,
@@ -2083,17 +2091,20 @@ fn render_target_sets(graph: &UnitGraph, prepared: &PreparedGraph) -> String {
20832091
};
20842092
let test_keys = compute_test_keys(graph, prepared);
20852093
let benchmark_keys = compute_benchmark_keys(graph, prepared);
2094+
let doctest_keys = compute_doctest_keys(graph, prepared);
2095+
let doctest_indexes = doctest_keys.keys().copied().collect::<Vec<_>>();
20862096

20872097
root_sets
20882098
.iter()
20892099
.map(|roots| {
20902100
format!(
2091-
" {{\n roots = [ {} ];\n binaries = {{\n{} }};\n libraries = {{\n{} }};\n benchmarks = {{\n{} }};\n tests = {{\n{} }};\n }}",
2101+
" {{\n roots = [ {} ];\n binaries = {{\n{} }};\n libraries = {{\n{} }};\n benchmarks = {{\n{} }};\n tests = {{\n{} }};\n doctests = {{\n{} }};\n }}",
20922102
render_unit_refs(roots, prepared),
20932103
render_root_entries_for(roots, &graph.units, prepared, Unit::is_bin),
20942104
render_root_entries_for(roots, &graph.units, prepared, Unit::is_library),
20952105
render_benchmark_entries_for(roots, &graph.units, prepared, &benchmark_keys),
20962106
render_test_entries_for(roots, &graph.units, prepared, &test_keys),
2107+
render_doctest_entries_for(&doctest_indexes, &graph.units, &doctest_keys),
20972108
)
20982109
})
20992110
.collect::<Vec<_>>()
@@ -2149,6 +2160,117 @@ fn test_binary_expr(unit: &Unit, prepared: &PreparedGraph, index: usize) -> Stri
21492160
format!("{unit_ref}/bin/{}", unit.target.name)
21502161
}
21512162

2163+
fn render_doctest_command(
2164+
graph: &UnitGraph,
2165+
prepared: &PreparedGraph,
2166+
index: usize,
2167+
mode: DoctestCommandMode,
2168+
) -> Result<String> {
2169+
let unit = &graph.units[index];
2170+
let source = prepared.source_entry(index)?;
2171+
let package_root = source_path_expr(source, &crate_root_for_unit(unit))?;
2172+
let source_path = source_path_expr(source, Path::new(&unit.target.src_path))?;
2173+
let unit_ref = format!("${{units.{}}}", nix_attr(&prepared.names[index]));
2174+
let mut script = String::new();
2175+
2176+
script.push_str("set -euo pipefail\n");
2177+
writeln!(script, "export src=\"${{{}}}\"", prepared.source_ref(index))?;
2178+
writeln!(
2179+
script,
2180+
"export CARGO_MANIFEST_DIR={}",
2181+
shell::double_quote(&package_root)
2182+
)?;
2183+
script.push_str("cd \"$CARGO_MANIFEST_DIR\"\n");
2184+
script.push_str(&cargo_package_exports(unit)?);
2185+
script.push_str("rustdoc_args=( --test -Z unstable-options )\n");
2186+
match mode {
2187+
DoctestCommandMode::List => {
2188+
script.push_str("rustdoc_args+=( --output-format doctest )\n");
2189+
}
2190+
DoctestCommandMode::RunAll => {}
2191+
DoctestCommandMode::RunCase => {
2192+
script
2193+
.push_str("rustdoc_args+=( --test-args \"$TEST_NAME\" --test-args --nocapture )\n");
2194+
}
2195+
}
2196+
push_rustdoc_arg(&mut script, "--crate-name");
2197+
push_rustdoc_arg(&mut script, &unit.target.name.replace('-', "_"));
2198+
push_rustdoc_arg(&mut script, "--edition");
2199+
push_rustdoc_arg(&mut script, &unit.target.edition);
2200+
for feature in &unit.features {
2201+
push_rustdoc_arg(&mut script, "--cfg");
2202+
push_rustdoc_arg(&mut script, &format!("feature=\"{feature}\""));
2203+
}
2204+
if let Some(platform) = &unit.platform {
2205+
push_rustdoc_arg(&mut script, "--target");
2206+
push_rustdoc_arg(&mut script, platform);
2207+
}
2208+
for rustflag in &unit.profile.rustflags {
2209+
push_rustdoc_arg(&mut script, "--doctest-build-arg");
2210+
push_rustdoc_arg(&mut script, rustflag);
2211+
}
2212+
for dep_index in &prepared.transitive_unit_deps[index] {
2213+
let dep = &graph.units[*dep_index];
2214+
if dep.is_bin() {
2215+
continue;
2216+
}
2217+
writeln!(
2218+
script,
2219+
"rustdoc_args+=( -L \"dependency=${{units.{}}}/lib\" )",
2220+
nix_attr(&prepared.names[*dep_index])
2221+
)?;
2222+
}
2223+
writeln!(script, "rustdoc_args+=( -L \"dependency={unit_ref}/lib\" )")?;
2224+
writeln!(
2225+
script,
2226+
"rustdoc_args+=( --extern \"{}=$(cat {unit_ref}/nix-support/extern-path)\" )",
2227+
unit.target.name.replace('-', "_")
2228+
)?;
2229+
for dependency in &unit.dependencies {
2230+
let dep_unit = &graph.units[dependency.index];
2231+
if dep_unit.is_run_custom_build() || dep_unit.is_bin() {
2232+
continue;
2233+
}
2234+
writeln!(
2235+
script,
2236+
"rustdoc_args+=( --extern \"{}=$(cat ${{units.{}}}/nix-support/extern-path)\" )",
2237+
dependency.extern_crate_name,
2238+
nix_attr(&prepared.names[dependency.index])
2239+
)?;
2240+
}
2241+
writeln!(
2242+
script,
2243+
"rustdoc_args+=( {} )",
2244+
shell::double_quote(&source_path)
2245+
)?;
2246+
script.push_str("set -x\n");
2247+
match mode {
2248+
DoctestCommandMode::RunCase => {
2249+
script.push_str("doctest_log=$(mktemp)\n");
2250+
script.push_str("rustdoc \"''${rustdoc_args[@]}\" 2>&1 | tee \"$doctest_log\"\n");
2251+
script.push_str("if grep -q 'running 0 tests' \"$doctest_log\"; then\n");
2252+
script.push_str(" echo \"rustdoc filter did not run a doctest: $TEST_NAME\" >&2\n");
2253+
script.push_str(" exit 1\n");
2254+
script.push_str("fi\n");
2255+
}
2256+
DoctestCommandMode::List | DoctestCommandMode::RunAll => {
2257+
script.push_str("rustdoc \"''${rustdoc_args[@]}\"\n");
2258+
}
2259+
}
2260+
Ok(script)
2261+
}
2262+
2263+
#[derive(Clone, Copy)]
2264+
enum DoctestCommandMode {
2265+
List,
2266+
RunAll,
2267+
RunCase,
2268+
}
2269+
2270+
fn push_rustdoc_arg(script: &mut String, value: &str) {
2271+
let _ = writeln!(script, "rustdoc_args+=( {} )", shell::quote(value));
2272+
}
2273+
21522274
fn render_benchmark_entries_for(
21532275
roots: &[usize],
21542276
units: &[Unit],
@@ -2222,6 +2344,73 @@ fn render_test_entries(graph: &UnitGraph, prepared: &PreparedGraph) -> String {
22222344
render_test_entries_for(&graph.roots, &graph.units, prepared, &keys)
22232345
}
22242346

2347+
fn compute_doctest_keys(graph: &UnitGraph, prepared: &PreparedGraph) -> BTreeMap<usize, String> {
2348+
let indexes: Vec<usize> = graph
2349+
.units
2350+
.iter()
2351+
.enumerate()
2352+
.filter_map(|(index, unit)| unit.has_doctests().then_some(index))
2353+
.collect();
2354+
2355+
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
2356+
for index in &indexes {
2357+
*counts
2358+
.entry(graph.units[*index].target.name.as_str())
2359+
.or_insert(0) += 1;
2360+
}
2361+
2362+
let mut keys = BTreeMap::new();
2363+
for index in indexes {
2364+
let unit = &graph.units[index];
2365+
let key = if counts[unit.target.name.as_str()] == 1 {
2366+
unit.target.name.clone()
2367+
} else {
2368+
prepared.names[index].clone()
2369+
};
2370+
keys.insert(index, key);
2371+
}
2372+
keys
2373+
}
2374+
2375+
fn render_doctest_entries_for(
2376+
roots: &[usize],
2377+
units: &[Unit],
2378+
keys: &BTreeMap<usize, String>,
2379+
) -> String {
2380+
let mut entries = String::new();
2381+
let mut seen = BTreeSet::new();
2382+
for index in roots {
2383+
let unit = &units[*index];
2384+
if !unit.has_doctests() {
2385+
continue;
2386+
}
2387+
let key = keys
2388+
.get(index)
2389+
.expect("compute_doctest_keys covers every root doctest unit")
2390+
.clone();
2391+
if !seen.insert(key.clone()) {
2392+
continue;
2393+
}
2394+
let _ = writeln!(
2395+
entries,
2396+
" {} = mkDoctestEntry (builtins.head (builtins.filter (target: target.name == {}) doctestTargets));",
2397+
nix_attr(&key),
2398+
nix_attr(&key),
2399+
);
2400+
}
2401+
2402+
entries
2403+
}
2404+
2405+
fn render_doctest_entries(graph: &UnitGraph, prepared: &PreparedGraph) -> String {
2406+
let keys = compute_doctest_keys(graph, prepared);
2407+
render_doctest_entries_for(
2408+
&keys.keys().copied().collect::<Vec<_>>(),
2409+
&graph.units,
2410+
&keys,
2411+
)
2412+
}
2413+
22252414
/// One `{ name; binary; }` per unique test target across every root set.
22262415
/// The template feeds this into a single manifest derivation so test
22272416
/// enumeration is one IFD instead of one per binary.
@@ -2261,6 +2450,46 @@ fn render_test_target_entries(graph: &UnitGraph, prepared: &PreparedGraph) -> St
22612450
entries
22622451
}
22632452

2453+
fn render_doctest_target_entries(graph: &UnitGraph, prepared: &PreparedGraph) -> Result<String> {
2454+
let keys = compute_doctest_keys(graph, prepared);
2455+
let mut by_key: BTreeMap<String, String> = BTreeMap::new();
2456+
for (&index, key) in &keys {
2457+
if by_key.contains_key(key) {
2458+
continue;
2459+
}
2460+
by_key.insert(
2461+
key.clone(),
2462+
format!(
2463+
"{{ name = {}; listCommand = {}; allCommand = {}; runCommand = {}; }}",
2464+
nix_attr(key),
2465+
nix_indented_string(&render_doctest_command(
2466+
graph,
2467+
prepared,
2468+
index,
2469+
DoctestCommandMode::List,
2470+
)?),
2471+
nix_indented_string(&render_doctest_command(
2472+
graph,
2473+
prepared,
2474+
index,
2475+
DoctestCommandMode::RunAll,
2476+
)?),
2477+
nix_indented_string(&render_doctest_command(
2478+
graph,
2479+
prepared,
2480+
index,
2481+
DoctestCommandMode::RunCase,
2482+
)?),
2483+
),
2484+
);
2485+
}
2486+
let mut entries = String::new();
2487+
for (_key, target) in by_key {
2488+
let _ = writeln!(entries, " {target}");
2489+
}
2490+
Ok(entries)
2491+
}
2492+
22642493
/// One `{ name; binary; }` per unique benchmark target across every root set.
22652494
/// The template feeds this into benchmark plans and previous-vs-next Tango
22662495
/// comparisons without another Cargo metadata pass.

packages/nix-cargo-unit/templates/units.nix.askama

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ let
116116
testTargets = [
117117
{{ test_target_entries }} ];
118118

119+
doctestTargets = [
120+
{{ doctest_target_entries }} ];
121+
119122
benchmarkTargets = [
120123
{{ benchmark_target_entries }} ];
121124

@@ -568,6 +571,55 @@ let
568571
);
569572
};
570573

574+
parseDoctestList = path:
575+
map (test: test.name) (
576+
builtins.filter
577+
(test:
578+
test.doctest_attributes.rust or true
579+
&& ((test.doctest_attributes.ignore or "None") == "None" || includeIgnored)
580+
)
581+
(builtins.fromJSON (builtins.unsafeDiscardStringContext (builtins.readFile path))).doctests
582+
);
583+
584+
doctestManifestDrv =
585+
if doctestTargets == [] then null
586+
else pkgs.runCommand "cargo-unit-doctest-manifest" { nativeBuildInputs = [ rustToolchain ]; } (
587+
''
588+
mkdir -p "$out"
589+
''
590+
+ pkgs.lib.concatMapStrings (target: ''
591+
(
592+
${target.listCommand}
593+
) > "$out"/${pkgs.lib.escapeShellArg target.name}.json
594+
'') doctestTargets
595+
);
596+
597+
mkDoctestCases =
598+
{ name, runCommand }:
599+
pkgs.lib.listToAttrs (
600+
map (testName: {
601+
name = testName;
602+
value =
603+
pkgs.runCommand
604+
"cargo-unit-doctest-${name}-${sanitizeTestName testName}"
605+
{ TEST_NAME = testName; nativeBuildInputs = [ rustToolchain ]; }
606+
''
607+
${runCommand}
608+
mkdir -p "$out"
609+
'';
610+
}) (parseDoctestList "${doctestManifestDrv}/${name}.json")
611+
);
612+
613+
mkDoctestEntry =
614+
{ name, allCommand, runCommand, ... }:
615+
{
616+
all = pkgs.runCommand "cargo-unit-doctest-${name}" { nativeBuildInputs = [ rustToolchain ]; } ''
617+
${allCommand}
618+
mkdir -p "$out"
619+
'';
620+
cases = mkDoctestCases { inherit name runCommand; };
621+
};
622+
571623
policyChecks = {
572624
{{ policy_check_entries }} } // extraPolicyChecks;
573625
withPolicyChecks = package:
@@ -599,6 +651,8 @@ in
599651
{{ benchmark_entries }} };
600652
tests = {
601653
{{ test_entries }} };
654+
doctests = {
655+
{{ doctest_entries }} };
602656
compareTangoBenchmarks = mkTangoBenchmarkComparison;
603657
targetSets = [
604658
{{ target_set_entries }}

0 commit comments

Comments
 (0)