Skip to main content

hydro_lang/compile/trybuild/
generate.rs

1use std::fs::{self, File};
2use std::io::{Read, Seek, SeekFrom, Write};
3use std::path::{Path, PathBuf};
4
5#[cfg(any(feature = "deploy", feature = "maelstrom"))]
6use dfir_lang::diagnostic::Diagnostics;
7#[cfg(any(feature = "deploy", feature = "maelstrom"))]
8use dfir_lang::graph::DfirGraph;
9use sha2::{Digest, Sha256};
10#[cfg(any(feature = "deploy", feature = "maelstrom"))]
11use stageleft::internal::quote;
12use trybuild_internals_api::cargo::{self, Metadata};
13use trybuild_internals_api::env::Update;
14use trybuild_internals_api::run::{PathDependency, Project};
15use trybuild_internals_api::{Runner, dependencies, features, path};
16
17pub const HYDRO_RUNTIME_FEATURES: &[&str] = &[
18    "deploy_integration",
19    "runtime_measure",
20    "docker_runtime",
21    "ecs_runtime",
22    "maelstrom_runtime",
23    "sim_runtime",
24];
25
26#[cfg(any(feature = "deploy", feature = "maelstrom"))]
27/// Whether to use dynamic linking for the generated binary.
28/// - `Static`: Place in base crate examples (for remote/containerized deploys)
29/// - `Dynamic`: Place in dylib crate examples (for sim and localhost deploys)
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LinkingMode {
32    // `Static` is only constructed by the deploy backends; Maelstrom-only builds
33    // always use `Dynamic`.
34    #[cfg_attr(
35        not(feature = "deploy"),
36        expect(
37            dead_code,
38            reason = "only constructed by the deploy backends; Maelstrom-only builds use Dynamic"
39        )
40    )]
41    Static,
42    #[cfg(any(feature = "deploy", feature = "maelstrom"))]
43    Dynamic,
44}
45
46#[cfg(any(feature = "deploy", feature = "maelstrom"))]
47/// The deployment mode for code generation.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum DeployMode {
50    #[cfg(feature = "deploy")]
51    /// Standard HydroDeploy
52    HydroDeploy,
53    #[cfg(any(feature = "docker_deploy", feature = "ecs_deploy"))]
54    /// Containerized deployment (Docker/ECS)
55    Containerized,
56    #[cfg(feature = "maelstrom")]
57    /// Maelstrom deployment with stdin/stdout JSON protocol
58    Maelstrom,
59}
60
61pub(crate) static IS_TEST: std::sync::atomic::AtomicBool =
62    std::sync::atomic::AtomicBool::new(false);
63
64pub(crate) static CONCURRENT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
65
66/// Enables "test mode" for Hydro, which makes it possible to compile Hydro programs written
67/// inside a `#[cfg(test)]` module. This should be enabled in a global [`ctor`] hook.
68///
69/// # Example
70/// ```ignore
71/// #[cfg(test)]
72/// mod test_init {
73///    #[ctor::ctor]
74///    fn init() {
75///        hydro_lang::compile::init_test();
76///    }
77/// }
78/// ```
79pub fn init_test() {
80    IS_TEST.store(true, std::sync::atomic::Ordering::Relaxed);
81}
82
83#[cfg(any(feature = "deploy", feature = "maelstrom"))]
84fn clean_bin_name_prefix(bin_name_prefix: &str) -> String {
85    bin_name_prefix
86        .replace("::", "__")
87        .replace(" ", "_")
88        .replace(",", "_")
89        .replace("<", "_")
90        .replace(">", "")
91        .replace("(", "")
92        .replace(")", "")
93        .replace("{", "_")
94        .replace("}", "_")
95}
96
97#[derive(Debug, Clone)]
98pub struct TrybuildConfig {
99    pub project_dir: PathBuf,
100    pub target_dir: PathBuf,
101    pub features: Option<Vec<String>>,
102    #[cfg(any(feature = "deploy", feature = "maelstrom"))]
103    // Only the deploy backends read this field; Maelstrom-only builds derive the
104    // linking behavior directly.
105    #[cfg_attr(
106        not(feature = "deploy"),
107        expect(dead_code, reason = "only read by the deploy backends")
108    )]
109    /// Which crate within the workspace to use for examples.
110    /// - `Static`: base crate (for remote/containerized deploys)
111    /// - `Dynamic`: dylib-examples crate (for sim and localhost deploys)
112    pub linking_mode: LinkingMode,
113}
114
115#[cfg(any(feature = "deploy", feature = "maelstrom"))]
116pub fn create_graph_trybuild(
117    graph: DfirGraph,
118    extra_stmts: &[syn::Stmt],
119    sidecars: &[syn::Expr],
120    bin_name_prefix: Option<&str>,
121    deploy_mode: DeployMode,
122    linking_mode: LinkingMode,
123) -> (String, TrybuildConfig) {
124    let source_dir = cargo::manifest_dir().unwrap();
125    let source_manifest = dependencies::get_manifest(&source_dir).unwrap();
126    let crate_name = source_manifest.package.name.replace("-", "_");
127
128    let is_test = IS_TEST.load(std::sync::atomic::Ordering::Relaxed);
129
130    let generated_code = {
131        let _span = tracing::debug_span!(target: "hydro_build", "graph_codegen").entered();
132        compile_graph_trybuild(graph, extra_stmts, sidecars, &crate_name, deploy_mode)
133    };
134
135    let source = {
136        let _span = tracing::debug_span!(target: "hydro_build", "unparse_source").entered();
137        prettyplease::unparse(&generated_code)
138    };
139
140    let hash = format!("{:X}", Sha256::digest(&source))
141        .chars()
142        .take(8)
143        .collect::<String>();
144
145    let bin_name = if let Some(bin_name_prefix) = &bin_name_prefix {
146        format!("{}_{}", clean_bin_name_prefix(bin_name_prefix), &hash)
147    } else {
148        hash
149    };
150
151    let (project_dir, target_dir, mut cur_bin_enabled_features) = create_trybuild().unwrap();
152
153    // Determine which crate's examples folder to use based on linking mode
154    let examples_dir = match linking_mode {
155        LinkingMode::Static => path!(project_dir / "examples"),
156        #[cfg(any(feature = "deploy", feature = "maelstrom"))]
157        LinkingMode::Dynamic => path!(project_dir / "dylib-examples" / "examples"),
158    };
159
160    // TODO(shadaj): garbage collect this directory occasionally
161    fs::create_dir_all(&examples_dir).unwrap();
162
163    let out_path = path!(examples_dir / format!("{bin_name}.rs"));
164    {
165        let _span =
166            tracing::debug_span!(target: "hydro_build", "write_generated_sources").entered();
167        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
168        write_atomic(source.as_ref(), &out_path).unwrap();
169    }
170
171    if is_test {
172        write_staged_source_cached(source_dir.as_ref(), &crate_name, &project_dir);
173    }
174
175    if is_test {
176        if cur_bin_enabled_features.is_none() {
177            cur_bin_enabled_features = Some(vec![]);
178        }
179
180        cur_bin_enabled_features
181            .as_mut()
182            .unwrap()
183            .push("hydro___test".to_owned());
184    }
185
186    (
187        bin_name,
188        TrybuildConfig {
189            project_dir,
190            target_dir,
191            features: cur_bin_enabled_features,
192            #[cfg(any(feature = "deploy", feature = "maelstrom"))]
193            linking_mode,
194        },
195    )
196}
197
198#[cfg(any(feature = "deploy", feature = "maelstrom"))]
199pub fn compile_graph_trybuild(
200    partitioned_graph: DfirGraph,
201    extra_stmts: &[syn::Stmt],
202    sidecars: &[syn::Expr],
203    crate_name: &str,
204    deploy_mode: DeployMode,
205) -> syn::File {
206    use crate::staging_util::get_this_crate;
207
208    let mut diagnostics = Diagnostics::new();
209    let dfir_expr: syn::Expr = syn::parse2(
210        partitioned_graph
211            .as_code(&quote! { __root_dfir_rs }, true, quote!(), &mut diagnostics)
212            .expect("DFIR code generation failed with diagnostics."),
213    )
214    .unwrap();
215
216    let orig_crate_name = quote::format_ident!("{}", crate_name);
217    let trybuild_crate_name_ident = quote::format_ident!("{}_hydro_trybuild", crate_name);
218    let root = get_this_crate();
219    let tokio_main_ident = format!("{}::runtime_support::tokio", root);
220    let dfir_ident = quote::format_ident!("{}", crate::compile::DFIR_IDENT);
221
222    let source_ast: syn::File = match deploy_mode {
223        #[cfg(any(feature = "docker_deploy", feature = "ecs_deploy"))]
224        DeployMode::Containerized => {
225            syn::parse_quote! {
226                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
227                use #trybuild_crate_name_ident::__root as #orig_crate_name;
228                use #orig_crate_name::*;
229                use #orig_crate_name::__staged::__deps::*;
230                use #root::prelude::*;
231                use #root::runtime_support::dfir_rs as __root_dfir_rs;
232                pub use #orig_crate_name::__staged;
233
234                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
235                async fn main() {
236                    #root::telemetry::initialize_tracing();
237
238                    #( #extra_stmts )*
239
240                    let mut #dfir_ident = #dfir_expr;
241
242                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
243                    #(
244                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
245                    )*
246
247                    let _ = local_set.run_until(#dfir_ident.run()).await;
248                }
249            }
250        }
251        #[cfg(feature = "deploy")]
252        DeployMode::HydroDeploy => {
253            syn::parse_quote! {
254                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
255                use #trybuild_crate_name_ident::__root as #orig_crate_name;
256                use #orig_crate_name::*;
257                use #orig_crate_name::__staged::__deps::*;
258                use #root::prelude::*;
259                use #root::runtime_support::dfir_rs as __root_dfir_rs;
260                pub use #orig_crate_name::__staged;
261
262                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
263                async fn main() {
264                    let __hydro_lang_trybuild_cli_owned: #root::runtime_support::hydro_deploy_integration::DeployPorts<#root::__staged::deploy::deploy_runtime::HydroMeta> = #root::runtime_support::launch::init_no_ack_start().await;
265                    let __hydro_lang_trybuild_cli = &__hydro_lang_trybuild_cli_owned;
266
267                    #( #extra_stmts )*
268
269                    let mut #dfir_ident = #dfir_expr;
270                    println!("ack start");
271
272                    // TODO(mingwei): initialize `tracing` at this point in execution.
273                    // After "ack start" is when we can print whatever we want.
274
275                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
276                    #(
277                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
278                    )*
279
280                    let _ = local_set.run_until(#root::runtime_support::launch::run_stdin_commands(
281                        async move {
282                            #dfir_ident.run().await
283                        }
284                    )).await;
285                }
286            }
287        }
288        #[cfg(feature = "maelstrom")]
289        DeployMode::Maelstrom => {
290            syn::parse_quote! {
291                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
292                use #trybuild_crate_name_ident::__root as #orig_crate_name;
293                use #orig_crate_name::*;
294                use #orig_crate_name::__staged::__deps::*;
295                use #root::prelude::*;
296                use #root::runtime_support::dfir_rs as __root_dfir_rs;
297                pub use #orig_crate_name::__staged;
298
299                #[allow(unused)]
300                fn __hydro_runtime<'a>(
301                    __hydro_lang_maelstrom_meta: &'a #root::__staged::deploy::maelstrom::deploy_runtime_maelstrom::MaelstromMeta
302                )
303                    -> #root::runtime_support::dfir_rs::scheduled::context::Dfir<impl #root::runtime_support::dfir_rs::scheduled::context::TickClosure + 'a>
304                {
305                    #( #extra_stmts )*
306
307                    #dfir_expr
308                }
309
310                #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
311                async fn main() {
312                    #root::telemetry::initialize_tracing();
313
314                    // Initialize Maelstrom protocol - read init message and send init_ok
315                    let __hydro_lang_maelstrom_meta = #root::__staged::deploy::maelstrom::deploy_runtime_maelstrom::maelstrom_init();
316
317                    let mut #dfir_ident = __hydro_runtime(&__hydro_lang_maelstrom_meta);
318
319                    __hydro_lang_maelstrom_meta.start_receiving(); // start receiving messages after initializing subscribers
320
321                    let local_set = #root::runtime_support::tokio::task::LocalSet::new();
322                    #(
323                        let _ = local_set.spawn_local( #sidecars ); // Uses #dfir_ident
324                    )*
325
326                    let _ = local_set.run_until(#dfir_ident.run()).await;
327                }
328            }
329        }
330    };
331    source_ast
332}
333
334/// Configuration for [`compile_trybuild_example`], the shared concurrent-build
335/// entrypoint used by both the simulator and the Maelstrom deployment target.
336#[cfg(any(feature = "sim", feature = "maelstrom"))]
337pub struct ExampleBuildConfig<'a> {
338    /// The trybuild project + target directories and enabled features.
339    pub trybuild: TrybuildConfig,
340    /// The generated example base name (a content hash). Used as the per-job
341    /// directory name and, when [`Self::set_trybuild_lib_name`] is set, as the
342    /// value of the `TRYBUILD_LIB_NAME` environment variable.
343    pub bin_name: String,
344    /// A runtime feature to enable in addition to [`TrybuildConfig::features`]
345    /// (e.g. `hydro___feature_sim_runtime` or `hydro___feature_maelstrom_runtime`).
346    pub runtime_feature: &'a str,
347    /// The cargo `--example` target to build. For the simulator this is the
348    /// fixed `sim-dylib` wrapper; for Maelstrom it is the generated `bin_name`.
349    pub example_name: String,
350    /// If `Some`, override the crate type on the command line (e.g. `cdylib`
351    /// for the simulator). `None` builds a normal executable example.
352    pub crate_type: Option<&'a str>,
353    /// Whether to set `TRYBUILD_LIB_NAME` to `bin_name` (the simulator uses this
354    /// for its `include!`-based indirection).
355    pub set_trybuild_lib_name: bool,
356    /// Whether to honor the `BOLERO_FUZZER` environment variable. Only the
357    /// simulator supports fuzzing; other targets should set this to `false`.
358    pub allow_fuzz: bool,
359}
360
361/// Returns the toolchain's target libdir (where the shared `libstd` lives), memoized.
362#[cfg(any(feature = "sim", feature = "maelstrom"))]
363fn rustc_target_libdir() -> Option<String> {
364    static LIBDIR: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
365    LIBDIR
366        .get_or_init(|| {
367            let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
368            std::process::Command::new(rustc)
369                .args(["--print", "target-libdir"])
370                .output()
371                .ok()
372                .filter(|out| out.status.success())
373                .map(|out| String::from_utf8(out.stdout).unwrap().trim().to_owned())
374        })
375        .clone()
376}
377
378/// Compiles a generated trybuild example against the prebuilt dylib crate,
379/// using the shared parallel-compilation machinery (per-job target dirs with
380/// symlinked shared artifacts, plus a prebuild of the dylib dependencies).
381///
382/// Returns the path to a temporary copy of the built artifact (a `cdylib` for
383/// the simulator, or an executable for Maelstrom). The copy allows the caller
384/// to hold onto the artifact independently of the shared target directory.
385#[cfg(any(feature = "sim", feature = "maelstrom"))]
386pub fn compile_trybuild_example(config: ExampleBuildConfig<'_>) -> Result<tempfile::TempPath, ()> {
387    use std::process::{Command, Stdio};
388
389    let ExampleBuildConfig {
390        trybuild,
391        bin_name,
392        runtime_feature,
393        example_name,
394        crate_type,
395        set_trybuild_lib_name,
396        allow_fuzz,
397    } = config;
398
399    let is_fuzz = allow_fuzz && std::env::var("BOLERO_FUZZER").is_ok();
400    // When RUSTFLAGS is set, our prebuild fingerprint doesn't account for it, so skip the
401    // parallel build machinery entirely and build directly into the shared target dir.
402    let has_custom_rustflags = std::env::var("RUSTFLAGS").is_ok();
403
404    // Run from dylib-examples crate which has the dylib as a dev-dependency (only if not fuzzing)
405    let crate_to_compile = if is_fuzz {
406        trybuild.project_dir.clone()
407    } else {
408        path!(trybuild.project_dir / "dylib-examples")
409    };
410
411    let (final_target_dir, _prebuild_guard, _cargo_lock) = if !has_custom_rustflags {
412        let prebuild_span =
413            tracing::debug_span!(target: "hydro_build", "prebuild", bin_name = %bin_name).entered();
414        let shared_debug = trybuild.target_dir.join("debug");
415        let jobs_dir = trybuild.target_dir.join("jobs");
416        let per_job = hydro_concurrent_cargo::setup_job_dir(&jobs_dir, &bin_name, &shared_debug);
417
418        let mut features: Vec<String> = trybuild.features.clone().unwrap_or_default();
419        features.push(runtime_feature.to_owned());
420
421        let staged_paths = vec![
422            path!(trybuild.project_dir / "src" / "__staged.rs"),
423            path!(trybuild.project_dir / "Cargo.lock"),
424            std::env::current_exe().unwrap(),
425        ];
426
427        let project_dir = trybuild.project_dir.clone();
428        let features_for_closure = features.clone();
429        let is_fuzz_for_closure = is_fuzz;
430
431        let (guard, cargo_lock) = hydro_concurrent_cargo::run_prebuild(
432            &trybuild.target_dir,
433            trybuild.project_dir.file_name().unwrap().to_str().unwrap(),
434            &features,
435            &staged_paths,
436            |prebuild_target| {
437                let features_str = features_for_closure.join(",");
438
439                // Prebuild the lib that final builds will link against, which transitively
440                // builds the trybuild dylib *as a dependency*. This matters: cargo passes
441                // `-C prefer-dynamic` to dylib crates when they are built as dependencies
442                // (linking libstd dynamically), but *not* when they are the primary build
443                // target — and the two variants share a cargo fingerprint, so whichever is
444                // built first wins. Building the dylib as a primary target would poison the
445                // cache with a statically-linked-std variant that later fails to link into
446                // examples ("cannot satisfy dependencies so `std` only shows up once").
447                //
448                // In fuzz mode, examples are compiled from the base trybuild crate directly
449                // (no dylib-examples), so prebuild the base crate's lib instead.
450                let prebuild_crate = if is_fuzz_for_closure {
451                    project_dir.clone()
452                } else {
453                    path!(project_dir / "dylib-examples")
454                };
455                let mut lib_cmd = Command::new("cargo");
456                lib_cmd.current_dir(&prebuild_crate);
457                lib_cmd.args(["build", "--locked", "--lib"]);
458                lib_cmd.args(["--target-dir", prebuild_target.to_str().unwrap()]);
459                lib_cmd.arg("--no-default-features");
460                lib_cmd.args(["--features", &features_str]);
461                lib_cmd.args(["--config", "build.incremental = false"]);
462                lib_cmd.env("STAGELEFT_TRYBUILD_BUILD_STAGED", "1");
463                let status = lib_cmd.stdin(Stdio::null()).status().unwrap();
464                if !status.success() {
465                    panic!("dep prebuild failed");
466                }
467            },
468        );
469
470        // Close the prebuild span before returning the guards: the guards are held for the
471        // entire final build, but the prebuild phase (freshness check + possible dep build)
472        // ends here.
473        drop(prebuild_span);
474        (per_job, Some(guard), Some(cargo_lock))
475    } else {
476        (trybuild.target_dir.clone(), None, None)
477    };
478
479    // Populate per-job build/ dir right before final build. Hold guard for entire build.
480    let _job_build_guard = if !has_custom_rustflags {
481        let populate_span =
482            tracing::debug_span!(target: "hydro_build", "populate_job_dir", bin_name = %bin_name)
483                .entered();
484        let shared_debug = trybuild.target_dir.join("debug");
485        let guard = hydro_concurrent_cargo::populate_job_build_dir(
486            &final_target_dir.join("debug"),
487            &shared_debug,
488        );
489        // Close the populate span here: the returned guard is held until the final build
490        // finishes, but the population work itself ends here.
491        drop(populate_span);
492        Some(guard)
493    } else {
494        None
495    };
496
497    let final_build_span =
498        tracing::debug_span!(target: "hydro_build", "final_build", bin_name = %bin_name).entered();
499    let mut command = Command::new("cargo");
500    command.current_dir(&crate_to_compile);
501    command.args([
502        "rustc",
503        if has_custom_rustflags {
504            "--locked"
505        } else {
506            "--frozen"
507        },
508    ]);
509    command.args(["--example", &example_name]);
510    command.args(["--target-dir", final_target_dir.to_str().unwrap()]);
511    // Never enable default features: the generated example gets exactly the
512    // features it needs via `--features` (plus the runtime feature). This keeps
513    // the feature set minimal and deterministic — matching what the deploy
514    // backends and the base trybuild crate (which carries the source crate's
515    // `default`) would otherwise pull in — and matters for the `is_fuzz` path
516    // that builds from the base crate directly.
517    command.arg("--no-default-features");
518    command.args([
519        "--features",
520        &trybuild
521            .features
522            .clone()
523            .into_iter()
524            .flatten()
525            .chain([runtime_feature.to_owned()])
526            .collect::<Vec<_>>()
527            .join(","),
528    ]);
529    command.args(["--config", "build.incremental = false"]);
530    if let Some(crate_type) = crate_type {
531        command.args(["--crate-type", crate_type]);
532    }
533    command.arg("--message-format=json-diagnostic-rendered-ansi");
534    command.env("STAGELEFT_TRYBUILD_BUILD_STAGED", "1");
535    if set_trybuild_lib_name {
536        command.env("TRYBUILD_LIB_NAME", &bin_name);
537    }
538
539    command.arg("--");
540
541    if cfg!(any(target_os = "linux", target_os = "macos")) {
542        let debug_path = if let Ok(target) = std::env::var("CARGO_BUILD_TARGET") {
543            path!(final_target_dir / target / "debug")
544        } else {
545            path!(final_target_dir / "debug")
546        };
547
548        // The built example links the trybuild dylib dynamically. Bake rpath entries for
549        // where cargo places it (debug/ and debug/deps/) and for the toolchain's shared
550        // libstd, so the artifact can be loaded/run without LD_LIBRARY_PATH.
551        let mut rpaths = vec![debug_path.clone(), path!(debug_path / "deps")];
552        if let Some(libdir) = rustc_target_libdir() {
553            rpaths.push(PathBuf::from(libdir));
554        }
555        for rpath in rpaths {
556            if cfg!(target_os = "macos") {
557                // On macOS rustc may invoke the linker directly (`rust-lld -flavor darwin`),
558                // which rejects `-Wl,`-wrapped arguments. Use raw ld64 syntax (`-rpath <path>`
559                // as two arguments), which the clang driver also forwards to the linker.
560                command.args([
561                    "-Clink-arg=-rpath".to_owned(),
562                    format!("-Clink-arg={}", rpath.to_str().unwrap()),
563                ]);
564            } else {
565                command.args([format!("-Clink-arg=-Wl,-rpath,{}", rpath.to_str().unwrap())]);
566            }
567        }
568
569        if cfg!(all(target_os = "linux", target_env = "gnu")) {
570            command.arg(
571                // https://github.com/rust-lang/rust/issues/91979
572                "-Clink-args=-Wl,-z,nodelete",
573            );
574        }
575    }
576
577    if allow_fuzz && let Ok(fuzzer) = std::env::var("BOLERO_FUZZER") {
578        command.env_remove("BOLERO_FUZZER");
579
580        if fuzzer == "libfuzzer" {
581            #[cfg(target_os = "macos")]
582            {
583                command.args(["-Clink-arg=-undefined", "-Clink-arg=dynamic_lookup"]);
584            }
585
586            #[cfg(target_os = "linux")]
587            {
588                command.args(["-Clink-arg=-Wl,--unresolved-symbols=ignore-all"]);
589            }
590        }
591    }
592
593    tracing::debug!(
594        target: "hydro_build",
595        "final build command (cwd={}): {:?}",
596        crate_to_compile.display(),
597        command
598    );
599
600    let mut spawned = command
601        .stdout(Stdio::piped())
602        .stderr(Stdio::piped())
603        .stdin(Stdio::null())
604        .spawn()
605        .unwrap();
606    let reader = std::io::BufReader::new(spawned.stdout.take().unwrap());
607    let stderr_handle = spawned.stderr.take().unwrap();
608    let stderr_thread = std::thread::spawn(move || {
609        use std::io::Read;
610        let mut buf = String::new();
611        std::io::BufReader::new(stderr_handle)
612            .read_to_string(&mut buf)
613            .unwrap();
614        buf
615    });
616
617    let mut out = Err(());
618    for message in cargo_metadata::Message::parse_stream(reader) {
619        match message.unwrap() {
620            cargo_metadata::Message::CompilerArtifact(artifact) => {
621                // unlike dylib, cdylib only exports the explicitly exported symbols
622                let is_output = artifact.target.is_example();
623
624                if is_output {
625                    let path = artifact.filenames.first().unwrap();
626                    let path_buf: PathBuf = path.clone().into();
627                    out = Ok(path_buf);
628                }
629            }
630            cargo_metadata::Message::CompilerMessage(mut msg) => {
631                // Update the path displayed to enable clicking in IDE.
632                // TODO(mingwei): deduplicate code with hydro_deploy rust_crate/build.rs
633                if let Some(rendered) = msg.message.rendered.as_mut() {
634                    let file_names = msg
635                        .message
636                        .spans
637                        .iter()
638                        .map(|s| &s.file_name)
639                        .collect::<std::collections::BTreeSet<_>>();
640                    for file_name in file_names {
641                        *rendered = rendered.replace(
642                            file_name,
643                            &format!("(full path) {}/{file_name}", trybuild.project_dir.display()),
644                        )
645                    }
646                }
647                eprintln!("{}", msg.message);
648            }
649            cargo_metadata::Message::TextLine(line) => {
650                eprintln!("{}", line);
651            }
652            cargo_metadata::Message::BuildFinished(_) => {}
653            cargo_metadata::Message::BuildScriptExecuted(_) => {}
654            msg => panic!("Unexpected message type: {:?}", msg),
655        }
656    }
657
658    spawned.wait().unwrap();
659    let stderr_output = stderr_thread.join().unwrap();
660    drop(final_build_span);
661
662    // Check for unexpected recompilations — only dylib-examples should be compiled.
663    // (Only relevant when prebuild is active, i.e. no custom RUSTFLAGS.)
664    if !has_custom_rustflags {
665        for line in stderr_output.lines() {
666            if line.contains("Compiling") && !line.contains("dylib-examples") {
667                panic!(
668                    "unexpected recompilation in final build: {line}\nfull stderr:\n{stderr_output}"
669                );
670            }
671        }
672    }
673
674    if out.is_err() {
675        panic!("final build failed to produce binary.\nstderr:\n{stderr_output}");
676    }
677
678    let out_file = tempfile::NamedTempFile::new().unwrap().into_temp_path();
679    fs::copy(out.as_ref().unwrap(), &out_file).unwrap();
680    Ok(out_file)
681}
682
683/// Generates the inlined `__staged.rs` source for the source crate and writes it into the
684/// trybuild project, caching the (expensive) generation across test processes.
685///
686/// The staged source is a pure function of the source crate's files, and cargo rebuilds the
687/// test executable whenever those change, so the identity (path + mtime) of
688/// [`std::env::current_exe`] is a sound freshness proxy. All tests in a run share the same
689/// executable, so only the first test per test binary pays the ~1s `syn` parse +
690/// `prettyplease` unparse; the rest hit the cache. The stamp holds a single entry — the last
691/// executable to write `__staged.rs` — so a different test binary of the same crate
692/// regenerates on its first test (a no-op rewrite when sources are unchanged). Keeping old
693/// entries around would risk a stale match (e.g. an executable restored with an old mtime
694/// after another binary regenerated `__staged.rs` from different sources).
695pub(crate) fn write_staged_source_cached(source_dir: &Path, crate_name: &str, project_dir: &Path) {
696    let _span = tracing::debug_span!(target: "hydro_build", "gen_staged").entered();
697
698    let staged_path = path!(project_dir / "src" / "__staged.rs");
699    let stamp_path = path!(project_dir / ".hydro-staged-stamp");
700
701    let exe_stamp = std::env::current_exe().ok().and_then(|exe| {
702        let mtime = fs::metadata(&exe)
703            .ok()?
704            .modified()
705            .ok()?
706            .duration_since(std::time::SystemTime::UNIX_EPOCH)
707            .ok()?
708            .as_nanos();
709        Some(format!("{}\t{}", exe.display(), mtime))
710    });
711
712    let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
713
714    fs::create_dir_all(path!(project_dir / "src")).unwrap();
715
716    // Hold an exclusive lock on the stamp file for the entire check + generate: when many
717    // test processes start concurrently with a cold cache, the first one generates while the
718    // others block here and then hit the cache, instead of all doing the expensive work.
719    let mut stamp_file = File::options()
720        .read(true)
721        .write(true)
722        .create(true)
723        .truncate(false)
724        .open(&stamp_path)
725        .unwrap();
726    stamp_file.lock().unwrap();
727
728    let mut existing_stamp = String::new();
729    if stamp_file.read_to_string(&mut existing_stamp).is_err() {
730        existing_stamp.clear();
731    }
732
733    if let Some(stamp) = &exe_stamp
734        && existing_stamp == *stamp
735        && staged_path.exists()
736    {
737        return;
738    }
739
740    let raw_toml_manifest = toml::from_str::<toml::Value>(
741        &fs::read_to_string(path!(source_dir / "Cargo.toml")).unwrap(),
742    )
743    .unwrap();
744
745    let maybe_custom_lib_path = raw_toml_manifest
746        .get("lib")
747        .and_then(|lib| lib.get("path"))
748        .and_then(|path| path.as_str());
749
750    let mut gen_staged = stageleft_tool::gen_staged_trybuild(
751        &maybe_custom_lib_path
752            .map(|s| path!(source_dir / s))
753            .unwrap_or_else(|| path!(source_dir / "src" / "lib.rs")),
754        &path!(source_dir / "Cargo.toml"),
755        crate_name,
756        Some("hydro___test".to_owned()),
757    );
758
759    gen_staged.attrs.insert(
760        0,
761        syn::parse_quote! {
762            #![allow(
763                unused,
764                ambiguous_glob_reexports,
765                clippy::suspicious_else_formatting,
766                unexpected_cfgs,
767                reason = "generated code"
768            )]
769        },
770    );
771
772    let inlined_staged = prettyplease::unparse(&gen_staged);
773
774    write_atomic(inlined_staged.as_bytes(), &staged_path).unwrap();
775
776    if let Some(stamp) = exe_stamp {
777        stamp_file.set_len(0).unwrap();
778        stamp_file.seek(SeekFrom::Start(0)).unwrap();
779        stamp_file.write_all(stamp.as_bytes()).unwrap();
780    }
781}
782
783pub fn create_trybuild()
784-> Result<(PathBuf, PathBuf, Option<Vec<String>>), trybuild_internals_api::error::Error> {
785    let _span = tracing::debug_span!(target: "hydro_build", "create_trybuild").entered();
786    let Metadata {
787        target_directory: target_dir,
788        workspace_root: workspace,
789        packages,
790    } = {
791        let _span = tracing::debug_span!(target: "hydro_build", "cargo_metadata").entered();
792        cargo::metadata()?
793    };
794
795    let source_dir = cargo::manifest_dir()?;
796    let mut source_manifest = dependencies::get_manifest(&source_dir)?;
797
798    let mut dev_dependency_features = vec![];
799    source_manifest.dev_dependencies.retain(|k, v| {
800        if source_manifest.dependencies.contains_key(k) {
801            // already a non-dev dependency, so drop the dep and put the features under the test flag
802            for feat in &v.features {
803                dev_dependency_features.push(format!("{}/{}", k, feat));
804            }
805
806            false
807        } else {
808            // only enable this in test mode, so make it optional otherwise
809            dev_dependency_features.push(format!("dep:{k}"));
810
811            v.optional = true;
812            true
813        }
814    });
815
816    // When the example is re-executed from a test binary by `example_test` (signaled via this
817    // env var), skip feature discovery: it would pick up the *test* binary's features (e.g.
818    // test-only harness features). A real `cargo run --example` invocation has no fingerprint
819    // hash in `argv[0]`, so discovery finds nothing there; emulating that here ensures the test
820    // exercises the example the same way it actually runs.
821    let mut features = if std::env::var("RUNNING_AS_EXAMPLE_TEST").is_ok_and(|v| v == "1") {
822        None
823    } else {
824        features::find()
825    };
826
827    let path_dependencies = source_manifest
828        .dependencies
829        .iter()
830        .filter_map(|(name, dep)| {
831            let path = dep.path.as_ref()?;
832            if packages.iter().any(|p| &p.name == name) {
833                // Skip path dependencies coming from the workspace itself
834                None
835            } else {
836                Some(PathDependency {
837                    name: name.clone(),
838                    normalized_path: path.canonicalize().ok()?,
839                })
840            }
841        })
842        .collect();
843
844    let crate_name = source_manifest.package.name.clone();
845    let project_dir = path!(target_dir / "hydro_trybuild" / crate_name /);
846    fs::create_dir_all(&project_dir)?;
847
848    let project_name = format!("{}-hydro-trybuild", crate_name);
849    let mut manifest = Runner::make_manifest(
850        &workspace,
851        &project_name,
852        &source_dir,
853        &packages,
854        &[],
855        source_manifest,
856    )?;
857
858    if let Some(enabled_features) = &mut features {
859        enabled_features
860            .retain(|feature| manifest.features.contains_key(feature) || feature == "default");
861    }
862
863    for runtime_feature in HYDRO_RUNTIME_FEATURES {
864        manifest.features.insert(
865            format!("hydro___feature_{runtime_feature}"),
866            vec![format!("hydro_lang/{runtime_feature}")],
867        );
868    }
869
870    manifest
871        .dependencies
872        .get_mut("hydro_lang")
873        .unwrap()
874        .features
875        .push("runtime_support".to_owned());
876
877    manifest
878        .features
879        .insert("hydro___test".to_owned(), dev_dependency_features);
880
881    if manifest
882        .workspace
883        .as_ref()
884        .is_some_and(|w| w.dependencies.is_empty())
885    {
886        manifest.workspace = None;
887    }
888
889    let project = Project {
890        dir: project_dir,
891        source_dir,
892        target_dir,
893        name: project_name.clone(),
894        update: Update::env()?,
895        has_pass: false,
896        has_compile_fail: false,
897        features,
898        workspace,
899        path_dependencies,
900        manifest,
901        keep_going: false,
902    };
903
904    {
905        let _span = tracing::debug_span!(target: "hydro_build", "write_project_files").entered();
906        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
907
908        let project_lock = File::create(path!(project.dir / ".hydro-trybuild-lock"))?;
909        project_lock.lock()?;
910
911        fs::create_dir_all(path!(project.dir / "src"))?;
912        fs::create_dir_all(path!(project.dir / "examples"))?;
913
914        let crate_name_ident = syn::Ident::new(
915            &crate_name.replace("-", "_"),
916            proc_macro2::Span::call_site(),
917        );
918
919        write_atomic(
920            prettyplease::unparse(&syn::parse_quote! {
921                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
922
923                pub mod __root {
924                    pub use #crate_name_ident::*;
925                    #[cfg(feature = "hydro___test")]
926                    pub use super::__staged;
927                }
928
929                #[cfg(feature = "hydro___test")]
930                pub mod __staged;
931            })
932            .as_bytes(),
933            &path!(project.dir / "src" / "lib.rs"),
934        )
935        .unwrap();
936
937        let base_manifest = toml::to_string(&project.manifest)?;
938
939        // Collect feature names for forwarding to dylib and dylib-examples crates
940        let feature_names: Vec<_> = project.manifest.features.keys().cloned().collect();
941
942        // Create dylib crate directory
943        let dylib_dir = path!(project.dir / "dylib");
944        fs::create_dir_all(path!(dylib_dir / "src"))?;
945
946        let trybuild_crate_name_ident = syn::Ident::new(
947            &project_name.replace("-", "_"),
948            proc_macro2::Span::call_site(),
949        );
950        write_atomic(
951            // The leading comment busts cargo's fingerprint for caches where the dylib was
952            // built as a *primary* target (statically linking libstd); it must be built as a
953            // dependency (with `-C prefer-dynamic`) for examples to link against it. The
954            // linkage variant is not part of cargo's fingerprint, so a content change is
955            // needed to force old caches to rebuild.
956            [
957                "// v2: dylib must be built as a dependency (prefer-dynamic).\n".as_bytes(),
958                prettyplease::unparse(&syn::parse_quote! {
959                    #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
960                    pub use #trybuild_crate_name_ident::*;
961                })
962                .as_bytes(),
963            ]
964            .concat()
965            .as_slice(),
966            &path!(dylib_dir / "src" / "lib.rs"),
967        )?;
968
969        let serialized_edition = toml::to_string(
970            &vec![("edition", &project.manifest.package.edition)]
971                .into_iter()
972                .collect::<std::collections::HashMap<_, _>>(),
973        )
974        .unwrap();
975
976        // Dylib crate Cargo.toml - only dylib crate-type, with feature forwarding to base crate
977        // On Windows, we currently disable dylib compilation due to https://github.com/bevyengine/bevy/pull/2016
978        let dylib_features_section = feature_names
979            .iter()
980            .map(|f| format!("{f} = [\"{project_name}/{f}\"]"))
981            .collect::<Vec<_>>()
982            .join("\n");
983
984        let dylib_manifest = format!(
985            r#"[package]
986name = "{project_name}-dylib"
987version = "0.0.0"
988{}
989
990[lib]
991crate-type = ["{}"]
992
993[dependencies]
994{project_name} = {{ path = "..", default-features = false }}
995
996[features]
997{dylib_features_section}
998"#,
999            serialized_edition,
1000            if cfg!(target_os = "windows") {
1001                "rlib"
1002            } else {
1003                "dylib"
1004            }
1005        );
1006        write_atomic(dylib_manifest.as_ref(), &path!(dylib_dir / "Cargo.toml"))?;
1007
1008        let dylib_examples_dir = path!(project.dir / "dylib-examples");
1009        fs::create_dir_all(path!(dylib_examples_dir / "src"))?;
1010        fs::create_dir_all(path!(dylib_examples_dir / "examples"))?;
1011
1012        write_atomic(
1013            b"#![allow(unused_crate_dependencies)]\n",
1014            &path!(dylib_examples_dir / "src" / "lib.rs"),
1015        )?;
1016
1017        // Build feature forwarding for dylib-examples - forward through the (renamed) dylib crate
1018        let features_section = feature_names
1019            .iter()
1020            .map(|f| format!("{f} = [\"{project_name}/{f}\"]"))
1021            .collect::<Vec<_>>()
1022            .join("\n");
1023
1024        // Dylib-examples crate Cargo.toml - depends *only* on the dylib crate, renamed to the
1025        // base crate's package name so that generated examples referencing
1026        // `{crate}_hydro_trybuild` resolve to the dylib. This is what makes dynamic linking
1027        // actually kick in: if the base crate were also a direct dependency, rustc would
1028        // statically link its rlib (and the entire dependency graph) into every example,
1029        // making the per-example "final compile" link take several seconds. With only the
1030        // dylib in scope, examples link against the prebuilt shared library instead.
1031        //
1032        // The dylib is a regular dependency (not a dev-dependency) so that prebuilding this
1033        // crate's (empty) lib builds the dylib as a dependency, which is required for cargo
1034        // to pass `-C prefer-dynamic` (see the prebuild in `compile_trybuild_example`).
1035        let dylib_examples_manifest = format!(
1036            r#"[package]
1037name = "{project_name}-dylib-examples"
1038version = "0.0.0"
1039{}
1040
1041[dependencies]
1042{project_name} = {{ package = "{project_name}-dylib", path = "../dylib", default-features = false }}
1043
1044[features]
1045{features_section}
1046
1047[[example]]
1048name = "sim-dylib"
1049crate-type = ["cdylib"]
1050"#,
1051            serialized_edition
1052        );
1053        write_atomic(
1054            dylib_examples_manifest.as_ref(),
1055            &path!(dylib_examples_dir / "Cargo.toml"),
1056        )?;
1057
1058        // sim-dylib.rs for the base crate and dylib-examples crate
1059        let sim_dylib_contents = prettyplease::unparse(&syn::parse_quote! {
1060            #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case, unexpected_cfgs, unfulfilled_lint_expectations)]
1061            include!(std::concat!(env!("TRYBUILD_LIB_NAME"), ".rs"));
1062        });
1063        write_atomic(
1064            sim_dylib_contents.as_bytes(),
1065            &path!(project.dir / "examples" / "sim-dylib.rs"),
1066        )?;
1067        write_atomic(
1068            sim_dylib_contents.as_bytes(),
1069            &path!(dylib_examples_dir / "examples" / "sim-dylib.rs"),
1070        )?;
1071
1072        let workspace_manifest = format!(
1073            r#"{}
1074[[example]]
1075name = "sim-dylib"
1076crate-type = ["cdylib"]
1077
1078[workspace]
1079members = ["dylib", "dylib-examples"]
1080"#,
1081            base_manifest,
1082        );
1083
1084        write_atomic(
1085            workspace_manifest.as_ref(),
1086            &path!(project.dir / "Cargo.toml"),
1087        )?;
1088
1089        // Compute hash for cache invalidation, covering all generated manifests (the dylib and
1090        // dylib-examples manifests affect Cargo.lock, so they must participate in the hash)
1091        let manifest_hash = {
1092            let mut hasher = Sha256::new();
1093            hasher.update(&workspace_manifest);
1094            hasher.update(&dylib_manifest);
1095            hasher.update(&dylib_examples_manifest);
1096            format!("{:X}", hasher.finalize())
1097                .chars()
1098                .take(8)
1099                .collect::<String>()
1100        };
1101
1102        let workspace_cargo_lock = path!(project.workspace / "Cargo.lock");
1103        let workspace_cargo_lock_contents_and_hash = if workspace_cargo_lock.exists() {
1104            let cargo_lock_contents = fs::read_to_string(&workspace_cargo_lock)?;
1105
1106            let hash = format!("{:X}", Sha256::digest(&cargo_lock_contents))
1107                .chars()
1108                .take(8)
1109                .collect::<String>();
1110
1111            Some((cargo_lock_contents, hash))
1112        } else {
1113            None
1114        };
1115
1116        let trybuild_hash = format!(
1117            "{}-{}",
1118            manifest_hash,
1119            workspace_cargo_lock_contents_and_hash
1120                .as_ref()
1121                .map(|(_contents, hash)| &**hash)
1122                .unwrap_or_default()
1123        );
1124
1125        if !check_contents(
1126            trybuild_hash.as_bytes(),
1127            &path!(project.dir / ".hydro-trybuild-manifest"),
1128        )
1129        .is_ok_and(|b| b)
1130        {
1131            let _span = tracing::debug_span!(target: "hydro_build", "update_lockfile").entered();
1132            // this is expensive, so we only do it if the manifest changed
1133            if let Some((cargo_lock_contents, _)) = workspace_cargo_lock_contents_and_hash {
1134                // only overwrite when the hash changed, because writing Cargo.lock must be
1135                // immediately followed by a local `cargo update -w`
1136                write_atomic(
1137                    cargo_lock_contents.as_ref(),
1138                    &path!(project.dir / "Cargo.lock"),
1139                )?;
1140            } else {
1141                let _ = cargo::cargo(&project).arg("generate-lockfile").status();
1142            }
1143
1144            // not `--offline` because some new runtime features may be enabled
1145            std::process::Command::new("cargo")
1146                .current_dir(&project.dir)
1147                .args(["update", "-w"]) // -w to not actually update any versions
1148                .stdout(std::process::Stdio::null())
1149                .stderr(std::process::Stdio::null())
1150                .status()
1151                .unwrap();
1152
1153            write_atomic(
1154                trybuild_hash.as_bytes(),
1155                &path!(project.dir / ".hydro-trybuild-manifest"),
1156            )?;
1157        }
1158
1159        // Create examples folder for base crate (static linking)
1160        let examples_folder = path!(project.dir / "examples");
1161        fs::create_dir_all(&examples_folder)?;
1162
1163        let workspace_dot_cargo_config_toml = path!(project.workspace / ".cargo" / "config.toml");
1164        if workspace_dot_cargo_config_toml.exists() {
1165            let dot_cargo_folder = path!(project.dir / ".cargo");
1166            fs::create_dir_all(&dot_cargo_folder)?;
1167
1168            write_atomic(
1169                fs::read_to_string(&workspace_dot_cargo_config_toml)?.as_ref(),
1170                &path!(dot_cargo_folder / "config.toml"),
1171            )?;
1172        }
1173
1174        let vscode_folder = path!(project.dir / ".vscode");
1175        fs::create_dir_all(&vscode_folder)?;
1176        write_atomic(
1177            include_bytes!("./vscode-trybuild.json"),
1178            &path!(vscode_folder / "settings.json"),
1179        )?;
1180    }
1181
1182    Ok((
1183        project.dir.as_ref().into(),
1184        project.target_dir.as_ref().into(),
1185        project.features,
1186    ))
1187}
1188
1189fn check_contents(contents: &[u8], path: &Path) -> Result<bool, std::io::Error> {
1190    let mut file = File::options()
1191        .read(true)
1192        .write(false)
1193        .create(false)
1194        .truncate(false)
1195        .open(path)?;
1196    file.lock()?;
1197
1198    let mut existing_contents = Vec::new();
1199    file.read_to_end(&mut existing_contents)?;
1200    Ok(existing_contents == contents)
1201}
1202
1203pub(crate) fn write_atomic(contents: &[u8], path: &Path) -> Result<(), std::io::Error> {
1204    let mut file = File::options()
1205        .read(true)
1206        .write(true)
1207        .create(true)
1208        .truncate(false)
1209        .open(path)?;
1210
1211    let mut existing_contents = Vec::new();
1212    file.read_to_end(&mut existing_contents)?;
1213    if existing_contents != contents {
1214        file.lock()?;
1215        file.seek(SeekFrom::Start(0))?;
1216        file.set_len(0)?;
1217        file.write_all(contents)?;
1218    }
1219
1220    Ok(())
1221}