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