diff --git a/crates/perry-codegen-js/src/emit.rs b/crates/perry-codegen-js/src/emit.rs index 9b161574d..a4594460d 100644 --- a/crates/perry-codegen-js/src/emit.rs +++ b/crates/perry-codegen-js/src/emit.rs @@ -1323,6 +1323,13 @@ impl JsEmitter { self.emit_expr(b); self.output.push(')'); } + Expr::PathWin32Join(a, b) => { + self.output.push_str("__perry.path.win32.join("); + self.emit_expr(a); + self.output.push_str(", "); + self.emit_expr(b); + self.output.push(')'); + } Expr::PathDirname(p) => { self.output.push_str("__perry.path.dirname("); self.emit_expr(p); diff --git a/crates/perry-codegen-wasm/src/emit.rs b/crates/perry-codegen-wasm/src/emit.rs index 780a368e0..69f8c09d0 100644 --- a/crates/perry-codegen-wasm/src/emit.rs +++ b/crates/perry-codegen-wasm/src/emit.rs @@ -8560,6 +8560,12 @@ impl<'a> FuncEmitCtx<'a> { self.emit_store_arg(func, 1, b); self.emit_memcall(func, "path_join", 2); } + Expr::PathWin32Join(a, b) => { + self.emit_frame_begin(func, 2); + self.emit_store_arg(func, 0, a); + self.emit_store_arg(func, 1, b); + self.emit_memcall(func, "path_win32_join", 2); + } Expr::PathDirname(p) => { self.emit_frame_begin(func, 1); self.emit_store_arg(func, 0, p); diff --git a/crates/perry-codegen/src/collectors.rs b/crates/perry-codegen/src/collectors.rs index 909b9a24d..8e0f7eb73 100644 --- a/crates/perry-codegen/src/collectors.rs +++ b/crates/perry-codegen/src/collectors.rs @@ -839,7 +839,10 @@ pub(crate) fn collect_ref_ids_in_expr(e: &perry_hir::Expr, out: &mut HashSet { + Expr::MathPow(a, b) + | Expr::PathJoin(a, b) + | Expr::PathRelative(a, b) + | Expr::PathWin32Join(a, b) => { walk(a, out); walk(b, out); } @@ -3308,7 +3311,10 @@ fn collect_localset_ids_in_expr_filtered( walk(v, out); } } - Expr::MathPow(a, b) | Expr::PathJoin(a, b) | Expr::PathRelative(a, b) => { + Expr::MathPow(a, b) + | Expr::PathJoin(a, b) + | Expr::PathRelative(a, b) + | Expr::PathWin32Join(a, b) => { walk(a, out); walk(b, out); } @@ -4849,6 +4855,7 @@ fn check_escapes_in_expr( } Expr::MathPow(a, b) | Expr::PathJoin(a, b) + | Expr::PathWin32Join(a, b) | Expr::ObjectIs(a, b) | Expr::ObjectHasOwn(a, b) => { check_escapes_in_expr(a, candidates, classes, escaped); diff --git a/crates/perry-codegen/src/expr.rs b/crates/perry-codegen/src/expr.rs index 1ec7366fc..c4fe347d0 100644 --- a/crates/perry-codegen/src/expr.rs +++ b/crates/perry-codegen/src/expr.rs @@ -6507,6 +6507,24 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Ok(nanbox_string_inline(blk, &result)) } + // -------- path.win32.join(a, b) -> string (issue #810) -------- + // Windows-style join with `\` separator, regardless of host + // platform. Multi-arg path.win32.join lowers to chained + // PathWin32Join in the HIR. + Expr::PathWin32Join(a, b) => { + let a_box = lower_expr(ctx, a)?; + let b_box = lower_expr(ctx, b)?; + let blk = ctx.block(); + let a_handle = unbox_to_i64(blk, &a_box); + let b_handle = unbox_to_i64(blk, &b_box); + let result = blk.call( + I64, + "js_path_win32_join", + &[(I64, &a_handle), (I64, &b_handle)], + ); + Ok(nanbox_string_inline(blk, &result)) + } + // -------- queueMicrotask(fn) / process.nextTick(fn) stubs -------- // Real microtask scheduling needs the runtime's queue. For // now we lower the callback for side effects (it might be a diff --git a/crates/perry-codegen/src/runtime_decls.rs b/crates/perry-codegen/src/runtime_decls.rs index b4d37a7a1..1534476d1 100644 --- a/crates/perry-codegen/src/runtime_decls.rs +++ b/crates/perry-codegen/src/runtime_decls.rs @@ -730,6 +730,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_object_values", I64, &[I64]); module.declare_function("js_object_entries", I64, &[I64]); module.declare_function("js_path_join", I64, &[I64, I64]); + module.declare_function("js_path_win32_join", I64, &[I64, I64]); module.declare_function("js_path_dirname", I64, &[I64]); module.declare_function("js_path_relative", I64, &[I64, I64]); module.declare_function("js_path_to_namespaced_path", I64, &[I64]); diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index bd512ad03..e1fcd1751 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -770,6 +770,7 @@ pub(crate) fn is_definitely_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { | Expr::PathNormalize(_) | Expr::PathToNamespacedPath(_) | Expr::PathResolveJoin(..) + | Expr::PathWin32Join(..) | Expr::ProcessVersion | Expr::ProcessCwd | Expr::OsArch @@ -875,7 +876,8 @@ pub(crate) fn is_string_expr(ctx: &FnCtx<'_>, e: &Expr) -> bool { | Expr::PathResolve(_) | Expr::PathNormalize(_) | Expr::PathToNamespacedPath(_) - | Expr::PathResolveJoin(..) => true, + | Expr::PathResolveJoin(..) + | Expr::PathWin32Join(..) => true, // String.fromCodePoint(...) / String.fromCharCode(...) / str.at(i) // / RegExp.source|flags — all produce string handles. Expr::StringFromCodePoint(_) diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index f6d050c77..e074b0f08 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -442,7 +442,10 @@ pub(crate) fn collect_assigned_locals_expr(expr: &Expr, assigned: &mut Vec { + Expr::PathJoin(a, b) + | Expr::PathMatchesGlob(a, b) + | Expr::PathResolveJoin(a, b) + | Expr::PathWin32Join(a, b) => { collect_assigned_locals_expr(a, assigned); collect_assigned_locals_expr(b, assigned); } diff --git a/crates/perry-hir/src/ir.rs b/crates/perry-hir/src/ir.rs index 3366b4b34..31173a2c1 100644 --- a/crates/perry-hir/src/ir.rs +++ b/crates/perry-hir/src/ir.rs @@ -1276,6 +1276,7 @@ pub enum Expr { PathToNamespacedPath(Box), // path.toNamespacedPath(path) -> string (POSIX: no-op) PathMatchesGlob(Box, Box), // path.matchesGlob(path, pattern) -> boolean PathResolveJoin(Box, Box), // internal: join with reset-on-absolute (multi-arg resolve) + PathWin32Join(Box, Box), // path.win32.join(a, b) -> string (issue #810) // WeakRef and FinalizationRegistry WeakRefNew(Box), // new WeakRef(obj) -> WeakRef diff --git a/crates/perry-hir/src/js_transform.rs b/crates/perry-hir/src/js_transform.rs index e38806ac3..5d3702f03 100644 --- a/crates/perry-hir/src/js_transform.rs +++ b/crates/perry-hir/src/js_transform.rs @@ -938,7 +938,7 @@ fn transform_expr( Expr::FsReadFileSync(e) | Expr::FsExistsSync(e) | Expr::FsMkdirSync(e) | Expr::FsUnlinkSync(e) => { transform_expr(e, js_imports, extern_func_to_js, local_name_to_js, tracker); } - Expr::FsWriteFileSync(a, b) | Expr::FsAppendFileSync(a, b) | Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) | Expr::MathPow(a, b) | Expr::MathImul(a, b) => { + Expr::FsWriteFileSync(a, b) | Expr::FsAppendFileSync(a, b) | Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) | Expr::PathWin32Join(a, b) | Expr::MathPow(a, b) | Expr::MathImul(a, b) => { transform_expr(a, js_imports, extern_func_to_js, local_name_to_js, tracker); transform_expr(b, js_imports, extern_func_to_js, local_name_to_js, tracker); } diff --git a/crates/perry-hir/src/lower/expr_call.rs b/crates/perry-hir/src/lower/expr_call.rs index ce7a57b81..b9207bb02 100644 --- a/crates/perry-hir/src/lower/expr_call.rs +++ b/crates/perry-hir/src/lower/expr_call.rs @@ -570,6 +570,172 @@ pub(super) fn lower_call(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Res } } + // `path.posix.(args)` / `path.win32.(args)` — + // sub-namespace dispatch (issue #810). Without this arm the + // generic mod.X.Y() block below skips the call (path.posix / + // path.win32 are in its sub-namespace exclusion list to keep + // them off the strict-API gate) and the call falls through + // to the receiver-less dispatch, returning undefined. + // + // - `path.posix.X` routes to the existing Expr::PathX variant. + // The runtime js_path_* functions use POSIX (`/`) semantics, + // so this is a direct shape rewrite. + // - `path.win32.join` routes to a dedicated Expr::PathWin32Join + // so the result uses `\` separators. Other path.win32.* + // methods are intentionally unwired here — falling through + // to a POSIX impl would silently mis-handle drive letters + // and `\` segments. Track those in a follow-up. + if let ast::Expr::Member(outer_member) = expr.as_ref() { + if let ast::Expr::Member(inner_member) = outer_member.obj.as_ref() { + if let ast::Expr::Ident(root_ident) = inner_member.obj.as_ref() { + let root_name = root_ident.sym.as_ref(); + let is_path_root = root_name == "path" + || ctx.lookup_builtin_module_alias(root_name) == Some("path") + || ctx + .lookup_native_module(root_name) + .map(|(m, _)| m == "path") + .unwrap_or(false); + if is_path_root { + if let ( + ast::MemberProp::Ident(sub_prop), + ast::MemberProp::Ident(method_prop), + ) = (&inner_member.prop, &outer_member.prop) + { + let sub = sub_prop.sym.as_ref(); + let method = method_prop.sym.as_ref(); + if sub == "posix" || sub == "win32" { + // path..join(...) + if method == "join" { + if args.is_empty() { + return Ok(Expr::String(".".to_string())); + } + if sub == "win32" { + let mut iter = args.into_iter(); + let mut result = iter.next().unwrap(); + for next_arg in iter { + result = Expr::PathWin32Join( + Box::new(result), + Box::new(next_arg), + ); + } + return Ok(result); + } else { + // posix.join → existing PathJoin + if args.len() == 1 { + return Ok(Expr::PathNormalize(Box::new( + args.into_iter().next().unwrap(), + ))); + } + let mut iter = args.into_iter(); + let mut result = iter.next().unwrap(); + for next_arg in iter { + result = Expr::PathJoin( + Box::new(result), + Box::new(next_arg), + ); + } + return Ok(result); + } + } + + // The remaining methods route to the + // existing POSIX Expr::Path* variants + // only for the `posix` sub-namespace. + // For `win32` we deliberately fall + // through to the receiver-less path + // (returns undefined) rather than + // give a wrong POSIX answer. + if sub == "posix" { + match method { + "dirname" if !args.is_empty() => { + return Ok(Expr::PathDirname(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "basename" if args.len() >= 2 => { + let mut it = args.into_iter(); + let p = it.next().unwrap(); + let e = it.next().unwrap(); + return Ok(Expr::PathBasenameExt( + Box::new(p), + Box::new(e), + )); + } + "basename" if !args.is_empty() => { + return Ok(Expr::PathBasename(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "extname" if !args.is_empty() => { + return Ok(Expr::PathExtname(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "isAbsolute" if !args.is_empty() => { + return Ok(Expr::PathIsAbsolute(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "normalize" if !args.is_empty() => { + return Ok(Expr::PathNormalize(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "parse" if !args.is_empty() => { + return Ok(Expr::PathParse(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "format" if !args.is_empty() => { + return Ok(Expr::PathFormat(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "toNamespacedPath" if !args.is_empty() => { + return Ok(Expr::PathToNamespacedPath(Box::new( + args.into_iter().next().unwrap(), + ))); + } + "relative" if args.len() >= 2 => { + let mut it = args.into_iter(); + let from = it.next().unwrap(); + let to = it.next().unwrap(); + return Ok(Expr::PathRelative( + Box::new(from), + Box::new(to), + )); + } + "resolve" if !args.is_empty() => { + let mut it = args.into_iter(); + let first = it.next().unwrap(); + let mut joined = first; + for next_arg in it { + joined = Expr::PathResolveJoin( + Box::new(joined), + Box::new(next_arg), + ); + } + return Ok(Expr::PathResolve(Box::new(joined))); + } + "matchesGlob" if args.len() >= 2 => { + let mut it = args.into_iter(); + let p = it.next().unwrap(); + let pat = it.next().unwrap(); + return Ok(Expr::PathMatchesGlob( + Box::new(p), + Box::new(pat), + )); + } + _ => {} + } + } + } + } + } + } + } + } + // Check for module.Class.staticMethod() pattern (e.g., // ethers.Wallet.createRandom()). Modelled after the // process.hrtime.bigint() handler above. diff --git a/crates/perry-hir/src/monomorph.rs b/crates/perry-hir/src/monomorph.rs index bfc7ec773..8ba5a7d85 100644 --- a/crates/perry-hir/src/monomorph.rs +++ b/crates/perry-hir/src/monomorph.rs @@ -1240,6 +1240,10 @@ fn substitute_expr(expr: &Expr, substitutions: &HashMap) -> Expr { Box::new(substitute_expr(a, substitutions)), Box::new(substitute_expr(b, substitutions)), ), + Expr::PathWin32Join(a, b) => Expr::PathWin32Join( + Box::new(substitute_expr(a, substitutions)), + Box::new(substitute_expr(b, substitutions)), + ), Expr::PathDirname(path) => { Expr::PathDirname(Box::new(substitute_expr(path, substitutions))) } @@ -2416,7 +2420,10 @@ fn collect_instantiations_in_expr( collect_instantiations_in_expr(path, ctx, module, idx); collect_instantiations_in_expr(content, ctx, module, idx); } - Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) => { + Expr::PathJoin(a, b) + | Expr::PathMatchesGlob(a, b) + | Expr::PathResolveJoin(a, b) + | Expr::PathWin32Join(a, b) => { collect_instantiations_in_expr(a, ctx, module, idx); collect_instantiations_in_expr(b, ctx, module, idx); } @@ -2935,7 +2942,10 @@ fn update_call_sites_in_expr( update_call_sites_in_expr(path, ctx, lookup); update_call_sites_in_expr(content, ctx, lookup); } - Expr::PathJoin(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) => { + Expr::PathJoin(a, b) + | Expr::PathMatchesGlob(a, b) + | Expr::PathResolveJoin(a, b) + | Expr::PathWin32Join(a, b) => { update_call_sites_in_expr(a, ctx, lookup); update_call_sites_in_expr(b, ctx, lookup); } diff --git a/crates/perry-hir/src/stable_hash.rs b/crates/perry-hir/src/stable_hash.rs index a9e5890e4..731b08d03 100644 --- a/crates/perry-hir/src/stable_hash.rs +++ b/crates/perry-hir/src/stable_hash.rs @@ -1733,6 +1733,11 @@ impl SH for Expr { a.as_ref().hash(h); b.as_ref().hash(h); } + Expr::PathWin32Join(a, b) => { + tag(h, 462); + a.as_ref().hash(h); + b.as_ref().hash(h); + } Expr::PathDirname(e) => { tag(h, 91); e.as_ref().hash(h); diff --git a/crates/perry-hir/src/walker.rs b/crates/perry-hir/src/walker.rs index fa2dee0fb..e7d540de9 100644 --- a/crates/perry-hir/src/walker.rs +++ b/crates/perry-hir/src/walker.rs @@ -436,6 +436,7 @@ where | Expr::PathBasenameExt(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) + | Expr::PathWin32Join(a, b) | Expr::ObjectGetOwnPropertyDescriptor(a, b) | Expr::ObjectIs(a, b) | Expr::ObjectHasOwn(a, b) @@ -1701,6 +1702,7 @@ where | Expr::PathBasenameExt(a, b) | Expr::PathMatchesGlob(a, b) | Expr::PathResolveJoin(a, b) + | Expr::PathWin32Join(a, b) | Expr::ObjectGetOwnPropertyDescriptor(a, b) | Expr::ObjectIs(a, b) | Expr::ObjectHasOwn(a, b) diff --git a/crates/perry-runtime/src/path.rs b/crates/perry-runtime/src/path.rs index 84ac85386..5f0ef3754 100644 --- a/crates/perry-runtime/src/path.rs +++ b/crates/perry-runtime/src/path.rs @@ -46,6 +46,95 @@ pub extern "C" fn js_path_join( } } +/// `path.win32.join(a, b)` — Windows-style join. Always emits backslash +/// separators regardless of host platform. Treats both `/` and `\` as +/// segment separators in normalization (Node's win32 implementation does +/// the same) and collapses repeated separators. +#[no_mangle] +pub extern "C" fn js_path_win32_join( + a_ptr: *const StringHeader, + b_ptr: *const StringHeader, +) -> *mut StringHeader { + unsafe { + let a = string_from_header(a_ptr).unwrap_or_default(); + let b = string_from_header(b_ptr).unwrap_or_default(); + + let joined = if a.is_empty() { + b + } else if b.is_empty() { + a + } else if a.ends_with('\\') || a.ends_with('/') { + format!("{}{}", a, b) + } else { + format!("{}\\{}", a, b) + }; + string_to_js(&normalize_win32_str(&joined)) + } +} + +/// Win32 normalization. Treats both `/` and `\` as separators (matching +/// Node), preserves a leading drive letter (`C:`), collapses repeated +/// separators, resolves `.` and `..`, and emits backslash separators. +fn normalize_win32_str(input: &str) -> String { + if input.is_empty() { + return ".".to_string(); + } + // Peel off drive prefix like "C:" — anything before the first separator + // that ends with ":" is treated as the drive root. + let (drive, rest) = match input.find(|c| c == '\\' || c == '/') { + Some(i) => { + let head = &input[..i]; + if head.ends_with(':') { + (head.to_string(), &input[i..]) + } else { + (String::new(), input) + } + } + None => { + if input.ends_with(':') { + (input.to_string(), "") + } else { + (String::new(), input) + } + } + }; + + let is_absolute = rest.starts_with('\\') || rest.starts_with('/'); + let trailing_sep = rest.ends_with('\\') || rest.ends_with('/'); + let mut out: Vec<&str> = Vec::new(); + for seg in rest.split(|c: char| c == '\\' || c == '/') { + if seg.is_empty() || seg == "." { + continue; + } + if seg == ".." { + if let Some(last) = out.last() { + if *last == ".." { + out.push(".."); + } else { + out.pop(); + } + } else if !is_absolute { + out.push(".."); + } + continue; + } + out.push(seg); + } + + let mut result = drive; + if is_absolute { + result.push('\\'); + } + result.push_str(&out.join("\\")); + if result.is_empty() { + return ".".to_string(); + } + if trailing_sep && !result.ends_with('\\') { + result.push('\\'); + } + result +} + /// Get directory name from path. Per Node spec, the root's dirname is the /// root itself (`/` → `/`), not an empty string — Rust's `Path::parent` /// returns `None` there, which we treat as "stay at root".