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