From 2ecd513f93c207398decd077f123a0c32736414a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 08:38:02 +0000 Subject: [PATCH 1/2] refactor(stdlib): remove commander native binding Fixes #10686 -- the removal is the fix. Native program.args was undefined; boolean option defaults serialized as the truthy string "false"; subcommand .action() callbacks never fired; missing-required-argument and unknown-option validation (Node's commander.missingArgument / commander.unknownOption) was entirely absent. Removes both copies (crates/perry-ext-commander/ and the feature-gated crates/perry-stdlib/src/commander.rs, including its registered GC-root scanner), the Command-only arms in every shared HIR/codegen recognition point (LRUCache/Command/Big/Decimal/BigNumber share several match blocks; only Command's line is touched here, including the dedicated is_commander/is_commander_method fluent-chain continuation in static_and_instance.rs), and every registry row (well_known_bindings.toml, NATIVE_MODULES, the API manifest, stdlib_features.rs, native_result_ledger, gc_runtime_root_holders.json, workspace-architecture.json, Android stubs). commander's real npm source subclasses node:events' EventEmitter directly (class Command extends EventEmitter) -- Perry's existing generic EventEmitter-subclass support already handles that once compiled from source, so no dedicated native-subclass machinery was needed here (unlike bundled-commander is referenced only by perry-stdlib's `full` feature umbrella (checked every other umbrella in Cargo.toml); no other umbrella needs retargeting by this or the sibling decimal.js/lru-cache removals. Based on PR #10699's branch (fix/10439-native-binding-import-provenance): without that fix, commander at its default import name is unreachable regardless of perry.compilePackages, so this removal is not independently mergeable. --- Cargo.lock | 8 - Cargo.toml | 2 - crates/perry-api-manifest/src/entries.rs | 1 - .../perry-api-manifest/src/entries/part_1.rs | 18 - .../perry-codegen/src/lower_call/builtin.rs | 13 - .../src/lower_call/native_table/node_misc.rs | 114 --- .../src/runtime_decls/stdlib_ffi.rs | 2 +- .../src/runtime_decls/stdlib_ffi/utilities.rs | 17 +- .../src/runtime_decls/stdlib_ffi_part2.rs | 6 - crates/perry-ext-commander/Cargo.toml | 38 - crates/perry-ext-commander/src/lib.rs | 787 ------------------ .../destructuring/var_decl/native_fetch.rs | 1 - .../src/destructuring/var_decl/native_new.rs | 2 - crates/perry-hir/src/js_transform/imports.rs | 2 +- .../lower/expr_call/static_and_instance.rs | 25 - crates/perry-hir/src/lower/module_decl.rs | 2 - crates/perry-hir/src/lower_patterns.rs | 1 - crates/perry-stdlib/Cargo.toml | 7 +- crates/perry-stdlib/src/commander.rs | 737 ---------------- crates/perry-stdlib/src/lib.rs | 7 - crates/perry-ui-android/src/stdlib_stubs.rs | 52 -- .../compile/collect_modules/feature_detect.rs | 8 +- crates/perry/src/commands/stdlib_features.rs | 3 - ..._10439_native_binding_import_provenance.rs | 33 +- crates/perry/well_known_bindings.toml | 12 - docs/api/perry.d.ts | 7 +- docs/examples/stdlib/other/snippets.ts | 23 +- docs/src/api/reference.md | 23 +- docs/src/native-libraries/governance.md | 1 - docs/src/stdlib/other.md | 6 - docs/src/stdlib/overview.md | 1 - scripts/gc_runtime_root_holders.json | 4 - scripts/native_result_ledger.py | 4 +- scripts/native_result_ledger.tsv | 11 - scripts/unrooted_local_shape_baseline.json | 4 +- .../next-app-route/provider/stdlib/Cargo.toml | 1 - workspace-architecture.json | 9 +- 37 files changed, 18 insertions(+), 1974 deletions(-) delete mode 100644 crates/perry-ext-commander/Cargo.toml delete mode 100644 crates/perry-ext-commander/src/lib.rs delete mode 100644 crates/perry-stdlib/src/commander.rs diff --git a/Cargo.lock b/Cargo.lock index 37062ae16c..981344b78b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5880,14 +5880,6 @@ dependencies = [ "scraper", ] -[[package]] -name = "perry-ext-commander" -version = "0.5.1606" -dependencies = [ - "perry-ffi", - "perry-runtime", -] - [[package]] name = "perry-ext-cron" version = "0.5.1606" diff --git a/Cargo.toml b/Cargo.toml index 766712ef22..626c8eb42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,6 @@ members = [ "crates/perry-ext-cheerio", "crates/perry-ext-sharp", "crates/perry-ext-ratelimit", - "crates/perry-ext-commander", "crates/perry-ext-ethers", "crates/perry-ext-nodemailer", "crates/perry-ext-cron", @@ -487,7 +486,6 @@ perry-ext-moment = { path = "crates/perry-ext-moment" } perry-ext-cheerio = { path = "crates/perry-ext-cheerio" } perry-ext-sharp = { path = "crates/perry-ext-sharp" } perry-ext-ratelimit = { path = "crates/perry-ext-ratelimit" } -perry-ext-commander = { path = "crates/perry-ext-commander" } perry-ext-ethers = { path = "crates/perry-ext-ethers" } perry-ext-nodemailer = { path = "crates/perry-ext-nodemailer" } perry-ext-cron = { path = "crates/perry-ext-cron" } diff --git a/crates/perry-api-manifest/src/entries.rs b/crates/perry-api-manifest/src/entries.rs index 53e9dbe339..2bd42480ce 100644 --- a/crates/perry-api-manifest/src/entries.rs +++ b/crates/perry-api-manifest/src/entries.rs @@ -91,7 +91,6 @@ pub const NATIVE_MODULES: &[&str] = &[ "url", // URL / URLSearchParams // ── More third-party npm packages ── "lru-cache", // LRU cache - "commander", // CLI argument parser "decimal.js", // arbitrary-precision decimals "bignumber.js", // arbitrary-precision big numbers "exponential-backoff", // retry-with-backoff helper diff --git a/crates/perry-api-manifest/src/entries/part_1.rs b/crates/perry-api-manifest/src/entries/part_1.rs index 9dc26c00a7..c05787b074 100644 --- a/crates/perry-api-manifest/src/entries/part_1.rs +++ b/crates/perry-api-manifest/src/entries/part_1.rs @@ -1016,24 +1016,6 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[ method("lru-cache", "size", true, None), // `peek(key)` — read without refreshing recency (#7136). method("lru-cache", "peek", true, None), - method("commander", "name", true, None), - method("commander", "description", true, None), - method("commander", "version", true, None), - method("commander", "command", true, None), - method("commander", "option", true, None), - method("commander", "requiredOption", true, None), - method("commander", "action", true, None), - method("commander", "parse", true, None), - method("commander", "opts", true, None), - method("commander", "argument", true, None), - // `program.args` is a bare member read modeled as a property for the - // `.d.ts` surface (`export const args`), but the dispatch table lowers - // it to a 0-arg instance getter row (`commander::args`, has_receiver). - // The drift gate (every_dispatch_entry_has_manifest_counterpart) wants - // a Method counterpart for that row; keep both — the has_receiver - // method isn't emitted as a module export, so docs are unchanged (#5137). - method("commander", "args", true, None), - property("commander", "args"), property("async_hooks", "default"), property("async_hooks", "asyncWrapProviders"), method("async_hooks", "createHook", false, None), diff --git a/crates/perry-codegen/src/lower_call/builtin.rs b/crates/perry-codegen/src/lower_call/builtin.rs index d976f3e675..fb2c7600ad 100644 --- a/crates/perry-codegen/src/lower_call/builtin.rs +++ b/crates/perry-codegen/src/lower_call/builtin.rs @@ -397,19 +397,6 @@ pub(super) fn lower_builtin_new<'a>( ); Ok(Some(nanbox_pointer_inline(blk, &handle))) } - // commander Command — `new Command()` allocates a real CommanderHandle - // via the runtime constructor so subsequent `.command(...).action(...) - // .parse(...)` calls operate on a registered handle. Without this, - // `lower_new` falls back to an empty placeholder ObjectHeader and the - // entire fluent chain dispatches against junk (closes #187). - "Command" => { - for a in args { - let _ = lower_expr(ctx, a)?; - } - let blk = ctx.block(); - let handle = blk.call(I64, "js_commander_new", &[]); - Ok(Some(nanbox_pointer_inline(blk, &handle))) - } // events.EventEmitter — `new EventEmitter()` produces a real // EventEmitterHandle so `.on(...)` / `.emit(...)` find their // registered handle (NATIVE_MODULE_TABLE wires those methods diff --git a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs index e51fd585f9..a0557cd0a3 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs @@ -354,118 +354,4 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ args: &[NA_F64], ret: NR_F64, }, - // ========== commander (CLI parsing) ========== - // `new Command()` is dispatched separately by `lower_builtin_new` so it - // produces a real CommanderHandle instead of an empty placeholder. The - // entries below cover the fluent chain methods + the parse() entry that - // actually reads argv and fires the registered .action() callback. - NativeModSig { - module: "commander", - has_receiver: true, - method: "name", - class_filter: None, - runtime: "js_commander_name", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "commander", - has_receiver: true, - method: "description", - class_filter: None, - runtime: "js_commander_description", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "commander", - has_receiver: true, - method: "version", - class_filter: None, - runtime: "js_commander_version", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "commander", - has_receiver: true, - method: "command", - class_filter: None, - runtime: "js_commander_command", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "commander", - has_receiver: true, - method: "option", - class_filter: None, - runtime: "js_commander_option", - args: &[NA_STR, NA_STR, NA_STR], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "commander", - has_receiver: true, - method: "requiredOption", - class_filter: None, - runtime: "js_commander_required_option", - args: &[NA_STR, NA_STR, NA_STR], - ret: NR_HANDLE_ID, - }, - // .action(cb) — NA_PTR coerces the NaN-boxed closure to its raw i64 - // pointer so the runtime can call back through `js_closure_call1`. - NativeModSig { - module: "commander", - has_receiver: true, - method: "action", - class_filter: None, - runtime: "js_commander_action", - args: &[NA_PTR], - ret: NR_HANDLE_ID, - }, - // .parse(argv) — runtime reads std::env::args() directly; user-provided - // argv expression evaluates for side effects but is not forwarded. - // NA_F64 keeps the LLVM call signature aligned with the runtime decl - // (`(I64, DOUBLE) -> I64`). - NativeModSig { - module: "commander", - has_receiver: true, - method: "parse", - class_filter: None, - runtime: "js_commander_parse", - args: &[NA_F64], - ret: NR_HANDLE_ID, - }, - NativeModSig { - module: "commander", - has_receiver: true, - method: "opts", - class_filter: None, - runtime: "js_commander_opts", - args: &[], - ret: NR_HANDLE_ID, - }, - // `.argument("")` declares a positional; returns the same handle so - // the fluent chain continues (#5137). - NativeModSig { - module: "commander", - has_receiver: true, - method: "argument", - class_filter: None, - runtime: "js_commander_argument", - args: &[NA_STR], - ret: NR_HANDLE_ID, - }, - // `program.args` — a bare member read lowers to this 0-arg getter, which - // returns a JS array of the parsed positional arguments (#5137). - NativeModSig { - module: "commander", - has_receiver: true, - method: "args", - class_filter: None, - runtime: "js_commander_args_array", - args: &[], - ret: NR_HANDLE_ID, - }, ]; diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs index 05bf3a210f..1014e330cf 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi.rs @@ -42,7 +42,7 @@ pub fn declare_stdlib_ffi(module: &mut LlModule) { declare_third_party(module); // URL / URLSearchParams + WebSocket. declare_web(module); - // @perryts/pdf, commander, dotenv, date libs, decimal.js, ethers, lodash, + // @perryts/pdf, dotenv, date libs, decimal.js, ethers, lodash, // lru-cache. declare_utilities(module); // node:stream, EventEmitter, domain, StringDecoder, querystring, fastify, diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs index cf739da7f8..1637c8ceb2 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/utilities.rs @@ -1,5 +1,5 @@ //! Utility-package stdlib FFI declarations (extracted from stdlib_ffi.rs): -//! @perryts/pdf, commander, dotenv, date libs (dayjs/datefns/moment), +//! @perryts/pdf, dotenv, date libs (dayjs/datefns/moment), //! decimal.js, ethers, lodash, lru-cache. use crate::module::LlModule; @@ -20,21 +20,6 @@ pub(crate) fn declare_utilities(module: &mut LlModule) { module.declare_function("js_pdf_new_page", VOID, &[I64]); module.declare_function("js_pdf_save", VOID, &[I64]); - // ========== Commander CLI ========== - module.declare_function("js_commander_action", I64, &[I64, I64]); - module.declare_function("js_commander_command", I64, &[I64, I64]); - module.declare_function("js_commander_description", I64, &[I64, I64]); - module.declare_function("js_commander_get_option", I64, &[I64, I64]); - module.declare_function("js_commander_get_option_bool", DOUBLE, &[I64, I64]); - module.declare_function("js_commander_get_option_number", DOUBLE, &[I64, I64]); - module.declare_function("js_commander_name", I64, &[I64, I64]); - module.declare_function("js_commander_new", I64, &[]); - module.declare_function("js_commander_option", I64, &[I64, I64, I64, I64]); - module.declare_function("js_commander_opts", I64, &[I64]); - module.declare_function("js_commander_parse", I64, &[I64, DOUBLE]); - module.declare_function("js_commander_required_option", I64, &[I64, I64, I64, I64]); - module.declare_function("js_commander_version", I64, &[I64, I64]); - // ========== Date libs (dayjs/datefns/moment) ========== module.declare_function("js_datefns_add_days", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_datefns_add_months", DOUBLE, &[DOUBLE, DOUBLE]); diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi_part2.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi_part2.rs index 853727b2d5..9ecbc5edf4 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi_part2.rs @@ -24,10 +24,4 @@ pub(crate) fn declare_stdlib_ffi_part2(module: &mut LlModule) { // #4975 — self-returning IncomingMessage pause()/resume() (return `this`). module.declare_function("js_node_http_im_pause_self", I64, &[I64]); module.declare_function("js_node_http_im_resume_self", I64, &[I64]); - - // ========== Commander CLI (#5137) ========== - // `program.args` getter + `.argument(spec)` — kept here (not stdlib_ffi.rs) - // so that file stays under the 2000-line CI cap. - module.declare_function("js_commander_args_array", I64, &[I64]); - module.declare_function("js_commander_argument", I64, &[I64, I64]); } diff --git a/crates/perry-ext-commander/Cargo.toml b/crates/perry-ext-commander/Cargo.toml deleted file mode 100644 index 4ee3fc6cfd..0000000000 --- a/crates/perry-ext-commander/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "perry-ext-commander" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "Native bindings for npm `commander` — uses only `perry-ffi`. Sync, handle-based fluent CLI parser. Uses GC root scanner to keep .action() closures alive across allocations." - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib", "rlib"] - -[dependencies] -perry-ffi.workspace = true - -[dev-dependencies] -perry-ffi = { workspace = true, features = ["runtime-link"] } -# #6303: perry-runtime MUST be built here with the same feature set the shipped -# `libperry_runtime.a` / `libperry_stdlib.a` carry (i.e. its `default`). This crate -# is a `staticlib`, so it BUNDLES the perry-runtime rlib objects into -# `libperry_ext_*.a` — and perry links the ext archives BEFORE stdlib/runtime -# (`prefer_well_known_before_stdlib`), so those bundled objects WIN the link for -# every symbol they define. The workspace dep is `default-features = false`, so -# without `"default"` here a per-crate `cargo build -p perry-ext-` (exactly what -# release-packages.yml does in its per-crate loop) bundles a runtime with -# `regex-engine`/`temporal`/... compiled OUT. The dispatchers those features gate -# are exported UNCONDITIONALLY (`js_string_replace_search_dyn`, -# `js_native_call_method`, ...) with the feature-gated logic `#[cfg]`-ed out of the -# BODY — so the degraded copy silently ToString-coerces a RegExp argument and -# searches for it literally instead of matching it (str.replace(re, fn) never fires -# its callback). Keep `"default"` in lock-step with perry-runtime's default feature -# list; the `ext_crates_bundle_a_full_featured_perry_runtime` test (well_known.rs) guards it. -# `stdlib`: this crate bundles perry-runtime into its staticlib and is co-linked -# with the real perry-stdlib, so drop the bundled no-op stdlib_stubs that would -# otherwise shadow perry-stdlib's real symbols (#6314). `default`: keep the copy -# feature-identical to the shipped runtime so gated dispatchers behave (#6303). -perry-runtime = { workspace = true, features = ["default", "stdlib"] } diff --git a/crates/perry-ext-commander/src/lib.rs b/crates/perry-ext-commander/src/lib.rs deleted file mode 100644 index ba6eede278..0000000000 --- a/crates/perry-ext-commander/src/lib.rs +++ /dev/null @@ -1,787 +0,0 @@ -//! Native bindings for the npm `commander` package — fluent CLI -//! parser. Uses only perry-ffi v0.5: strings, handle registry, -//! object alloc-with-shape, closure invocation, GC root scanner. -//! -//! Functional parity with perry-stdlib's existing copy: command + -//! subcommand fluent setup, option parsing (long/short, flags vs -//! values, defaults, --key=value), `.action(opts => ...)`, -//! automatic --help / --version, post-parse query accessors. - -use perry_ffi::{ - alloc_string, gc_register_mutable_root_scanner_named, get_handle, get_handle_mut, - iter_handles_of_mut, js_array_alloc, js_array_get, js_array_length, js_array_push, - js_object_alloc_with_shape, js_object_set_field, read_string, register_handle, with_handle_mut, - ArrayHeader, GcRootVisitor, Handle, JsClosure, JsString, JsValue, RawClosureHeader, - StringHeader, -}; -use std::collections::HashMap; - -const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; -const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; -pub struct CommanderHandle { - name: String, - description: String, - version: String, - options: Vec, - parsed_values: HashMap, - args: Vec, - /// Declared positional argument specs from `.argument("")` / - /// `.argument("[dir]")` — used only for the `--help` usage line. Parsing - /// itself collects every non-option token into `args` regardless. - declared_args: Vec, - /// (subcommand-name, sub-CommanderHandle) — populated by `.command(name)`. - subcommands: Vec<(String, Handle)>, - /// Closure pointer (raw bits) for `.action(cb)`. 0 = no action. - /// Stored as i64 for the same Send + Sync reason perry-ext-events - /// stores listener closures as i64 — raw pointers aren't - /// Send/Sync but the underlying closure data is GC-managed. - action_callback: i64, -} - -struct CommandOption { - short: Option, - long: String, - description: String, - default_value: Option, - is_flag: bool, -} - -#[derive(Clone)] -enum ParsedValue { - Str(String), - Bool(bool), -} - -impl Default for CommanderHandle { - fn default() -> Self { - Self::new() - } -} - -impl CommanderHandle { - pub fn new() -> Self { - CommanderHandle { - name: String::new(), - description: String::new(), - version: String::new(), - options: Vec::new(), - parsed_values: HashMap::new(), - args: Vec::new(), - declared_args: Vec::new(), - subcommands: Vec::new(), - action_callback: 0, - } - } -} - -// ── GC root scanning ────────────────────────────────────────────── - -static GC_REGISTERED: std::sync::Once = std::sync::Once::new(); - -fn ensure_gc_scanner_registered() { - GC_REGISTERED.call_once(|| { - gc_register_mutable_root_scanner_named("perry-ext-commander", scan_commander_roots); - }); -} - -fn scan_commander_roots(visitor: &mut GcRootVisitor<'_>) { - iter_handles_of_mut::(|cmd| { - visitor.visit_i64_slot(&mut cmd.action_callback); - }); -} - -unsafe fn read_str(ptr: *const StringHeader) -> Option { - if ptr.is_null() || (ptr as usize) < 4096 { - return None; - } - let handle = JsString::from_raw(ptr as *mut StringHeader); - read_string(handle).map(String::from) -} - -/// Parse the commander flag-spec mini-language used in `.option(...)`: -/// `"-p, --port "` → `(Some('p'), "port", false)`. -/// `"-v, --verbose"` → `(Some('v'), "verbose", true)`. -/// `"--config "` → `(None, "config", false)`. -fn parse_flag_spec(flags: &str) -> (Option, String, bool) { - let is_flag = !flags.contains('<') && !flags.contains('['); - let mut short: Option = None; - let mut long = String::new(); - for part in flags.split(',') { - let part = part.trim(); - if let Some(rest) = part.strip_prefix("--") { - long = rest.split_whitespace().next().unwrap_or("").to_string(); - } else if let Some(rest) = part.strip_prefix('-') { - short = rest.chars().next(); - } - } - (short, long, is_flag) -} - -// ── Constructor + fluent setters ────────────────────────────────── - -#[no_mangle] -pub extern "C" fn js_commander_new() -> Handle { - ensure_gc_scanner_registered(); - register_handle(CommanderHandle::new()) -} - -/// # Safety -/// `name_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_name( - handle: Handle, - name_ptr: *const StringHeader, -) -> Handle { - if let Some(name) = read_str(name_ptr) { - with_handle_mut::(handle, |cmd| cmd.name = name); - } - handle -} - -/// # Safety -/// `desc_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_description( - handle: Handle, - desc_ptr: *const StringHeader, -) -> Handle { - if let Some(desc) = read_str(desc_ptr) { - with_handle_mut::(handle, |cmd| cmd.description = desc); - } - handle -} - -/// # Safety -/// `version_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_version( - handle: Handle, - version_ptr: *const StringHeader, -) -> Handle { - if let Some(version) = read_str(version_ptr) { - with_handle_mut::(handle, |cmd| cmd.version = version); - } - handle -} - -/// # Safety -/// All string pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_commander_option( - handle: Handle, - flags_ptr: *const StringHeader, - desc_ptr: *const StringHeader, - default_ptr: *const StringHeader, -) -> Handle { - let flags = match read_str(flags_ptr) { - Some(f) => f, - None => return handle, - }; - let description = read_str(desc_ptr).unwrap_or_default(); - let default_value = read_str(default_ptr); - let (short, long, is_flag) = parse_flag_spec(&flags); - with_handle_mut::(handle, |cmd| { - cmd.options.push(CommandOption { - short, - long, - description, - default_value, - is_flag, - }); - }); - handle -} - -/// Required-validation isn't enforced at runtime yet; treat as a normal option. -/// -/// # Safety -/// All string pointers must be null or Perry-runtime `StringHeader`s. -#[no_mangle] -pub unsafe extern "C" fn js_commander_required_option( - handle: Handle, - flags_ptr: *const StringHeader, - desc_ptr: *const StringHeader, - default_ptr: *const StringHeader, -) -> Handle { - js_commander_option(handle, flags_ptr, desc_ptr, default_ptr) -} - -/// `.argument("")` / `.argument("[dir]")` — declare a positional -/// argument. Parsing always collects non-option tokens into `args`, so this -/// only records the spec for the `--help` usage line and returns the handle so -/// the fluent chain keeps flowing. #5137: without this entry the call fell -/// through to generic dynamic dispatch (a silent no-op) instead of staying on -/// the commander handle. -/// -/// # Safety -/// `spec_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_argument( - handle: Handle, - spec_ptr: *const StringHeader, -) -> Handle { - if let Some(spec) = read_str(spec_ptr) { - with_handle_mut::(handle, |cmd| { - cmd.declared_args.push(spec); - }); - } - handle -} - -/// Register an action callback. `callback` is a raw closure pointer -/// (NaN-box-stripped) — codegen passes it via the NA_PTR coercion which -/// runs `unbox_to_i64` before this entry sees it. Non-zero is the -/// stable "action registered" signal. -#[no_mangle] -pub extern "C" fn js_commander_action(handle: Handle, callback: i64) -> Handle { - ensure_gc_scanner_registered(); - with_handle_mut::(handle, |cmd| { - cmd.action_callback = callback; - }); - handle -} - -/// Create a subcommand and register it on the parent. Returns the -/// new sub-handle so chained `.command("x").option(...).action(...)` -/// accrues state on the subcommand, not the parent. -/// -/// # Safety -/// `name_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_command( - handle: Handle, - name_ptr: *const StringHeader, -) -> Handle { - let sub_name = read_str(name_ptr).unwrap_or_default(); - let sub_handle = register_handle(CommanderHandle::new()); - with_handle_mut::(handle, |parent| { - parent.subcommands.push((sub_name, sub_handle)); - }); - sub_handle -} - -// ── Parse + dispatch ────────────────────────────────────────────── - -/// Resolve the argument list `parse(argv?)` should operate on. -/// -/// npm commander's `parse()` defaults to `from: 'node'`: when an explicit -/// array is supplied (`program.parse(['node', 'script', ...])`) the first two -/// entries are the executable + script path and the real args start at index -/// 2. When called with no argument it reads `process.argv`, which on a Perry -/// binary is `[exePath, ...realArgs]` (no separate script entry) — so we skip -/// only the leading exe path. #5137: previously this always read -/// `std::env::args()` and ignored the passed array, so `program.parse([...])` -/// with a synthetic argv (the common test/REPL shape, and the issue repro) -/// silently parsed nothing. -fn resolve_parse_args(argv: f64) -> Vec { - let value = JsValue::from_bits(argv.to_bits()); - if value.is_pointer() { - let arr = value.as_pointer::(); - if !arr.is_null() { - let len = unsafe { js_array_length(arr) }; - let mut out = Vec::with_capacity(len as usize); - for i in 0..len { - let elem = unsafe { js_array_get(arr, i) }; - if let Some(s) = unsafe { read_str(elem.as_string_ptr()) } { - out.push(s); - } - } - // `from: 'node'` default — drop argv[0] (exe) and argv[1] (script). - return out.into_iter().skip(2).collect(); - } - } - // #9401: `std::env::args()` panics on a non-UTF-8 argument; Node decodes - // argv leniently (every invalid byte becomes U+FFFD) and so must this. - std::env::args_os() - .skip(1) - .map(|arg| arg.to_string_lossy().into_owned()) - .collect() -} - -/// Top-level parse entry. The second arg is the user's `parse(argv)` -/// expression: when it's an explicit array we honor it (commander's -/// `from: 'node'` default), otherwise we fall back to the real -/// `std::env::args()`. Codegen passes the NaN-boxed value through unchanged -/// via the NA_F64 dispatch slot. -#[no_mangle] -pub extern "C" fn js_commander_parse(handle: Handle, argv: f64) -> Handle { - let args = resolve_parse_args(argv); - parse_and_dispatch(handle, &args); - handle -} - -struct ParseSnapshot { - name: String, - description: String, - version: String, - options: Vec, - subcommands: Vec<(String, Handle)>, - declared_args: Vec, -} - -struct OptionMeta { - short: Option, - long: String, - is_flag: bool, - description: String, -} - -fn snapshot_for_parse(handle: Handle) -> Option { - get_handle_mut::(handle).map(|cmd| { - cmd.parsed_values.clear(); - cmd.args.clear(); - for opt in &cmd.options { - if let Some(ref dv) = opt.default_value { - cmd.parsed_values - .insert(opt.long.clone(), ParsedValue::Str(dv.clone())); - } - } - ParseSnapshot { - name: cmd.name.clone(), - description: cmd.description.clone(), - version: cmd.version.clone(), - options: cmd - .options - .iter() - .map(|o| OptionMeta { - short: o.short, - long: o.long.clone(), - is_flag: o.is_flag, - description: o.description.clone(), - }) - .collect(), - subcommands: cmd.subcommands.clone(), - declared_args: cmd.declared_args.clone(), - } - }) -} - -/// Parse `args` against the command at `handle`, then run its -/// `.action()` (or recurse into a matched subcommand). On `--help` -/// / `--version` this exits the process with code 0 directly, -/// matching npm commander's behavior. -fn parse_and_dispatch(handle: Handle, args: &[String]) { - let Some(snapshot) = snapshot_for_parse(handle) else { - return; - }; - - let mut i = 0usize; - let mut positional: Vec = Vec::new(); - while i < args.len() { - let arg = &args[i]; - if arg == "--help" || arg == "-h" { - print_help(&snapshot); - std::process::exit(0); - } - if (arg == "--version" || arg == "-V") && !snapshot.version.is_empty() { - println!("{}", snapshot.version); - std::process::exit(0); - } - if positional.is_empty() { - if let Some((_, sub_handle)) = snapshot.subcommands.iter().find(|(n, _)| n == arg) { - let rest: Vec = args[i + 1..].to_vec(); - parse_and_dispatch(*sub_handle, &rest); - return; - } - } - if let Some(opt_name) = arg.strip_prefix("--") { - if let Some(eq_pos) = opt_name.find('=') { - let key = opt_name[..eq_pos].to_string(); - let value = opt_name[eq_pos + 1..].to_string(); - set_str(handle, &key, &value); - } else if let Some(meta) = snapshot.options.iter().find(|o| o.long == opt_name) { - if meta.is_flag { - set_bool(handle, &meta.long, true); - } else if i + 1 < args.len() { - i += 1; - set_str(handle, &meta.long, &args[i]); - } - } else { - set_bool(handle, opt_name, true); - } - } else if let Some(short_str) = arg.strip_prefix('-') { - if short_str.len() == 1 { - let ch = short_str.chars().next().unwrap(); - if let Some(meta) = snapshot.options.iter().find(|o| o.short == Some(ch)) { - if meta.is_flag { - set_bool(handle, &meta.long, true); - } else if i + 1 < args.len() { - i += 1; - set_str(handle, &meta.long, &args[i]); - } - } - } - } else { - positional.push(arg.clone()); - } - i += 1; - } - - with_handle_mut::(handle, |cmd| { - cmd.args = positional; - }); - - run_action(handle); -} - -fn set_str(handle: Handle, key: &str, value: &str) { - let key = key.to_string(); - let value = value.to_string(); - with_handle_mut::(handle, |cmd| { - cmd.parsed_values.insert(key, ParsedValue::Str(value)); - }); -} - -fn set_bool(handle: Handle, key: &str, value: bool) { - let key = key.to_string(); - with_handle_mut::(handle, |cmd| { - cmd.parsed_values.insert(key, ParsedValue::Bool(value)); - }); -} - -/// Build the `options` JS object passed to `.action(opts => ...)` -/// and invoke the registered closure. No-op if no closure was -/// registered. -fn run_action(handle: Handle) { - let parsed = match get_handle::(handle) { - Some(cmd) => (cmd.action_callback, cmd.parsed_values.clone()), - None => return, - }; - let (cb, parsed) = parsed; - if cb == 0 { - return; - } - let opts_value = build_options_object(&parsed); - let closure = unsafe { JsClosure::from_raw(cb as *const RawClosureHeader) }; - if !closure.is_null() { - // SAFETY: cb is a non-null closure pointer kept alive by the - // GC root scanner registered in `ensure_gc_scanner_registered`. - let _ = unsafe { closure.call1(f64::from_bits(opts_value.bits())) }; - } -} - -/// Allocate a fresh JS Object using perry-ffi's -/// `js_object_alloc_with_shape` and populate it with one field per -/// parsed option. Strings are stored as STRING_TAG-tagged, booleans -/// as TAG_TRUE / TAG_FALSE — the dynamic property lookup user code -/// runs on `options.port` traverses the same path it would for a -/// hand-built object literal. -fn build_options_object(parsed: &HashMap) -> JsValue { - if parsed.is_empty() { - // Allocate an empty object (zero fields) so user code can - // still call .someField → undefined without faulting. - let (packed, shape_id) = perry_ffi::build_object_shape(&[]); - let obj = unsafe { - js_object_alloc_with_shape(shape_id, 0, packed.as_ptr(), packed.len() as u32) - }; - return JsValue::from_object_ptr(obj); - } - - let keys: Vec = parsed.keys().cloned().collect(); - let key_refs: Vec<&str> = keys.iter().map(|s| s.as_str()).collect(); - let (packed, shape_id) = perry_ffi::build_object_shape(&key_refs); - let obj = unsafe { - js_object_alloc_with_shape( - shape_id, - keys.len() as u32, - packed.as_ptr(), - packed.len() as u32, - ) - }; - for (i, k) in keys.iter().enumerate() { - let val = match parsed.get(k) { - Some(ParsedValue::Str(s)) => JsValue::from_string_ptr(alloc_string(s).as_raw()), - Some(ParsedValue::Bool(true)) => JsValue::from_bits(TAG_TRUE), - Some(ParsedValue::Bool(false)) => JsValue::from_bits(TAG_FALSE), - None => JsValue::UNDEFINED, - }; - unsafe { js_object_set_field(obj, i as u32, val) }; - } - JsValue::from_object_ptr(obj) -} - -// ── Help formatting ─────────────────────────────────────────────── - -fn print_help(s: &ParseSnapshot) { - if !s.description.is_empty() { - println!("{}", s.description); - println!(); - } - let prog = if s.name.is_empty() { - "".to_string() - } else { - s.name.clone() - }; - let mut usage_tail = if s.subcommands.is_empty() { - "[options]".to_string() - } else { - "[options] [command]".to_string() - }; - for arg in &s.declared_args { - usage_tail.push(' '); - usage_tail.push_str(arg); - } - println!("Usage: {} {}", prog, usage_tail); - println!(); - println!("Options:"); - if !s.version.is_empty() { - println!(" {:<24} output the version number", "-V, --version"); - } - for opt in &s.options { - let placeholder = if opt.is_flag { "" } else { " " }; - let flag_str = match opt.short { - Some(ch) => format!("-{}, --{}{}", ch, opt.long, placeholder), - None => format!("--{}{}", opt.long, placeholder), - }; - println!(" {:<24} {}", flag_str, opt.description); - } - println!(" {:<24} display help for command", "-h, --help"); - if !s.subcommands.is_empty() { - println!(); - println!("Commands:"); - for (sub_name, _) in &s.subcommands { - println!(" {}", sub_name); - } - } -} - -// ── Read-back accessors ─────────────────────────────────────────── - -/// `program.opts()` — return a fresh plain object of the parsed option -/// values (matching npm commander, where `opts()` returns a data object). -/// #5137: previously returned the raw handle, so `JSON.stringify(opts)` saw a -/// bogus pointer and printed `null` and `opts.verbose` never resolved. The -/// NR_PTR return ABI NaN-boxes the returned heap pointer as a JS object value. -#[no_mangle] -pub extern "C" fn js_commander_opts(handle: Handle) -> Handle { - let parsed = get_handle_mut::(handle) - .map(|cmd| cmd.parsed_values.clone()) - .unwrap_or_default(); - build_options_object(&parsed).as_pointer::() as Handle -} - -/// `program.args` — return a fresh JS array of the parsed positional -/// arguments (everything that wasn't an option flag or option value). -/// #5137: a bare `program.args` member read lowers to a 0-arg -/// NativeMethodCall through the commander table; without this getter it -/// resolved to the zero-sentinel and `program.args[0]` read `undefined`. -#[no_mangle] -pub extern "C" fn js_commander_args_array(handle: Handle) -> Handle { - let args = get_handle_mut::(handle) - .map(|cmd| cmd.args.clone()) - .unwrap_or_default(); - unsafe { - let mut arr = js_array_alloc(args.len() as u32); - for a in &args { - let val = JsValue::from_string_ptr(alloc_string(a).as_raw()); - arr = js_array_push(arr, val); - } - arr as Handle - } -} - -/// # Safety -/// `name_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_get_option( - handle: Handle, - name_ptr: *const StringHeader, -) -> *const StringHeader { - let name = match read_str(name_ptr) { - Some(n) => n, - None => return std::ptr::null(), - }; - if let Some(cmd) = get_handle::(handle) { - if let Some(ParsedValue::Str(value)) = cmd.parsed_values.get(&name) { - return alloc_string(value).as_raw(); - } - } - std::ptr::null() -} - -/// # Safety -/// `name_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_get_option_number( - handle: Handle, - name_ptr: *const StringHeader, -) -> f64 { - let name = match read_str(name_ptr) { - Some(n) => n, - None => return f64::NAN, - }; - if let Some(cmd) = get_handle::(handle) { - if let Some(ParsedValue::Str(value)) = cmd.parsed_values.get(&name) { - return value.parse::().unwrap_or(f64::NAN); - } - } - f64::NAN -} - -/// # Safety -/// `name_ptr` must be null or a Perry-runtime `StringHeader`. -#[no_mangle] -pub unsafe extern "C" fn js_commander_get_option_bool( - handle: Handle, - name_ptr: *const StringHeader, -) -> f64 { - let name = match read_str(name_ptr) { - Some(n) => n, - None => return f64::from_bits(TAG_FALSE), - }; - if let Some(cmd) = get_handle::(handle) { - match cmd.parsed_values.get(&name) { - Some(ParsedValue::Bool(true)) => return f64::from_bits(TAG_TRUE), - Some(ParsedValue::Str(_)) => return f64::from_bits(TAG_TRUE), - _ => {} - } - } - f64::from_bits(TAG_FALSE) -} - -#[no_mangle] -pub extern "C" fn js_commander_args_count(handle: Handle) -> f64 { - get_handle::(handle) - .map(|cmd| cmd.args.len() as f64) - .unwrap_or(0.0) -} - -#[no_mangle] -pub extern "C" fn js_commander_get_arg(handle: Handle, index: f64) -> *const StringHeader { - let idx = index as usize; - if let Some(cmd) = get_handle::(handle) { - if idx < cmd.args.len() { - return alloc_string(&cmd.args[idx]).as_raw(); - } - } - std::ptr::null() -} - -#[cfg(test)] -mod tests { - use super::*; - use perry_ffi::drop_handle; - use std::sync::{Mutex, MutexGuard}; - - static GC_TEST_LOCK: Mutex<()> = Mutex::new(()); - - struct GcTestGuard { - frame: u64, - previous_force_evacuation: i32, - _lock: MutexGuard<'static, ()>, - } - - impl GcTestGuard { - fn new() -> Self { - let lock = GC_TEST_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let previous_force_evacuation = - perry_runtime::gc::js_gc_force_evacuation_test_override(1); - perry_runtime::gc::js_gc_write_barriers_emitted(1); - let frame = perry_runtime::gc::js_shadow_frame_push(0); - Self { - frame, - previous_force_evacuation, - _lock: lock, - } - } - } - - impl Drop for GcTestGuard { - fn drop(&mut self) { - perry_runtime::gc::js_shadow_frame_pop(self.frame); - perry_runtime::gc::js_gc_write_barriers_emitted(0); - perry_runtime::gc::js_gc_force_evacuation_test_override(self.previous_force_evacuation); - } - } - - fn young_gc_root() -> i64 { - perry_runtime::arena::arena_alloc_gc(32, 8, perry_runtime::gc::GC_TYPE_STRING) as i64 - } - - fn assert_rewritten(before: i64, after: i64) { - assert_ne!(after, before); - assert!(perry_runtime::arena::pointer_in_nursery(after as usize)); - } - - #[test] - fn parse_flag_spec_value_with_short() { - let (s, l, f) = parse_flag_spec("-p, --port "); - assert_eq!(s, Some('p')); - assert_eq!(l, "port"); - assert!(!f); - } - - #[test] - fn parse_flag_spec_boolean_long_only() { - let (s, l, f) = parse_flag_spec("--verbose"); - assert_eq!(s, None); - assert_eq!(l, "verbose"); - assert!(f); - } - - #[test] - fn parse_flag_spec_optional_value() { - let (s, l, f) = parse_flag_spec("-c, --config [path]"); - assert_eq!(s, Some('c')); - assert_eq!(l, "config"); - assert!(!f); - } - - #[test] - fn gc_mutable_scanner_rewrites_action_callback_root() { - let _guard = GcTestGuard::new(); - perry_ffi::gc_register_mutable_root_scanner_named( - "perry-ext-commander", - scan_commander_roots, - ); - - let callback = young_gc_root(); - let mut cmd = CommanderHandle::new(); - cmd.action_callback = callback; - let handle = register_handle(cmd); - - let _ = perry_runtime::gc::gc_collect_minor(); - - { - let cmd = - get_handle::(handle).expect("commander handle should remain live"); - assert_rewritten(callback, cmd.action_callback); - } - drop_handle(handle); - } - - #[test] - fn fluent_setters_round_trip() { - let h = js_commander_new(); - let name = alloc_string("myprog"); - unsafe { js_commander_name(h, name.as_raw()) }; - let desc = alloc_string("A test program"); - unsafe { js_commander_description(h, desc.as_raw()) }; - let ver = alloc_string("1.2.3"); - unsafe { js_commander_version(h, ver.as_raw()) }; - if let Some(cmd) = get_handle::(h) { - assert_eq!(cmd.name, "myprog"); - assert_eq!(cmd.description, "A test program"); - assert_eq!(cmd.version, "1.2.3"); - } else { - panic!("handle missing"); - } - } - - #[test] - fn option_added_to_command() { - let h = js_commander_new(); - let flags = alloc_string("-p, --port "); - let desc = alloc_string("listen port"); - let null = std::ptr::null::(); - unsafe { js_commander_option(h, flags.as_raw(), desc.as_raw(), null) }; - if let Some(cmd) = get_handle::(h) { - assert_eq!(cmd.options.len(), 1); - assert_eq!(cmd.options[0].long, "port"); - assert_eq!(cmd.options[0].short, Some('p')); - assert!(!cmd.options[0].is_flag); - } - } -} diff --git a/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs b/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs index 8a445b4a3f..a4cfa29eac 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_fetch.rs @@ -165,7 +165,6 @@ pub(crate) fn register_native_fetch_and_streams( "decimal.js" => "Decimal", "bignumber.js" => "BigNumber", "lru-cache" => "LRUCache", - "commander" => "Command", _ => "", }; if !class_name.is_empty() { diff --git a/crates/perry-hir/src/destructuring/var_decl/native_new.rs b/crates/perry-hir/src/destructuring/var_decl/native_new.rs index 145a34a2f6..5974dcf25e 100644 --- a/crates/perry-hir/src/destructuring/var_decl/native_new.rs +++ b/crates/perry-hir/src/destructuring/var_decl/native_new.rs @@ -90,7 +90,6 @@ pub(crate) fn register_native_from_new_and_calls( "WebSocket" | "WebSocketServer" => Some("ws".to_string()), "Redis" => Some("ioredis".to_string()), "LRUCache" => Some("lru-cache".to_string()), - "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), "BigNumber" => Some("bignumber.js".to_string()), @@ -224,7 +223,6 @@ pub(crate) fn register_native_from_new_and_calls( "WebSocket" | "WebSocketServer" => Some("ws".to_string()), "Redis" => Some("ioredis".to_string()), "LRUCache" => Some("lru-cache".to_string()), - "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), "BigNumber" => Some("bignumber.js".to_string()), diff --git a/crates/perry-hir/src/js_transform/imports.rs b/crates/perry-hir/src/js_transform/imports.rs index 420e379028..a0f1e0c3cb 100644 --- a/crates/perry-hir/src/js_transform/imports.rs +++ b/crates/perry-hir/src/js_transform/imports.rs @@ -674,7 +674,7 @@ pub fn transform_expr( // Classes with native codegen support should NOT be converted to JsNew // even if imported from JS modules - the codegen handles them directly const NATIVE_CODEGEN_CLASSES: &[&str] = &[ - "Redis", "Command", "Pool", "WebSocket", "WebSocketServer", + "Redis", "Pool", "WebSocket", "WebSocketServer", "LRUCache", "Big", "Decimal", "BigNumber", "URLSearchParams", ]; // Check if this is a JS class (but not one handled natively) diff --git a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs index bb29ba08df..f4667c2693 100644 --- a/crates/perry-hir/src/lower/expr_call/static_and_instance.rs +++ b/crates/perry-hir/src/lower/expr_call/static_and_instance.rs @@ -427,30 +427,6 @@ pub(super) fn try_static_method_and_instance( "eq" | "lt" | "lte" | "gt" | "gte" | "cmp" | "isZero" | "isPositive" | "isNegative" ); - // commander Command — every fluent method either - // returns the same handle (name/version/description/ - // option/requiredOption/action) or a sub-Command with - // the same module + class (.command(name)). Either way - // the next chained call must dispatch through the - // commander NativeModSig table, not the generic - // dynamic-property fallback. Without this branch - // `program.name(...).version(...)` only the first - // call landed as a NativeMethodCall and the rest - // silently no-op'd at codegen — issue #187. - let is_commander = module.as_str() == "commander"; - let is_commander_method = matches!( - method_name.as_str(), - "name" - | "version" - | "description" - | "option" - | "requiredOption" - | "action" - | "command" - | "parse" - | "opts" - | "argument" - ); // #1048 — fastify Reply chainable methods. `reply.code(201) // .type("application/json").send(payload)` ships every method // returning the same reply handle for chaining; without this @@ -497,7 +473,6 @@ pub(super) fn try_static_method_and_instance( | "end" ); if (is_math_lib && is_math_method) - || (is_commander && is_commander_method) || (is_fastify_reply && is_fastify_reply_chain_method) || (is_http_client_request && is_client_request_chain_method) { diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index 0a5f50f6b6..0f8a14473b 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -689,7 +689,6 @@ pub(crate) fn lower_module_decl( } "Redis" => Some("ioredis".to_string()), "LRUCache" => Some("lru-cache".to_string()), - "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), "BigNumber" => Some("bignumber.js".to_string()), @@ -757,7 +756,6 @@ pub(crate) fn lower_module_decl( } "Redis" => Some("ioredis".to_string()), "LRUCache" => Some("lru-cache".to_string()), - "Command" => Some("commander".to_string()), "Big" => Some("big.js".to_string()), "Decimal" => Some("decimal.js".to_string()), "BigNumber" => Some("bignumber.js".to_string()), diff --git a/crates/perry-hir/src/lower_patterns.rs b/crates/perry-hir/src/lower_patterns.rs index ab3b35cb8e..13ca8e7d6f 100644 --- a/crates/perry-hir/src/lower_patterns.rs +++ b/crates/perry-hir/src/lower_patterns.rs @@ -1444,7 +1444,6 @@ pub(crate) fn detect_native_instance_expr( "Decimal" => "decimal.js", "BigNumber" => "bignumber.js", "LRUCache" => "lru-cache", - "Command" => "commander", _ => return None, }; match ctx.lookup_native_module(class_name) { diff --git a/crates/perry-stdlib/Cargo.toml b/crates/perry-stdlib/Cargo.toml index 0357575ca8..d9487984e2 100644 --- a/crates/perry-stdlib/Cargo.toml +++ b/crates/perry-stdlib/Cargo.toml @@ -23,7 +23,7 @@ default = ["full"] # must stay out of this list: release archives enable `full` without linking # their per-program provider archives, and adding an external HTTP pump here # made HTTP-free Linux UI links require libperry_ext_http.a (#5983, #8587). -full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "net", "tls", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-commander", "bundled-streams"] +full = ["http-server", "http-client", "database", "crypto", "compression", "email", "websocket", "image", "scheduler", "ids", "html-parser", "rate-limit", "net", "tls", "bundled-lru-cache", "bundled-exponential-backoff", "bundled-events", "bundled-decimal", "bundled-dayjs", "bundled-moment", "bundled-streams"] # Minimal core - just what's needed for basic programs core = [] @@ -55,11 +55,6 @@ bundled-dayjs = [] # by the well-known table. bundled-moment = [] -# commander — pure-Rust, no extra deps. Default-on through `full`; -# the well-known flip strips this when `import 'commander'` resolves -# to perry-ext-commander instead. -bundled-commander = [] - # HTTP server (hyper-based native framework) # Note: dashmap is now always-on (used by core handle registry), no longer listed here. # Gates the `framework` module (non-fastify hyper plumbing) only. fastify is no diff --git a/crates/perry-stdlib/src/commander.rs b/crates/perry-stdlib/src/commander.rs deleted file mode 100644 index 8be1558a61..0000000000 --- a/crates/perry-stdlib/src/commander.rs +++ /dev/null @@ -1,737 +0,0 @@ -//! Commander implementation -//! -//! Native implementation of the commander npm package for CLI parsing. -//! Provides a fluent API for building command-line interfaces, including -//! subcommands, action callbacks, automatic `--help` / `--version`, and -//! options object construction passed back to the user's `.action()` -//! handler. -//! -//! Closes #187: pre-fix this module stored option metadata but never -//! invoked the `.action()` callback, never linked subcommands to their -//! parent, and never printed help. The docs example silently no-op'd. - -use perry_runtime::array::{js_array_from_f64, js_array_get_f64, js_array_length, ArrayHeader}; -use perry_runtime::closure::js_closure_call1; -use perry_runtime::value::js_jsvalue_to_string; -use perry_runtime::{ - js_object_alloc, js_object_set_field_by_name, js_string_from_bytes, ClosureHeader, StringHeader, -}; -use std::collections::HashMap; - -use crate::common::{ - for_each_handle_mut_of, get_handle_mut, register_handle, - string_from_header_lossy as string_from_header, Handle, -}; - -// NaN-box tags. Mirror perry-runtime/src/value.rs constants. Duplicated -// here because they're not exported across crate boundaries; if either -// definition drifts the runtime tests catch it before this code does. -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; -const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; -const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; - -#[inline(always)] -fn nanbox_pointer(addr: u64) -> u64 { - POINTER_TAG | (addr & 0x0000_FFFF_FFFF_FFFF) -} - -#[inline(always)] -fn nanbox_string(addr: u64) -> u64 { - STRING_TAG | (addr & 0x0000_FFFF_FFFF_FFFF) -} - -/// CommanderHandle stores the command configuration and parsed values. -pub struct CommanderHandle { - name: String, - description: String, - version: String, - options: Vec, - parsed_values: HashMap, - args: Vec, - /// Declared positional argument specs from `.argument("")` / - /// `.argument("[dir]")` — used only for the `--help` usage line. Parsing - /// itself collects every non-option token into `args` regardless. - declared_args: Vec, - /// (subcommand-name, sub-CommanderHandle) — populated by `.command(name)`. - subcommands: Vec<(String, Handle)>, - /// Closure pointer (raw bits) for `.action(cb)`. 0 = no action registered. - /// Stored as i64 for the same Send + Sync reason events.rs stores listener - /// closures as i64 — raw pointers aren't Send/Sync but the underlying - /// closure data is managed by the runtime + GC root scanner below. - action_callback: i64, -} - -struct CommandOption { - short: Option, - long: String, - description: String, - default_value: Option, - is_flag: bool, // true for boolean flags, false for value options -} - -#[derive(Clone)] -enum ParsedValue { - Str(String), - Bool(bool), -} - -impl CommanderHandle { - fn new() -> Self { - CommanderHandle { - name: String::new(), - description: String::new(), - version: String::new(), - options: Vec::new(), - parsed_values: HashMap::new(), - args: Vec::new(), - declared_args: Vec::new(), - subcommands: Vec::new(), - action_callback: 0, - } - } -} - -// --------------------------------------------------------------------------- -// GC root scanning — pin user-supplied .action() closures across collections. - -thread_local! { - // The mutable-root scanner registry is thread-local, so this latch must be too. - static GC_REGISTERED: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -fn ensure_gc_scanner_registered() { - GC_REGISTERED.with(|registered| { - if registered.get() { - return; - } - perry_runtime::gc::gc_register_mutable_root_scanner_named( - "stdlib:commander", - scan_commander_roots_mut, - ); - registered.set(true); - }); -} - -#[allow(dead_code)] -fn scan_commander_roots(mark: &mut dyn FnMut(f64)) { - let mut visitor = perry_runtime::gc::RuntimeRootVisitor::for_copy(mark); - scan_commander_roots_mut(&mut visitor); -} - -fn scan_commander_roots_mut(visitor: &mut perry_runtime::gc::RuntimeRootVisitor<'_>) { - for_each_handle_mut_of::(|cmd| { - visitor.visit_i64_slot(&mut cmd.action_callback); - }); -} - -// --------------------------------------------------------------------------- -// Helpers - -/// Parse the commander flag-spec mini-language used in `.option(...)`: -/// `"-p, --port "` → `(Some('p'), "port", false)`. -/// `"-v, --verbose"` → `(Some('v'), "verbose", true)`. -/// `"--config "` → `(None, "config", false)`. -fn parse_flag_spec(flags: &str) -> (Option, String, bool) { - let is_flag = !flags.contains('<') && !flags.contains('['); - let mut short: Option = None; - let mut long = String::new(); - for part in flags.split(',') { - let part = part.trim(); - if let Some(rest) = part.strip_prefix("--") { - long = rest.split_whitespace().next().unwrap_or("").to_string(); - } else if let Some(rest) = part.strip_prefix('-') { - short = rest.chars().next(); - } - } - (short, long, is_flag) -} - -// --------------------------------------------------------------------------- -// Constructor + fluent setters - -#[no_mangle] -pub extern "C" fn js_commander_new() -> Handle { - ensure_gc_scanner_registered(); - register_handle(CommanderHandle::new()) -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_name( - handle: Handle, - name_ptr: *const StringHeader, -) -> Handle { - if let Some(name) = string_from_header(name_ptr) { - if let Some(cmd) = get_handle_mut::(handle) { - cmd.name = name; - } - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_description( - handle: Handle, - desc_ptr: *const StringHeader, -) -> Handle { - if let Some(desc) = string_from_header(desc_ptr) { - if let Some(cmd) = get_handle_mut::(handle) { - cmd.description = desc; - } - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_version( - handle: Handle, - version_ptr: *const StringHeader, -) -> Handle { - if let Some(version) = string_from_header(version_ptr) { - if let Some(cmd) = get_handle_mut::(handle) { - cmd.version = version; - } - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_option( - handle: Handle, - flags_ptr: *const StringHeader, - desc_ptr: *const StringHeader, - default_ptr: *const StringHeader, -) -> Handle { - let flags = match string_from_header(flags_ptr) { - Some(f) => f, - None => return handle, - }; - let description = string_from_header(desc_ptr).unwrap_or_default(); - let default_value = string_from_header(default_ptr); - let (short, long, is_flag) = parse_flag_spec(&flags); - if let Some(cmd) = get_handle_mut::(handle) { - cmd.options.push(CommandOption { - short, - long, - description, - default_value, - is_flag, - }); - } - handle -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_required_option( - handle: Handle, - flags_ptr: *const StringHeader, - desc_ptr: *const StringHeader, - default_ptr: *const StringHeader, -) -> Handle { - // Required-validation isn't enforced at runtime yet; treat as a normal option. - js_commander_option(handle, flags_ptr, desc_ptr, default_ptr) -} - -/// `.argument("")` / `.argument("[dir]")` — declare a positional -/// argument. Parsing always collects non-option tokens into `args`, so this -/// only records the spec for the `--help` usage line and returns the handle so -/// the fluent chain keeps flowing. #5137: without this entry the call fell -/// through to generic dynamic dispatch (a silent no-op) instead of staying on -/// the commander handle. -#[no_mangle] -pub unsafe extern "C" fn js_commander_argument( - handle: Handle, - spec_ptr: *const StringHeader, -) -> Handle { - if let Some(spec) = string_from_header(spec_ptr) { - if let Some(cmd) = get_handle_mut::(handle) { - cmd.declared_args.push(spec); - } - } - handle -} - -/// Register an action callback. `callback` is a raw closure pointer -/// (NaN-box-stripped) — codegen passes it via the NA_PTR coercion which -/// runs `unbox_to_i64` before this entry sees it. Non-zero is the stable -/// "action registered" signal (a real ClosureHeader pointer is far above -/// the small-handle range). -#[no_mangle] -pub extern "C" fn js_commander_action(handle: Handle, callback: i64) -> Handle { - if let Some(cmd) = get_handle_mut::(handle) { - cmd.action_callback = callback; - } - handle -} - -/// Create a subcommand and register it on the parent. Returns the new -/// sub-handle so chained `.command("x").option(...).action(...)` accrues -/// state on the subcommand, not the parent. -#[no_mangle] -pub unsafe extern "C" fn js_commander_command( - handle: Handle, - name_ptr: *const StringHeader, -) -> Handle { - let sub_name = string_from_header(name_ptr).unwrap_or_default(); - let sub_handle = register_handle(CommanderHandle::new()); - if let Some(parent) = get_handle_mut::(handle) { - parent.subcommands.push((sub_name, sub_handle)); - } - sub_handle -} - -// --------------------------------------------------------------------------- -// Parse + dispatch - -/// Resolve the argument list `parse(argv?)` should operate on. -/// -/// npm commander's `parse()` defaults to `from: 'node'`: when an explicit -/// array is supplied (`program.parse(['node', 'script', ...])`) the first two -/// entries are the executable + script path and the real args start at index -/// 2. When called with no argument it reads `process.argv`, which on a Perry -/// binary is `[exePath, ...realArgs]` (no separate script entry) — so we skip -/// only the leading exe path. #5137: previously this always read -/// `std::env::args()` and ignored the passed array, so `program.parse([...])` -/// with a synthetic argv (the common test/REPL shape, and the issue repro) -/// silently parsed nothing. -unsafe fn resolve_parse_args(argv: f64) -> Vec { - let bits = argv.to_bits(); - // A pointer-tagged value is the user's explicit argv array. Anything else - // (undefined when `parse()` is called with no argument, or a primitive) - // falls back to the real process args. - if (bits & 0xFFFF_0000_0000_0000) == POINTER_TAG { - let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize as *const ArrayHeader; - if !ptr.is_null() { - // Read elements through the bounds-checked, layout-abstracting - // runtime accessor rather than indexing the ArrayHeader data - // region directly — mirrors the perry-ext-commander copy and - // stays correct if the array layout ever changes. - let len = js_array_length(ptr); - let mut out = Vec::with_capacity(len as usize); - for i in 0..len { - let elem = js_array_get_f64(ptr, i); - if let Some(s) = string_from_header(js_jsvalue_to_string(elem)) { - out.push(s); - } - } - // `from: 'node'` default — drop argv[0] (exe) and argv[1] (script). - return out.into_iter().skip(2).collect(); - } - } - // #9401: `std::env::args()` panics on a non-UTF-8 argument; Node decodes - // argv leniently (every invalid byte becomes U+FFFD) and so must this. - std::env::args_os() - .skip(1) - .map(|arg| arg.to_string_lossy().into_owned()) - .collect() -} - -/// Top-level parse entry. The second arg is the user's `parse(argv)` -/// expression: when it's an explicit array we honor it (commander's -/// `from: 'node'` default), otherwise we fall back to the real -/// `std::env::args()`. Codegen passes the NaN-boxed value through unchanged -/// via the NA_F64 dispatch slot. -#[no_mangle] -pub unsafe extern "C" fn js_commander_parse(handle: Handle, argv: f64) -> Handle { - let args = resolve_parse_args(argv); - parse_and_dispatch(handle, &args); - handle -} - -/// Parse `args` against the command at `handle`, then run its `.action()` -/// (or recurse into a matched subcommand which does the same). On -/// `--help` / `--version` this exits the process with code 0 directly, -/// matching npm commander's behavior. -fn parse_and_dispatch(handle: Handle, args: &[String]) { - // Snapshot what we need from the command up front. `for_each_handle_of` - // and `get_handle_mut` both borrow the same handle registry; cloning - // the relevant fields out avoids overlapping borrows during recursion. - let snapshot = match get_handle_mut::(handle) { - Some(cmd) => { - // Reset parsed state from any prior invocation, then seed with - // declared defaults. - cmd.parsed_values.clear(); - cmd.args.clear(); - for opt in &cmd.options { - if let Some(ref dv) = opt.default_value { - cmd.parsed_values - .insert(opt.long.clone(), ParsedValue::Str(dv.clone())); - } - } - ParseSnapshot { - name: cmd.name.clone(), - description: cmd.description.clone(), - version: cmd.version.clone(), - options: cmd - .options - .iter() - .map(|o| OptionMeta { - short: o.short, - long: o.long.clone(), - is_flag: o.is_flag, - description: o.description.clone(), - }) - .collect(), - subcommands: cmd.subcommands.clone(), - declared_args: cmd.declared_args.clone(), - } - } - None => return, - }; - - let mut i = 0usize; - let mut positional: Vec = Vec::new(); - while i < args.len() { - let arg = &args[i]; - - // --help / -h: print help and exit. Mirrors npm commander. - if arg == "--help" || arg == "-h" { - print_help(&snapshot); - std::process::exit(0); - } - // --version / -V: print version and exit (only if a version was set). - if (arg == "--version" || arg == "-V") && !snapshot.version.is_empty() { - println!("{}", snapshot.version); - std::process::exit(0); - } - // No version registered: fall through to the unknown-flag path. - - // Subcommand dispatch: when no positional has been collected yet, - // a bare token matching a registered subcommand recurses with the - // remaining args, and we hand off entirely (the parent's action - // does NOT also run — npm commander semantics). - if positional.is_empty() { - if let Some((_, sub_handle)) = snapshot.subcommands.iter().find(|(n, _)| n == arg) { - let rest: Vec = args[i + 1..].to_vec(); - parse_and_dispatch(*sub_handle, &rest); - return; - } - } - - if let Some(opt_name) = arg.strip_prefix("--") { - if let Some(eq_pos) = opt_name.find('=') { - let key = opt_name[..eq_pos].to_string(); - let value = opt_name[eq_pos + 1..].to_string(); - set_str(handle, &key, &value); - } else if let Some(meta) = snapshot.options.iter().find(|o| o.long == opt_name) { - if meta.is_flag { - set_bool(handle, &meta.long, true); - } else if i + 1 < args.len() { - i += 1; - set_str(handle, &meta.long, &args[i]); - } - } else { - // Unknown long option — store as boolean true so user code - // calling `options.someFlag` at least sees a defined value. - set_bool(handle, opt_name, true); - } - } else if let Some(short_str) = arg.strip_prefix('-') { - if short_str.len() == 1 { - let ch = short_str.chars().next().unwrap(); - if let Some(meta) = snapshot.options.iter().find(|o| o.short == Some(ch)) { - if meta.is_flag { - set_bool(handle, &meta.long, true); - } else if i + 1 < args.len() { - i += 1; - set_str(handle, &meta.long, &args[i]); - } - } - } - } else { - positional.push(arg.clone()); - } - - i += 1; - } - - // Persist positionals (queryable via getArg/argsCount). - if let Some(cmd) = get_handle_mut::(handle) { - cmd.args = positional; - } - - // No subcommand consumed. If this command has its own .action(), run - // it now. Otherwise it's a no-op (matches npm commander when neither - // an action nor a subcommand fires). - run_action(handle); -} - -fn set_str(handle: Handle, key: &str, value: &str) { - if let Some(cmd) = get_handle_mut::(handle) { - cmd.parsed_values - .insert(key.to_string(), ParsedValue::Str(value.to_string())); - } -} - -fn set_bool(handle: Handle, key: &str, value: bool) { - if let Some(cmd) = get_handle_mut::(handle) { - cmd.parsed_values - .insert(key.to_string(), ParsedValue::Bool(value)); - } -} - -/// Build the `options` JS object passed to `.action(opts => ...)` and -/// invoke the registered closure. No-op if no closure was registered. -fn run_action(handle: Handle) { - let (cb, parsed) = match get_handle_mut::(handle) { - Some(cmd) => (cmd.action_callback, cmd.parsed_values.clone()), - None => return, - }; - if cb == 0 { - return; - } - unsafe { - let opts_obj = build_options_object(&parsed); - let opts_f64 = f64::from_bits(nanbox_pointer(opts_obj as u64)); - let closure_ptr = cb as *const ClosureHeader; - js_closure_call1(closure_ptr, opts_f64); - } -} - -/// Allocate a fresh JS Object and populate it with one field per parsed -/// option. Strings are stored as STRING_TAG-tagged StringHeader pointers, -/// booleans as the canonical TAG_TRUE / TAG_FALSE bits — matching the -/// values codegen emits for string literals and boolean literals so the -/// user's `options.port` access goes through the same dynamic property -/// lookup path it would for a hand-built object literal. -unsafe fn build_options_object( - parsed: &HashMap, -) -> *mut perry_runtime::ObjectHeader { - let count = parsed.len() as u32; - let obj = js_object_alloc(0, count); - for (key, value) in parsed.iter() { - let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); - let val_bits: u64 = match value { - ParsedValue::Str(s) => { - let s_ptr = js_string_from_bytes(s.as_ptr(), s.len() as u32); - nanbox_string(s_ptr as u64) - } - ParsedValue::Bool(true) => TAG_TRUE, - ParsedValue::Bool(false) => TAG_FALSE, - }; - js_object_set_field_by_name(obj, key_ptr, f64::from_bits(val_bits)); - } - obj -} - -// --------------------------------------------------------------------------- -// Help formatting - -struct ParseSnapshot { - name: String, - description: String, - version: String, - options: Vec, - subcommands: Vec<(String, Handle)>, - declared_args: Vec, -} - -struct OptionMeta { - short: Option, - long: String, - is_flag: bool, - description: String, -} - -fn print_help(s: &ParseSnapshot) { - if !s.description.is_empty() { - println!("{}", s.description); - println!(); - } - let prog = if s.name.is_empty() { - "".to_string() - } else { - s.name.clone() - }; - let mut usage_tail = if s.subcommands.is_empty() { - "[options]".to_string() - } else { - "[options] [command]".to_string() - }; - for arg in &s.declared_args { - usage_tail.push(' '); - usage_tail.push_str(arg); - } - println!("Usage: {} {}", prog, usage_tail); - println!(); - println!("Options:"); - if !s.version.is_empty() { - println!(" {:<24} output the version number", "-V, --version"); - } - for opt in &s.options { - let placeholder = if opt.is_flag { "" } else { " " }; - let flag_str = match opt.short { - Some(ch) => format!("-{}, --{}{}", ch, opt.long, placeholder), - None => format!("--{}{}", opt.long, placeholder), - }; - println!(" {:<24} {}", flag_str, opt.description); - } - println!(" {:<24} display help for command", "-h, --help"); - if !s.subcommands.is_empty() { - println!(); - println!("Commands:"); - for (sub_name, _) in &s.subcommands { - println!(" {}", sub_name); - } - } -} - -// --------------------------------------------------------------------------- -// Read-back accessors (queryable post-parse from user TS code). - -/// `program.opts()` — return a fresh plain object of the parsed option -/// values (matching npm commander, where `opts()` returns a data object). -/// #5137: previously returned the raw handle, so `JSON.stringify(opts)` saw a -/// bogus pointer and printed `null` and `opts.verbose` never resolved. The -/// NR_PTR return ABI NaN-boxes this heap pointer as a JS object value. -#[no_mangle] -pub extern "C" fn js_commander_opts(handle: Handle) -> Handle { - let parsed = match get_handle_mut::(handle) { - Some(cmd) => cmd.parsed_values.clone(), - None => HashMap::new(), - }; - unsafe { build_options_object(&parsed) as Handle } -} - -/// `program.args` — return a fresh JS array of the parsed positional -/// arguments (everything that wasn't an option flag or option value). -/// #5137: a bare `program.args` member read lowers to a 0-arg -/// NativeMethodCall through the commander table; without this getter it -/// resolved to the zero-sentinel and `program.args[0]` read `undefined`. -#[no_mangle] -pub extern "C" fn js_commander_args_array(handle: Handle) -> Handle { - let args = match get_handle_mut::(handle) { - Some(cmd) => cmd.args.clone(), - None => Vec::new(), - }; - { - let boxed: Vec = args - .iter() - .map(|a| { - let s = js_string_from_bytes(a.as_ptr(), a.len() as u32); - f64::from_bits(nanbox_string(s as u64)) - }) - .collect(); - js_array_from_f64(boxed.as_ptr(), boxed.len() as u32) as Handle - } -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_get_option( - handle: Handle, - name_ptr: *const StringHeader, -) -> *const StringHeader { - let name = match string_from_header(name_ptr) { - Some(n) => n, - None => return std::ptr::null(), - }; - if let Some(cmd) = get_handle_mut::(handle) { - if let Some(ParsedValue::Str(value)) = cmd.parsed_values.get(&name) { - return js_string_from_bytes(value.as_ptr(), value.len() as u32); - } - } - std::ptr::null() -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_get_option_number( - handle: Handle, - name_ptr: *const StringHeader, -) -> f64 { - let name = match string_from_header(name_ptr) { - Some(n) => n, - None => return f64::NAN, - }; - if let Some(cmd) = get_handle_mut::(handle) { - if let Some(ParsedValue::Str(value)) = cmd.parsed_values.get(&name) { - return value.parse::().unwrap_or(f64::NAN); - } - } - f64::NAN -} - -#[no_mangle] -pub unsafe extern "C" fn js_commander_get_option_bool( - handle: Handle, - name_ptr: *const StringHeader, -) -> f64 { - let name = match string_from_header(name_ptr) { - Some(n) => n, - None => return f64::from_bits(TAG_FALSE), - }; - if let Some(cmd) = get_handle_mut::(handle) { - match cmd.parsed_values.get(&name) { - Some(ParsedValue::Bool(true)) => return f64::from_bits(TAG_TRUE), - Some(ParsedValue::Str(_)) => return f64::from_bits(TAG_TRUE), - _ => {} - } - } - f64::from_bits(TAG_FALSE) -} - -#[no_mangle] -pub extern "C" fn js_commander_args_count(handle: Handle) -> f64 { - if let Some(cmd) = get_handle_mut::(handle) { - return cmd.args.len() as f64; - } - 0.0 -} - -#[no_mangle] -pub extern "C" fn js_commander_get_arg(handle: Handle, index: f64) -> *const StringHeader { - let idx = index as usize; - if let Some(cmd) = get_handle_mut::(handle) { - if idx < cmd.args.len() { - let arg = &cmd.args[idx]; - return js_string_from_bytes(arg.as_ptr(), arg.len() as u32); - } - } - std::ptr::null() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_flag_spec_value_with_short() { - let (s, l, f) = parse_flag_spec("-p, --port "); - assert_eq!(s, Some('p')); - assert_eq!(l, "port"); - assert!(!f); - } - - #[test] - fn parse_flag_spec_boolean_long_only() { - let (s, l, f) = parse_flag_spec("--verbose"); - assert_eq!(s, None); - assert_eq!(l, "verbose"); - assert!(f); - } - - #[test] - fn parse_flag_spec_optional_value() { - let (s, l, f) = parse_flag_spec("-c, --config [path]"); - assert_eq!(s, Some('c')); - assert_eq!(l, "config"); - assert!(!f); - } - - #[test] - fn root_scanner_emits_action_callback() { - let handle = register_handle(CommanderHandle { - name: String::new(), - description: String::new(), - version: String::new(), - options: Vec::new(), - parsed_values: HashMap::new(), - args: Vec::new(), - declared_args: Vec::new(), - subcommands: Vec::new(), - action_callback: 0x1234_5678, - }); - let mut emitted = Vec::new(); - scan_commander_roots(&mut |value| emitted.push(value.to_bits())); - assert!(emitted.contains(&nanbox_pointer(0x1234_5678))); - crate::common::drop_handle(handle); - } -} diff --git a/crates/perry-stdlib/src/lib.rs b/crates/perry-stdlib/src/lib.rs index 7c4e9ed331..1fa0867b59 100644 --- a/crates/perry-stdlib/src/lib.rs +++ b/crates/perry-stdlib/src/lib.rs @@ -30,11 +30,6 @@ pub mod perry_ffi_async; // Core modules - always available pub mod async_local_storage; -// commander — feature-gated as of v0.5.555 so the well-known flip -// can route `import { Command } from 'commander'` to -// perry-ext-commander without duplicate `_js_commander_*` symbols. -#[cfg(feature = "bundled-commander")] -pub mod commander; pub mod common; pub mod domain; // dayjs / date-fns — feature-gated as of v0.5.548 so the well-known @@ -91,8 +86,6 @@ mod multipart_parser; // Re-export core pub use async_local_storage::*; -#[cfg(feature = "bundled-commander")] -pub use commander::*; pub use common::*; #[cfg(feature = "bundled-dayjs")] pub use dayjs::*; diff --git a/crates/perry-ui-android/src/stdlib_stubs.rs b/crates/perry-ui-android/src/stdlib_stubs.rs index 6da9579d68..367392ab70 100644 --- a/crates/perry-ui-android/src/stdlib_stubs.rs +++ b/crates/perry-ui-android/src/stdlib_stubs.rs @@ -148,58 +148,6 @@ pub extern "C" fn js_cheerio_selection_to_array() -> i64 { 0 } #[no_mangle] -pub extern "C" fn js_commander_action() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_command() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_description() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_get_option() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_get_option_bool() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_get_option_number() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_name() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_new() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_option() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_opts() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_parse() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_required_option() -> i64 { - 0 -} -#[no_mangle] -pub extern "C" fn js_commander_version() -> i64 { - 0 -} -#[no_mangle] pub extern "C" fn js_create_callback() -> i64 { 0 } diff --git a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs index a62db7c938..67c285030f 100644 --- a/crates/perry/src/commands/compile/collect_modules/feature_detect.rs +++ b/crates/perry/src/commands/compile/collect_modules/feature_detect.rs @@ -339,13 +339,7 @@ pub(super) fn detect_optional_feature_usage( if hir_debug.contains("module: \"bun\"") || hir_debug.contains("NativeModuleRef(\"bun\")") { ctx.native_module_imports.insert("bun".to_string()); } - for native_module in [ - "lru-cache", - "big.js", - "decimal.js", - "bignumber.js", - "commander", - ] { + for native_module in ["lru-cache", "big.js", "decimal.js", "bignumber.js"] { if hir_debug.contains(&format!("module: \"{native_module}\"")) { ctx.needs_stdlib = true; ctx.native_module_imports.insert(native_module.to_string()); diff --git a/crates/perry/src/commands/stdlib_features.rs b/crates/perry/src/commands/stdlib_features.rs index b1b39d6359..42a169e86a 100644 --- a/crates/perry/src/commands/stdlib_features.rs +++ b/crates/perry/src/commands/stdlib_features.rs @@ -196,9 +196,6 @@ pub fn module_to_features(module: &str) -> &'static [&'static str] { // rate-limiter-flexible: feature-gated v0.5.552 — well-known // flip routes to perry-ext-ratelimit. "rate-limiter-flexible" => &["bundled-ratelimit"], - // commander: feature-gated v0.5.555 — well-known flip routes - // to perry-ext-commander. - "commander" => &["bundled-commander"], // readline (#347) — needs the async-runtime feature so the // event-loop pump tick drains its line / data / keypress // queues. Without async-runtime, `import readline` still diff --git a/crates/perry/tests/issue_10439_native_binding_import_provenance.rs b/crates/perry/tests/issue_10439_native_binding_import_provenance.rs index 6bd71c58d8..5d020d0988 100644 --- a/crates/perry/tests/issue_10439_native_binding_import_provenance.rs +++ b/crates/perry/tests/issue_10439_native_binding_import_provenance.rs @@ -261,35 +261,10 @@ console.log(new Dec(1).dividedBy(4).toString()); ); } -/// The legitimate case this issue explicitly warns against regressing: with -/// NO `perry.compilePackages` entry (and no real package installed at all — -/// there is nothing else it COULD mean), `new Command()...` must still route -/// to the native binding exactly as before. Values asserted here are the -/// native binding's own pre-existing (documented-limited) behavior, captured -/// against this same commit's pre-fix binary — this test exists to prove the -/// fix does not change them, not to bless them as correct. -#[test] -fn commander_default_name_still_uses_native_binding_without_compile_packages() { - let dir = tempfile::tempdir().expect("tempdir"); - let root = dir.path(); - // No package.json, no node_modules: "commander" can only resolve to - // Perry's bundled native shim. - std::fs::write( - root.join("main.ts"), - r#" -import { Command } from "commander"; -const program = new Command(); -console.log(new Command().name("x").name()); -console.log(program.constructor.name); -"#, - ) - .expect("write main.ts"); - assert_eq!( - compile_and_run(root, "main.ts"), - "{}\nundefined\n", - "the native-binding path (no compilePackages) must be byte-for-byte unchanged" - ); -} +// `commander_default_name_still_uses_native_binding_without_compile_packages` +// removed here -- it guarded the "legitimate native case" (no +// compilePackages, native binding still handles `new Command(...)`), which +// no longer exists: #10686 deletes the native commander binding entirely. /// Same legitimate-case guard for lru-cache: without `compilePackages`, /// `new LRUCache(...).set(...).get(...)` must still reach the native diff --git a/crates/perry/well_known_bindings.toml b/crates/perry/well_known_bindings.toml index 4721c63bc0..b44e468690 100644 --- a/crates/perry/well_known_bindings.toml +++ b/crates/perry/well_known_bindings.toml @@ -278,18 +278,6 @@ repo = "https://github.com/animir/node-rate-limiter-flexible" ref = "2c12f60043c4bc2d0a7b7d05392824027d810b75" ported-at = "11.2.0" date = "2026-07-30" -[bindings.commander] -crate = "perry-ext-commander" -lib = "perry_ext_commander" -tracking = "#466" - -[bindings.commander.upstream] -version = "15.0.0" -sha256 = "632c1e039b31e98fa79c4fae5b10a5ffbbf9df0f21c9ffb3d74e95734b30696f" -repo = "https://github.com/tj/commander.js" -ref = "ba6d13ddb4243e5913367734f8c159089ffe7834" -ported-at = "15.0.0" -date = "2026-07-30" [bindings.ethers] crate = "perry-ext-ethers" lib = "perry_ext_ethers" diff --git a/docs/api/perry.d.ts b/docs/api/perry.d.ts index 47a24f494b..0fe9186069 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: 2065 entries across 131 modules +// Coverage: 2064 entries across 130 modules type PerryI8 = number & { readonly __perryI8?: never }; type PerryI16 = number & { readonly __perryI16?: never }; @@ -492,11 +492,6 @@ declare module "cluster" { export function setupPrimary(...args: any[]): any; } -declare module "commander" { - /** stdlib */ - export const args: any; -} - declare module "console" { /** stdlib */ export class Console { [key: string]: any; } diff --git a/docs/examples/stdlib/other/snippets.ts b/docs/examples/stdlib/other/snippets.ts index f6d39f6873..739512a95f 100644 --- a/docs/examples/stdlib/other/snippets.ts +++ b/docs/examples/stdlib/other/snippets.ts @@ -13,7 +13,7 @@ // connects to an SMTP server and child_process spawns + sleeps a real // process, neither hermetic in CI. Compile + link is the contract here. // -// Only packages with wired NativeModSig dispatch (nodemailer, commander, +// Only packages with wired NativeModSig dispatch (nodemailer, // decimal.js, lru-cache, child_process) are anchored. sharp / cheerio / // zlib / cron / worker_threads have runtime declarations but no dispatch // path from user-visible imports yet, so the markdown page keeps those @@ -38,25 +38,6 @@ async function nodemailerExample(): Promise { } // ANCHOR_END: nodemailer -// ANCHOR: commander -import { Command } from "commander" - -function commanderExample(): void { - const program = new Command() - program.name("my-cli").version("1.0.0").description("My CLI tool") - - program - .command("serve") - .option("-p, --port ", "Port number") - .option("--verbose", "Verbose output") - .action((options: any) => { - console.log(`Starting server on port ${options.port}`) - }) - - program.parse(process.argv) -} -// ANCHOR_END: commander - // ANCHOR: decimal import Decimal from "decimal.js" @@ -109,5 +90,5 @@ function childProcessExample(): void { // ANCHOR_END: child-process // Reference everything so unused-import elimination doesn't strip it. -const _keep = [nodemailerExample, commanderExample, decimalExample, lruCacheExample, childProcessExample] +const _keep = [nodemailerExample, decimalExample, lruCacheExample, childProcessExample] console.log(`other-snippets: ${_keep.length}`) diff --git a/docs/src/api/reference.md b/docs/src/api/reference.md index c0dd3c2f51..1da5124d8d 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: 3007 entries across 133 modules. +Total: 2995 entries across 132 modules. ## Modules @@ -34,7 +34,6 @@ Total: 3007 entries across 133 modules. - [`cheerio`](#cheerio) - [`child_process`](#child_process) - [`cluster`](#cluster) -- [`commander`](#commander) - [`console`](#console) - [`constants`](#constants) - [`cron`](#cron) @@ -575,26 +574,6 @@ Total: 3007 entries across 133 modules. - `settings` - `workers` -## `commander` - -### Methods - -- `action` — instance -- `args` — instance -- `argument` — instance -- `command` — instance -- `description` — instance -- `name` — instance -- `option` — instance -- `opts` — instance -- `parse` — instance -- `requiredOption` — instance -- `version` — instance - -### Properties - -- `args` - ## `console` ### Classes diff --git a/docs/src/native-libraries/governance.md b/docs/src/native-libraries/governance.md index 3939cc0e72..41f64232c7 100644 --- a/docs/src/native-libraries/governance.md +++ b/docs/src/native-libraries/governance.md @@ -89,7 +89,6 @@ from `well_known_bindings.toml`. Regenerate this table with | `perry-ext-bcrypt` | `bcrypt` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-better-sqlite3` | `better-sqlite3` | External integration | Move to an external native package | Bundled; migration pending | | `perry-ext-cheerio` | `cheerio` | Source package | Compile the upstream package source | Bundled; migration pending | -| `perry-ext-commander` | `commander` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-cron` | `cron`
`node-cron` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-dayjs` | `date-fns`
`dayjs` | Source package | Compile the upstream package source | Bundled; migration pending | | `perry-ext-decimal` | `bignumber.js`
`decimal.js` | Source package | Compile the upstream package source | Bundled; migration pending | diff --git a/docs/src/stdlib/other.md b/docs/src/stdlib/other.md index 9bed2ebc46..8349565985 100644 --- a/docs/src/stdlib/other.md +++ b/docs/src/stdlib/other.md @@ -292,12 +292,6 @@ per path; unsupported or over-budget helpers emit a diagnostic and throw if the Worker is constructed. The original filename expression still runs at runtime. Static `file:` URLs are decoded before file lookup, including Bun embedded paths such as `file:///$bunfs/root/worker.js` mapped through `--bunfs-root`. -## commander (CLI Parsing) - -```typescript,no-test -{{#include ../../examples/stdlib/other/snippets.ts:commander}} -``` - ## lru-cache The wired constructor takes the npm v7+ options-object shape diff --git a/docs/src/stdlib/overview.md b/docs/src/stdlib/overview.md index 90543c9487..940342baad 100644 --- a/docs/src/stdlib/overview.md +++ b/docs/src/stdlib/overview.md @@ -54,7 +54,6 @@ for compatibility guarantees. - **validator** — String validation ### CLI & Data -- **commander** — CLI argument parsing - **decimal.js** — Arbitrary precision decimals - **bignumber.js** — Big number math - **lru-cache** — LRU caching diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index ac1a0cd1bb..99bcacc603 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -4032,10 +4032,6 @@ "file": "crates/perry-runtime/src/web_storage.rs", "name": "SESSION_STORE" }, - { - "file": "crates/perry-stdlib/src/commander.rs", - "name": "GC_REGISTERED" - }, { "file": "crates/perry-stdlib/src/common/async_bridge.rs", "name": "GC_SCANNER_REGISTERED" diff --git a/scripts/native_result_ledger.py b/scripts/native_result_ledger.py index 7ca06de8c5..e8cb56d934 100644 --- a/scripts/native_result_ledger.py +++ b/scripts/native_result_ledger.py @@ -32,8 +32,8 @@ # `js_net_socket_unpipe`. Each returns its `handle: i64` argument unchanged, a # `next_id_or_throw()` registry id rather than a heap address, so all four are # NR_HANDLE_ID. -EXPECTED_ROWS = 376 -EXPECTED_PROVIDERS = 326 +EXPECTED_ROWS = 365 +EXPECTED_PROVIDERS = 315 KINDS = { "NR_GCPTR", "NR_NULLABLE_GCPTR", diff --git a/scripts/native_result_ledger.tsv b/scripts/native_result_ledger.tsv index 170454102d..cd548d5d1c 100644 --- a/scripts/native_result_ledger.tsv +++ b/scripts/native_result_ledger.tsv @@ -28,17 +28,6 @@ js_cheerio_selection_find NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handl js_cheerio_selection_first NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle js_cheerio_selection_last NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle js_cheerio_selection_parent NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle -js_commander_action NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_args_array NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_argument NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_command NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_description NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_name NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_option NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_opts NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_parse NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_required_option NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle -js_commander_version NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle js_cron_schedule NR_HANDLE_ID crates/perry-ext-cron/src/lib.rs Handle js_decimal_abs NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle js_decimal_ceil NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle diff --git a/scripts/unrooted_local_shape_baseline.json b/scripts/unrooted_local_shape_baseline.json index 7aae581ae2..883bd58ee6 100644 --- a/scripts/unrooted_local_shape_baseline.json +++ b/scripts/unrooted_local_shape_baseline.json @@ -2,7 +2,6 @@ "per_file": { "crates/perry-ext-better-sqlite3/src/lib.rs": 13, "crates/perry-ext-cheerio/src/lib.rs": 7, - "crates/perry-ext-commander/src/lib.rs": 4, "crates/perry-ext-cron/src/lib.rs": 2, "crates/perry-ext-decimal/src/lib.rs": 1, "crates/perry-ext-events/src/lib.rs": 12, @@ -32,7 +31,6 @@ "crates/perry-ext-ws/src/server.rs": 3, "crates/perry-ext-zlib/src/stream.rs": 3, "crates/perry-stdlib/src/cheerio.rs": 6, - "crates/perry-stdlib/src/commander.rs": 3, "crates/perry-stdlib/src/cron.rs": 2, "crates/perry-stdlib/src/crypto/kdf.rs": 9, "crates/perry-stdlib/src/crypto/keys.rs": 1, @@ -85,5 +83,5 @@ "crates/perry-stdlib/src/zlib.rs": 3 }, "schema_version": 3, - "total": 578 + "total": 571 } diff --git a/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml b/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml index 89baefca64..d9d3f7ea24 100644 --- a/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml +++ b/tests/release/packages/next-app-route/provider/stdlib/Cargo.toml @@ -23,7 +23,6 @@ perry-stdlib-core = { package = "perry-stdlib", path = "../../../../../../crates "bundled-decimal", "bundled-dayjs", "bundled-moment", - "bundled-commander", "external-events-construct", "external-http-server-pump", "external-net-pump", diff --git a/workspace-architecture.json b/workspace-architecture.json index 85139f6cfb..40f306f045 100644 --- a/workspace-architecture.json +++ b/workspace-architecture.json @@ -25,7 +25,7 @@ ] }, "baseline": { - "workspace_members": 77, + "workspace_members": 76, "default_dependency_closure": [ "perry", "perry-api-manifest", @@ -68,7 +68,7 @@ "perry-updater" ], "decision_counts": { - "externalize": 28, + "externalize": 27, "keep": 44, "merge": 1, "remove": 1, @@ -165,11 +165,6 @@ "decision": "externalize", "migration": "compile-source" }, - "perry-ext-commander": { - "category": "binding", - "decision": "externalize", - "migration": "compile-source" - }, "perry-ext-cron": { "category": "binding", "decision": "externalize", From 00617f4cb3cbd1696ae3600f0352976cdd22efd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 08:39:06 +0000 Subject: [PATCH 2/2] changelog: add fragment for #10712 (commander binding removal) --- changelog.d/10712-remove-commander-binding.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/10712-remove-commander-binding.md diff --git a/changelog.d/10712-remove-commander-binding.md b/changelog.d/10712-remove-commander-binding.md new file mode 100644 index 0000000000..51a3f90266 --- /dev/null +++ b/changelog.d/10712-remove-commander-binding.md @@ -0,0 +1,8 @@ +**Removed the native `commander` binding** — `import { Command } from "commander"` now resolves to +the real npm package, compiled from source. Native `program.args` was `undefined`; boolean option +defaults serialized as the truthy string `"false"`; subcommand `.action()` callbacks never fired; +missing-required-argument and unknown-option validation (Node's `commander.missingArgument` / +`commander.unknownOption`) was entirely absent. `class Command extends EventEmitter` in the real +source needs no dedicated native-subclass support — Perry's existing generic EventEmitter-subclass +machinery already covers it. Fixes #10686. Requires #10439's import-provenance fix (#10699) to reach +the real package at its default import name.