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(feature = "deploy")]
6use dfir_lang::graph::DfirGraph;
7use sha2::{Digest, Sha256};
8#[cfg(feature = "deploy")]
9use stageleft::internal::quote;
10#[cfg(feature = "deploy")]
11use syn::visit_mut::VisitMut;
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
17#[cfg(feature = "deploy")]
18use super::rewriters::UseTestModeStaged;
19
20pub const HYDRO_RUNTIME_FEATURES: &[&str] = &[
21    "deploy_integration",
22    "runtime_measure",
23    "docker_runtime",
24    "ecs_runtime",
25];
26
27#[cfg(feature = "deploy")]
28/// Whether to use dynamic linking for the generated binary.
29/// - `Static`: Place in base crate examples (for remote/containerized deploys)
30/// - `Dynamic`: Place in dylib crate examples (for sim and localhost deploys)
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum LinkingMode {
33    Static,
34    Dynamic,
35}
36
37pub(crate) static IS_TEST: std::sync::atomic::AtomicBool =
38    std::sync::atomic::AtomicBool::new(false);
39
40pub(crate) static CONCURRENT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
41
42/// Enables "test mode" for Hydro, which makes it possible to compile Hydro programs written
43/// inside a `#[cfg(test)]` module. This should be enabled in a global [`ctor`] hook.
44///
45/// # Example
46/// ```ignore
47/// #[cfg(test)]
48/// mod test_init {
49///    #[ctor::ctor]
50///    fn init() {
51///        hydro_lang::compile::init_test();
52///    }
53/// }
54/// ```
55pub fn init_test() {
56    IS_TEST.store(true, std::sync::atomic::Ordering::Relaxed);
57}
58
59#[cfg(feature = "deploy")]
60fn clean_name_hint(name_hint: &str) -> String {
61    name_hint
62        .replace("::", "__")
63        .replace(" ", "_")
64        .replace(",", "_")
65        .replace("<", "_")
66        .replace(">", "")
67        .replace("(", "")
68        .replace(")", "")
69}
70
71#[derive(Debug, Clone)]
72pub struct TrybuildConfig {
73    pub project_dir: PathBuf,
74    pub target_dir: PathBuf,
75    pub features: Option<Vec<String>>,
76    #[cfg(feature = "deploy")]
77    /// Which crate within the workspace to use for examples.
78    /// - `Static`: base crate (for remote/containerized deploys)
79    /// - `Dynamic`: dylib-examples crate (for sim and localhost deploys)
80    pub linking_mode: LinkingMode,
81}
82
83#[cfg(feature = "deploy")]
84pub fn create_graph_trybuild(
85    graph: DfirGraph,
86    extra_stmts: Vec<syn::Stmt>,
87    name_hint: &Option<String>,
88    is_containerized: bool,
89    linking_mode: LinkingMode,
90) -> (String, TrybuildConfig) {
91    let source_dir = cargo::manifest_dir().unwrap();
92    let source_manifest = dependencies::get_manifest(&source_dir).unwrap();
93    let crate_name = &source_manifest.package.name.to_string().replace("-", "_");
94
95    let is_test = IS_TEST.load(std::sync::atomic::Ordering::Relaxed);
96
97    let generated_code = compile_graph_trybuild(
98        graph,
99        extra_stmts,
100        crate_name.clone(),
101        is_test,
102        is_containerized,
103    );
104
105    let inlined_staged = if is_test {
106        let raw_toml_manifest = toml::from_str::<toml::Value>(
107            &fs::read_to_string(path!(source_dir / "Cargo.toml")).unwrap(),
108        )
109        .unwrap();
110
111        let maybe_custom_lib_path = raw_toml_manifest
112            .get("lib")
113            .and_then(|lib| lib.get("path"))
114            .and_then(|path| path.as_str());
115
116        let mut gen_staged = stageleft_tool::gen_staged_trybuild(
117            &maybe_custom_lib_path
118                .map(|s| path!(source_dir / s))
119                .unwrap_or_else(|| path!(source_dir / "src" / "lib.rs")),
120            &path!(source_dir / "Cargo.toml"),
121            crate_name.clone(),
122            Some("hydro___test".to_string()),
123        );
124
125        gen_staged.attrs.insert(
126            0,
127            syn::parse_quote! {
128                #![allow(
129                    unused,
130                    ambiguous_glob_reexports,
131                    clippy::suspicious_else_formatting,
132                    unexpected_cfgs,
133                    reason = "generated code"
134                )]
135            },
136        );
137
138        Some(prettyplease::unparse(&gen_staged))
139    } else {
140        None
141    };
142
143    let source = prettyplease::unparse(&generated_code);
144
145    let hash = format!("{:X}", Sha256::digest(&source))
146        .chars()
147        .take(8)
148        .collect::<String>();
149
150    let bin_name = if let Some(name_hint) = &name_hint {
151        format!("{}_{}", clean_name_hint(name_hint), &hash)
152    } else {
153        hash
154    };
155
156    let (project_dir, target_dir, mut cur_bin_enabled_features) = create_trybuild().unwrap();
157
158    // Determine which crate's examples folder to use based on linking mode
159    let examples_dir = match linking_mode {
160        LinkingMode::Static => path!(project_dir / "examples"),
161        LinkingMode::Dynamic => path!(project_dir / "dylib-examples" / "examples"),
162    };
163
164    // TODO(shadaj): garbage collect this directory occasionally
165    fs::create_dir_all(&examples_dir).unwrap();
166
167    let out_path = path!(examples_dir / format!("{bin_name}.rs"));
168    {
169        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
170        write_atomic(source.as_ref(), &out_path).unwrap();
171    }
172
173    if let Some(inlined_staged) = inlined_staged {
174        let staged_path = path!(project_dir / "src" / "__staged.rs");
175        {
176            let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
177            write_atomic(inlined_staged.as_bytes(), &staged_path).unwrap();
178        }
179    }
180
181    if is_test {
182        if cur_bin_enabled_features.is_none() {
183            cur_bin_enabled_features = Some(vec![]);
184        }
185
186        cur_bin_enabled_features
187            .as_mut()
188            .unwrap()
189            .push("hydro___test".to_string());
190    }
191
192    (
193        bin_name,
194        TrybuildConfig {
195            project_dir,
196            target_dir,
197            features: cur_bin_enabled_features,
198            linking_mode,
199        },
200    )
201}
202
203#[cfg(feature = "deploy")]
204pub fn compile_graph_trybuild(
205    partitioned_graph: DfirGraph,
206    extra_stmts: Vec<syn::Stmt>,
207    crate_name: String,
208    is_test: bool,
209    is_containerized: bool,
210) -> syn::File {
211    use crate::staging_util::get_this_crate;
212
213    let mut diagnostics = Vec::new();
214    let mut dfir_expr: syn::Expr = syn::parse2(partitioned_graph.as_code(
215        &quote! { __root_dfir_rs },
216        true,
217        quote!(),
218        &mut diagnostics,
219    ))
220    .unwrap();
221
222    if is_test {
223        UseTestModeStaged {
224            crate_name: crate_name.clone(),
225        }
226        .visit_expr_mut(&mut dfir_expr);
227    }
228
229    let orig_crate_name = quote::format_ident!("{}", crate_name);
230    let trybuild_crate_name_ident = quote::format_ident!("{}_hydro_trybuild", crate_name);
231    let root = get_this_crate();
232    let tokio_main_ident = format!("{}::runtime_support::tokio", root);
233
234    let source_ast: syn::File = if is_containerized {
235        syn::parse_quote! {
236            #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
237            use #trybuild_crate_name_ident::__root as #orig_crate_name;
238            use #trybuild_crate_name_ident::__staged::__deps::*;
239            use #root::prelude::*;
240            use #root::runtime_support::dfir_rs as __root_dfir_rs;
241            pub use #trybuild_crate_name_ident::__staged;
242
243            #[allow(unused)]
244            async fn __hydro_runtime<'a>() -> #root::runtime_support::dfir_rs::scheduled::graph::Dfir<'a> {
245                /// extra_stmts
246                #(#extra_stmts)*
247
248                /// dfir_expr
249                #dfir_expr
250            }
251
252            #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
253            async fn main() {
254                #root::telemetry::initialize_tracing();
255
256                let flow = __hydro_runtime().await;
257
258                #root::runtime_support::launch::run_containerized(flow).await;
259            }
260        }
261    } else {
262        syn::parse_quote! {
263            #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
264            use #trybuild_crate_name_ident::__root as #orig_crate_name;
265            use #trybuild_crate_name_ident::__staged::__deps::*;
266            use #root::prelude::*;
267            use #root::runtime_support::dfir_rs as __root_dfir_rs;
268            pub use #trybuild_crate_name_ident::__staged;
269
270            #[allow(unused)]
271            fn __hydro_runtime<'a>(__hydro_lang_trybuild_cli: &'a #root::runtime_support::hydro_deploy_integration::DeployPorts<#root::__staged::deploy::deploy_runtime::HydroMeta>) -> #root::runtime_support::dfir_rs::scheduled::graph::Dfir<'a> {
272                #(#extra_stmts)*
273                #dfir_expr
274            }
275
276            #[#root::runtime_support::tokio::main(crate = #tokio_main_ident, flavor = "current_thread")]
277            async fn main() {
278                let ports = #root::runtime_support::launch::init_no_ack_start().await;
279                let flow = __hydro_runtime(&ports);
280                println!("ack start");
281
282                #root::runtime_support::launch::run(flow).await;
283            }
284        }
285    };
286    source_ast
287}
288
289pub fn create_trybuild()
290-> Result<(PathBuf, PathBuf, Option<Vec<String>>), trybuild_internals_api::error::Error> {
291    let Metadata {
292        target_directory: target_dir,
293        workspace_root: workspace,
294        packages,
295    } = cargo::metadata()?;
296
297    let source_dir = cargo::manifest_dir()?;
298    let mut source_manifest = dependencies::get_manifest(&source_dir)?;
299
300    let mut dev_dependency_features = vec![];
301    source_manifest.dev_dependencies.retain(|k, v| {
302        if source_manifest.dependencies.contains_key(k) {
303            // already a non-dev dependency, so drop the dep and put the features under the test flag
304            for feat in &v.features {
305                dev_dependency_features.push(format!("{}/{}", k, feat));
306            }
307
308            false
309        } else {
310            // only enable this in test mode, so make it optional otherwise
311            dev_dependency_features.push(format!("dep:{k}"));
312
313            v.optional = true;
314            true
315        }
316    });
317
318    let mut features = features::find();
319
320    let path_dependencies = source_manifest
321        .dependencies
322        .iter()
323        .filter_map(|(name, dep)| {
324            let path = dep.path.as_ref()?;
325            if packages.iter().any(|p| &p.name == name) {
326                // Skip path dependencies coming from the workspace itself
327                None
328            } else {
329                Some(PathDependency {
330                    name: name.clone(),
331                    normalized_path: path.canonicalize().ok()?,
332                })
333            }
334        })
335        .collect();
336
337    let crate_name = source_manifest.package.name.clone();
338    let project_dir = path!(target_dir / "hydro_trybuild" / crate_name /);
339    fs::create_dir_all(&project_dir)?;
340
341    let project_name = format!("{}-hydro-trybuild", crate_name);
342    let mut manifest = Runner::make_manifest(
343        &workspace,
344        &project_name,
345        &source_dir,
346        &packages,
347        &[],
348        source_manifest,
349    )?;
350
351    if let Some(enabled_features) = &mut features {
352        enabled_features
353            .retain(|feature| manifest.features.contains_key(feature) || feature == "default");
354    }
355
356    for runtime_feature in HYDRO_RUNTIME_FEATURES {
357        manifest.features.insert(
358            format!("hydro___feature_{runtime_feature}"),
359            vec![format!("hydro_lang/{runtime_feature}")],
360        );
361    }
362
363    manifest
364        .dependencies
365        .get_mut("hydro_lang")
366        .unwrap()
367        .features
368        .push("runtime_support".to_string());
369
370    manifest
371        .features
372        .insert("hydro___test".to_string(), dev_dependency_features);
373
374    if manifest
375        .workspace
376        .as_ref()
377        .is_some_and(|w| w.dependencies.is_empty())
378    {
379        manifest.workspace = None;
380    }
381
382    let project = Project {
383        dir: project_dir,
384        source_dir,
385        target_dir,
386        name: project_name.clone(),
387        update: Update::env()?,
388        has_pass: false,
389        has_compile_fail: false,
390        features,
391        workspace,
392        path_dependencies,
393        manifest,
394        keep_going: false,
395    };
396
397    {
398        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
399
400        let project_lock = File::create(path!(project.dir / ".hydro-trybuild-lock"))?;
401        project_lock.lock()?;
402
403        fs::create_dir_all(path!(project.dir / "src"))?;
404
405        let crate_name_ident = syn::Ident::new(
406            &crate_name.replace("-", "_"),
407            proc_macro2::Span::call_site(),
408        );
409
410        write_atomic(
411            prettyplease::unparse(&syn::parse_quote! {
412                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
413
414                pub use #crate_name_ident as __root;
415
416                #[cfg(feature = "hydro___test")]
417                pub mod __staged;
418
419                #[cfg(not(feature = "hydro___test"))]
420                pub use #crate_name_ident::__staged;
421            })
422            .as_bytes(),
423            &path!(project.dir / "src" / "lib.rs"),
424        )
425        .unwrap();
426
427        let manifest_toml = toml::to_string(&project.manifest)?;
428        let base_manifest = manifest_toml.clone();
429
430        // Collect feature names for forwarding to dylib and dylib-examples crates
431        let feature_names: Vec<_> = project.manifest.features.keys().cloned().collect();
432
433        // Create dylib crate directory
434        let dylib_dir = path!(project.dir / "dylib");
435        fs::create_dir_all(path!(dylib_dir / "src"))?;
436
437        let trybuild_crate_name_ident = syn::Ident::new(
438            &project_name.replace("-", "_"),
439            proc_macro2::Span::call_site(),
440        );
441        write_atomic(
442            prettyplease::unparse(&syn::parse_quote! {
443                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
444                pub use #trybuild_crate_name_ident::*;
445            })
446            .as_bytes(),
447            &path!(dylib_dir / "src" / "lib.rs"),
448        )?;
449
450        let serialized_edition = toml::to_string(
451            &vec![("edition", &project.manifest.package.edition)]
452                .into_iter()
453                .collect::<std::collections::HashMap<_, _>>(),
454        )
455        .unwrap();
456
457        // Dylib crate Cargo.toml - only dylib crate-type, no features needed
458        // Features are enabled on the base crate directly from dylib-examples
459        // On Windows, we currently disable dylib compilation due to https://github.com/bevyengine/bevy/pull/2016
460        let dylib_manifest = format!(
461            r#"[package]
462name = "{project_name}-dylib"
463version = "0.0.0"
464{}
465
466[lib]
467crate-type = ["{}"]
468
469[dependencies]
470{project_name} = {{ path = "..", default-features = false }}
471"#,
472            serialized_edition,
473            if cfg!(target_os = "windows") {
474                "rlib"
475            } else {
476                "dylib"
477            }
478        );
479        write_atomic(dylib_manifest.as_ref(), &path!(dylib_dir / "Cargo.toml"))?;
480
481        let dylib_examples_dir = path!(project.dir / "dylib-examples");
482        fs::create_dir_all(path!(dylib_examples_dir / "src"))?;
483        fs::create_dir_all(path!(dylib_examples_dir / "examples"))?;
484
485        write_atomic(
486            b"#![allow(unused_crate_dependencies)]\n",
487            &path!(dylib_examples_dir / "src" / "lib.rs"),
488        )?;
489
490        // Build feature forwarding for dylib-examples - forward directly to base crate
491        let features_section = feature_names
492            .iter()
493            .map(|f| format!("{f} = [\"{project_name}/{f}\"]"))
494            .collect::<Vec<_>>()
495            .join("\n");
496
497        // Dylib-examples crate Cargo.toml - has dylib as dev-dependency, features go to base crate
498        let dylib_examples_manifest = format!(
499            r#"[package]
500name = "{project_name}-dylib-examples"
501version = "0.0.0"
502{}
503
504[dev-dependencies]
505{project_name} = {{ path = "..", default-features = false }}
506{project_name}-dylib = {{ path = "../dylib", default-features = false }}
507
508[features]
509{features_section}
510
511[[example]]
512name = "sim-dylib"
513crate-type = ["cdylib"]
514"#,
515            serialized_edition
516        );
517        write_atomic(
518            dylib_examples_manifest.as_ref(),
519            &path!(dylib_examples_dir / "Cargo.toml"),
520        )?;
521
522        // sim-dylib.rs for the dylib-examples crate
523        write_atomic(
524            prettyplease::unparse(&syn::parse_quote! {
525                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
526                include!(std::concat!(env!("TRYBUILD_LIB_NAME"), ".rs"));
527            })
528            .as_bytes(),
529            &path!(dylib_examples_dir / "examples" / "sim-dylib.rs"),
530        )?;
531
532        let workspace_manifest = format!(
533            r#"{}
534[workspace]
535members = ["dylib", "dylib-examples"]
536"#,
537            base_manifest,
538        );
539
540        write_atomic(
541            workspace_manifest.as_ref(),
542            &path!(project.dir / "Cargo.toml"),
543        )?;
544
545        // Compute hash for cache invalidation (dylib and dylib-examples are functions of workspace_manifest)
546        let manifest_hash = format!("{:X}", Sha256::digest(&workspace_manifest))
547            .chars()
548            .take(8)
549            .collect::<String>();
550
551        let workspace_cargo_lock = path!(project.workspace / "Cargo.lock");
552        let workspace_cargo_lock_contents_and_hash = if workspace_cargo_lock.exists() {
553            let cargo_lock_contents = fs::read_to_string(&workspace_cargo_lock)?;
554
555            let hash = format!("{:X}", Sha256::digest(&cargo_lock_contents))
556                .chars()
557                .take(8)
558                .collect::<String>();
559
560            Some((cargo_lock_contents, hash))
561        } else {
562            None
563        };
564
565        let trybuild_hash = format!(
566            "{}-{}",
567            manifest_hash,
568            workspace_cargo_lock_contents_and_hash
569                .as_ref()
570                .map(|s| s.1.as_ref())
571                .unwrap_or("")
572        );
573
574        if !check_contents(
575            trybuild_hash.as_bytes(),
576            &path!(project.dir / ".hydro-trybuild-manifest"),
577        )
578        .is_ok_and(|b| b)
579        {
580            // this is expensive, so we only do it if the manifest changed
581            if let Some((cargo_lock_contents, _)) = workspace_cargo_lock_contents_and_hash {
582                // only overwrite when the hash changed, because writing Cargo.lock must be
583                // immediately followed by a local `cargo update -w`
584                write_atomic(
585                    cargo_lock_contents.as_ref(),
586                    &path!(project.dir / "Cargo.lock"),
587                )?;
588            } else {
589                let _ = cargo::cargo(&project).arg("generate-lockfile").status();
590            }
591
592            // not `--offline` because some new runtime features may be enabled
593            std::process::Command::new("cargo")
594                .current_dir(&project.dir)
595                .args(["update", "-w"]) // -w to not actually update any versions
596                .stdout(std::process::Stdio::null())
597                .stderr(std::process::Stdio::null())
598                .status()
599                .unwrap();
600
601            write_atomic(
602                trybuild_hash.as_bytes(),
603                &path!(project.dir / ".hydro-trybuild-manifest"),
604            )?;
605        }
606
607        // Create examples folder for base crate (static linking)
608        let examples_folder = path!(project.dir / "examples");
609        fs::create_dir_all(&examples_folder)?;
610
611        let workspace_dot_cargo_config_toml = path!(project.workspace / ".cargo" / "config.toml");
612        if workspace_dot_cargo_config_toml.exists() {
613            let dot_cargo_folder = path!(project.dir / ".cargo");
614            fs::create_dir_all(&dot_cargo_folder)?;
615
616            write_atomic(
617                fs::read_to_string(&workspace_dot_cargo_config_toml)?.as_ref(),
618                &path!(dot_cargo_folder / "config.toml"),
619            )?;
620        }
621
622        let vscode_folder = path!(project.dir / ".vscode");
623        fs::create_dir_all(&vscode_folder)?;
624        write_atomic(
625            include_bytes!("./vscode-trybuild.json"),
626            &path!(vscode_folder / "settings.json"),
627        )?;
628    }
629
630    Ok((
631        project.dir.as_ref().into(),
632        project.target_dir.as_ref().into(),
633        project.features,
634    ))
635}
636
637fn check_contents(contents: &[u8], path: &Path) -> Result<bool, std::io::Error> {
638    let mut file = File::options()
639        .read(true)
640        .write(false)
641        .create(false)
642        .truncate(false)
643        .open(path)?;
644    file.lock()?;
645
646    let mut existing_contents = Vec::new();
647    file.read_to_end(&mut existing_contents)?;
648    Ok(existing_contents == contents)
649}
650
651pub(crate) fn write_atomic(contents: &[u8], path: &Path) -> Result<(), std::io::Error> {
652    let mut file = File::options()
653        .read(true)
654        .write(true)
655        .create(true)
656        .truncate(false)
657        .open(path)?;
658
659    let mut existing_contents = Vec::new();
660    file.read_to_end(&mut existing_contents)?;
661    if existing_contents != contents {
662        file.lock()?;
663        file.seek(SeekFrom::Start(0))?;
664        file.set_len(0)?;
665        file.write_all(contents)?;
666    }
667
668    Ok(())
669}