From 8f9b9c8b9f2596ff15a52ee3a9edf69fa8feb68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 28 May 2026 20:54:02 +0200 Subject: [PATCH] fix(runtime): implement process.loadEnvFile to actually read .env (#2135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `process.loadEnvFile(path?)` was lowered to `Expr::Undefined` per #1399's original note that `process.env.X = v` writes didn't persist (#1344) — so eager loading would be moot. #1344 has since wired writes through `std::env::set_var`, making the no-op stub a real parity gap. Now lower to a `NativeMethodCall` that calls the new `js_process_load_env_file` runtime. The runtime reads the file (defaulting to `.env` when the optional path is omitted via HIR), parses each non-comment line on the first `=`, strips a matched surrounding quote pair, and writes each `KEY=value` pair via `std::env::set_var` so `process.env.KEY` reads back in subsequent code (Node-compatible). Missing files throw a Node-shaped Error (`code: "ENOENT"`, `syscall: "open"`, `path: `), matching libuv's wording. Closes one of the #2135 process-tracker stubbed-method gaps. --- crates/perry-api-manifest/src/entries.rs | 1 + .../src/lower_call/native_table/node_core.rs | 12 +++ .../src/lower/expr_call/native_module.rs | 32 +++++-- .../src/object/native_module_dispatch.rs | 4 + crates/perry-runtime/src/process.rs | 85 +++++++++++++++++++ docs/api/perry.d.ts | 4 +- docs/src/api/reference.md | 3 +- test-files/test_issue_2135_load_env_file.ts | 39 +++++++++ 8 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 test-files/test_issue_2135_load_env_file.ts diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 41aa5b9c4..cd6c8c726 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -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), diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core.rs b/crates/perry-codegen/src/lower_call/native_table/node_core.rs index 90207e5a0..e6a1c85dc 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core.rs @@ -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) diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index e224dee25..eec368750 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -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 diff --git a/crates/perry-runtime/src/object/native_module_dispatch.rs b/crates/perry-runtime/src/object/native_module_dispatch.rs index cc26f26a3..ef99e242b 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch.rs @@ -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) diff --git a/crates/perry-runtime/src/process.rs b/crates/perry-runtime/src/process.rs index 3a35ca961..e45b3e8d2 100644 --- a/crates/perry-runtime/src/process.rs +++ b/crates/perry-runtime/src/process.rs @@ -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::()); + 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)); +} diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 0d02ea29e..40845b0ff 100644 --- a/docs/api/perry.d.ts +++ b/docs/api/perry.d.ts @@ -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 */ @@ -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; diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index 3787ba53f..16cb938b9 100644 --- a/docs/src/api/reference.md +++ b/docs/src/api/reference.md @@ -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 @@ -1683,6 +1683,7 @@ Total: 1480 entries across 82 modules. - `kill` — module - `listenerCount` — module - `listeners` — module +- `loadEnvFile` — module - `memoryUsage` — module - `nextTick` — module - `off` — module diff --git a/test-files/test_issue_2135_load_env_file.ts b/test-files/test_issue_2135_load_env_file.ts new file mode 100644 index 000000000..aa028b8dc --- /dev/null +++ b/test-files/test_issue_2135_load_env_file.ts @@ -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); +}