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