diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index adfad3b6d1..1ecef823c1 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -36,7 +36,7 @@ //! follows up to a small depth (2 levels) to handle one level of env //! switching; deeper indirection is rare and gets the no-op fallback. -mod detect; +pub(in crate::commands::compile) mod detect; mod extract_exports; mod extract_requires; mod hoist_classes; diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index a166b3518f..16f0a92dd8 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -31,18 +31,17 @@ use super::{ ParseCache, }; -mod create_require_transform; mod crypto_ns; mod dynamic_glob; mod feature_detect; mod import_helpers; mod native_addon; mod parse_error; +mod static_require_transform; #[cfg(test)] mod tests; mod wasm_asset; -use create_require_transform::transform_create_require_literal_requires; use dynamic_glob::expand_dynamic_import_glob; use import_helpers::{ cached_resolve_import_with_lexical_base, collect_js_module_imports, env_defines_for_lowering, @@ -52,6 +51,7 @@ use import_helpers::{ pub(super) use import_helpers::known_node_submodule_key; use native_addon::refuse_compile_package_native_addon; use parse_error::annotate_parse_error; +use static_require_transform::transform_static_literal_requires; use wasm_asset::{is_wasm_asset, synthesize_wasm_stub_module}; const MAX_CROSS_MODULE_INLINE_PRIOR_MODULES: usize = 128; @@ -427,13 +427,13 @@ fn collect_module_one( } else { raw_source }; - // #5247: the create-require transform may prepend `import * as` lines, + // #5247: the static-require transform may prepend `import * as` lines, // shifting BOTH the prefix and the body down by the same number of lines. // Capture the wrapped line count, run the transform, then add the line // delta to the prefix so the wrapped-line → original-line subtraction is // computed against the FINAL parsed source. let lines_before_transform = source.bytes().filter(|&b| b == b'\n').count(); - let source = transform_create_require_literal_requires(&source, &ctx.compile_packages); + let source = transform_static_literal_requires(&source, &ctx.compile_packages); if was_cjs_wrapped && ctx.debug_symbols { if let Some(prefix_lines) = cjs_wrap_body_prefix_lines { let lines_after_transform = source.bytes().filter(|&b| b == b'\n').count(); diff --git a/crates/perry/src/commands/compile/collect_modules/create_require_transform.rs b/crates/perry/src/commands/compile/collect_modules/create_require_transform.rs deleted file mode 100644 index 55d27c494c..0000000000 --- a/crates/perry/src/commands/compile/collect_modules/create_require_transform.rs +++ /dev/null @@ -1,200 +0,0 @@ -use std::collections::HashSet; -use std::sync::OnceLock; - -use regex::Regex; - -use super::parse_package_specifier; - -pub(super) fn transform_create_require_literal_requires( - source: &str, - compile_packages: &HashSet, -) -> String { - let create_require_aliases = collect_create_require_aliases(source); - if create_require_aliases.is_empty() { - return source.to_string(); - } - - let require_aliases = collect_require_aliases(source, &create_require_aliases); - if require_aliases.is_empty() { - return source.to_string(); - } - - let mut transformed = source.to_string(); - let mut imports = Vec::new(); - let mut next_id = 0usize; - for alias in require_aliases { - let call_re = require_assignment_re(&alias); - let mut replacements = Vec::new(); - for cap in call_re.captures_iter(&transformed) { - let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); - if should_leave_runtime_require(specifier, compile_packages) { - continue; - } - let Some(full) = cap.get(0) else { - continue; - }; - let temp = unique_temp_name(&transformed, &mut next_id); - imports.push(format!("import * as {temp} from {:?};", specifier)); - let indent = cap.name("indent").map(|m| m.as_str()).unwrap_or(""); - let kind = cap.name("kind").map(|m| m.as_str()).unwrap_or("const"); - let lhs = cap.name("lhs").map(|m| m.as_str()).unwrap_or("").trim_end(); - let tail = cap.name("tail").map(|m| m.as_str()).unwrap_or(""); - replacements.push(( - full.start(), - full.end(), - format!("{indent}{kind} {lhs} = {temp};{tail}"), - )); - } - - if replacements.is_empty() { - continue; - } - for (start, end, replacement) in replacements.into_iter().rev() { - transformed.replace_range(start..end, &replacement); - } - } - - if imports.is_empty() { - return source.to_string(); - } - - let mut prefix = imports.join("\n"); - prefix.push('\n'); - if transformed.starts_with("#!") { - if let Some(line_end) = transformed.find('\n') { - let mut out = String::new(); - out.push_str(&transformed[..=line_end]); - out.push_str(&prefix); - out.push_str(&transformed[line_end + 1..]); - out - } else { - format!("{transformed}\n{prefix}") - } - } else { - prefix.push_str(&transformed); - prefix - } -} - -fn collect_create_require_aliases(source: &str) -> HashSet { - static IMPORT_RE: OnceLock = OnceLock::new(); - let import_re = IMPORT_RE.get_or_init(|| { - Regex::new( - r#"(?m)^\s*import\s*\{(?P[^}]*)\}\s*from\s*['"](?:node:)?module['"]\s*;?"#, - ) - .expect("createRequire import regex") - }); - - let mut aliases = HashSet::new(); - for cap in import_re.captures_iter(source) { - let Some(specs) = cap.name("specs") else { - continue; - }; - for part in specs.as_str().split(',') { - let part = part.trim(); - if part == "createRequire" { - aliases.insert("createRequire".to_string()); - continue; - } - if let Some(rest) = part.strip_prefix("createRequire as ") { - let alias = rest.trim(); - if is_identifier(alias) { - aliases.insert(alias.to_string()); - } - } - } - } - aliases -} - -fn collect_require_aliases(source: &str, create_require_aliases: &HashSet) -> Vec { - let mut out = Vec::new(); - for create_alias in create_require_aliases { - let decl_re = create_require_decl_re(create_alias); - for cap in decl_re.captures_iter(source) { - let Some(alias) = cap.name("alias").map(|m| m.as_str()) else { - continue; - }; - if !out.iter().any(|existing| existing == alias) { - out.push(alias.to_string()); - } - } - } - out -} - -fn create_require_decl_re(create_alias: &str) -> Regex { - Regex::new(&format!( - r#"(?m)^\s*(?:const|let|var)\s+(?P[A-Za-z_$][A-Za-z0-9_$]*)(?:\s*:\s*[^=;]+)?\s*=\s*{}\s*\(\s*import\.meta\.url\s*\)\s*;?"#, - regex::escape(create_alias) - )) - .expect("createRequire declaration regex") -} - -fn require_assignment_re(require_alias: &str) -> Regex { - Regex::new(&format!( - r#"(?m)^(?P[ \t]*)(?Pconst|let|var)\s+(?P[^=\n;]+?)\s*=\s*{}\s*\(\s*['"](?P[^'"]+)['"]\s*\)\s*;?(?P[ \t]*(?://[^\n]*)?)$"#, - regex::escape(require_alias) - )) - .expect("createRequire literal call regex") -} - -fn should_leave_runtime_require(specifier: &str, compile_packages: &HashSet) -> bool { - let (package_name, _) = parse_package_specifier(specifier); - perry_hir::is_native_module(specifier) && !compile_packages.contains(&package_name) -} - -fn unique_temp_name(source: &str, next_id: &mut usize) -> String { - loop { - let name = format!("__perry_create_require_{}", *next_id); - *next_id += 1; - if !source.contains(&name) { - return name; - } - } -} - -fn is_identifier(value: &str) -> bool { - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return false; - }; - if !(first == '_' || first == '$' || first.is_ascii_alphabetic()) { - return false; - } - chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn hoists_non_builtin_literal_requires_from_create_require() { - let source = r#" -import { createRequire } from "node:module"; -const require = createRequire(import.meta.url); -const Discord = require("discord.js"); -const local: any = require("./local"); -const path = require("node:path"); -"#; - let got = transform_create_require_literal_requires(source, &HashSet::new()); - assert!(got.contains(r#"import * as __perry_create_require_0 from "discord.js";"#)); - assert!(got.contains(r#"import * as __perry_create_require_1 from "./local";"#)); - assert!(got.contains("const Discord = __perry_create_require_0;")); - assert!(got.contains("const local: any = __perry_create_require_1;")); - assert!(got.contains(r#"const path = require("node:path");"#)); - } - - #[test] - fn supports_renamed_create_require_and_destructuring() { - let source = r#" -import { createRequire as makeRequire } from "module"; -const req = makeRequire(import.meta.url); -const { Client } = req("mini"); -"#; - let got = transform_create_require_literal_requires(source, &HashSet::new()); - assert!(got.contains(r#"import * as __perry_create_require_0 from "mini";"#)); - assert!(got.contains("const { Client } = __perry_create_require_0;")); - } -} diff --git a/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs new file mode 100644 index 0000000000..036e68e9cd --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/static_require_transform.rs @@ -0,0 +1,288 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use regex::Regex; + +use super::parse_package_specifier; +use crate::commands::compile::cjs_wrap::detect::strip_comments_and_strings; + +pub(super) fn transform_static_literal_requires( + source: &str, + compile_packages: &HashSet, +) -> String { + let create_require_aliases = collect_create_require_aliases(source); + let mut require_aliases = + collect_create_require_aliases_from_decls(source, &create_require_aliases); + if !require_is_shadowed_by_non_create_require(source, &require_aliases) { + require_aliases.insert("require".to_string()); + } + if require_aliases.is_empty() { + return source.to_string(); + } + + let masked_source = strip_comments_and_strings(source); + let mut imported_specs = HashMap::new(); + let mut imports = Vec::new(); + let mut replacements = Vec::new(); + let mut next_id = 0usize; + for alias in require_aliases { + let call_re = literal_require_call_re(&alias); + for cap in call_re.captures_iter(source) { + let specifier = cap.name("spec").map(|m| m.as_str()).unwrap_or_default(); + if should_leave_runtime_require(specifier, compile_packages) { + continue; + } + let Some(full) = cap.name("call") else { + continue; + }; + if masked_source[full.start()..full.end()] + .bytes() + .all(|b| b == b' ' || b == b'\t' || b == b'\r' || b == b'\n') + { + continue; + } + let temp = imported_specs + .entry(specifier.to_string()) + .or_insert_with(|| { + let temp = unique_temp_name(source, &mut next_id); + imports.push(format!("import * as {temp} from {:?};", specifier)); + temp + }) + .clone(); + replacements.push((full.start(), full.end(), temp)); + } + } + + if imports.is_empty() { + return source.to_string(); + } + replacements.sort_by_key(|(start, _, _)| *start); + let mut transformed = source.to_string(); + for (start, end, replacement) in replacements.into_iter().rev() { + transformed.replace_range(start..end, &replacement); + } + prepend_imports_preserving_shebang(&transformed, &imports) +} + +fn prepend_imports_preserving_shebang(source: &str, imports: &[String]) -> String { + let mut prefix = imports.join("\n"); + prefix.push('\n'); + if source.starts_with("#!") { + if let Some(line_end) = source.find('\n') { + let mut out = String::new(); + out.push_str(&source[..=line_end]); + out.push_str(&prefix); + out.push_str(&source[line_end + 1..]); + return out; + } + return format!("{source}\n{prefix}"); + } + prefix.push_str(source); + prefix +} + +fn collect_create_require_aliases(source: &str) -> HashSet { + static IMPORT_RE: OnceLock = OnceLock::new(); + let import_re = IMPORT_RE.get_or_init(|| { + Regex::new( + r#"(?m)^\s*import\s*\{(?P[^}]*)\}\s*from\s*['"](?:node:)?module['"]\s*;?"#, + ) + .expect("createRequire import regex") + }); + + let mut aliases = HashSet::new(); + for cap in import_re.captures_iter(source) { + let Some(specs) = cap.name("specs") else { + continue; + }; + for part in specs.as_str().split(',') { + let part = part.trim(); + if part == "createRequire" { + aliases.insert("createRequire".to_string()); + continue; + } + if let Some(rest) = part.strip_prefix("createRequire as ") { + let alias = rest.trim(); + if is_identifier(alias) { + aliases.insert(alias.to_string()); + } + } + } + } + aliases +} + +fn collect_create_require_aliases_from_decls( + source: &str, + create_require_aliases: &HashSet, +) -> HashSet { + let mut out = HashSet::new(); + for create_alias in create_require_aliases { + let decl_re = create_require_decl_re(create_alias); + for cap in decl_re.captures_iter(source) { + if let Some(alias) = cap.name("alias").map(|m| m.as_str()) { + out.insert(alias.to_string()); + } + } + } + out +} + +fn create_require_decl_re(create_alias: &str) -> Regex { + Regex::new(&format!( + r#"(?m)^\s*(?:const|let|var)\s+(?P[A-Za-z_$][A-Za-z0-9_$]*)(?:\s*:\s*[^=;]+)?\s*=\s*{}\s*\(\s*import\.meta\.url\s*\)\s*;?"#, + regex::escape(create_alias) + )) + .expect("createRequire declaration regex") +} + +fn literal_require_call_re(require_alias: &str) -> Regex { + Regex::new(&format!( + r#"(?m)(?:^|[^A-Za-z0-9_$\.])(?P{}\s*\(\s*['"](?P[^'"]+)['"]\s*\))"#, + regex::escape(require_alias) + )) + .expect("static require literal call regex") +} + +fn should_leave_runtime_require(specifier: &str, compile_packages: &HashSet) -> bool { + if perry_hir::is_native_module(specifier) { + return true; + } + if is_relative_or_absolute_specifier(specifier) { + return false; + } + let (package_name, _) = parse_package_specifier(specifier); + !compile_packages.contains(&package_name) +} + +fn is_relative_or_absolute_specifier(specifier: &str) -> bool { + specifier.starts_with("./") + || specifier.starts_with("../") + || specifier.starts_with('/') + || specifier.starts_with('\\') + || specifier.as_bytes().get(1) == Some(&b':') +} + +fn require_is_shadowed_by_non_create_require( + source: &str, + create_require_aliases: &HashSet, +) -> bool { + if create_require_aliases.contains("require") { + return false; + } + static SHADOW_RE: OnceLock = OnceLock::new(); + let shadow_re = SHADOW_RE.get_or_init(|| { + Regex::new(r#"(?m)^\s*(?:function\s+require\s*\(|(?:const|let|var)\s+require\b)"#) + .expect("require shadow regex") + }); + shadow_re.is_match(source) +} + +fn unique_temp_name(source: &str, next_id: &mut usize) -> String { + loop { + let name = format!("__perry_static_require_{}", *next_id); + *next_id += 1; + if !source.contains(&name) { + return name; + } + } +} + +fn is_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !(first == '_' || first == '$' || first.is_ascii_alphabetic()) { + return false; + } + chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hoists_direct_relative_literal_require() { + let source = r#" +const local = require("./local"); +const { Client } = require("../client"); +"#; + let got = transform_static_literal_requires(source, &HashSet::new()); + assert!(got.contains(r#"import * as __perry_static_require_0 from "./local";"#)); + assert!(got.contains(r#"import * as __perry_static_require_1 from "../client";"#)); + assert!(got.contains("const local = __perry_static_require_0;")); + assert!(got.contains("const { Client } = __perry_static_require_1;")); + } + + #[test] + fn hoists_inline_member_literal_require() { + let source = r#" +console.log(require("./local").value); +"#; + let got = transform_static_literal_requires(source, &HashSet::new()); + assert!(got.contains(r#"import * as __perry_static_require_0 from "./local";"#)); + assert!(got.contains("console.log(__perry_static_require_0.value);")); + } + + #[test] + fn hoists_allowed_package_literal_require() { + let source = r#" +const Discord = require("discord.js"); +"#; + let mut compile_packages = HashSet::new(); + compile_packages.insert("discord.js".to_string()); + let got = transform_static_literal_requires(source, &compile_packages); + assert!(got.contains(r#"import * as __perry_static_require_0 from "discord.js";"#)); + assert!(got.contains("const Discord = __perry_static_require_0;")); + } + + #[test] + fn leaves_disallowed_package_and_builtin_requires() { + let source = r#" +const Discord = require("discord.js"); +const path = require("node:path"); +"#; + let got = transform_static_literal_requires(source, &HashSet::new()); + assert!(!got.contains("__perry_static_require_")); + assert!(got.contains(r#"const Discord = require("discord.js");"#)); + assert!(got.contains(r#"const path = require("node:path");"#)); + } + + #[test] + fn supports_create_require_aliases() { + let source = r#" +import { createRequire as makeRequire } from "module"; +const req = makeRequire(import.meta.url); +const { Client } = req("mini"); +"#; + let mut compile_packages = HashSet::new(); + compile_packages.insert("mini".to_string()); + let got = transform_static_literal_requires(source, &compile_packages); + assert!(got.contains(r#"import * as __perry_static_require_0 from "mini";"#)); + assert!(got.contains("const { Client } = __perry_static_require_0;")); + } + + #[test] + fn direct_require_is_not_transformed_when_shadowed() { + let source = r#" +function require(name) { + return name; +} +const local = require("./local"); +"#; + let got = transform_static_literal_requires(source, &HashSet::new()); + assert_eq!(got, source); + } + + #[test] + fn ignores_require_mentions_in_comments_and_strings() { + let source = r#" +// const local = require("./local"); +const text = 'require("./local")'; +"#; + let got = transform_static_literal_requires(source, &HashSet::new()); + assert_eq!(got, source); + } +} diff --git a/crates/perry/tests/create_require_package.rs b/crates/perry/tests/create_require_package.rs index 0daae89701..cff2c6bf4a 100644 --- a/crates/perry/tests/create_require_package.rs +++ b/crates/perry/tests/create_require_package.rs @@ -1,19 +1,15 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; fn perry_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_perry")) } -#[test] -fn create_require_literal_package_and_file_resolve_to_compiled_modules() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path(); - +fn write_minicord_fixture(root: &Path) { std::fs::write( root.join("package.json"), r#"{ - "name": "create-require-package-reducer", + "name": "require-package-reducer", "type": "module", "perry": { "compilePackages": ["minicord"], @@ -60,32 +56,14 @@ export function localCall(value: string): string { "#, ) .expect("write local module"); +} - let entry = root.join("main.ts"); - std::fs::write( - &entry, - r#" -import { createRequire } from "node:module"; - -const req = createRequire(import.meta.url); -console.log("builtin:", typeof req("node:path").join); - -const require = createRequire(import.meta.url); -const Mini = require("minicord"); -const Local = require("./local"); - -const client = new Mini.Client("A"); -console.log("package:", Mini.version, Mini.make("B"), client.login()); -console.log("file:", Local.localValue, Local.localCall("C")); -"#, - ) - .expect("write entry"); - +fn compile_and_run(root: &Path, entry: &Path) -> String { let output = root.join("main_bin"); let compile = Command::new(perry_bin()) .current_dir(root) .arg("compile") - .arg(&entry) + .arg(entry) .arg("-o") .arg(&output) .output() @@ -105,9 +83,63 @@ console.log("file:", Local.localValue, Local.localCall("C")); String::from_utf8_lossy(&run.stdout), String::from_utf8_lossy(&run.stderr) ); - let stdout = String::from_utf8_lossy(&run.stdout); + String::from_utf8(run.stdout).expect("stdout utf8") +} + +#[test] +fn create_require_literal_package_and_file_resolve_to_compiled_modules() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_minicord_fixture(root); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import { createRequire } from "node:module"; + +const req = createRequire(import.meta.url); +console.log("builtin:", typeof req("node:path").join); + +const require = createRequire(import.meta.url); +const Mini = require("minicord"); +const Local = require("./local"); + +const client = new Mini.Client("A"); +console.log("package:", Mini.version, Mini.make("B"), client.login()); +console.log("file:", Local.localValue, Local.localCall("C")); +"#, + ) + .expect("write entry"); + assert_eq!( - stdout, + compile_and_run(root, &entry), "builtin: function\npackage: mini-1 make:B login:A\nfile: local-ok local:C\n" ); } + +#[test] +fn direct_literal_require_package_and_file_resolve_to_compiled_modules() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + write_minicord_fixture(root); + + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +const Mini = require("minicord"); +const { localValue, localCall } = require("./local"); + +const client = new Mini.Client("A"); +console.log("package:", Mini.version, Mini.make("B"), client.login()); +console.log("file:", localValue, localCall("C")); +"#, + ) + .expect("write entry"); + + assert_eq!( + compile_and_run(root, &entry), + "package: mini-1 make:B login:A\nfile: local-ok local:C\n" + ); +} diff --git a/crates/perry/tests/module_import_forms.rs b/crates/perry/tests/module_import_forms.rs index 098613803f..8af52a8dd5 100644 --- a/crates/perry/tests/module_import_forms.rs +++ b/crates/perry/tests/module_import_forms.rs @@ -523,7 +523,7 @@ console.log("inline-platform", require("node:os").platform() === process.platfor } #[test] -fn create_require_package_specifier_reports_unsupported_interop() { +fn create_require_package_specifier_resolves_to_compiled_namespace() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); write_mini_require_target(root); @@ -536,13 +536,8 @@ import { createRequire } from "node:module"; import { version } from "mini-require-target"; const require = createRequire(import.meta.url); -try { - console.log("esm-version", version); - console.log(require("mini-require-target").version); -} catch (err) { - console.log("require-error-code", err.code); - console.log("require-error-message", err.message); -} +console.log("esm-version", version); +console.log("require-version", require("mini-require-target").version); "#, ) .expect("write entry"); @@ -572,19 +567,8 @@ try { String::from_utf8_lossy(&run.stdout), String::from_utf8_lossy(&run.stderr) ); - let stdout = String::from_utf8_lossy(&run.stdout); - assert!( - stdout.contains("require-error-code ERR_PERRY_UNSUPPORTED_CREATE_REQUIRE"), - "missing error code\nstdout:\n{}\nstderr:\n{}", - stdout, - String::from_utf8_lossy(&run.stderr) - ); - assert!( - stdout.contains( - "Perry createRequire() currently supports built-in modules only; package/file require('mini-require-target') is not supported under perry compile" - ), - "missing unsupported createRequire diagnostic\nstdout:\n{}\nstderr:\n{}", - stdout, - String::from_utf8_lossy(&run.stderr) + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "esm-version require-target-1\nrequire-version require-target-1\n" ); }