Skip to main content

nodejs/stdlib/
util.rs

1//! Node `util` module: `format`, `inspect`, and a subset of `util.types`.
2
3use crate::host::{with_host, JsObj};
4use fusevm::Value;
5use indexmap::IndexMap;
6
7pub const METHODS: &[&str] = &[
8    "format",
9    "formatWithOptions",
10    "inspect",
11    "deprecate",
12    "inherits",
13    "types",
14    "types.isMap",
15    "types.isSet",
16    "types.isPromise",
17    "types.isDate",
18    "types.isRegExp",
19    "types.isNativeError",
20    "types.isAsyncFunction",
21    "isDeepStrictEqual",
22    "isArray",
23    "debuglog",
24    "stripVTControlCharacters",
25    "toUSVString",
26    "getSystemErrorName",
27    "getSystemErrorMessage",
28    "getSystemErrorMap",
29    "styleText",
30    "parseArgs",
31    "promisify",
32    "aborted",
33    "callbackify",
34    "parseEnv",
35    "debug",
36];
37
38/// Non-function `util` exports (`require('util').TextEncoder`, `.MIMEType`, …).
39/// Each resolves to a `Builtin("<name>")` the parent `construct`s via `new`.
40pub fn constant(name: &str) -> Option<Value> {
41    match name {
42        "types" => Some(with_host(|h| h.alloc(JsObj::Builtin("util/types".into())))),
43        "TextEncoder" | "TextDecoder" | "MIMEType" | "MIMEParams" => {
44            Some(with_host(|h| h.alloc(JsObj::Builtin(name.into()))))
45        }
46        _ => None,
47    }
48}
49
50pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
51    if let Some(pred) = method.strip_prefix("types.") {
52        return Some(Ok(Value::Bool(type_predicate(pred, args.first()))));
53    }
54    Some(match method {
55        "format" => {
56            // `format` re-enters `with_host` internally, so build the string first
57            // and only then allocate — never nest the borrow.
58            match format(args) {
59                Ok(s) => Ok(with_host(|h| h.new_str(s))),
60                Err(e) => Err(e),
61            }
62        }
63        // `formatWithOptions(inspectOptions, fmt, ...args)`: the options object
64        // only tunes `inspect` styling, which we do not vary — drop it and format
65        // the remaining arguments exactly like `format`.
66        "formatWithOptions" => match format(args.get(1..).unwrap_or(&[])) {
67            Ok(s) => Ok(with_host(|h| h.new_str(s))),
68            Err(e) => Err(e),
69        },
70        "inspect" => {
71            if let Some(v) = args.first() {
72                crate::builtins::materialize_stack(v);
73            }
74            // Honor an options `{ depth: N | null }` (default 2; null = unlimited).
75            let depth = match args.get(1) {
76                Some(opts) => match crate::builtins::get_property(opts, "depth") {
77                    Ok(Value::Undef) => 2,
78                    Ok(v) if with_host(|h| h.is_null(&v)) => i64::MAX,
79                    Ok(v) => {
80                        let n = with_host(|h| h.to_number(&v));
81                        // A NEGATIVE depth is legal and means "already past the
82                        // limit"; only `null` and a non-finite depth mean
83                        // unlimited. Treating anything below zero as unlimited
84                        // made `{ depth: -1 }` print the whole object.
85                        match n {
86                            _ if n.is_nan() || n == f64::INFINITY => i64::MAX,
87                            _ if n == f64::NEG_INFINITY => i64::MIN,
88                            _ => n.trunc().clamp(i64::MIN as f64, i64::MAX as f64) as i64,
89                        }
90                    }
91                    Err(_) => 2,
92                },
93                None => 2,
94            };
95            crate::host::set_inspect_max_depth(depth);
96            // `compact` (default 3) decides how deep a subtree may be and still
97            // print on one line; `false` means never. `breakLength` (80 here) is
98            // the column budget for that line.
99            let opts = args.get(1).cloned().unwrap_or(Value::Undef);
100            let read = |k: &str| crate::builtins::get_property(&opts, k).unwrap_or(Value::Undef);
101            let compact = match read("compact") {
102                Value::Undef => 3,
103                Value::Bool(false) => 0,
104                // `compact: true` is node's legacy "always join" mode; no depth
105                // gate applies, which a very large number expresses exactly.
106                Value::Bool(true) => i64::MAX,
107                v => with_host(|h| h.to_number(&v)).max(0.0) as i64,
108            };
109            let break_length = match read("breakLength") {
110                Value::Undef => 80,
111                v => {
112                    let n = with_host(|h| h.to_number(&v));
113                    if n.is_finite() {
114                        n.max(0.0) as usize
115                    } else {
116                        usize::MAX
117                    }
118                }
119            };
120            // `sorted` accepts a comparator function in node; only the boolean
121            // form is honored here, and any truthy value turns plain sorting on.
122            // Read the option OUT of the host before borrowing it: `read` borrows
123            // the host itself, so `with_host(|h| h.truthy(&read("sorted")))` is a
124            // nested borrow and aborts the process with "RefCell already
125            // borrowed", which no script can catch.
126            let sorted_opt = read("sorted");
127            let sorted = with_host(|h| h.truthy(&sorted_opt));
128            // `customInspect` defaults to TRUE, so only an explicit `false`
129            // turns a value's own `[util.inspect.custom]` rendering off.
130            let custom_inspect = match read("customInspect") {
131                Value::Undef => true,
132                v => with_host(|h| h.truthy(&v)),
133            };
134            let show_hidden_opt = read("showHidden");
135            let show_hidden = with_host(|h| h.truthy(&show_hidden_opt));
136            // `maxArrayLength: null | Infinity` means "no limit". Node also
137            // accepts 0 (show none but the tail count), which falls out of the
138            // clamp below without a special case.
139            let max_array_length = match read("maxArrayLength") {
140                Value::Undef => crate::host::DEFAULT_MAX_ARRAY_LENGTH,
141                v if with_host(|h| h.is_null(&v)) => usize::MAX,
142                v => {
143                    let n = with_host(|h| h.to_number(&v));
144                    if n.is_finite() {
145                        n.max(0.0) as usize
146                    } else {
147                        usize::MAX
148                    }
149                }
150            };
151            crate::host::set_inspect_compact(compact);
152            crate::host::set_inspect_break_length(break_length);
153            crate::host::set_inspect_sorted(sorted);
154            crate::host::set_inspect_max_array_length(max_array_length);
155            crate::host::set_inspect_custom(custom_inspect);
156            crate::host::set_inspect_show_hidden(show_hidden);
157            let out = with_host(|h| {
158                let s = h.inspect(&args.first().cloned().unwrap_or(Value::Undef));
159                h.new_str(s)
160            });
161            crate::host::set_inspect_max_depth(2);
162            crate::host::set_inspect_compact(3);
163            crate::host::set_inspect_break_length(80);
164            crate::host::set_inspect_sorted(false);
165            crate::host::set_inspect_max_array_length(crate::host::DEFAULT_MAX_ARRAY_LENGTH);
166            crate::host::set_inspect_custom(true);
167            crate::host::set_inspect_show_hidden(false);
168            Ok(out)
169        }
170        // `deprecate(fn, msg)`: return a callable that behaves like `fn`. The
171        // house rule is no deprecation nags, so no warning is emitted — the
172        // original function is handed back unchanged.
173        "deprecate" => Ok(args.first().cloned().unwrap_or(Value::Undef)),
174        // `inherits(ctor, superCtor)`: modern Node semantics — set `ctor.super_`
175        // and re-link `ctor.prototype`'s `[[Prototype]]` to `superCtor.prototype`
176        // (methods already on `ctor.prototype` are preserved).
177        "inherits" => Ok(inherits(args)),
178        // Bare `util.types` accessed then called is not meaningful; return the
179        // namespace value so a stray call is a harmless undefined.
180        "types" => Ok(Value::Undef),
181        "isDeepStrictEqual" => Ok(Value::Bool(super::assert::deep_equal(
182            &args.first().cloned().unwrap_or(Value::Undef),
183            &args.get(1).cloned().unwrap_or(Value::Undef),
184            true,
185        ))),
186        // `util.isArray === Array.isArray` (a legacy alias Node still ships).
187        "isArray" => Ok(Value::Bool(with_host(|h| {
188            matches!(
189                h.get(&args.first().cloned().unwrap_or(Value::Undef)),
190                Some(JsObj::Array(_))
191            )
192        }))),
193        // `stripVTControlCharacters(str)`: remove ANSI/VT escape sequences.
194        "stripVTControlCharacters" => {
195            let stripped = strip_vt(&super::arg_str(args, 0));
196            Ok(with_host(|h| h.new_str(stripped)))
197        }
198        // `toUSVString(str)`: replace unpaired surrogates with U+FFFD. Strings are
199        // already well-formed UTF-8 here (no lone surrogates survive), so the input
200        // round-trips unchanged.
201        "toUSVString" => Ok(with_host(|h| {
202            let s = h.str_of(&args.first().cloned().unwrap_or(Value::Undef));
203            h.new_str(s)
204        })),
205        "getSystemErrorName" => {
206            let e = errno_of(super::arg_num(args, 0));
207            let name = errno_name(e)
208                .map(str::to_string)
209                .unwrap_or_else(|| format!("Unknown system error {e}"));
210            Ok(with_host(|h| h.new_str(name)))
211        }
212        "getSystemErrorMessage" => {
213            let e = errno_of(super::arg_num(args, 0));
214            let msg = errno_message(e)
215                .map(str::to_string)
216                .unwrap_or_else(|| format!("Unknown system error {e}"));
217            Ok(with_host(|h| h.new_str(msg)))
218        }
219        "getSystemErrorMap" => Ok(system_error_map()),
220        "styleText" => return Some(style_text(args)),
221        "parseArgs" => return Some(parse_args(args)),
222        "debuglog" => return Some(debuglog(args)),
223        "promisify" => return Some(promisify(args)),
224        // `util.aborted(signal, resource)` — a promise that settles when the
225        // signal aborts. It did not exist, so the documented way to await an
226        // abort threw.
227        "aborted" => {
228            let signal = args.first().cloned().unwrap_or(Value::Undef);
229            return Some(aborted(&signal));
230        }
231        "callbackify" => return Some(callbackify(args)),
232        // `util.parseEnv(content)` → an object of the parsed dotenv assignments.
233        "parseEnv" => Ok(parse_env(&super::arg_str(args, 0))),
234        // `util.debug === util.debuglog` (a documented alias).
235        "debug" => return Some(debuglog(args)),
236        _ => return None,
237    })
238}
239
240/// `util.inherits(ctor, superCtor)`.
241fn inherits(args: &[Value]) -> Value {
242    let ctor = args.first().cloned().unwrap_or(Value::Undef);
243    let sup = args.get(1).cloned().unwrap_or(Value::Undef);
244    with_host(|h| {
245        // Get-or-create `superCtor.prototype` and store it, so a later
246        // `superCtor.prototype` read returns the same object identity (`===`).
247        let sup_proto = h.fn_prop(&sup, "prototype").unwrap_or_else(|| {
248            let mut props = indexmap::IndexMap::new();
249            props.insert("constructor".to_string(), sup.clone());
250            let p = h.new_object(props);
251            h.set_fn_prop(&sup, "prototype", p.clone());
252            p
253        });
254        // Get-or-create `ctor.prototype` (with a `constructor` back-link).
255        let ctor_proto = h.fn_prop(&ctor, "prototype").unwrap_or_else(|| {
256            let mut props = indexmap::IndexMap::new();
257            props.insert("constructor".to_string(), ctor.clone());
258            let p = h.new_object(props);
259            h.set_fn_prop(&ctor, "prototype", p.clone());
260            p
261        });
262        h.set_proto(&ctor_proto, sup_proto);
263        h.set_fn_prop(&ctor, "super_", sup);
264    });
265    Value::Undef
266}
267
268fn type_predicate(pred: &str, v: Option<&Value>) -> bool {
269    let Some(v) = v else { return false };
270    with_host(|h| match pred {
271        "isMap" => matches!(h.get(v), Some(JsObj::Map { weak: false, .. })),
272        "isSet" => matches!(h.get(v), Some(JsObj::Set { weak: false, .. })),
273        "isPromise" => matches!(h.get(v), Some(JsObj::Promise { .. })),
274        _ => false,
275    })
276}
277
278/// Node's `formatNumber` for the `%d`/`%i`/`%f` directives: the ordinary number
279/// rendering, except that negative zero prints as `-0` (plain string coercion
280/// would flatten it to `0`, so `console.log("%d", -0)` would lose the sign).
281fn fmt_directive_number(n: f64) -> String {
282    if n == 0.0 && n.is_sign_negative() {
283        return "-0".into();
284    }
285    crate::host::fmt_number(n)
286}
287
288/// Run one of the global numeric parsers over a directive argument. `%i` is
289/// `parseInt` and `%f` is `parseFloat` — string-prefix parses, NOT `Number()`,
290/// which is why `"3.9abc"` yields `3`/`3.9` rather than `NaN`.
291fn coerce_via(parser: &str, arg: &Value) -> Result<f64, String> {
292    // The parse itself cannot fail, but coercing the ARGUMENT to a string can:
293    // `parseInt([Symbol()])` joins the array, and a Symbol has no string form.
294    // Node lets that TypeError out of `util.format`; swallowing it turned a
295    // throwing program into one that printed `NaN`.
296    let v = crate::builtins::call_builtin_function(parser, vec![arg.clone()])?;
297    Ok(with_host(|h| h.to_number(&v)))
298}
299
300/// `util.format(fmt, ...args)` — printf-style substitution (`%s %d %i %f %j %o %O
301/// %c %%`) with any leftover arguments appended space-separated.
302/// Fallible because three of the directives can throw and node lets those
303/// throws out: `%j` on a BigInt is `TypeError: Do not know how to serialize a
304/// BigInt`, and `%d`/`%i`/`%f` on a value whose coercion reaches a Symbol is
305/// `TypeError: Cannot convert a Symbol value to a string`. Returning a bare
306/// `String` meant each of those printed `undefined` or `NaN` instead.
307pub fn format(args: &[Value]) -> Result<String, String> {
308    // Node's inspect reads `err.stack`, and that read is what FORMATS the header
309    // and freezes it. Doing it here, before the inspect borrow, keeps
310    // `console.log(err)` showing the error's current `name` — the inspect walk
311    // itself runs under an immutable host borrow and cannot re-format anything.
312    for a in args {
313        crate::builtins::materialize_stack(a);
314    }
315    if args.is_empty() {
316        return Ok(String::new());
317    }
318    // Node: a single argument is returned as-is (no specifier processing) —
319    // `util.format("100%% done")` === "100%% done".
320    if args.len() == 1 {
321        return Ok(with_host(|h| h.console_format(&args[0])));
322    }
323    let fmt = with_host(|h| h.str_of(&args[0]));
324    // A non-string first argument: inspect everything, space-joined.
325    if !matches!(args[0], Value::Str(_))
326        && !with_host(|h| matches!(h.get(&args[0]), Some(JsObj::Str(_))))
327    {
328        return Ok(with_host(|h| {
329            args.iter()
330                .map(|a| h.console_format(a))
331                .collect::<Vec<_>>()
332                .join(" ")
333        }));
334    }
335
336    let mut out = String::new();
337    let mut ai = 1usize;
338    let mut chars = fmt.chars().peekable();
339    while let Some(c) = chars.next() {
340        if c != '%' {
341            out.push(c);
342            continue;
343        }
344        let Some(&spec) = chars.peek() else {
345            out.push('%');
346            break;
347        };
348        if spec == '%' {
349            out.push('%');
350            chars.next();
351            continue;
352        }
353        if !matches!(spec, 's' | 'd' | 'i' | 'f' | 'j' | 'o' | 'O' | 'c') || ai >= args.len() {
354            out.push('%');
355            continue;
356        }
357        chars.next();
358        let arg = &args[ai];
359        ai += 1;
360        match spec {
361            // Node's %s renders a BigInt with the trailing `n` (unlike String()).
362            // `%s` is NOT `String(x)` for objects. Node stringifies an object
363            // only when its `toString` is one the SCRIPT wrote; an object that
364            // would merely inherit the built-in is inspected instead, at
365            // `depth: 0`. So `%s` on `{a:{b:1}}` is `{ a: [Object] }`, on a Date
366            // the ISO form, and on a Map `Map(1) { 'a' => 1 }` — where plain
367            // `String()` would flatten all three to `[object Object]`, which is
368            // what this printed for every object, Map, Set, Date and boxed
369            // primitive. A class that defines `toString` (or `Symbol
370            // .toPrimitive`) still wins, at any depth of its prototype chain.
371            's' => {
372                // A NUMBER goes through node's number formatter, not `String()`:
373                // that is the only path that distinguishes `-0` from `0`, and
374                // `util.format('%s', -0)` is `-0` where `String(-0)` is `0`.
375                if let Value::Float(n) = arg {
376                    out.push_str(&fmt_directive_number(*n));
377                    continue;
378                }
379                let (bigint, use_inspect) = with_host(|h| match h.get(arg) {
380                    Some(JsObj::BigInt(b)) => (Some(format!("{b}n")), false),
381                    _ => {
382                        let is_obj = h.type_of(arg) == "object" && !h.is_null(arg);
383                        // Only a SCRIPT-DEFINED conversion counts. A boxed
384                        // primitive carries a real `toString` on its prototype,
385                        // but it is the built-in one, so node still inspects it
386                        // (`new String('b')` under `%s` is `[String: 'b']`, not
387                        // `b`) — testing merely for the property's presence put
388                        // every boxed primitive on the stringifying path.
389                        // A Buffer is the one core type whose `toString` node
390                        // does NOT count as built-in: `lib/buffer.js` is ordinary
391                        // JavaScript, so `%s` decodes the bytes
392                        // (`util.format('%s', Buffer.from('hi'))` is `hi`) rather
393                        // than inspecting them. Its `toString` is native here and
394                        // would otherwise be classified the other way. Read from
395                        // the ALREADY-BORROWED host: `stdlib::native_tag` opens
396                        // its own borrow and would abort the process from here.
397                        // The same applies to every BYTE VIEW: node stringifies
398                        // a `Uint8Array` under `%s` as `1,2`, where a plain Array
399                        // is inspected as `[ 1, 2 ]`. An ArrayBuffer is NOT one of
400                        // these — it has no `toString` of its own and is inspected.
401                        let stringifies = matches!(h.get(arg), Some(JsObj::Object(p))
402                        if matches!(
403                            p.get("@@native").map(|t| h.str_of(t)).as_deref(),
404                            Some("Buffer") | Some("TypedArray")
405                        ));
406                        if stringifies {
407                            return (None, false);
408                        }
409                        let scripted = ["toString", "@@toPrimitive"].iter().any(|k| {
410                            crate::host::lookup_chain(h, arg, k).is_some_and(|f| {
411                                matches!(
412                                    h.get(&f),
413                                    Some(JsObj::Func(_))
414                                        | Some(JsObj::Class(_))
415                                        | Some(JsObj::BoundFunc { .. })
416                                )
417                            })
418                        });
419                        (None, is_obj && !scripted)
420                    }
421                });
422                if let Some(b) = bigint {
423                    out.push_str(&b);
424                } else if use_inspect {
425                    crate::host::set_inspect_max_depth(0);
426                    let s = with_host(|h| h.inspect(arg));
427                    crate::host::set_inspect_max_depth(2);
428                    out.push_str(&s);
429                } else {
430                    // Full `ToPrimitive`, so a scripted `toString` actually runs;
431                    // `str_of` reads the object's brand and would answer
432                    // `[object Object]` without calling anything.
433                    let s = crate::host::to_string_value(arg)
434                        .map(|v| with_host(|h| h.str_of(&v)))
435                        .unwrap_or_else(|_| with_host(|h| h.str_of(arg)));
436                    out.push_str(&s);
437                }
438            }
439            // The three numeric directives use three DIFFERENT conversions, and
440            // collapsing them to one truncating `to_number` got all three wrong:
441            //   %d -> Number(x)      ("%d", 1.7)      is 1.7,      not 1
442            //   %i -> parseInt(x,10) ("%i", "3.9abc") is 3,        not NaN
443            //   %f -> parseFloat(x)  ("%f", "1.5x")   is 1.5,      not NaN
444            // and `n.trunc() as i64` additionally saturated every non-finite and
445            // out-of-i64-range value to 9223372036854775807 (`Infinity`, `1e21`).
446            // BigInt keeps its `n` suffix under %d/%i (Node prints `10n`).
447            'd' | 'i' => {
448                let big = with_host(|h| match h.get(arg) {
449                    Some(JsObj::BigInt(b)) => Some(format!("{b}n")),
450                    _ => None,
451                });
452                // A bare Symbol is `NaN` under %d rather than a throw — node
453                // tests `typeof === 'symbol'` before it ever coerces. Only a
454                // Symbol reached THROUGH a coercion (inside an array, say) throws.
455                let is_symbol = with_host(|h| h.type_of(arg) == "symbol");
456                match big {
457                    Some(s) => out.push_str(&s),
458                    None if is_symbol => out.push_str("NaN"),
459                    None if spec == 'd' => {
460                        out.push_str(&fmt_directive_number(crate::host::to_number_value(arg)?))
461                    }
462                    None => out.push_str(&fmt_directive_number(coerce_via("parseInt", arg)?)),
463                }
464            }
465            'f' => {
466                let is_symbol = with_host(|h| h.type_of(arg) == "symbol");
467                if is_symbol {
468                    out.push_str("NaN");
469                } else {
470                    out.push_str(&fmt_directive_number(coerce_via("parseFloat", arg)?));
471                }
472            }
473            'j' => {
474                // A CIRCULAR structure is the one JSON failure node absorbs,
475                // rendering it as `[Circular]`; every other failure — a BigInt
476                // above all — propagates.
477                match crate::builtins::call_builtin_function("JSON.stringify", vec![arg.clone()]) {
478                    Ok(v) => out.push_str(&with_host(|h| h.str_of(&v))),
479                    Err(e) if e.contains("circular structure") => out.push_str("[Circular]"),
480                    Err(e) => return Err(e),
481                }
482            }
483            // `%o` and `%O` are NOT the same directive. `%O` is a plain
484            // `inspect` at the default depth; `%o` is `inspect(v, { showHidden:
485            // true, showProxy: true, depth: 4 })`, so it reveals an array's
486            // `[length]`, a typed array's window onto its buffer, and four levels
487            // instead of two. Rendering both as the default inspect made `%o` a
488            // silent alias of `%O`.
489            'O' => out.push_str(&with_host(|h| h.inspect(arg))),
490            'o' => {
491                crate::host::set_inspect_show_hidden(true);
492                crate::host::set_inspect_max_depth(4);
493                let s = with_host(|h| h.inspect(arg));
494                crate::host::set_inspect_show_hidden(false);
495                crate::host::set_inspect_max_depth(2);
496                out.push_str(&s);
497            }
498            'c' => {} // CSS directive: consumes the arg, emits nothing.
499            _ => {}
500        }
501    }
502    // Append remaining arguments.
503    for a in &args[ai..] {
504        out.push(' ');
505        out.push_str(&with_host(|h| h.console_format(a)));
506    }
507    Ok(out)
508}
509
510// ── promisify / callbackify ──────────────────────────────────────────────────
511// These build a REAL JS closure by compiling a factory expression and invoking it
512// with the wrapped function — the same re-entrant nested-run path `vm.runInThisContext`
513// uses — so the returned value is an ordinary user function (`typeof === "function"`).
514
515/// Compile a single JS expression and run it on the LIVE host, returning its
516/// completion value. Delegates to the frontend's ONE runtime-source evaluator
517/// (`crate::eval_in_global_scope`), which runs the factory in the program's
518/// module scope rather than in the calling function's frame.
519fn run_completion(src: &str) -> Result<Value, String> {
520    crate::eval_in_global_scope(src)
521}
522
523const PROMISIFY_SRC: &str = "(function(original){\n\
524  return function(){\n\
525    var self = this;\n\
526    var args = Array.prototype.slice.call(arguments);\n\
527    return new Promise(function(resolve, reject){\n\
528      args.push(function(err, value){ if (err) reject(err); else resolve(value); });\n\
529      original.apply(self, args);\n\
530    });\n\
531  };\n\
532})";
533
534const CALLBACKIFY_SRC: &str = "(function(original){\n\
535  return function(){\n\
536    var self = this;\n\
537    var args = Array.prototype.slice.call(arguments);\n\
538    var cb = args.pop();\n\
539    Promise.resolve(original.apply(self, args)).then(\n\
540      function(value){ cb.call(self, null, value); },\n\
541      function(err){ cb.call(self, err || new Error('Promise was rejected with a falsy value')); }\n\
542    );\n\
543  };\n\
544})";
545
546/// `util.promisify(fn)` → a function returning a Promise that resolves with the
547/// callback's value (rejecting on its error argument).
548/// `util.aborted(signal, resource)`.
549///
550/// Resolves when `signal` aborts — immediately if it already has. The
551/// `resource` argument only ties the promise to an async resource for tracking,
552/// which this runtime does not model, so it is accepted and ignored.
553fn aborted(signal: &Value) -> Result<Value, String> {
554    let already = crate::builtins::get_property(signal, "aborted").unwrap_or(Value::Undef);
555    if with_host(|h| h.truthy(&already)) {
556        return crate::builtins::promise_resolve_pub(Value::Undef);
557    }
558    let (promise, resolve) = crate::builtins::pending_promise_with_resolver();
559    // The listener is an ordinary `abort` handler, so it goes through the same
560    // path a script's own `signal.addEventListener('abort', …)` would.
561    crate::host::call_method(
562        signal,
563        "addEventListener",
564        vec![with_host(|h| h.new_str("abort")), resolve],
565    )?;
566    Ok(promise)
567}
568
569fn promisify(args: &[Value]) -> Result<Value, String> {
570    let orig = args.first().cloned().unwrap_or(Value::Undef);
571    if !with_host(|h| crate::host::is_callable(h, &orig)) {
572        return Err(std::format!(
573            "TypeError [ERR_INVALID_ARG_TYPE]: The \"original\" argument must be of \
574             type function. Received {}",
575            super::received_desc(&orig)
576        ));
577    }
578    let factory = run_completion(PROMISIFY_SRC)?;
579    crate::host::invoke(&factory, vec![orig], None)
580}
581
582/// `util.callbackify(fn)` → a function taking a trailing `(err, value)` callback,
583/// invoked from the async function's resolved/rejected result.
584fn callbackify(args: &[Value]) -> Result<Value, String> {
585    let orig = args.first().cloned().unwrap_or(Value::Undef);
586    if !with_host(|h| crate::host::is_callable(h, &orig)) {
587        return Err(std::format!(
588            "TypeError [ERR_INVALID_ARG_TYPE]: The \"original\" argument must be of \
589             type function. Received {}",
590            super::received_desc(&orig)
591        ));
592    }
593    let factory = run_completion(CALLBACKIFY_SRC)?;
594    crate::host::invoke(&factory, vec![orig], None)
595}
596
597// ── debuglog ─────────────────────────────────────────────────────────────────
598
599const DEBUGLOG_ENABLED_SRC: &str = "(function(prefix){\n\
600  var util = require('util');\n\
601  return function(){\n\
602    console.error(prefix + ' ' + util.format.apply(null, arguments));\n\
603  };\n\
604})";
605
606/// `util.debuglog(section)` → a logging function gated by the `NODE_DEBUG` env var.
607/// When the section is not enabled, a no-op function is returned (Node's contract).
608fn debuglog(args: &[Value]) -> Result<Value, String> {
609    let section = super::arg_str(args, 0);
610    if debuglog_enabled(&section) {
611        let prefix = format!("{} {}:", section.to_uppercase(), std::process::id());
612        let factory = run_completion(DEBUGLOG_ENABLED_SRC)?;
613        let pfx = with_host(|h| h.new_str(prefix));
614        crate::host::invoke(&factory, vec![pfx], None)
615    } else {
616        run_completion("(function(){})")
617    }
618}
619
620/// Whether `NODE_DEBUG` enables `section` (comma/space-separated, case-insensitive,
621/// `*` wildcards allowed — matching Node's env parsing).
622fn debuglog_enabled(section: &str) -> bool {
623    let Ok(env) = std::env::var("NODE_DEBUG") else {
624        return false;
625    };
626    let sec = section.to_uppercase();
627    env.split(|c: char| c == ',' || c.is_whitespace())
628        .filter(|s| !s.is_empty())
629        .any(|pat| {
630            let pat = pat.to_uppercase();
631            if pat.contains('*') {
632                wildcard_match(&pat, &sec)
633            } else {
634                pat == sec
635            }
636        })
637}
638
639/// Minimal glob match (`*` = any run) for `NODE_DEBUG` section patterns.
640fn wildcard_match(pat: &str, s: &str) -> bool {
641    let parts: Vec<&str> = pat.split('*').collect();
642    if parts.len() == 1 {
643        return pat == s;
644    }
645    let mut pos = 0usize;
646    for (i, part) in parts.iter().enumerate() {
647        if part.is_empty() {
648            continue;
649        }
650        if i == 0 {
651            if !s[pos..].starts_with(part) {
652                return false;
653            }
654            pos += part.len();
655        } else if i == parts.len() - 1 {
656            return s[pos..].ends_with(part);
657        } else if let Some(idx) = s[pos..].find(part) {
658            pos += idx + part.len();
659        } else {
660            return false;
661        }
662    }
663    true
664}
665
666// ── stripVTControlCharacters ─────────────────────────────────────────────────
667
668/// Remove ANSI/VT escape sequences: two-char escapes, CSI (`ESC [ … final`),
669/// and OSC (`ESC ] … BEL|ST`), including the C1 CSI introducer `›`.
670fn strip_vt(s: &str) -> String {
671    let mut out = String::with_capacity(s.len());
672    let mut chars = s.chars().peekable();
673    while let Some(c) = chars.next() {
674        if c == '\u{9b}' {
675            // C1 CSI: consume params/intermediates until a final byte.
676            while let Some(&n) = chars.peek() {
677                chars.next();
678                if ('\u{40}'..='\u{7e}').contains(&n) {
679                    break;
680                }
681            }
682            continue;
683        }
684        if c != '\u{1b}' {
685            out.push(c);
686            continue;
687        }
688        match chars.peek() {
689            Some('[') => {
690                chars.next();
691                while let Some(&n) = chars.peek() {
692                    chars.next();
693                    if ('\u{40}'..='\u{7e}').contains(&n) {
694                        break;
695                    }
696                }
697            }
698            Some(']') => {
699                chars.next();
700                while let Some(&n) = chars.peek() {
701                    if n == '\u{7}' {
702                        chars.next();
703                        break;
704                    }
705                    if n == '\u{1b}' {
706                        chars.next();
707                        if chars.peek() == Some(&'\\') {
708                            chars.next();
709                        }
710                        break;
711                    }
712                    chars.next();
713                }
714            }
715            Some(_) => {
716                chars.next();
717            }
718            None => {}
719        }
720    }
721    out
722}
723
724// ── styleText ────────────────────────────────────────────────────────────────
725
726/// The format name(s) passed to `styleText` (a string or an array of strings).
727fn style_names(v: &Value) -> Vec<String> {
728    with_host(|h| match h.get(v) {
729        Some(JsObj::Array(items)) => items.iter().map(|x| h.str_of(x)).collect(),
730        _ => vec![h.str_of(v)],
731    })
732}
733
734/// `(open, close)` SGR parameter numbers for a `util.inspect.colors` name.
735/// `util.inspect.colors`: every SGR style name Node accepts, as
736/// `(name, open, close)`, in `Object.keys(util.inspect.colors)` order.
737///
738/// One table rather than a `match`, because `styleText`'s rejection message
739/// ENUMERATES these names and the enumeration has to come from the same source
740/// the lookup does. The old code had a `match` for the lookup and a
741/// hand-written sentence for the message, so the sentence could not go stale
742/// visibly — and it had: it said "must be a valid util.inspect.colors key",
743/// a wording no Node emits.
744pub(crate) const STYLES: &[(&str, u16, u16)] = &[
745    ("reset", 0, 0),
746    ("bold", 1, 22),
747    ("dim", 2, 22),
748    ("italic", 3, 23),
749    ("underline", 4, 24),
750    ("blink", 5, 25),
751    ("inverse", 7, 27),
752    ("hidden", 8, 28),
753    ("strikethrough", 9, 29),
754    ("doubleunderline", 21, 24),
755    ("black", 30, 39),
756    ("red", 31, 39),
757    ("green", 32, 39),
758    ("yellow", 33, 39),
759    ("blue", 34, 39),
760    ("magenta", 35, 39),
761    ("cyan", 36, 39),
762    ("white", 37, 39),
763    ("bgBlack", 40, 49),
764    ("bgRed", 41, 49),
765    ("bgGreen", 42, 49),
766    ("bgYellow", 43, 49),
767    ("bgBlue", 44, 49),
768    ("bgMagenta", 45, 49),
769    ("bgCyan", 46, 49),
770    ("bgWhite", 47, 49),
771    ("framed", 51, 54),
772    ("overlined", 53, 55),
773    ("gray", 90, 39),
774    ("redBright", 91, 39),
775    ("greenBright", 92, 39),
776    ("yellowBright", 93, 39),
777    ("blueBright", 94, 39),
778    ("magentaBright", 95, 39),
779    ("cyanBright", 96, 39),
780    ("whiteBright", 97, 39),
781    ("bgGray", 100, 49),
782    ("bgRedBright", 101, 49),
783    ("bgGreenBright", 102, 49),
784    ("bgYellowBright", 103, 49),
785    ("bgBlueBright", 104, 49),
786    ("bgMagentaBright", 105, 49),
787    ("bgCyanBright", 106, 49),
788    ("bgWhiteBright", 107, 49),
789];
790
791/// The aliases Node accepts but does not list in `inspect.colors`' own keys. It
792/// still names them in the rejection message, after the listed keys.
793pub(crate) const STYLE_ALIASES: &[(&str, u16, u16)] = &[
794    ("grey", 90, 39),
795    ("blackBright", 90, 39),
796    ("bgGrey", 100, 49),
797    ("bgBlackBright", 100, 49),
798    ("faint", 2, 22),
799    ("crossedout", 9, 29),
800    ("strikeThrough", 9, 29),
801    ("crossedOut", 9, 29),
802    ("conceal", 8, 28),
803    ("swapColors", 7, 27),
804    ("swapcolors", 7, 27),
805    ("doubleUnderline", 21, 24),
806];
807
808/// The `(open, close)` SGR pair for a style name, or `None` if unknown.
809fn style_codes(name: &str) -> Option<(u16, u16)> {
810    STYLES
811        .iter()
812        .chain(STYLE_ALIASES.iter())
813        .find(|(n, _, _)| *n == name)
814        .map(|(_, o, c)| (*o, *c))
815}
816
817/// `util.styleText(format, text)` — wrap `text` in the SGR codes for `format`
818/// (a color/modifier name or an array of them). `"none"` is a no-op passthrough.
819fn style_text(args: &[Value]) -> Result<Value, String> {
820    let fmt = args.first().cloned().unwrap_or(Value::Undef);
821    let names = style_names(&fmt);
822    let mut result = super::arg_str(args, 1);
823    for name in &names {
824        if name == "none" {
825            continue;
826        }
827        let (open, close) = style_codes(name).ok_or_else(|| {
828            // Node spells out every accepted name. Generated from the same table
829            // the lookup reads, so a style can never be accepted-but-unlisted.
830            let listed: Vec<String> = STYLES
831                .iter()
832                .chain(STYLE_ALIASES.iter())
833                .map(|(n, _, _)| format!("'{n}'"))
834                .collect();
835            crate::host::coded_error(
836                "TypeError",
837                "ERR_INVALID_ARG_VALUE",
838                &format!(
839                    "The argument 'format' must be one of: {}. Received '{name}'",
840                    listed.join(", ")
841                ),
842            )
843        })?;
844        result = format!("\u{1b}[{open}m{result}\u{1b}[{close}m");
845    }
846    Ok(with_host(|h| h.new_str(result)))
847}
848
849// ── getSystemError{Name,Message,Map} ─────────────────────────────────────────
850// Node's system error numbers are negative libuv errnos. We resolve them through
851// the platform's own `libc` constants so macOS/Linux each report their native
852// numbering, and pair each with libuv's canonical (lowercase) message string.
853
854/// `(name, platform errno, libuv message)` for the common POSIX errors. Only
855/// constants present on every supported Unix target are listed (cross-platform).
856const ERRNO_TABLE: &[(&str, i32, &str)] = &[
857    ("E2BIG", libc::E2BIG, "argument list too long"),
858    ("EACCES", libc::EACCES, "permission denied"),
859    ("EADDRINUSE", libc::EADDRINUSE, "address already in use"),
860    (
861        "EADDRNOTAVAIL",
862        libc::EADDRNOTAVAIL,
863        "address not available",
864    ),
865    (
866        "EAFNOSUPPORT",
867        libc::EAFNOSUPPORT,
868        "address family not supported",
869    ),
870    ("EAGAIN", libc::EAGAIN, "resource temporarily unavailable"),
871    ("EALREADY", libc::EALREADY, "connection already in progress"),
872    ("EBADF", libc::EBADF, "bad file descriptor"),
873    ("EBUSY", libc::EBUSY, "resource busy or locked"),
874    ("ECANCELED", libc::ECANCELED, "operation canceled"),
875    (
876        "ECONNABORTED",
877        libc::ECONNABORTED,
878        "software caused connection abort",
879    ),
880    ("ECONNREFUSED", libc::ECONNREFUSED, "connection refused"),
881    ("ECONNRESET", libc::ECONNRESET, "connection reset by peer"),
882    (
883        "EDESTADDRREQ",
884        libc::EDESTADDRREQ,
885        "destination address required",
886    ),
887    ("EEXIST", libc::EEXIST, "file already exists"),
888    (
889        "EFAULT",
890        libc::EFAULT,
891        "bad address in system call argument",
892    ),
893    ("EFBIG", libc::EFBIG, "file too large"),
894    ("EHOSTDOWN", libc::EHOSTDOWN, "host is down"),
895    ("EHOSTUNREACH", libc::EHOSTUNREACH, "host is unreachable"),
896    ("EINTR", libc::EINTR, "interrupted system call"),
897    ("EINVAL", libc::EINVAL, "invalid argument"),
898    ("EIO", libc::EIO, "i/o error"),
899    ("EISCONN", libc::EISCONN, "socket is already connected"),
900    ("EISDIR", libc::EISDIR, "illegal operation on a directory"),
901    ("ELOOP", libc::ELOOP, "too many symbolic links encountered"),
902    ("EMFILE", libc::EMFILE, "too many open files"),
903    ("EMLINK", libc::EMLINK, "too many links"),
904    ("EMSGSIZE", libc::EMSGSIZE, "message too long"),
905    ("ENAMETOOLONG", libc::ENAMETOOLONG, "name too long"),
906    ("ENETDOWN", libc::ENETDOWN, "network is down"),
907    ("ENETUNREACH", libc::ENETUNREACH, "network is unreachable"),
908    ("ENFILE", libc::ENFILE, "file table overflow"),
909    ("ENOBUFS", libc::ENOBUFS, "no buffer space available"),
910    ("ENODEV", libc::ENODEV, "no such device"),
911    ("ENOENT", libc::ENOENT, "no such file or directory"),
912    ("ENOMEM", libc::ENOMEM, "not enough memory"),
913    ("ENOPROTOOPT", libc::ENOPROTOOPT, "protocol not available"),
914    ("ENOSPC", libc::ENOSPC, "no space left on device"),
915    ("ENOSYS", libc::ENOSYS, "function not implemented"),
916    ("ENOTCONN", libc::ENOTCONN, "socket is not connected"),
917    ("ENOTDIR", libc::ENOTDIR, "not a directory"),
918    ("ENOTEMPTY", libc::ENOTEMPTY, "directory not empty"),
919    ("ENOTSOCK", libc::ENOTSOCK, "socket operation on non-socket"),
920    ("ENXIO", libc::ENXIO, "no such device or address"),
921    (
922        "EOPNOTSUPP",
923        libc::EOPNOTSUPP,
924        "operation not supported on socket",
925    ),
926    (
927        "EOVERFLOW",
928        libc::EOVERFLOW,
929        "value too large for defined data type",
930    ),
931    ("EPERM", libc::EPERM, "operation not permitted"),
932    ("EPIPE", libc::EPIPE, "broken pipe"),
933    ("EPROTO", libc::EPROTO, "protocol error"),
934    (
935        "EPROTONOSUPPORT",
936        libc::EPROTONOSUPPORT,
937        "protocol not supported",
938    ),
939    (
940        "EPROTOTYPE",
941        libc::EPROTOTYPE,
942        "protocol wrong type for socket",
943    ),
944    ("ERANGE", libc::ERANGE, "result too large"),
945    ("EROFS", libc::EROFS, "read-only file system"),
946    (
947        "ESHUTDOWN",
948        libc::ESHUTDOWN,
949        "cannot send after transport endpoint shutdown",
950    ),
951    ("ESPIPE", libc::ESPIPE, "invalid seek"),
952    ("ESRCH", libc::ESRCH, "no such process"),
953    ("ETIMEDOUT", libc::ETIMEDOUT, "connection timed out"),
954    ("ETXTBSY", libc::ETXTBSY, "text file is busy"),
955    ("EXDEV", libc::EXDEV, "cross-device link not permitted"),
956];
957
958/// Normalize a `getSystemError*` argument (a negative libuv errno) to a positive
959/// platform errno for table lookup.
960fn errno_of(err: f64) -> i32 {
961    if err < 0.0 {
962        (-err) as i32
963    } else {
964        err as i32
965    }
966}
967
968fn errno_name(e: i32) -> Option<&'static str> {
969    ERRNO_TABLE
970        .iter()
971        .find(|(_, code, _)| *code == e)
972        .map(|(n, _, _)| *n)
973}
974
975fn errno_message(e: i32) -> Option<&'static str> {
976    ERRNO_TABLE
977        .iter()
978        .find(|(_, code, _)| *code == e)
979        .map(|(_, _, m)| *m)
980}
981
982/// `util.getSystemErrorMap()` → a `Map` of negative errno → `[name, message]`.
983fn system_error_map() -> Value {
984    with_host(|h| {
985        let mut entries = indexmap::IndexMap::new();
986        for (name, code, msg) in ERRNO_TABLE {
987            let key_val = Value::Float(-(*code as f64));
988            let name_v = h.new_str(*name);
989            let msg_v = h.new_str(*msg);
990            let pair = h.new_array(vec![name_v, msg_v]);
991            let key = crate::host::map_key(h, &key_val);
992            entries.insert(key, (key_val, pair));
993        }
994        h.alloc(JsObj::Map {
995            entries,
996            weak: false,
997        })
998    })
999}
1000
1001// ── parseArgs ────────────────────────────────────────────────────────────────
1002
1003/// A parsed option value (or list, when `multiple` is set).
1004enum Slot {
1005    Bool(bool),
1006    Str(String),
1007    ListBool(Vec<bool>),
1008    ListStr(Vec<String>),
1009}
1010
1011/// The declared config for one option.
1012struct OptCfg {
1013    long: String,
1014    is_string: bool,
1015    multiple: bool,
1016    short: Option<String>,
1017    default: Option<Value>,
1018}
1019
1020/// `util.parseArgs(config)` → `{ values, positionals }`. Implements the documented
1021/// short/long/`--`/`=`/grouped-short algorithm. `config.tokens` is not emitted.
1022fn parse_args(config_args: &[Value]) -> Result<Value, String> {
1023    let config = config_args.first().cloned().unwrap_or(Value::Undef);
1024    let tokens = read_arg_tokens(&config);
1025    let strict = read_bool_prop(&config, "strict", true);
1026    let allow_positionals = read_bool_prop(&config, "allowPositionals", false);
1027    let allow_negative = read_bool_prop(&config, "allowNegative", false);
1028    let opts = read_options(&config);
1029
1030    let lookup_long = |name: &str| opts.iter().find(|o| o.long == name);
1031    let lookup_short = |c: &str| opts.iter().find(|o| o.short.as_deref() == Some(c));
1032    let is_bool_long = |name: &str| opts.iter().any(|o| o.long == name && !o.is_string);
1033
1034    let mut values: IndexMap<String, Slot> = IndexMap::new();
1035    let mut positionals: Vec<String> = Vec::new();
1036
1037    let mut i = 0usize;
1038    while i < tokens.len() {
1039        let tok = tokens[i].clone();
1040        if tok == "--" {
1041            for t in &tokens[i + 1..] {
1042                positionals.push(t.clone());
1043            }
1044            break;
1045        }
1046        if let Some(rest) = tok.strip_prefix("--") {
1047            let (raw_name, inline) = match rest.split_once('=') {
1048                Some((n, v)) => (n.to_string(), Some(v.to_string())),
1049                None => (rest.to_string(), None),
1050            };
1051            // `--no-foo` negation for boolean options.
1052            let (name, negate) = match raw_name.strip_prefix("no-") {
1053                Some(base) if allow_negative && is_bool_long(base) => (base.to_string(), true),
1054                _ => (raw_name, false),
1055            };
1056            match lookup_long(&name) {
1057                None if strict => {
1058                    return Err(format!(
1059                        "TypeError [ERR_PARSE_ARGS_UNKNOWN_OPTION]: Unknown option '--{name}'"
1060                    ))
1061                }
1062                None => {
1063                    // Lenient: record as a boolean flag.
1064                    store(&mut values, &name, Slot::Bool(true), false);
1065                }
1066                Some(cfg) if cfg.is_string => {
1067                    let val = match inline {
1068                        Some(v) => v,
1069                        None => {
1070                            i += 1;
1071                            tokens.get(i).cloned().ok_or_else(|| {
1072                                format!(
1073                                    "TypeError [ERR_PARSE_ARGS_INVALID_OPTION_VALUE]: \
1074                                     Option '--{name} <value>' argument missing"
1075                                )
1076                            })?
1077                        }
1078                    };
1079                    store(&mut values, &cfg.long, Slot::Str(val), cfg.multiple);
1080                }
1081                Some(cfg) => {
1082                    if inline.is_some() && strict {
1083                        return Err(format!(
1084                            "TypeError [ERR_PARSE_ARGS_INVALID_OPTION_VALUE]: \
1085                             Option '--{}' does not take an argument",
1086                            cfg.long
1087                        ));
1088                    }
1089                    store(&mut values, &cfg.long, Slot::Bool(!negate), cfg.multiple);
1090                }
1091            }
1092        } else if tok.len() > 1 && tok.starts_with('-') {
1093            let chars: Vec<char> = tok[1..].chars().collect();
1094            let mut ci = 0usize;
1095            while ci < chars.len() {
1096                let short = chars[ci].to_string();
1097                match lookup_short(&short) {
1098                    None if strict => {
1099                        return Err(format!(
1100                            "TypeError [ERR_PARSE_ARGS_UNKNOWN_OPTION]: Unknown option '-{short}'"
1101                        ))
1102                    }
1103                    None => {
1104                        store(&mut values, &short, Slot::Bool(true), false);
1105                        ci += 1;
1106                    }
1107                    Some(cfg) if cfg.is_string => {
1108                        let remainder: String = chars[ci + 1..].iter().collect();
1109                        let val = if !remainder.is_empty() {
1110                            remainder
1111                        } else {
1112                            i += 1;
1113                            tokens.get(i).cloned().ok_or_else(|| {
1114                                format!(
1115                                    "TypeError [ERR_PARSE_ARGS_INVALID_OPTION_VALUE]: \
1116                                     Option '-{short}, --{} <value>' argument missing",
1117                                    cfg.long
1118                                )
1119                            })?
1120                        };
1121                        store(&mut values, &cfg.long, Slot::Str(val), cfg.multiple);
1122                        break;
1123                    }
1124                    Some(cfg) => {
1125                        store(&mut values, &cfg.long, Slot::Bool(true), cfg.multiple);
1126                        ci += 1;
1127                    }
1128                }
1129            }
1130        } else {
1131            if !allow_positionals && strict {
1132                return Err(format!(
1133                    "TypeError [ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL]: \
1134                     Unexpected argument '{tok}'. This command does not take positional arguments"
1135                ));
1136            }
1137            positionals.push(tok);
1138        }
1139        i += 1;
1140    }
1141
1142    // Apply declared defaults for options that were never provided.
1143    for cfg in &opts {
1144        if !values.contains_key(&cfg.long) {
1145            if let Some(def) = &cfg.default {
1146                store_default(&mut values, cfg, def.clone());
1147            }
1148        }
1149    }
1150
1151    Ok(build_parse_result(values, positionals))
1152}
1153
1154/// Insert/append a parsed value under `name`, honoring `multiple`.
1155fn store(values: &mut IndexMap<String, Slot>, name: &str, slot: Slot, multiple: bool) {
1156    if !multiple {
1157        values.insert(name.to_string(), slot);
1158        return;
1159    }
1160    match values.get_mut(name) {
1161        Some(Slot::ListBool(v)) => {
1162            if let Slot::Bool(b) = slot {
1163                v.push(b);
1164            }
1165        }
1166        Some(Slot::ListStr(v)) => {
1167            if let Slot::Str(s) = slot {
1168                v.push(s);
1169            }
1170        }
1171        _ => {
1172            let init = match slot {
1173                Slot::Bool(b) => Slot::ListBool(vec![b]),
1174                Slot::Str(s) => Slot::ListStr(vec![s]),
1175                other => other,
1176            };
1177            values.insert(name.to_string(), init);
1178        }
1179    }
1180}
1181
1182/// Seed a default value (already a JS `Value`) for an unset option.
1183fn store_default(values: &mut IndexMap<String, Slot>, cfg: &OptCfg, def: Value) {
1184    // A `multiple` default is expected to be an array; a scalar default is stored
1185    // directly. We coerce through the option's declared type.
1186    let slot = with_host(|h| match h.get(&def) {
1187        Some(JsObj::Array(items)) => {
1188            if cfg.is_string {
1189                Slot::ListStr(items.iter().map(|v| h.str_of(v)).collect())
1190            } else {
1191                Slot::ListBool(items.iter().map(|v| h.truthy(v)).collect())
1192            }
1193        }
1194        _ if cfg.is_string => Slot::Str(h.str_of(&def)),
1195        _ => Slot::Bool(h.truthy(&def)),
1196    });
1197    values.insert(cfg.long.clone(), slot);
1198}
1199
1200/// Materialize `{ values, positionals }` in a single host borrow.
1201fn build_parse_result(values: IndexMap<String, Slot>, positionals: Vec<String>) -> Value {
1202    with_host(|h| {
1203        let mut vobj = IndexMap::new();
1204        for (k, slot) in values {
1205            let v = match slot {
1206                Slot::Bool(b) => Value::Bool(b),
1207                Slot::Str(s) => h.new_str(s),
1208                Slot::ListBool(items) => {
1209                    let arr = items.into_iter().map(Value::Bool).collect();
1210                    h.new_array(arr)
1211                }
1212                Slot::ListStr(items) => {
1213                    let arr = items.into_iter().map(|s| h.new_str(s)).collect();
1214                    h.new_array(arr)
1215                }
1216            };
1217            vobj.insert(k, v);
1218        }
1219        let values_v = h.new_object(vobj);
1220        let pos: Vec<Value> = positionals.into_iter().map(|s| h.new_str(s)).collect();
1221        let positionals_v = h.new_array(pos);
1222        let mut out = IndexMap::new();
1223        out.insert("values".to_string(), values_v);
1224        out.insert("positionals".to_string(), positionals_v);
1225        h.new_object(out)
1226    })
1227}
1228
1229/// `config.args` as strings, or `process.argv.slice(2)` (the runtime's own tail).
1230fn read_arg_tokens(config: &Value) -> Vec<String> {
1231    let arr = crate::builtins::get_property(config, "args").unwrap_or(Value::Undef);
1232    let from_config = with_host(|h| match h.get(&arr) {
1233        Some(JsObj::Array(items)) => Some(items.iter().map(|v| h.str_of(v)).collect::<Vec<_>>()),
1234        _ => None,
1235    });
1236    from_config.unwrap_or_else(|| std::env::args().skip(2).collect())
1237}
1238
1239/// Read a boolean config property, defaulting when absent/undefined.
1240fn read_bool_prop(config: &Value, name: &str, default: bool) -> bool {
1241    match crate::builtins::get_property(config, name) {
1242        Ok(Value::Undef) | Err(_) => default,
1243        Ok(v) => with_host(|h| h.truthy(&v)),
1244    }
1245}
1246
1247/// Read `config.options` into an ordered list of `OptCfg`.
1248fn read_options(config: &Value) -> Vec<OptCfg> {
1249    let options = crate::builtins::get_property(config, "options").unwrap_or(Value::Undef);
1250    let keys: Vec<String> = with_host(|h| match h.get(&options) {
1251        Some(JsObj::Object(m)) => m.keys().filter(|k| !k.starts_with("@@")).cloned().collect(),
1252        _ => Vec::new(),
1253    });
1254    keys.into_iter()
1255        .map(|long| {
1256            let spec = crate::builtins::get_property(&options, &long).unwrap_or(Value::Undef);
1257            let type_str = crate::builtins::get_property(&spec, "type")
1258                .ok()
1259                .map(|v| with_host(|h| h.str_of(&v)))
1260                .unwrap_or_default();
1261            let short = match crate::builtins::get_property(&spec, "short") {
1262                Ok(Value::Undef) | Err(_) => None,
1263                Ok(v) => Some(with_host(|h| h.str_of(&v))),
1264            };
1265            let multiple = read_bool_prop(&spec, "multiple", false);
1266            let default = match crate::builtins::get_property(&spec, "default") {
1267                Ok(Value::Undef) | Err(_) => None,
1268                Ok(v) => Some(v),
1269            };
1270            OptCfg {
1271                long,
1272                is_string: type_str == "string",
1273                multiple,
1274                short,
1275                default,
1276            }
1277        })
1278        .collect()
1279}
1280
1281// ── parseEnv ─────────────────────────────────────────────────────────────────
1282// Faithful port of Node's C++ `Dotenv::ParseContent` (src/node_dotenv.cc): CRLF
1283// normalization, `#`/blank-line skipping, `export ` prefix stripping, single /
1284// double / backtick quotes (`\n` expanded only inside double quotes), unquoted
1285// inline `#` comments, unterminated quotes, and last-wins on duplicate keys.
1286// Node emits the keys sorted, so we `sort_keys` before materializing.
1287
1288/// Trim ASCII whitespace (space, tab, newline) from both ends, matching Node's
1289/// `trim_spaces` (which trims only `" \t\n"`).
1290fn env_trim(s: &str) -> &str {
1291    s.trim_matches(|c: char| c == ' ' || c == '\t' || c == '\n')
1292}
1293
1294/// `util.parseEnv(content)` → an object of parsed `KEY=VALUE` assignments.
1295fn parse_env(input: &str) -> Value {
1296    let lines = input.replace('\r', "");
1297    let mut pairs: IndexMap<String, String> = IndexMap::new();
1298    let mut content: &str = env_trim(&lines);
1299
1300    while !content.is_empty() {
1301        let first = content.as_bytes()[0];
1302        // Skip blank lines and full-line comments.
1303        if first == b'\n' || first == b'#' {
1304            match content.find('\n') {
1305                Some(nl) => content = &content[nl + 1..],
1306                None => content = "",
1307            }
1308            continue;
1309        }
1310        // Next `=` or newline: a newline first means the line has no assignment.
1311        let Some(eq_or_nl) = content.find(['=', '\n']) else {
1312            break;
1313        };
1314        if content.as_bytes()[eq_or_nl] == b'\n' {
1315            content = env_trim(&content[eq_or_nl + 1..]);
1316            continue;
1317        }
1318        // Key up to `=`.
1319        let mut key = env_trim(&content[..eq_or_nl]);
1320        content = &content[eq_or_nl + 1..];
1321        // `KEY=` (empty value).
1322        if content.is_empty() || content.as_bytes()[0] == b'\n' {
1323            pairs.insert(key.to_string(), String::new());
1324            continue;
1325        }
1326        content = env_trim(content);
1327        // Skip empty keys (`=value`, `"   "=value`).
1328        if key.is_empty() {
1329            continue;
1330        }
1331        // `export ` prefix.
1332        if let Some(rest) = key.strip_prefix("export ") {
1333            key = env_trim(rest);
1334        }
1335        if content.is_empty() {
1336            pairs.insert(key.to_string(), String::new());
1337            break;
1338        }
1339        let vfirst = content.as_bytes()[0];
1340        // Double-quoted value: expand literal `\n`, may span raw newlines.
1341        if vfirst == b'"' {
1342            if let Some(rel) = content[1..].find('"') {
1343                let closing = rel + 1;
1344                let value = content[1..closing].replace("\\n", "\n");
1345                pairs.insert(key.to_string(), value);
1346                match content[closing + 1..].find('\n') {
1347                    Some(nl) => content = &content[closing + 1 + nl + 1..],
1348                    None => content = "",
1349                }
1350                continue;
1351            }
1352            // No closing quote — fall through to the generic quote handler.
1353        }
1354        // Single / double / backtick quoted value (no escape expansion).
1355        if vfirst == b'\'' || vfirst == b'"' || vfirst == b'`' {
1356            match content[1..].find(vfirst as char) {
1357                None => match content.find('\n') {
1358                    Some(nl) => {
1359                        pairs.insert(key.to_string(), content[..nl].to_string());
1360                        content = &content[nl + 1..];
1361                    }
1362                    None => {
1363                        pairs.insert(key.to_string(), content.to_string());
1364                        break;
1365                    }
1366                },
1367                Some(rel) => {
1368                    let closing = rel + 1;
1369                    pairs.insert(key.to_string(), content[1..closing].to_string());
1370                    match content[closing + 1..].find('\n') {
1371                        Some(nl) => content = &content[closing + 1 + nl + 1..],
1372                        None => content = "",
1373                    }
1374                    continue;
1375                }
1376            }
1377        } else {
1378            // Unquoted value: up to the newline, `#` starts an inline comment, trim.
1379            let (raw, next) = match content.find('\n') {
1380                Some(nl) => (&content[..nl], &content[nl + 1..]),
1381                None => (content, ""),
1382            };
1383            let value = match raw.find('#') {
1384                Some(h) => &raw[..h],
1385                None => raw,
1386            };
1387            pairs.insert(key.to_string(), env_trim(value).to_string());
1388            content = next;
1389        }
1390        content = env_trim(content);
1391    }
1392
1393    pairs.sort_keys();
1394    with_host(|h| {
1395        let mut m = IndexMap::new();
1396        for (k, v) in pairs {
1397            let val = h.new_str(v);
1398            m.insert(k, val);
1399        }
1400        h.new_object(m)
1401    })
1402}
1403
1404// ── MIMEType / MIMEParams ────────────────────────────────────────────────────
1405// Faithful port of Node's `lib/internal/mime.js`. A `MIMEType` is a native object
1406// tagged `@@native = "MIMEType"` with `type`/`subtype`/`essence` data properties
1407// and a `params` `MIMEParams` instance; a `MIMEParams` is tagged `@@native =
1408// "MIMEParams"` with its ordered unique `(name, value)` pairs in a hidden
1409// `@@pairs` array (mirrors the URLSearchParams representation).
1410
1411/// An HTTP token code point (`NOT_HTTP_TOKEN_CODE_POINT` inverse).
1412fn is_token_char(c: char) -> bool {
1413    c.is_ascii_alphanumeric()
1414        || matches!(
1415            c,
1416            '!' | '#'
1417                | '$'
1418                | '%'
1419                | '&'
1420                | '\''
1421                | '*'
1422                | '+'
1423                | '-'
1424                | '.'
1425                | '^'
1426                | '_'
1427                | '`'
1428                | '|'
1429                | '~'
1430        )
1431}
1432
1433/// An HTTP quoted-string code point (`NOT_HTTP_QUOTED_STRING_CODE_POINT` inverse):
1434/// tab, printable ASCII, and Latin-1 supplement.
1435fn is_quoted_string_char(c: char) -> bool {
1436    c == '\t' || ('\u{20}'..='\u{7e}').contains(&c) || ('\u{80}'..='\u{ff}').contains(&c)
1437}
1438
1439/// HTTP whitespace (`\r \n \t space`).
1440fn is_http_ws(c: char) -> bool {
1441    matches!(c, '\r' | '\n' | '\t' | ' ')
1442}
1443
1444/// Lowercase only ASCII `A-Z`, leaving other code points intact (Node's
1445/// `toASCIILower`).
1446fn ascii_lower(s: &str) -> String {
1447    s.to_ascii_lowercase()
1448}
1449
1450/// Build the `TypeError [ERR_INVALID_MIME_SYNTAX]` message (with the offending
1451/// index when known).
1452fn mime_syntax_err(part: &str, s: &str, index: Option<usize>) -> String {
1453    match index {
1454        Some(i) => format!(
1455            "TypeError [ERR_INVALID_MIME_SYNTAX]: The MIME syntax for a {part} in \"{s}\" is invalid at {i}"
1456        ),
1457        None => format!(
1458            "TypeError [ERR_INVALID_MIME_SYNTAX]: The MIME syntax for a {part} in \"{s}\" is invalid"
1459        ),
1460    }
1461}
1462
1463/// Parse `type/subtype` off the front of a MIME string; returns
1464/// `(type, subtype, remaining-params-string)`.
1465fn parse_type_and_subtype(s: &str) -> Result<(String, String, String), String> {
1466    let chars: Vec<char> = s.chars().collect();
1467    let n = chars.len();
1468    // Skip leading HTTP whitespace.
1469    let mut pos = 0;
1470    while pos < n && is_http_ws(chars[pos]) {
1471        pos += 1;
1472    }
1473    // Read the type up to `/`.
1474    let type_end = (pos..n).find(|&i| chars[i] == '/');
1475    let trimmed_type: String = match type_end {
1476        Some(e) => chars[pos..e].iter().collect(),
1477        None => chars[pos..].iter().collect(),
1478    };
1479    let type_invalid = trimmed_type.chars().position(|c| !is_token_char(c));
1480    if trimmed_type.is_empty() || type_invalid.is_some() || type_end.is_none() {
1481        return Err(mime_syntax_err("type", s, type_invalid));
1482    }
1483    let type_end = type_end.unwrap();
1484    pos = type_end + 1;
1485    let mime_type = ascii_lower(&trimmed_type);
1486    // Read the subtype up to `;`.
1487    let sub_end = (pos..n).find(|&i| chars[i] == ';');
1488    let raw_subtype: &[char] = match sub_end {
1489        Some(e) => &chars[pos..e],
1490        None => &chars[pos..],
1491    };
1492    let mut new_pos = pos + raw_subtype.len();
1493    if sub_end.is_some() {
1494        new_pos += 1;
1495    }
1496    // Trim trailing HTTP whitespace from the subtype only.
1497    let mut end = raw_subtype.len();
1498    while end > 0 && is_http_ws(raw_subtype[end - 1]) {
1499        end -= 1;
1500    }
1501    let trimmed_subtype: String = raw_subtype[..end].iter().collect();
1502    let sub_invalid = trimmed_subtype.chars().position(|c| !is_token_char(c));
1503    if trimmed_subtype.is_empty() || sub_invalid.is_some() {
1504        return Err(mime_syntax_err("subtype", s, sub_invalid));
1505    }
1506    let subtype = ascii_lower(&trimmed_subtype);
1507    let params: String = chars[new_pos.min(n)..].iter().collect();
1508    Ok((mime_type, subtype, params))
1509}
1510
1511/// Scan a quoted parameter value starting just after the opening `"` (Node's
1512/// `QUOTED_VALUE_PATTERN`). Returns `(matched_len, lone_backslash, closing_quote)`.
1513fn scan_quoted(chars: &[char], start: usize) -> (usize, bool, bool) {
1514    let n = chars.len();
1515    let mut i = start;
1516    let mut lone_backslash = false;
1517    let mut closing_quote = false;
1518    while i < n {
1519        match chars[i] {
1520            '\\' => {
1521                if i + 1 >= n {
1522                    lone_backslash = true;
1523                    i += 1;
1524                    break;
1525                }
1526                i += 2;
1527            }
1528            '"' => {
1529                closing_quote = true;
1530                i += 1;
1531                break;
1532            }
1533            _ => i += 1,
1534        }
1535    }
1536    (i - start, lone_backslash, closing_quote)
1537}
1538
1539/// Remove single `\` escapes (Node's `removeBackslashes`).
1540fn remove_backslashes(s: &[char]) -> String {
1541    let n = s.len();
1542    if n == 0 {
1543        return String::new();
1544    }
1545    let mut ret = String::new();
1546    let mut i = 0usize;
1547    while i < n - 1 {
1548        if s[i] == '\\' {
1549            i += 1;
1550            ret.push(s[i]);
1551        } else {
1552            ret.push(s[i]);
1553        }
1554        i += 1;
1555    }
1556    if i == n - 1 {
1557        ret.push(s[i]);
1558    }
1559    ret
1560}
1561
1562/// Parse a MIME parameter string into ordered, unique `(name, value)` pairs.
1563fn parse_mime_params(s: &str) -> Vec<(String, String)> {
1564    let chars: Vec<char> = s.chars().collect();
1565    let n = chars.len();
1566    // The source ends where trailing whitespace begins.
1567    let mut end_of_source = n;
1568    while end_of_source > 0 && is_http_ws(chars[end_of_source - 1]) {
1569        end_of_source -= 1;
1570    }
1571    let mut out: Vec<(String, String)> = Vec::new();
1572    let mut position = 0usize;
1573    while position < end_of_source {
1574        // Skip whitespace before the parameter name.
1575        while position < n && is_http_ws(chars[position]) {
1576            position += 1;
1577        }
1578        // Read the name up to `;`, `=`, or end.
1579        let mut after = position;
1580        while after < n && chars[after] != ';' && chars[after] != '=' {
1581            after += 1;
1582        }
1583        let name = ascii_lower(&chars[position..after].iter().collect::<String>());
1584        position = after;
1585        if position < end_of_source {
1586            let ch = chars[position];
1587            position += 1;
1588            // A `;` terminator means a value-less parameter — ignore it.
1589            if ch == ';' {
1590                continue;
1591            }
1592        }
1593        if position >= end_of_source {
1594            break;
1595        }
1596        let value = if chars[position] == '"' {
1597            // Quoted-string value.
1598            position += 1;
1599            let (matched_len, lone_backslash, closing_quote) = scan_quoted(&chars, position);
1600            let matched = &chars[position..position + matched_len];
1601            position += matched_len;
1602            let inside: &[char] = if lone_backslash || closing_quote {
1603                &matched[..matched.len().saturating_sub(1)]
1604            } else {
1605                matched
1606            };
1607            let mut v = remove_backslashes(inside);
1608            if lone_backslash {
1609                v.push('\\');
1610            }
1611            v
1612        } else {
1613            // Bare value up to `;`, trailing whitespace trimmed.
1614            let value_end = (position..n).find(|&i| chars[i] == ';').unwrap_or(n);
1615            let raw = &chars[position..value_end];
1616            position += raw.len();
1617            let mut end = raw.len();
1618            while end > 0 && is_http_ws(raw[end - 1]) {
1619                end -= 1;
1620            }
1621            let trimmed: String = raw[..end].iter().collect();
1622            if trimmed.is_empty() {
1623                // Node `continue`s here without the trailing `position++`, leaving
1624                // `position` on the `;` so the next iteration consumes it.
1625                continue;
1626            }
1627            trimmed
1628        };
1629        // Keep only valid, non-duplicate parameters (first value wins).
1630        let name_ok = !name.is_empty() && name.chars().all(is_token_char);
1631        let value_ok = value.chars().all(is_quoted_string_char);
1632        if name_ok && value_ok && !out.iter().any(|(k, _)| *k == name) {
1633            out.push((name, value));
1634        }
1635        position += 1;
1636    }
1637    out
1638}
1639
1640/// Serialize a parameter value: bare if it is a valid token, else a quoted string
1641/// with `"`/`\` escaped (Node's `encode`).
1642fn encode_param_value(value: &str) -> String {
1643    if value.is_empty() {
1644        return "\"\"".to_string();
1645    }
1646    if value.chars().all(is_token_char) {
1647        return value.to_string();
1648    }
1649    let mut escaped = String::with_capacity(value.len() + 2);
1650    for c in value.chars() {
1651        if c == '"' || c == '\\' {
1652            escaped.push('\\');
1653        }
1654        escaped.push(c);
1655    }
1656    format!("\"{escaped}\"")
1657}
1658
1659/// Serialize ordered pairs as `name=value;name2=value2` (MIMEParams `toString`).
1660fn serialize_mime_params(pairs: &[(String, String)]) -> String {
1661    let mut ret = String::new();
1662    for (k, v) in pairs {
1663        if !ret.is_empty() {
1664            ret.push(';');
1665        }
1666        ret.push_str(k);
1667        ret.push('=');
1668        ret.push_str(&encode_param_value(v));
1669    }
1670    ret
1671}
1672
1673/// Method names dispatched through `mime_params_instance_call` (for
1674/// `instance_has_method` wiring; `@@iterator` powers `for..of` / spread).
1675pub const MIME_PARAMS_METHODS: &[&str] = &[
1676    "get",
1677    "set",
1678    "has",
1679    "delete",
1680    "entries",
1681    "keys",
1682    "values",
1683    "toString",
1684    "toJSON",
1685    "@@iterator",
1686];
1687
1688/// Method names dispatched through `mime_type_instance_call`.
1689pub const MIME_TYPE_METHODS: &[&str] = &["toString", "toJSON"];
1690
1691/// Build a `MIMEParams` native object from ordered pairs.
1692fn make_mime_params(pairs: &[(String, String)]) -> Value {
1693    with_host(|h| {
1694        let items: Vec<Value> = pairs
1695            .iter()
1696            .map(|(k, v)| {
1697                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
1698                h.new_array(kv)
1699            })
1700            .collect();
1701        let arr = h.new_array(items);
1702        let mut m = IndexMap::new();
1703        m.insert("@@native".into(), h.new_str("MIMEParams"));
1704        m.insert("@@pairs".into(), arr);
1705        h.new_object(m)
1706    })
1707}
1708
1709/// Read the ordered `(name, value)` pairs out of a `MIMEParams`.
1710fn mime_pairs_of(recv: &Value) -> Vec<(String, String)> {
1711    with_host(|h| {
1712        let items: Vec<Value> = match h.get(recv) {
1713            Some(JsObj::Object(p)) => match p.get("@@pairs").and_then(|a| h.get(a)) {
1714                Some(JsObj::Array(items)) => items.clone(),
1715                _ => Vec::new(),
1716            },
1717            _ => Vec::new(),
1718        };
1719        items
1720            .iter()
1721            .map(|it| match h.get(it) {
1722                Some(JsObj::Array(kv)) => {
1723                    let kv = kv.clone();
1724                    let k = kv.first().map(|x| h.str_of(x)).unwrap_or_default();
1725                    let v = kv.get(1).map(|x| h.str_of(x)).unwrap_or_default();
1726                    (k, v)
1727                }
1728                _ => (h.str_of(it), String::new()),
1729            })
1730            .collect()
1731    })
1732}
1733
1734/// Overwrite a `MIMEParams`' backing `@@pairs` array.
1735fn set_mime_pairs(recv: &Value, pairs: &[(String, String)]) {
1736    with_host(|h| {
1737        let items: Vec<Value> = pairs
1738            .iter()
1739            .map(|(k, v)| {
1740                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
1741                h.new_array(kv)
1742            })
1743            .collect();
1744        let arr = h.new_array(items);
1745        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
1746            p.insert("@@pairs".into(), arr);
1747        }
1748    });
1749}
1750
1751/// `new util.MIMEParams()` — an empty parameter set (Node's constructor takes no
1752/// arguments).
1753pub fn construct_mime_params(_args: &[Value]) -> Result<Value, String> {
1754    Ok(make_mime_params(&[]))
1755}
1756
1757/// `MIMEParams` instance methods.
1758pub fn mime_params_instance_call(
1759    recv: &Value,
1760    method: &str,
1761    args: &[Value],
1762) -> Result<Value, String> {
1763    match method {
1764        "get" => {
1765            let name = super::arg_str(args, 0);
1766            match mime_pairs_of(recv).into_iter().find(|(k, _)| *k == name) {
1767                Some((_, v)) => Ok(with_host(|h| h.new_str(v))),
1768                None => Ok(with_host(|h| h.null())),
1769            }
1770        }
1771        "has" => {
1772            let name = super::arg_str(args, 0);
1773            Ok(Value::Bool(
1774                mime_pairs_of(recv).iter().any(|(k, _)| *k == name),
1775            ))
1776        }
1777        "set" => {
1778            let name = super::arg_str(args, 0);
1779            let value = super::arg_str(args, 1);
1780            if let Some(i) = name.chars().position(|c| !is_token_char(c)) {
1781                return Err(mime_syntax_err("parameter name", &name, Some(i)));
1782            }
1783            if name.is_empty() {
1784                return Err(mime_syntax_err("parameter name", &name, None));
1785            }
1786            if let Some(i) = value.chars().position(|c| !is_quoted_string_char(c)) {
1787                return Err(mime_syntax_err("parameter value", &value, Some(i)));
1788            }
1789            let mut pairs = mime_pairs_of(recv);
1790            match pairs.iter_mut().find(|(k, _)| *k == name) {
1791                Some(slot) => slot.1 = value,
1792                None => pairs.push((name, value)),
1793            }
1794            set_mime_pairs(recv, &pairs);
1795            Ok(Value::Undef)
1796        }
1797        "delete" => {
1798            let name = super::arg_str(args, 0);
1799            let mut pairs = mime_pairs_of(recv);
1800            pairs.retain(|(k, _)| *k != name);
1801            set_mime_pairs(recv, &pairs);
1802            Ok(Value::Undef)
1803        }
1804        "keys" => {
1805            let pairs = mime_pairs_of(recv);
1806            Ok(with_host(|h| {
1807                let items = pairs.into_iter().map(|(k, _)| h.new_str(k)).collect();
1808                h.alloc(JsObj::Iter { items, idx: 0 })
1809            }))
1810        }
1811        "values" => {
1812            let pairs = mime_pairs_of(recv);
1813            Ok(with_host(|h| {
1814                let items = pairs.into_iter().map(|(_, v)| h.new_str(v)).collect();
1815                h.alloc(JsObj::Iter { items, idx: 0 })
1816            }))
1817        }
1818        "entries" | "@@iterator" => {
1819            let pairs = mime_pairs_of(recv);
1820            Ok(with_host(|h| {
1821                let items = pairs
1822                    .into_iter()
1823                    .map(|(k, v)| {
1824                        let kv = vec![h.new_str(k), h.new_str(v)];
1825                        h.new_array(kv)
1826                    })
1827                    .collect();
1828                h.alloc(JsObj::Iter { items, idx: 0 })
1829            }))
1830        }
1831        "toString" | "toJSON" => {
1832            let s = serialize_mime_params(&mime_pairs_of(recv));
1833            Ok(with_host(|h| h.new_str(s)))
1834        }
1835        _ => Err(crate::host::type_error(&format!(
1836            "mimeParams.{method} is not a function"
1837        ))),
1838    }
1839}
1840
1841/// `new util.MIMEType(input)` — parse into `type`/`subtype`/`essence`/`params`.
1842pub fn construct_mime_type(args: &[Value]) -> Result<Value, String> {
1843    // Node coerces the argument to a string first (`${string}`), so a missing
1844    // argument parses as the literal `"undefined"` (which then throws).
1845    let input = with_host(|h| h.str_of(&args.first().cloned().unwrap_or(Value::Undef)));
1846    let (mime_type, subtype, params_str) = parse_type_and_subtype(&input)?;
1847    let essence = format!("{mime_type}/{subtype}");
1848    // Build the `MIMEParams` instance BEFORE the allocating `with_host` (never nest).
1849    let params = make_mime_params(&parse_mime_params(&params_str));
1850    Ok(with_host(|h| {
1851        let mut m = IndexMap::new();
1852        m.insert("@@native".into(), h.new_str("MIMEType"));
1853        m.insert("type".into(), h.new_str(mime_type));
1854        m.insert("subtype".into(), h.new_str(subtype));
1855        m.insert("essence".into(), h.new_str(essence));
1856        m.insert("params".into(), params);
1857        h.new_object(m)
1858    }))
1859}
1860
1861/// `MIMEType` instance methods. `type`/`subtype`/`essence`/`params` are data
1862/// properties read directly; `toString`/`toJSON` serialize live (reflecting any
1863/// `params` mutation).
1864pub fn mime_type_instance_call(
1865    recv: &Value,
1866    method: &str,
1867    _args: &[Value],
1868) -> Result<Value, String> {
1869    match method {
1870        "toString" | "toJSON" => {
1871            // Read the essence and the live params object under one borrow.
1872            let (essence, params) = with_host(|h| match h.get(recv) {
1873                Some(JsObj::Object(p)) => (
1874                    p.get("essence").map(|x| h.str_of(x)).unwrap_or_default(),
1875                    p.get("params").cloned().unwrap_or(Value::Undef),
1876                ),
1877                _ => (String::new(), Value::Undef),
1878            });
1879            let param_str = serialize_mime_params(&mime_pairs_of(&params));
1880            let out = if param_str.is_empty() {
1881                essence
1882            } else {
1883                format!("{essence};{param_str}")
1884            };
1885            Ok(with_host(|h| h.new_str(out)))
1886        }
1887        _ => Err(crate::host::type_error(&format!(
1888            "mimeType.{method} is not a function"
1889        ))),
1890    }
1891}