diff --git a/Cargo.lock b/Cargo.lock index 7a76c558..acf97558 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -927,6 +927,38 @@ dependencies = [ "either", ] +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8769706aad5d996120af43197bf46ef6ad0fda35216b4505f926a365a232d924" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.3", +] + [[package]] name = "cc" version = "1.2.2" @@ -3500,6 +3532,20 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +[[package]] +name = "postcompile" +version = "0.0.4" +dependencies = [ + "cargo-platform", + "cargo_metadata", + "prettyplease", + "serde", + "serde_derive", + "serde_json", + "syn 2.0.90", + "target-triple", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -4701,6 +4747,9 @@ name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -5140,6 +5189,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-triple" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a4d50cdb458045afc8131fd91b64904da29548bcb63c7236e0844936c13078" + [[package]] name = "tempfile" version = "3.14.0" diff --git a/Cargo.toml b/Cargo.toml index 42211e1b..670b4039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/metrics", "crates/metrics/derive", "crates/metrics/examples", + "crates/postcompile", "crates/pprof", "crates/pprof/examples", "crates/settings", diff --git a/README.md b/README.md index c137a6ad..75dfa473 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ This repository houses a collection of crates, purpose-built libraries designed - 📈 **[scuffle-pprof](./crates/pprof)**: Helper crate for adding pprof support to your application. - ⚙️ **[scuffle-settings](./crates/settings)**: Tools for managing configuration from environment variables or config files. - 📶 **[scuffle-signal](./crates/signal)**: Ergonomic async signal handling. +- 📦 **[postcompile](./crates/postcompile)**: A macro for compiling Rust code at runtime. Useful for snapshot testing. ## 📦 Apps diff --git a/crates/postcompile/Cargo.toml b/crates/postcompile/Cargo.toml new file mode 100644 index 00000000..17314b3e --- /dev/null +++ b/crates/postcompile/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "postcompile" +version = "0.0.4" +edition = "2021" +build = "build.rs" +repository = "https://github.com/scufflecloud/scuffle" +authors = ["Scuffle "] +readme = "README.md" +documentation = "https://docs.rs/postcompile" +license = "MIT OR Apache-2.0" +description = "Helper crate for post-compiling Rust code." +keywords = ["postcompile", "snapshot", "test", "proc-macro"] + +[dependencies] +serde_json = "1.0" +cargo_metadata = "0.19.1" +cargo-platform = "0.1" +target-triple = "0.1" +serde_derive = "1.0" +serde = "1.0" +prettyplease = { version = "0.2", optional = true } +syn = { version = "2", features = ["full"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(trybuild_no_target)', 'cfg(postcompile_no_target)'] } + +[features] +prettyplease = ["dep:prettyplease"] diff --git a/crates/postcompile/LICENSE.Apache-2.0 b/crates/postcompile/LICENSE.Apache-2.0 new file mode 120000 index 00000000..5a4558f0 --- /dev/null +++ b/crates/postcompile/LICENSE.Apache-2.0 @@ -0,0 +1 @@ +../../LICENSE.Apache-2.0 \ No newline at end of file diff --git a/crates/postcompile/LICENSE.MIT b/crates/postcompile/LICENSE.MIT new file mode 120000 index 00000000..244dbbf0 --- /dev/null +++ b/crates/postcompile/LICENSE.MIT @@ -0,0 +1 @@ +../../LICENSE.MIT \ No newline at end of file diff --git a/crates/postcompile/README.md b/crates/postcompile/README.md new file mode 100644 index 00000000..c225e6a4 --- /dev/null +++ b/crates/postcompile/README.md @@ -0,0 +1,74 @@ +# postcompile + +> [!WARNING] +> This crate is under active development and may not be stable. + +[![crates.io](https://img.shields.io/crates/v/postcompile.svg)](https://crates.io/crates/postcompile) [![docs.rs](https://img.shields.io/docsrs/postcompile)](https://docs.rs/postcompile) + +--- + +A crate which allows you to compile Rust code at runtime (hence the name `postcompile`). + +What that means is that you can provide the input to `rustc` and then get back the expanded output, compiler errors, warnings, etc. + +This is particularly useful when making snapshot tests of proc-macros, look below for an example with the `insta` crate. + +## Usage + +```rs +#[test] +fn some_cool_test() { + insta::assert_snapshot!(postcompile::compile! { + #![allow(unused)] + + #[derive(Debug, Clone)] + struct Test { + a: u32, + b: i32, + } + + const TEST: Test = Test { a: 1, b: 3 }; + }); +} + +#[test] +fn some_cool_test_extern() { + insta::assert_snapshot!(postcompile::compile_str!(include_str!("some_file.rs"))); +} +``` + +## Features + +- Cached builds: This crate reuses the cargo build cache of the original crate so that only the contents of the macro are compiled & not any additional dependencies. +- Coverage: This crate works with [`cargo-llvm-cov`](https://crates.io/crates/cargo-llvm-cov) out of the box, which allows you to instrument the proc-macro expansion. + +## Alternatives + +- [`compiletest_rs`](https://crates.io/crates/compiletest_rs): This crate is used by the Rust compiler team to test the compiler itself. Not really useful for proc-macros. +- [`trybuild`](https://crates.io/crates/trybuild): This crate is an all-in-one solution for testing proc-macros, with built in snapshot testing. +- [`ui_test`](https://crates.io/crates/ui_test): Similar to `trybuild` with a slightly different API & used by the Rust compiler team to test the compiler itself. + +### Differences + +The other libraries are focused on testing & have built in test harnesses. This crate takes a step back and allows you to compile without a testing harness. This has the advantage of being more flexible, and allows you to use whatever testing framework you want. + +In the examples above I showcase how to use this crate with the `insta` crate for snapshot testing. + +## Status + +This crate is currently under development and is not yet stable, unit tests are not yet fully implemented. + +Unit tests are not yet fully implemented. Use at your own risk. + +## Limitations + +Please note that this crate does not work inside a running compiler process (inside a proc-macro) without hacky workarounds and complete build-cache invalidation. + +This is because `cargo` holds a lock on the build directory and that if we were to compile inside a proc-macro we would recursively invoke the compiler. + +## License + +This project is licensed under the [MIT](./LICENSE.MIT) or [Apache-2.0](./LICENSE.Apache-2.0) license. +You can choose between one of them if you use this work. + +`SPDX-License-Identifier: MIT OR Apache-2.0` diff --git a/crates/postcompile/build.rs b/crates/postcompile/build.rs new file mode 100644 index 00000000..28389b94 --- /dev/null +++ b/crates/postcompile/build.rs @@ -0,0 +1,3 @@ +/// This is a dummy build script, it does nothing. +/// But we need to get the `OUT_DIR` from the environment so that we can determine where the target directory is. +fn main() {} diff --git a/crates/postcompile/src/deps.rs b/crates/postcompile/src/deps.rs new file mode 100644 index 00000000..04c23537 --- /dev/null +++ b/crates/postcompile/src/deps.rs @@ -0,0 +1,380 @@ +/// Some of the code here is from the `ui_test` crate, and some is from `trybuild`. +/// Mainly related to how dependencies are found & included in the build. +/// Both of which are licensed under the MIT license. + +use std::{ + collections::{HashMap, HashSet}, + ffi::OsString, + path::PathBuf, + process::{Command, Stdio}, +}; + +use cargo_metadata::{BuildScript, DependencyKind, Edition}; +use cargo_platform::Cfg; +use target_triple::TARGET; + +use crate::{features, Config}; + +#[derive(Default, Debug)] +/// Describes where to find the binaries built for the dependencies +pub struct Dependencies { + pub import_paths: Vec, + pub import_libs: Vec, + pub dependencies: Vec<(String, Vec)>, + pub edition: Edition, + pub cfg: Vec, + pub env: Vec<(String, String)>, +} + +impl Dependencies { + pub fn new(config: &Config) -> Result { + let mut build = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); + + let target_dir = if config.target_dir.ends_with(TARGET) { + config.target_dir.parent().unwrap() + } else { + config.target_dir.as_ref() + }; + + build.arg("test"); + build.arg("--no-run"); + build.arg("--message-format=json"); + build.arg("--target-dir"); + build.arg(target_dir); + build.arg("--manifest-path"); + build.arg(config.manifest.as_ref()); + build.arg("--locked"); + if let Some(rustflags) = std::env::var_os("RUSTFLAGS") { + build.env("RUSTFLAGS", rustflags); + } + if let Some(llvm_profile_file) = std::env::var_os("LLVM_PROFILE_FILE") { + build.env("LLVM_PROFILE_FILE", llvm_profile_file); + } + + if let Some(features) = features::find() { + build.arg(format!("--features={}", features.join(","))); + } + + build.stderr(Stdio::piped()); + build.stdout(Stdio::piped()); + + // This isnt trybuild but a bunch of libraries set this cfg flag to avoid + // the runner from specifying a target. + if !cfg!(trybuild_no_target) && !cfg!(postcompile_no_target) && config.target_dir.ends_with(TARGET) { + build.arg(format!("--target={TARGET}")); + } + + let output = match build.output() { + Err(e) => { + return Err(Errored { + command: format!("{build:?}"), + errors: vec![], + stderr: e.to_string(), + stdout: String::new(), + }); + } + Ok(o) => o, + }; + + if !output.status.success() { + let stdout = output + .stdout + .split(|&b| b == b'\n') + .flat_map( + |line| match serde_json::from_slice::(line) { + Ok(cargo_metadata::Message::CompilerArtifact(artifact)) => { + format!("{artifact:?}\n").into_bytes() + } + Ok(cargo_metadata::Message::BuildFinished(bf)) => { + format!("{bf:?}\n").into_bytes() + } + Ok(cargo_metadata::Message::BuildScriptExecuted(be)) => { + format!("{be:?}\n").into_bytes() + } + Ok(cargo_metadata::Message::TextLine(s)) => s.into_bytes(), + Ok(cargo_metadata::Message::CompilerMessage(msg)) => msg + .target + .src_path + .as_str() + .as_bytes() + .iter() + .copied() + .chain([b'\n']) + .chain(msg.message.rendered.unwrap_or_default().into_bytes()) + .collect(), + Ok(_) => vec![], + Err(_) => line.iter().copied().chain([b'\n']).collect(), + }, + ) + .collect::>(); + + return Err(Errored { + command: format!("{build:?}"), + errors: vec![], + stderr: String::from_utf8(output.stderr).unwrap(), + stdout: String::from_utf8(stdout).unwrap(), + }); + } + + // Collect all artifacts generated + let artifact_output = output.stdout; + let mut import_paths: HashSet = HashSet::new(); + let mut import_libs: HashSet = HashSet::new(); + let mut artifacts = HashMap::new(); + let mut all_cfgs = HashMap::new(); + let mut all_env = HashMap::new(); + + for line in artifact_output.split(|&b| b == b'\n') { + let Ok(message) = serde_json::from_slice::(line) else { + continue; + }; + match message { + cargo_metadata::Message::CompilerArtifact(artifact) + if artifact.executable.is_none() => + { + if artifact.target.crate_types.iter().all(|ctype| { + !matches!( + ctype, + cargo_metadata::CrateType::ProcMacro + | cargo_metadata::CrateType::Lib + | cargo_metadata::CrateType::RLib + ) + }) { + continue; + } + + for filename in &artifact.filenames { + import_paths.insert(filename.parent().unwrap().into()); + } + + let package_id = artifact.package_id; + + if let Some(prev) = artifacts.insert( + package_id.clone(), + Ok((artifact.target.name, artifact.filenames)), + ) { + artifacts.insert( + package_id.clone(), + Err(format!( + "{prev:#?} vs {:#?} ({:?})", + artifacts[&package_id], artifact.target.crate_types + )), + ); + } + } + cargo_metadata::Message::BuildScriptExecuted(BuildScript { + linked_libs, + linked_paths, + cfgs, + env, + package_id, + .. + }) => { + import_paths.extend(linked_paths.into_iter().map(Into::into)); + import_libs.extend(linked_libs.into_iter().map(Into::into)); + + all_cfgs + .entry(package_id.clone()) + .or_insert_with(Vec::new) + .extend(cfgs); + all_env + .entry(package_id.clone()) + .or_insert_with(Vec::new) + .extend(env); + } + _ => {} + } + } + + // Check which crates are mentioned in the crate itself + let mut metadata = cargo_metadata::MetadataCommand::new().cargo_command(); + metadata.arg("--manifest-path").arg(config.manifest.as_ref()); + metadata.arg("--locked"); + if let Some(features) = features::find() { + metadata.arg(format!("--features={}", features.join(","))); + } + + let output = match metadata.output() { + Err(e) => { + eprintln!("failed to run cargo metadata: \n{:#}", e); + std::process::exit(1); + } + Ok(output) => output, + }; + + if !output.status.success() { + eprintln!( + "cargo metadata failed: \n{}", + String::from_utf8_lossy(&output.stderr) + ); + std::process::exit(1); + } + + let output = output.stdout; + + if let Some(line) = output + .split(|&b| b == b'\n') + .find(|line| line.starts_with(b"{")) + { + let rustc_cfg = rustc_cfg(); + + let metadata: cargo_metadata::Metadata = + serde_json::from_slice(line).map_err(|err| Errored { + command: "decoding cargo metadata json".into(), + errors: vec![], + stderr: err.to_string(), + stdout: String::new(), + })?; + + let root = metadata + .packages + .iter() + .find(|package| { + package.manifest_path.as_std_path().canonicalize().unwrap() + == config.manifest.as_ref().canonicalize().unwrap() + }) + .unwrap(); + + let rustc_cfg = rustc_cfg + .iter() + .chain(all_cfgs.get(&root.id).into_iter().flatten()) + .into_iter() + .map(|cfg| { + let mut splits = cfg.splitn(2, '='); + let key = splits.next().unwrap(); + let value = splits.next(); + if let Some(value) = value { + Cfg::KeyPair(key.to_string(), value.to_string()) + } else { + Cfg::Name(key.to_string()) + } + }) + .collect::>(); + + let dependencies = root + .dependencies + .iter() + .filter(|dep| matches!(dep.kind, DependencyKind::Normal | DependencyKind::Development)) + // Only consider dependencies that are enabled on the current target + .filter(|dep| match &dep.target { + Some(platform) => platform.matches(TARGET, &rustc_cfg), + None => true, + }) + .map(|dep| { + for p in &metadata.packages { + if p.name != dep.name { + continue; + } + if dep + .path + .as_ref() + .is_some_and(|path| p.manifest_path.parent().unwrap() == path) + || dep.req.matches(&p.version) + { + return (p, dep.rename.clone().unwrap_or_else(|| p.name.clone())); + } + } + panic!("dep not found: {dep:#?}") + }) + // Also expose the root crate + .chain(std::iter::once((root, root.name.clone()))) + .filter_map(|(package, name)| { + // Get the id for the package matching the version requirement of the dep + let id = &package.id; + // Return the name chosen in `Cargo.toml` and the path to the corresponding artifact + match artifacts.remove(id) { + Some(Ok((_, artifacts))) => Some(Ok((name.replace('-', "_"), artifacts.into_iter().map(Into::into).collect()))), + Some(Err(what)) => Some(Err(Errored { + command: what, + errors: vec![], + stderr: id.to_string(), + stdout: "`postcompile` does not support crates that appear as both build-dependencies and core dependencies".into(), + })), + None => { + if name == root.name { + // If there are no artifacts, this is the root crate and it is being built as a binary/test + // instead of a library. We simply add no artifacts, meaning you can't depend on functions + // and types declared in the root crate. + None + } else { + panic!("no artifact found for `{name}`(`{id}`):`\n{}", String::from_utf8_lossy(&artifact_output)) + } + } + } + }) + .collect::, Errored>>()?; + let import_paths = import_paths.into_iter().collect(); + let import_libs = import_libs.into_iter().collect(); + + return Ok(Dependencies { + dependencies, + import_paths, + import_libs, + edition: root.edition.clone(), + cfg: all_cfgs.get(&root.id).cloned().unwrap_or_default(), + env: all_env.get(&root.id).cloned().unwrap_or_default(), + }); + } + + Err(Errored { + command: "looking for json in cargo-metadata output".into(), + errors: vec![], + stderr: String::new(), + stdout: String::new(), + }) + } + + pub fn apply(&self, command: &mut Command) { + for (name, artifacts) in &self.dependencies { + for dependency in artifacts { + command.arg("--extern"); + let mut dep = OsString::from(&name); + dep.push("="); + dep.push(dependency); + command.arg(dep); + } + } + for import_path in &self.import_paths { + command.arg("-L"); + command.arg(import_path); + } + + for import_path in &self.import_libs { + command.arg("-l"); + command.arg(import_path); + } + + command.arg("--edition"); + command.arg(self.edition.as_str()); + + for cfg in &self.cfg { + command.arg("--cfg"); + command.arg(cfg); + } + + for (key, value) in &self.env { + command.env(key, value); + } + } +} + +#[derive(Debug)] +pub struct Errored { + pub command: String, + pub errors: Vec, + pub stderr: String, + pub stdout: String, +} + +fn rustc_cfg() -> Vec { + Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())) + .arg("--print") + .arg("cfg") + .output() + .unwrap() + .stdout + .split(|&b| b == b'\n') + .map(|line| String::from_utf8_lossy(line).to_string()) + .filter(|line| !line.is_empty()) + .collect() +} diff --git a/crates/postcompile/src/features.rs b/crates/postcompile/src/features.rs new file mode 100644 index 00000000..77678828 --- /dev/null +++ b/crates/postcompile/src/features.rs @@ -0,0 +1,104 @@ +use serde::de::{self, Deserialize, DeserializeOwned, Deserializer}; +use serde_derive::Deserialize; +use std::env; +use std::error::Error; +use std::ffi::OsStr; +use std::fs; +use std::path::PathBuf; + +pub(crate) fn find() -> Option> { + try_find().ok() +} + +struct Ignored; + +impl From for Ignored { + fn from(_error: E) -> Self { + Ignored + } +} + +#[derive(Deserialize)] +struct Build { + #[serde(deserialize_with = "from_json")] + features: Vec, +} + +fn try_find() -> Result, Ignored> { + // This will look something like: + // /path/to/crate_name/target/debug/deps/test_name-HASH + let test_binary = env::args_os().next().ok_or(Ignored)?; + + // The hash at the end is ascii so not lossy, rest of conversion doesn't + // matter. + let test_binary_lossy = test_binary.to_string_lossy(); + let hash_range = if cfg!(windows) { + // Trim ".exe" from the binary name for windows. + test_binary_lossy.len() - 21..test_binary_lossy.len() - 4 + } else { + test_binary_lossy.len() - 17..test_binary_lossy.len() + }; + let hash = test_binary_lossy.get(hash_range).ok_or(Ignored)?; + if !hash.starts_with('-') || !hash[1..].bytes().all(is_lower_hex_digit) { + return Err(Ignored); + } + + let binary_path = PathBuf::from(&test_binary); + + // Feature selection is saved in: + // /path/to/crate_name/target/debug/.fingerprint/*-HASH/*-HASH.json + let up = binary_path + .parent() + .ok_or(Ignored)? + .parent() + .ok_or(Ignored)?; + let fingerprint_dir = up.join(".fingerprint"); + if !fingerprint_dir.is_dir() { + return Err(Ignored); + } + + let mut hash_matches = Vec::new(); + for entry in fingerprint_dir.read_dir()? { + let entry = entry?; + let is_dir = entry.file_type()?.is_dir(); + let matching_hash = entry.file_name().to_string_lossy().ends_with(hash); + if is_dir && matching_hash { + hash_matches.push(entry.path()); + } + } + + if hash_matches.len() != 1 { + return Err(Ignored); + } + + let mut json_matches = Vec::new(); + for entry in hash_matches[0].read_dir()? { + let entry = entry?; + let is_file = entry.file_type()?.is_file(); + let is_json = entry.path().extension() == Some(OsStr::new("json")); + if is_file && is_json { + json_matches.push(entry.path()); + } + } + + if json_matches.len() != 1 { + return Err(Ignored); + } + + let build_json = fs::read_to_string(&json_matches[0])?; + let build: Build = serde_json::from_str(&build_json)?; + Ok(build.features) +} + +fn is_lower_hex_digit(byte: u8) -> bool { + byte >= b'0' && byte <= b'9' || byte >= b'a' && byte <= b'f' +} + +fn from_json<'de, T, D>(deserializer: D) -> Result +where + T: DeserializeOwned, + D: Deserializer<'de>, +{ + let json = String::deserialize(deserializer)?; + serde_json::from_str(&json).map_err(de::Error::custom) +} diff --git a/crates/postcompile/src/lib.rs b/crates/postcompile/src/lib.rs new file mode 100644 index 00000000..9f7d9640 --- /dev/null +++ b/crates/postcompile/src/lib.rs @@ -0,0 +1,300 @@ +#![doc = include_str!("../README.md")] + +use std::{ + borrow::Cow, + ffi::{OsStr, OsString}, + os::unix::ffi::OsStrExt, + path::Path, + process::Command, +}; + +use deps::{Dependencies, Errored}; + +mod deps; +mod features; + +/// The return status of the compilation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExitStatus { + /// If the compiler returned a 0 exit code. + Success, + /// If the compiler returned a non-0 exit code. + Failure(i32), +} + +impl std::fmt::Display for ExitStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExitStatus::Success => write!(f, "0"), + ExitStatus::Failure(code) => write!(f, "{}", code), + } + } +} + +/// The output of the compilation. +#[derive(Debug)] +pub struct CompileOutput { + /// The status of the compilation. + pub status: ExitStatus, + /// The stdout of the compilation. + /// This will contain the expanded code. + pub stdout: String, + /// The stderr of the compilation. + /// This will contain any errors or warnings from the compiler. + pub stderr: String, +} + +impl std::fmt::Display for CompileOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "exit status: {}\n", self.status)?; + if !self.stderr.is_empty() { + write!(f, "--- stderr \n{}\n", self.stderr)?; + } + if !self.stdout.is_empty() { + write!(f, "--- stdout \n{}\n", self.stdout)?; + } + Ok(()) + } +} + +fn rustc(config: &Config, tmp_file: &Path) -> Command { + let mut program = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())); + program.env("RUSTC_BOOTSTRAP", "1"); + let rust_flags = std::env::var_os("RUSTFLAGS"); + + if let Some(rust_flags) = &rust_flags { + program.args( + rust_flags + .as_encoded_bytes() + .split(|&b| b == b' ') + .map(|flag| OsString::from(OsStr::from_bytes(flag))), + ); + } + + program.arg("--crate-name"); + program.arg(config.function_name.split("::").last().unwrap_or("unnamed")); + program.arg(tmp_file); + program.envs(std::env::vars()); + + program.stderr(std::process::Stdio::piped()); + program.stdout(std::process::Stdio::piped()); + + program +} + +fn write_tmp_file(tokens: &str, tmp_file: &Path) { + #[cfg(feature = "prettyplease")] + { + if let Ok(syn_file) = syn::parse_file(&tokens) { + let pretty_file = prettyplease::unparse(&syn_file); + std::fs::write(tmp_file, pretty_file).unwrap(); + return; + } + } + + std::fs::write(tmp_file, tokens).unwrap(); +} + +/// Compiles the given tokens and returns the output. +pub fn compile_custom(tokens: &str, config: &Config) -> Result { + let tmp_file = Path::new(config.tmp_dir.as_ref()).join(format!("{}.rs", config.function_name)); + + write_tmp_file(tokens, &tmp_file); + + let dependencies = Dependencies::new(config)?; + + let mut program = rustc(config, &tmp_file); + + dependencies.apply(&mut program); + // The first invoke is used to get the macro expanded code. + program.arg("-Zunpretty=expanded"); + + let output = program.output().unwrap(); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let syn_file = syn::parse_file(&stdout); + #[cfg(feature = "prettyplease")] + let stdout = syn_file.as_ref().map(|file| prettyplease::unparse(&file)).unwrap_or(stdout); + + let mut crate_type = "lib"; + + if let Ok(file) = syn_file { + if file.items.iter().any(|item| { + let syn::Item::Fn(func) = item else { + return false; + }; + + func.sig.ident == "main" + }) { + crate_type = "bin"; + } + }; + + let mut status = if output.status.success() { + ExitStatus::Success + } else { + ExitStatus::Failure(output.status.code().unwrap_or(-1)) + }; + + let stderr = if status == ExitStatus::Success { + let mut program = rustc(config, &tmp_file); + dependencies.apply(&mut program); + program.arg("--emit=llvm-ir"); + program.arg(&format!("--crate-type={crate_type}")); + program.arg("-o"); + program.arg("-"); + let comp_output = program.output().unwrap(); + status = if comp_output.status.success() { + ExitStatus::Success + } else { + ExitStatus::Failure(comp_output.status.code().unwrap_or(-1)) + }; + String::from_utf8(comp_output.stderr).unwrap() + } else { + String::from_utf8(output.stderr).unwrap() + }; + + let stderr = stderr.replace(tmp_file.as_os_str().to_string_lossy().as_ref(), ""); + let stdout = stdout.replace(tmp_file.as_os_str().to_string_lossy().as_ref(), ""); + + Ok(CompileOutput { + status, + stdout, + stderr, + }) +} + +/// The configuration for the compilation. +#[derive(Clone, Debug)] +pub struct Config { + /// The path to the cargo manifest file of the library being tested. + /// This is so that we can include the `dependencies` & `dev-dependencies` making them available in the code provided. + pub manifest: Cow<'static, Path>, + /// The path to the target directory, used to cache builds & find dependencies. + pub target_dir: Cow<'static, Path>, + /// A temporary directory to write the expanded code to. + pub tmp_dir: Cow<'static, Path>, + /// The name of the function to compile. + pub function_name: Cow<'static, str>, +} + +#[macro_export] +#[doc(hidden)] +macro_rules! _function_name { + () => {{ + fn f() {} + fn type_name_of_val(_: T) -> &'static str { + std::any::type_name::() + } + let mut name = type_name_of_val(f).strip_suffix("::f").unwrap_or(""); + while let Some(rest) = name.strip_suffix("::{{closure}}") { + name = rest; + } + name + }}; +} + +#[doc(hidden)] +pub fn build_dir() -> &'static Path { + Path::new(env!("OUT_DIR")) +} + +#[doc(hidden)] +pub fn target_dir() -> &'static Path { + build_dir() + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() + .parent() + .unwrap() +} + +#[macro_export] +#[doc(hidden)] +macro_rules! _config { + () => {{ + $crate::Config { + manifest: ::std::borrow::Cow::Borrowed(::std::path::Path::new(env!("CARGO_MANIFEST_PATH"))), + tmp_dir: ::std::borrow::Cow::Borrowed($crate::build_dir()), + target_dir: ::std::borrow::Cow::Borrowed($crate::target_dir()), + function_name: ::std::borrow::Cow::Borrowed($crate::_function_name!()), + } + }}; +} + +/// Compiles the given tokens and returns the output. +/// +/// This macro will panic if we fail to invoke the compiler. +/// +/// ```rs +/// // Dummy macro to assert the snapshot. +/// macro_rules! assert_snapshot { +/// ($expr:expr) => {}; +/// } +/// +/// let output = postcompile::compile! { +/// const TEST: u32 = 1; +/// }; +/// +/// assert_eq!(output.status, postcompile::ExitStatus::Success); +/// assert!(output.stderr.is_empty()); +/// assert_snapshot!(output.stdout); // We dont have an assert_snapshot! macro in this crate, but you get the idea. +/// ``` +#[macro_export] +macro_rules! compile { + ($($tokens:tt)*) => { + $crate::compile_str!(stringify!($($tokens)*)) + }; +} + +/// Compiles the given string of tokens and returns the output. +/// +/// This macro will panic if we fail to invoke the compiler. +/// +/// Same as the [`compile!`] macro, but for strings. This allows you to do: +/// +/// ```rs +/// let output = postcompile::compile_str!(include_str!("some_file.rs")); +/// +/// // ... do something with the output +/// ``` +#[macro_export] +macro_rules! compile_str { + ($expr:expr) => { + $crate::try_compile_str!($expr).expect("failed to compile") + }; +} + +/// Compiles the given string of tokens and returns the output. +/// +/// This macro will return an error if we fail to invoke the compiler. Unlike the [`compile!`] macro, this will not panic. +/// +/// ```rs +/// let output = postcompile::try_compile! { +/// const TEST: u32 = 1; +/// }; +/// +/// assert!(output.is_ok()); +/// assert_eq!(output.unwrap().status, postcompile::ExitStatus::Success); +/// ``` +#[macro_export] +macro_rules! try_compile { + ($($tokens:tt)*) => { + $crate::try_compile_str!(stringify!($($tokens)*)) + }; +} + +/// Compiles the given string of tokens and returns the output. +/// +/// This macro will return an error if we fail to invoke the compiler. +/// +/// Same as the [`try_compile!`] macro, but for strings similar usage to [`compile_str!`]. +#[macro_export] +macro_rules! try_compile_str { + ($expr:expr) => { + $crate::compile_custom($expr, &$crate::_config!()) + }; +}