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
1 change: 1 addition & 0 deletions crates/perry-api-manifest/src/entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2125,6 +2125,7 @@ pub static API_MANIFEST: &[ApiEntry] = &[
method("process", "nextTick", false, None),
method("process", "chdir", false, None),
method("process", "kill", false, None),
method("process", "loadEnvFile", false, None),
method("process", "exit", false, None),
method("process", "umask", false, None),
method("process", "threadCpuUsage", false, None),
Expand Down
12 changes: 12 additions & 0 deletions crates/perry-codegen/src/lower_call/native_table/node_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ pub(super) const NODE_CORE_ROWS: &[NativeModSig] = &[
args: &[],
ret: NR_F64,
},
// #2135 (Node process parity): process.loadEnvFile(path?) — HIR
// defaults the missing path to ".env" so this row always sees a
// single string argument.
NativeModSig {
module: "process",
has_receiver: false,
method: "loadEnvFile",
class_filter: None,
runtime: "js_process_load_env_file",
args: &[NA_STR],
ret: NR_VOID,
},
// ========== Node URL ==========
// `new Number/String/Boolean(...)` now lowers to
// `Expr::BoxedPrimitiveNew` (see crates/perry-hir/src/lower/expr_new.rs)
Expand Down
32 changes: 23 additions & 9 deletions crates/perry-hir/src/lower/expr_call/native_module.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,29 @@ pub(super) fn try_native_module_methods(
return Ok(Ok(Expr::Undefined));
}
"loadEnvFile" => {
// #1399: process.loadEnvFile(path?) (Node 20.12+)
// reads a `.env` file from disk and adds its
// KEY=value entries to process.env. Perry today
// doesn't persist `process.env.X = v` writes
// (#1344) so eagerly loading would be moot;
// returning undefined is the closest no-op for
// call sites that probe-and-call. Real `.env`
// loading is tracked separately.
return Ok(Ok(Expr::Undefined));
// #1399 / #2135: process.loadEnvFile(path?)
// (Node 20.12+) reads a `.env` file from disk and
// merges its KEY=value entries into `process.env`.
// Previously a no-op because `process.env.X = v`
// didn't persist; #1344 has since wired writes
// through `std::env::set_var`, so we lower to a
// runtime call that actually reads the file.
// Default the optional path to `.env` (Node's
// default) so the dispatch-table row's single
// NA_STR arg stays satisfied for the no-arg call
// form.
let call_args = if args.is_empty() {
vec![Expr::String(".env".to_string())]
} else {
args
};
return Ok(Ok(Expr::NativeMethodCall {
module: "process".to_string(),
class_name: None,
object: None,
method: "loadEnvFile".to_string(),
args: call_args,
}));
}
"exit" => {
// process.exit() / process.exit(code) — never
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-runtime/src/object/native_module_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,10 @@ pub(crate) unsafe fn dispatch_native_module_method(
crate::os::js_process_chdir(arg_event_ptr(0));
f64::from_bits(crate::value::TAG_UNDEFINED)
}
("process", "loadEnvFile") => {
crate::process::js_process_load_env_file(optional_path_str_ptr(0));
f64::from_bits(crate::value::TAG_UNDEFINED)
}
("process", "kill") => {
crate::os::js_process_kill(arg(0), arg(1));
f64::from_bits(crate::value::TAG_UNDEFINED)
Expand Down
85 changes: 85 additions & 0 deletions crates/perry-runtime/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,3 +818,88 @@ pub extern "C" fn js_process_memory_usage() -> f64 {
// Return as NaN-boxed pointer (convert bits to f64)
f64::from_bits(JSValue::pointer(obj as *const u8).bits())
}

/// process.loadEnvFile(path?) — read a `.env`-formatted file from disk and
/// merge its `KEY=value` entries into `process.env`. Node 20.12+. With no
/// path, the default is `.env` in the current working directory. Throws a
/// Node-shaped `Error` (`code: "ENOENT"`, `syscall: "open"`) when the file
/// can't be opened. #2135 (#1399 follow-through): previously a no-op that
/// returned undefined so probe-and-call sites didn't crash; with
/// `process.env.X = v` now persisting via std::env (#1344), eager loading
/// is meaningful.
#[no_mangle]
pub extern "C" fn js_process_load_env_file(path_ptr: *const StringHeader) {
let target = unsafe {
if path_ptr.is_null() {
".env".to_string()
} else {
let len = (*path_ptr).byte_len as usize;
let data = (path_ptr as *const u8).add(std::mem::size_of::<StringHeader>());
let bytes = std::slice::from_raw_parts(data, len);
match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => return,
}
}
};
let contents = match std::fs::read_to_string(&target) {
Ok(s) => s,
Err(err) => unsafe {
throw_load_env_file_open_error(&err, &target);
},
};
for line in contents.lines() {
let trimmed = line.trim_start();
// Comments and blank lines.
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = trimmed.split_once('=') else {
continue;
};
let key = raw_key.trim();
if key.is_empty() {
continue;
}
// Strip a matched surrounding quote pair on the trimmed value;
// otherwise keep the trimmed text verbatim (so unquoted spaces
// around `=` are dropped but inner `=` survives — see Node's
// built-in `.env` parser).
let value_trimmed = raw_value.trim();
let value = strip_matched_quotes(value_trimmed);
std::env::set_var(key, value);
}
}

fn strip_matched_quotes(s: &str) -> &str {
let bytes = s.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0];
let last = bytes[bytes.len() - 1];
if (first == b'"' || first == b'\'') && first == last {
return &s[1..s.len() - 1];
}
}
s
}

unsafe fn throw_load_env_file_open_error(err: &std::io::Error, target: &str) -> ! {
use std::io::ErrorKind;
let code: &'static str = match err.kind() {
ErrorKind::NotFound => "ENOENT",
ErrorKind::PermissionDenied => "EACCES",
_ => "EIO",
};
let desc = match code {
"ENOENT" => "no such file or directory",
"EACCES" => "permission denied",
_ => "i/o error",
};
let message = format!("{code}: {desc}, open '{target}'");
let msg_ptr = js_string_from_bytes(message.as_ptr(), message.len() as u32);
crate::node_submodules::register_error_code_pub(msg_ptr, code);
crate::node_submodules::register_error_syscall(msg_ptr, "open");
crate::node_submodules::register_error_path(msg_ptr, target.to_string());
let err_ptr = crate::error::js_error_new_with_message(msg_ptr);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err_ptr as i64));
}
4 changes: 3 additions & 1 deletion docs/api/perry.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Auto-generated from Perry's API manifest (#465). Do not edit by hand.
// Source: perry-api-manifest::API_MANIFEST
// Coverage: 1480 entries across 82 modules
// Coverage: 1481 entries across 82 modules

declare module "@perryts/pdf" {
/** stdlib */
Expand Down Expand Up @@ -1799,6 +1799,8 @@ declare module "process" {
/** stdlib */
export function listeners(...args: any[]): any;
/** stdlib */
export function loadEnvFile(...args: any[]): any;
/** stdlib */
export function memoryUsage(...args: any[]): any;
/** stdlib */
export function nextTick(...args: any[]): any;
Expand Down
3 changes: 2 additions & 1 deletion docs/src/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This page is auto-generated from Perry's compile-time API manifest (`perry-api-manifest::API_MANIFEST`). It is the source of truth for what `perry compile` accepts; references to symbols not listed here produce `R005 UnimplementedApi` (issue #463). Stubs (#464) are flagged ⚠ — they link cleanly but no-op at runtime on the chosen target.

Total: 1480 entries across 82 modules.
Total: 1481 entries across 82 modules.

## Modules

Expand Down Expand Up @@ -1683,6 +1683,7 @@ Total: 1480 entries across 82 modules.
- `kill` — module
- `listenerCount` — module
- `listeners` — module
- `loadEnvFile` — module
- `memoryUsage` — module
- `nextTick` — module
- `off` — module
Expand Down
39 changes: 39 additions & 0 deletions test-files/test_issue_2135_load_env_file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Refs #2135 (#1399 follow-through): `process.loadEnvFile(path?)` previously
// returned `undefined` as a no-op because `process.env.X = v` didn't persist.
// #1344 has since wired writes through `std::env::set_var`, so eager loading
// is meaningful — Perry now reads the .env file and merges `KEY=value` pairs
// into `process.env`, matching Node 20.12+.

import { writeFileSync, unlinkSync } from "node:fs";

const path = "/tmp/.env_perry_2135";
writeFileSync(
path,
"# a comment\n" +
"KEY1=value1\n" +
"KEY2=\"quoted value\"\n" +
"KEY3='single quoted'\n" +
"KEY4=with spaces\n" +
"EMPTY=\n" +
"NOQUOTE=raw=val\n" +
"SHOULD_TRIM = trimmed\n",
);

process.loadEnvFile(path);
console.log("KEY1:", process.env.KEY1);
console.log("KEY2:", process.env.KEY2);
console.log("KEY3:", process.env.KEY3);
console.log("KEY4:", process.env.KEY4);
console.log("EMPTY:", JSON.stringify(process.env.EMPTY));
console.log("NOQUOTE:", process.env.NOQUOTE);
console.log("SHOULD_TRIM:", JSON.stringify(process.env.SHOULD_TRIM));

unlinkSync(path);

// Missing file → ENOENT/open
try {
process.loadEnvFile("/no/such/.env_perry_2135");
console.log("no throw");
} catch (e: any) {
console.log(e.code, e.syscall);
}