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", "runtime_measure", "docker_runtime"];
22
23pub(crate) static IS_TEST: std::sync::atomic::AtomicBool =
24    std::sync::atomic::AtomicBool::new(false);
25
26pub(crate) static CONCURRENT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
27
28/// Enables "test mode" for Hydro, which makes it possible to compile Hydro programs written
29/// inside a `#[cfg(test)]` module. This should be enabled in a global [`ctor`] hook.
30///
31/// # Example
32/// ```ignore
33/// #[cfg(test)]
34/// mod test_init {
35///    #[ctor::ctor]
36///    fn init() {
37///        hydro_lang::compile::init_test();
38///    }
39/// }
40/// ```
41pub fn init_test() {
42    IS_TEST.store(true, std::sync::atomic::Ordering::Relaxed);
43}
44
45#[cfg(feature = "deploy")]
46fn clean_name_hint(name_hint: &str) -> String {
47    name_hint
48        .replace("::", "__")
49        .replace(" ", "_")
50        .replace(",", "_")
51        .replace("<", "_")
52        .replace(">", "")
53        .replace("(", "")
54        .replace(")", "")
55}
56
57#[derive(Debug, Clone)]
58pub struct TrybuildConfig {
59    pub project_dir: PathBuf,
60    pub target_dir: PathBuf,
61    pub features: Option<Vec<String>>,
62}
63
64#[cfg(feature = "deploy")]
65pub fn create_graph_trybuild(
66    graph: DfirGraph,
67    extra_stmts: Vec<syn::Stmt>,
68    name_hint: &Option<String>,
69    is_containerized: bool,
70) -> (String, TrybuildConfig) {
71    let source_dir = cargo::manifest_dir().unwrap();
72    let source_manifest = dependencies::get_manifest(&source_dir).unwrap();
73    let crate_name = &source_manifest.package.name.to_string().replace("-", "_");
74
75    let is_test = IS_TEST.load(std::sync::atomic::Ordering::Relaxed);
76
77    let generated_code = compile_graph_trybuild(
78        graph,
79        extra_stmts,
80        crate_name.clone(),
81        is_test,
82        is_containerized,
83    );
84
85    let inlined_staged = if is_test {
86        let gen_staged = stageleft_tool::gen_staged_trybuild(
87            &path!(source_dir / "src" / "lib.rs"),
88            &path!(source_dir / "Cargo.toml"),
89            crate_name.clone(),
90            Some("hydro___test".to_string()),
91        );
92
93        Some(prettyplease::unparse(&syn::parse_quote! {
94            #![allow(
95                unused,
96                ambiguous_glob_reexports,
97                clippy::suspicious_else_formatting,
98                unexpected_cfgs,
99                reason = "generated code"
100            )]
101
102            #gen_staged
103        }))
104    } else {
105        None
106    };
107
108    let source = prettyplease::unparse(&generated_code);
109
110    let hash = format!("{:X}", Sha256::digest(&source))
111        .chars()
112        .take(8)
113        .collect::<String>();
114
115    let bin_name = if let Some(name_hint) = &name_hint {
116        format!("{}_{}", clean_name_hint(name_hint), &hash)
117    } else {
118        hash
119    };
120
121    let (project_dir, target_dir, mut cur_bin_enabled_features) = create_trybuild().unwrap();
122
123    // TODO(shadaj): garbage collect this directory occasionally
124    fs::create_dir_all(path!(project_dir / "examples")).unwrap();
125
126    let out_path = path!(project_dir / "examples" / format!("{bin_name}.rs"));
127    {
128        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
129        write_atomic(source.as_ref(), &out_path).unwrap();
130    }
131
132    if let Some(inlined_staged) = inlined_staged {
133        let staged_path = path!(project_dir / "src" / "__staged.rs");
134        {
135            let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
136            write_atomic(inlined_staged.as_bytes(), &staged_path).unwrap();
137        }
138    }
139
140    if is_test {
141        if cur_bin_enabled_features.is_none() {
142            cur_bin_enabled_features = Some(vec![]);
143        }
144
145        cur_bin_enabled_features
146            .as_mut()
147            .unwrap()
148            .push("hydro___test".to_string());
149    }
150
151    (
152        bin_name,
153        TrybuildConfig {
154            project_dir,
155            target_dir,
156            features: cur_bin_enabled_features,
157        },
158    )
159}
160
161#[cfg(feature = "deploy")]
162pub fn compile_graph_trybuild(
163    partitioned_graph: DfirGraph,
164    extra_stmts: Vec<syn::Stmt>,
165    crate_name: String,
166    is_test: bool,
167    is_containerized: bool,
168) -> syn::File {
169    let mut diagnostics = Vec::new();
170    let mut dfir_expr: syn::Expr = syn::parse2(partitioned_graph.as_code(
171        &quote! { __root_dfir_rs },
172        true,
173        quote!(),
174        &mut diagnostics,
175    ))
176    .unwrap();
177
178    if is_test {
179        UseTestModeStaged {
180            crate_name: crate_name.clone(),
181        }
182        .visit_expr_mut(&mut dfir_expr);
183    }
184
185    let trybuild_crate_name_ident = quote::format_ident!("{}_hydro_trybuild", crate_name);
186
187    let source_ast: syn::File = if is_containerized {
188        syn::parse_quote! {
189            #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
190            use hydro_lang::prelude::*;
191            use hydro_lang::runtime_support::dfir_rs as __root_dfir_rs;
192            pub use #trybuild_crate_name_ident::__staged;
193
194            #[allow(unused)]
195            async fn __hydro_runtime<'a>() -> hydro_lang::runtime_support::dfir_rs::scheduled::graph::Dfir<'a> {
196                /// extra_stmts
197                #(#extra_stmts)*
198
199                /// dfir_expr
200                #dfir_expr
201            }
202
203            #[hydro_lang::runtime_support::tokio::main(crate = "hydro_lang::runtime_support::tokio", flavor = "current_thread")]
204            async fn main() {
205                hydro_lang::telemetry::initialize_tracing();
206
207                let flow = __hydro_runtime().await;
208
209                hydro_lang::runtime_support::resource_measurement::run_containerized(flow).await;
210            }
211        }
212    } else {
213        syn::parse_quote! {
214            #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
215            use hydro_lang::prelude::*;
216            use hydro_lang::runtime_support::dfir_rs as __root_dfir_rs;
217            pub use #trybuild_crate_name_ident::__staged;
218
219            #[allow(unused)]
220            fn __hydro_runtime<'a>(
221                __hydro_lang_trybuild_cli: &'a hydro_lang::runtime_support::dfir_rs::util::deploy::DeployPorts<hydro_lang::__staged::deploy::deploy_runtime::HydroMeta>
222            )
223                -> hydro_lang::runtime_support::dfir_rs::scheduled::graph::Dfir<'a>
224            {
225                #(#extra_stmts)*
226                #dfir_expr
227            }
228
229            #[hydro_lang::runtime_support::tokio::main(crate = "hydro_lang::runtime_support::tokio", flavor = "current_thread")]
230            async fn main() {
231                let ports = hydro_lang::runtime_support::dfir_rs::util::deploy::init_no_ack_start().await;
232                let flow = __hydro_runtime(&ports);
233                println!("ack start");
234
235                hydro_lang::runtime_support::resource_measurement::run(flow).await;
236            }
237        }
238    };
239    source_ast
240}
241
242pub fn create_trybuild()
243-> Result<(PathBuf, PathBuf, Option<Vec<String>>), trybuild_internals_api::error::Error> {
244    let Metadata {
245        target_directory: target_dir,
246        workspace_root: workspace,
247        packages,
248    } = cargo::metadata()?;
249
250    let source_dir = cargo::manifest_dir()?;
251    let mut source_manifest = dependencies::get_manifest(&source_dir)?;
252
253    let mut dev_dependency_features = vec![];
254    source_manifest.dev_dependencies.retain(|k, v| {
255        if source_manifest.dependencies.contains_key(k) {
256            // already a non-dev dependency, so drop the dep and put the features under the test flag
257            for feat in &v.features {
258                dev_dependency_features.push(format!("{}/{}", k, feat));
259            }
260
261            false
262        } else {
263            // only enable this in test mode, so make it optional otherwise
264            dev_dependency_features.push(format!("dep:{k}"));
265
266            v.optional = true;
267            true
268        }
269    });
270
271    let mut features = features::find();
272
273    let path_dependencies = source_manifest
274        .dependencies
275        .iter()
276        .filter_map(|(name, dep)| {
277            let path = dep.path.as_ref()?;
278            if packages.iter().any(|p| &p.name == name) {
279                // Skip path dependencies coming from the workspace itself
280                None
281            } else {
282                Some(PathDependency {
283                    name: name.clone(),
284                    normalized_path: path.canonicalize().ok()?,
285                })
286            }
287        })
288        .collect();
289
290    let crate_name = source_manifest.package.name.clone();
291    let project_dir = path!(target_dir / "hydro_trybuild" / crate_name /);
292    fs::create_dir_all(&project_dir)?;
293
294    let project_name = format!("{}-hydro-trybuild", crate_name);
295    let mut manifest = Runner::make_manifest(
296        &workspace,
297        &project_name,
298        &source_dir,
299        &packages,
300        &[],
301        source_manifest,
302    )?;
303
304    if let Some(enabled_features) = &mut features {
305        enabled_features
306            .retain(|feature| manifest.features.contains_key(feature) || feature == "default");
307    }
308
309    for runtime_feature in HYDRO_RUNTIME_FEATURES {
310        manifest.features.insert(
311            format!("hydro___feature_{runtime_feature}"),
312            vec![format!("hydro_lang/{runtime_feature}")],
313        );
314    }
315
316    manifest
317        .dependencies
318        .get_mut("hydro_lang")
319        .unwrap()
320        .features
321        .push("runtime_support".to_string());
322
323    manifest
324        .features
325        .insert("hydro___test".to_string(), dev_dependency_features);
326
327    let project = Project {
328        dir: project_dir,
329        source_dir,
330        target_dir,
331        name: project_name,
332        update: Update::env()?,
333        has_pass: false,
334        has_compile_fail: false,
335        features,
336        workspace,
337        path_dependencies,
338        manifest,
339        keep_going: false,
340    };
341
342    {
343        let _concurrent_test_lock = CONCURRENT_TEST_LOCK.lock().unwrap();
344
345        let project_lock = File::create(path!(project.dir / ".hydro-trybuild-lock"))?;
346        project_lock.lock()?;
347
348        fs::create_dir_all(path!(project.dir / "src"))?;
349
350        let crate_name_ident = syn::Ident::new(
351            &crate_name.replace("-", "_"),
352            proc_macro2::Span::call_site(),
353        );
354        write_atomic(
355            prettyplease::unparse(&syn::parse_quote! {
356                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
357
358                #[cfg(feature = "hydro___test")]
359                pub mod __staged;
360
361                #[cfg(not(feature = "hydro___test"))]
362                pub use #crate_name_ident::__staged;
363            })
364            .as_bytes(),
365            &path!(project.dir / "src" / "lib.rs"),
366        )
367        .unwrap();
368
369        let manifest_toml = toml::to_string(&project.manifest)?;
370        let manifest_with_example = format!(
371            r#"{}
372
373[lib]
374crate-type = [{}]
375
376[[example]]
377name = "sim-dylib"
378crate-type = ["cdylib"]"#,
379            manifest_toml,
380            if cfg!(target_os = "windows") {
381                r#""rlib""# // see https://github.com/bevyengine/bevy/pull/2016
382            } else {
383                r#""rlib", "dylib""#
384            },
385        );
386
387        write_atomic(
388            manifest_with_example.as_ref(),
389            &path!(project.dir / "Cargo.toml"),
390        )?;
391
392        let manifest_hash = format!("{:X}", Sha256::digest(&manifest_with_example))
393            .chars()
394            .take(8)
395            .collect::<String>();
396
397        if !check_contents(
398            manifest_hash.as_bytes(),
399            &path!(project.dir / ".hydro-trybuild-manifest"),
400        )
401        .is_ok_and(|b| b)
402        {
403            // this is expensive, so we only do it if the manifest changed
404            let workspace_cargo_lock = path!(project.workspace / "Cargo.lock");
405            if workspace_cargo_lock.exists() {
406                write_atomic(
407                    fs::read_to_string(&workspace_cargo_lock)?.as_ref(),
408                    &path!(project.dir / "Cargo.lock"),
409                )?;
410            } else {
411                let _ = cargo::cargo(&project).arg("generate-lockfile").status();
412            }
413
414            // not `--offline` because some new runtime features may be enabled
415            std::process::Command::new("cargo")
416                .current_dir(&project.dir)
417                .args(["update", "-w"]) // -w to not actually update any versions
418                .stdout(std::process::Stdio::null())
419                .stderr(std::process::Stdio::null())
420                .status()
421                .unwrap();
422
423            write_atomic(
424                manifest_hash.as_bytes(),
425                &path!(project.dir / ".hydro-trybuild-manifest"),
426            )?;
427        }
428
429        let examples_folder = path!(project.dir / "examples");
430        fs::create_dir_all(&examples_folder)?;
431        write_atomic(
432            prettyplease::unparse(&syn::parse_quote! {
433                #![allow(unused_imports, unused_crate_dependencies, missing_docs, non_snake_case)]
434                include!(std::concat!(env!("TRYBUILD_LIB_NAME"), ".rs"));
435            })
436            .as_bytes(),
437            &path!(project.dir / "examples" / "sim-dylib.rs"),
438        )?;
439
440        let workspace_dot_cargo_config_toml = path!(project.workspace / ".cargo" / "config.toml");
441        if workspace_dot_cargo_config_toml.exists() {
442            let dot_cargo_folder = path!(project.dir / ".cargo");
443            fs::create_dir_all(&dot_cargo_folder)?;
444
445            write_atomic(
446                fs::read_to_string(&workspace_dot_cargo_config_toml)?.as_ref(),
447                &path!(dot_cargo_folder / "config.toml"),
448            )?;
449        }
450
451        let vscode_folder = path!(project.dir / ".vscode");
452        fs::create_dir_all(&vscode_folder)?;
453        write_atomic(
454            include_bytes!("./vscode-trybuild.json"),
455            &path!(vscode_folder / "settings.json"),
456        )?;
457    }
458
459    Ok((
460        project.dir.as_ref().into(),
461        path!(project.target_dir / "hydro_trybuild"),
462        project.features,
463    ))
464}
465
466fn check_contents(contents: &[u8], path: &Path) -> Result<bool, std::io::Error> {
467    let mut file = File::options()
468        .read(true)
469        .write(false)
470        .create(false)
471        .truncate(false)
472        .open(path)?;
473    file.lock()?;
474
475    let mut existing_contents = Vec::new();
476    file.read_to_end(&mut existing_contents)?;
477    Ok(existing_contents == contents)
478}
479
480pub(crate) fn write_atomic(contents: &[u8], path: &Path) -> Result<(), std::io::Error> {
481    let mut file = File::options()
482        .read(true)
483        .write(true)
484        .create(true)
485        .truncate(false)
486        .open(path)?;
487
488    let mut existing_contents = Vec::new();
489    file.read_to_end(&mut existing_contents)?;
490    if existing_contents != contents {
491        file.lock()?;
492        file.seek(SeekFrom::Start(0))?;
493        file.set_len(0)?;
494        file.write_all(contents)?;
495    }
496
497    Ok(())
498}