Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/perry/src/commands/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod bootstrap;
mod bundle_apple;
mod bundle_ios;
mod cjs_wrap;
mod codegen_steps;
mod collect_modules;
mod harmonyos_shim;
mod host_config;
Expand Down Expand Up @@ -218,6 +219,12 @@ pub fn run_with_parse_cache(
let (i18n_config, i18n_translations) =
apply_pkg_and_toml_config(&args, &project_root, &mut ctx, format)?;

// #1680 (Phase 2 of #1677): run host-declared build-time codegen steps
// (e.g. `ajv/standalone`, `prisma generate`) before module collection so
// the eval-free generated output is on disk for the normal compile path.
let skip_codegen = args.no_codegen || codegen_steps::skip_from_env();
codegen_steps::run_codegen_steps(&ctx, skip_codegen, format)?;

maybe_init_type_checker(&args, &project_root, format, &mut ctx);

let mut visited = HashSet::new();
Expand Down
185 changes: 185 additions & 0 deletions crates/perry/src/commands/compile/codegen_steps.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
//! #1680 (Phase 2 of #1677) — run host-declared build-time codegen steps.
//!
//! Several codegen libraries already ship an eval-free path built for CSP
//! environments that emits plain source at build time (`ajv/standalone`,
//! `prisma generate`, `drizzle-kit introspect`, `kysely-codegen`, the Vue
//! SFC compiler, …). Where that exists, Perry consumes the standalone
//! output instead of running a JIT at runtime — zero new evaluation infra,
//! highest ROI.
//!
//! The convention: the host `package.json` declares the build commands
//! under `perry.codegen`; Perry runs them (in the package.json's directory)
//! *before* module collection, so the generated, eval-free source is on
//! disk for the normal native compile path to pick up. Steps are read only
//! from the host package.json — never a dependency's — the same trust
//! boundary as `perry.compilePackages` (a transitive dep can't smuggle in a
//! build command). `--no-codegen` / `PERRY_SKIP_CODEGEN=1` skips the steps
//! for reproducible / sandboxed builds whose generated output is committed.

use std::process::Command;

use anyhow::{anyhow, bail, Result};

use super::CompilationContext;
use crate::OutputFormat;

/// Run every `perry.codegen` step declared in the host package.json, in
/// declaration order, in `ctx.codegen_dir` (falling back to the project
/// root). `skip` short-circuits the whole pass (driven by `--no-codegen` /
/// `PERRY_SKIP_CODEGEN`). Bails on the first command that fails to spawn or
/// exits non-zero, surfacing its captured stdout/stderr so the failure is
/// actionable.
pub(super) fn run_codegen_steps(
ctx: &CompilationContext,
skip: bool,
format: OutputFormat,
) -> Result<()> {
if ctx.codegen_steps.is_empty() {
return Ok(());
}
if skip {
if matches!(format, OutputFormat::Text) {
println!(
" Skipping {} perry.codegen step(s) (--no-codegen / PERRY_SKIP_CODEGEN)",
ctx.codegen_steps.len()
);
}
return Ok(());
}

let cwd = ctx
.codegen_dir
.clone()
.unwrap_or_else(|| ctx.project_root.clone());

for step in &ctx.codegen_steps {
let label = step.label.as_deref().unwrap_or(step.command.as_str());
if matches!(format, OutputFormat::Text) {
println!(" Codegen: {label}");
}
let output = shell_command(&step.command)
.current_dir(&cwd)
.output()
.map_err(|e| {
anyhow!(
"failed to spawn perry.codegen step `{}`: {}",
step.command,
e
)
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"perry.codegen step failed: `{cmd}`\n cwd: {cwd}\n exit: {status}\n\
--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}",
cmd = step.command,
cwd = cwd.display(),
status = output.status,
stdout = stdout.trim_end(),
stderr = stderr.trim_end(),
);
}
}
Ok(())
}

/// Whether `PERRY_SKIP_CODEGEN` is set to a truthy value.
pub(super) fn skip_from_env() -> bool {
match std::env::var("PERRY_SKIP_CODEGEN") {
Ok(v) => {
let v = v.trim().to_ascii_lowercase();
!matches!(v.as_str(), "" | "0" | "off" | "false" | "no")
}
Err(_) => false,
}
}

/// Build a shell command so full command strings (`node gen.mjs && …`)
/// work as written. Perry's supported compile hosts are unix; Windows uses
/// `cmd /C` for parity but is untested here.
#[cfg(not(windows))]
fn shell_command(cmd: &str) -> Command {
let mut c = Command::new("sh");
c.arg("-c").arg(cmd);
c
}

#[cfg(windows)]
fn shell_command(cmd: &str) -> Command {
let mut c = Command::new("cmd");
c.arg("/C").arg(cmd);
c
}

#[cfg(test)]
mod tests {
use super::*;
use crate::commands::compile::CodegenStep;

fn ctx_with_steps(dir: &std::path::Path, steps: Vec<CodegenStep>) -> CompilationContext {
let mut ctx = CompilationContext::new(dir.to_path_buf());
ctx.codegen_dir = Some(dir.to_path_buf());
ctx.codegen_steps = steps;
ctx
}

#[test]
fn runs_step_in_codegen_dir_and_produces_output() {
let dir = std::env::temp_dir().join(format!("perry_codegen_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ctx = ctx_with_steps(
&dir,
vec![CodegenStep {
label: Some("write sentinel".to_string()),
// Relative path → resolves against codegen_dir.
command: "printf done > generated.txt".to_string(),
}],
);
run_codegen_steps(&ctx, false, OutputFormat::Json).unwrap();
let produced = std::fs::read_to_string(dir.join("generated.txt")).unwrap();
assert_eq!(produced, "done");
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn skip_does_not_run_steps() {
let dir = std::env::temp_dir().join(format!("perry_codegen_skip_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ctx = ctx_with_steps(
&dir,
vec![CodegenStep {
label: None,
command: "printf done > should_not_exist.txt".to_string(),
}],
);
run_codegen_steps(&ctx, true, OutputFormat::Json).unwrap();
assert!(!dir.join("should_not_exist.txt").exists());
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn failing_step_bails_with_diagnostics() {
let dir = std::env::temp_dir().join(format!("perry_codegen_fail_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let ctx = ctx_with_steps(
&dir,
vec![CodegenStep {
label: None,
command: "echo boom 1>&2; exit 3".to_string(),
}],
);
let err = run_codegen_steps(&ctx, false, OutputFormat::Json).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("perry.codegen step failed"));
assert!(msg.contains("boom"));
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn no_steps_is_noop() {
let dir = std::env::temp_dir();
let ctx = CompilationContext::new(dir);
run_codegen_steps(&ctx, false, OutputFormat::Json).unwrap();
}
}
40 changes: 40 additions & 0 deletions crates/perry/src/commands/compile/host_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,46 @@ pub(super) fn apply_pkg_and_toml_config(
}
}
}
// #1680 (Phase 2 of #1677): build-time codegen steps. Each
// entry is a shell command (or `{ command, label }`) run
// before module collection so codegen libraries with an
// eval-free build-time output (`ajv/standalone`, `prisma
// generate`, …) emit native-compilable source. Read only
// from the host package.json — never a dependency's — so a
// transitive dep can't smuggle in a build command (same
// trust boundary as compilePackages). The run cwd is the
// host package.json's directory so relative script paths
// resolve correctly.
if let Some(steps) = pkg
.get("perry")
.and_then(|p| p.get("codegen"))
.and_then(|v| v.as_array())
{
ctx.codegen_dir = pkg_json_path.parent().map(Path::to_path_buf);
for entry in steps {
let step = if let Some(cmd) = entry.as_str() {
Some(super::CodegenStep {
label: None,
command: cmd.to_string(),
})
} else if let Some(obj) = entry.as_object() {
obj.get("command").and_then(|c| c.as_str()).map(|cmd| {
super::CodegenStep {
label: obj
.get("label")
.and_then(|l| l.as_str())
.map(str::to_string),
command: cmd.to_string(),
}
})
} else {
None
};
if let Some(step) = step {
ctx.codegen_steps.push(step);
}
}
}
// perry.fastMath: opt in to LLVM `reassoc` per-instruction
// FMF flags on f64 ops. Off by default — Perry produces
// bit-exact f64 with Node. See `docs/src/cli/fast-math.md`.
Expand Down
33 changes: 33 additions & 0 deletions crates/perry/src/commands/compile/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ pub struct CompileArgs {
#[arg(long)]
pub no_link: bool,

/// #1680: skip the host `package.json` `perry.codegen` build-time
/// steps (also via `PERRY_SKIP_CODEGEN=1`). Use for reproducible /
/// sandboxed builds where codegen output is committed and re-running
/// the generator is unnecessary or undesirable.
#[arg(long)]
pub no_codegen: bool,

/// Enable WebAssembly host runtime so the produced binary can load .wasm
/// modules at runtime via `WebAssembly.instantiate(bytes)`. Engine: wasmi
/// (pure-Rust interpreter). Adds ~1MB to the binary. Issue #76.
Expand Down Expand Up @@ -338,6 +345,17 @@ pub struct JsModule {
pub specifier: String,
}

/// #1680 (Phase 2 of #1677): one build-time codegen step from the host
/// `package.json` `perry.codegen` array. Declared as either a bare command
/// string or `{ "command": "...", "label": "..." }`.
#[derive(Debug, Clone)]
pub struct CodegenStep {
/// Optional human-readable label shown in build output.
pub label: Option<String>,
/// The shell command to run before compilation (via `sh -c`).
pub command: String,
}

/// Compilation context tracking all modules
pub struct CompilationContext {
/// Native TypeScript modules to compile
Expand Down Expand Up @@ -393,6 +411,19 @@ pub struct CompilationContext {
pub app_metadata: perry_codegen::AppMetadata,
/// First-resolved directory for each compile package (deduplication across nested node_modules)
pub compile_package_dirs: HashMap<String, PathBuf>,
/// #1680 (Phase 2 of #1677): build-time codegen steps declared in the
/// host `package.json` `perry.codegen`. Each is a shell command run
/// (in `codegen_dir`) before module collection, so a codegen library
/// with an eval-free build-time output (`ajv/standalone`, `prisma
/// generate`, `drizzle-kit introspect`, …) emits native-compilable
/// source the normal compile path then picks up — no runtime eval. Read
/// only from the host package.json (never a dependency's), same trust
/// boundary as `perry.compilePackages`.
pub codegen_steps: Vec<CodegenStep>,
/// Working directory for `codegen_steps` — the directory of the host
/// `package.json` they were declared in (relative script paths resolve
/// against it). `None` when no host package.json was found.
pub codegen_dir: Option<PathBuf>,
/// Optional tsgo type checker client (when --type-check is enabled)
pub type_checker: Option<crate::commands::typecheck::TsGoClient>,
/// Cache for resolve_import results: (import_source, importer_dir) -> Option<(resolved_path, kind)>
Expand Down Expand Up @@ -587,6 +618,8 @@ impl CompilationContext {
fp_contract_mode: perry_codegen::FpContractMode::Off,
app_metadata: perry_codegen::AppMetadata::default(),
compile_package_dirs: HashMap::new(),
codegen_steps: Vec::new(),
codegen_dir: None,
type_checker: None,
resolve_cache: HashMap::new(),
node_modules_cache: HashMap::new(),
Expand Down
1 change: 1 addition & 0 deletions crates/perry/src/commands/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ fn build_once(
keep_intermediates: false,
print_hir: false,
no_link: false,
no_codegen: false,
enable_wasm_runtime: false,
target: None,
app_bundle_id: None,
Expand Down
1 change: 1 addition & 0 deletions crates/perry/src/commands/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) ->
keep_intermediates: false,
print_hir: false,
no_link: false,
no_codegen: false,
enable_wasm_runtime: args.enable_wasm_runtime,
target: target.clone(),
app_bundle_id: Some(bundle_id),
Expand Down
1 change: 1 addition & 0 deletions docs/src/cli/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Use `--output-type` to change what's produced:
| `--trace <STAGES>` | Dump IR at one or more pipeline stages. Comma-separated: `hir` (post-transform HIR), `llvm` (per-module `.ll` into `.perry-trace/llvm/`), or `all` |
| `--focus <NAME>` | Restrict `--trace hir` to functions/methods/classes whose name contains `NAME`, suppressing import/export/init noise. Implies `--trace hir` if no stage is given |
| `--no-link` | Produce `.o` object file only, skip linking |
| `--no-codegen` | Skip the `package.json` `perry.codegen` build-time steps (also `PERRY_SKIP_CODEGEN=1`). See [Project Configuration](../getting-started/project-config.md) |
| `--keep-intermediates` | Keep `.o` and `.asm` intermediate files |

The `--trace`/`--focus` pair localizes "compiled to the wrong thing" bugs:
Expand Down
Loading