Skip to main content

nodejs/stdlib/
url.rs

1//! Node `url` module: the WHATWG `URL` class (global + `require('url').URL`) and
2//! the legacy `url.parse`. A `URL` instance stores its components as data
3//! properties (so `u.hostname` reads directly) plus a `@@native = "URL"` tag for
4//! `toString`.
5
6use super::arg_str;
7use crate::host::{with_host, JsObj};
8use fusevm::Value;
9use indexmap::IndexMap;
10
11pub const MODULE_METHODS: &[&str] = &[
12    "parse",
13    "format",
14    "fileURLToPath",
15    "fileURLToPathBuffer",
16    "pathToFileURL",
17    "domainToASCII",
18    "domainToUnicode",
19    "urlToHttpOptions",
20    "resolve",
21    "resolveObject",
22];
23
24/// Parsed URL components.
25struct Parts {
26    protocol: String,
27    username: String,
28    password: String,
29    hostname: String,
30    port: String,
31    pathname: String,
32    search: String,
33    hash: String,
34}
35
36impl Parts {
37    fn host(&self) -> String {
38        if self.port.is_empty() {
39            self.hostname.clone()
40        } else {
41            format!("{}:{}", self.hostname, self.port)
42        }
43    }
44    fn origin(&self) -> String {
45        if self.hostname.is_empty() {
46            "null".into()
47        } else {
48            format!("{}//{}", self.protocol, self.host())
49        }
50    }
51    fn href(&self) -> String {
52        let auth = if self.username.is_empty() {
53            String::new()
54        } else if self.password.is_empty() {
55            format!("{}@", self.username)
56        } else {
57            format!("{}:{}@", self.username, self.password)
58        };
59        format!(
60            "{}//{auth}{}{}{}{}",
61            self.protocol,
62            self.host(),
63            self.pathname,
64            self.search,
65            self.hash
66        )
67    }
68}
69
70/// Parse an absolute URL. Returns `None` if there is no `scheme://`.
71fn parse_absolute(input: &str) -> Option<Parts> {
72    let (scheme, rest) = input.split_once("://")?;
73    if scheme.is_empty()
74        || !scheme
75            .chars()
76            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
77    {
78        return None;
79    }
80    // authority is up to the first '/', '?' or '#'.
81    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
82    let authority = &rest[..auth_end];
83    let mut tail = &rest[auth_end..];
84
85    let (userinfo, hostport) = match authority.rsplit_once('@') {
86        Some((u, h)) => (u, h),
87        None => ("", authority),
88    };
89    let (username, password) = match userinfo.split_once(':') {
90        Some((u, p)) => (u.to_string(), p.to_string()),
91        None => (userinfo.to_string(), String::new()),
92    };
93    let (hostname, port) = match hostport.split_once(':') {
94        Some((h, p)) => (h.to_string(), p.to_string()),
95        None => (hostport.to_string(), String::new()),
96    };
97
98    let hash = match tail.find('#') {
99        Some(i) => {
100            let h = tail[i..].to_string();
101            tail = &tail[..i];
102            h
103        }
104        None => String::new(),
105    };
106    let search = match tail.find('?') {
107        Some(i) => {
108            let s = tail[i..].to_string();
109            tail = &tail[..i];
110            s
111        }
112        None => String::new(),
113    };
114    let pathname = if tail.is_empty() {
115        "/".to_string()
116    } else {
117        tail.to_string()
118    };
119
120    Some(Parts {
121        protocol: format!("{scheme}:"),
122        username,
123        password,
124        hostname,
125        port,
126        pathname,
127        search,
128        hash,
129    })
130}
131
132/// `new URL(input[, base])`.
133pub fn construct(args: &[Value]) -> Result<Value, String> {
134    let input = arg_str(args, 0);
135    let parts = parse_absolute(&input)
136        .or_else(|| {
137            // A base makes a relative input absolute (path replacement only).
138            if args.len() > 1 {
139                let base = arg_str(args, 1);
140                parse_absolute(&base).map(|mut b| {
141                    if input.starts_with('/') {
142                        b.pathname = input.clone();
143                    } else {
144                        b.pathname = format!("/{input}");
145                    }
146                    b.search.clear();
147                    b.hash.clear();
148                    b
149                })
150            } else {
151                None
152            }
153        })
154        .ok_or_else(|| format!("TypeError: Invalid URL: {input}"))?;
155    Ok(build(&parts))
156}
157
158fn build(p: &Parts) -> Value {
159    // Build the `URLSearchParams` snapshot BEFORE the allocating `with_host` below
160    // (never nest `with_host`); it is stored as the `searchParams` data property so
161    // `url.searchParams.get(...)` reads it directly. It is a static snapshot of the
162    // query at construction — mutating it does not rewrite `url.href`.
163    let query = p.search.strip_prefix('?').unwrap_or(&p.search);
164    let search_params = make_search_params(&parse_query(query));
165    with_host(|h| {
166        let mut m = IndexMap::new();
167        m.insert("@@native".into(), h.new_str("URL"));
168        m.insert("href".into(), h.new_str(p.href()));
169        m.insert("origin".into(), h.new_str(p.origin()));
170        m.insert("protocol".into(), h.new_str(p.protocol.clone()));
171        m.insert("username".into(), h.new_str(p.username.clone()));
172        m.insert("password".into(), h.new_str(p.password.clone()));
173        m.insert("host".into(), h.new_str(p.host()));
174        m.insert("hostname".into(), h.new_str(p.hostname.clone()));
175        m.insert("port".into(), h.new_str(p.port.clone()));
176        m.insert("pathname".into(), h.new_str(p.pathname.clone()));
177        m.insert("search".into(), h.new_str(p.search.clone()));
178        m.insert("searchParams".into(), search_params);
179        m.insert("hash".into(), h.new_str(p.hash.clone()));
180        h.new_object(m)
181    })
182}
183
184pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
185    Some(match method {
186        "parse" => Ok(legacy_parse(&arg_str(args, 0))),
187        "format" => Ok(with_host(|h| {
188            // format(URL|object): if it's a native URL, return its href.
189            let v = args.first().cloned().unwrap_or(Value::Undef);
190            let s = match h.get(&v) {
191                Some(JsObj::Object(p)) => p.get("href").map(|x| h.str_of(x)).unwrap_or_default(),
192                _ => h.str_of(&v),
193            };
194            h.new_str(s)
195        })),
196        // `url.fileURLToPath(url)` — a `file:` URL/string → a filesystem path
197        // (percent-decoded). POSIX best-effort: any authority (host) is accepted
198        // but not re-prefixed; Windows drive/UNC rewriting is not modeled.
199        "fileURLToPath" => file_url_to_path(args).map(|s| with_host(|h| h.new_str(s))),
200        // Same, but returns the path as a `Buffer`.
201        "fileURLToPathBuffer" => {
202            file_url_to_path(args).map(|s| super::buffer::from_bytes(s.as_bytes()))
203        }
204        // `url.pathToFileURL(path)` → a `URL` instance with a `file:` href.
205        "pathToFileURL" => Ok(path_to_file_url(&arg_str(args, 0))),
206        // `url.domainToASCII` / `url.domainToUnicode` — delegate to the punycode
207        // codec; an ASCII-only domain passes through unchanged, an invalid domain
208        // yields "" (matching Node, which never throws here).
209        "domainToASCII" => Ok(punycode_domain(args, true)),
210        "domainToUnicode" => Ok(punycode_domain(args, false)),
211        // `url.urlToHttpOptions(URL)` → an options object for http/https.request.
212        "urlToHttpOptions" => Ok(url_to_http_options(
213            &args.first().cloned().unwrap_or(Value::Undef),
214        )),
215        // Legacy `url.resolve(from, to)` — RFC 3986 §5 reference resolution.
216        "resolve" => {
217            let from = arg_str(args, 0);
218            let to = arg_str(args, 1);
219            Ok(with_host(|h| h.new_str(legacy_resolve(&from, &to))))
220        }
221        // Legacy `url.resolveObject(from, to)` — the resolved URL as a parsed object.
222        "resolveObject" => {
223            let from = arg_str(args, 0);
224            let to = arg_str(args, 1);
225            Ok(legacy_parse(&legacy_resolve(&from, &to)))
226        }
227        _ => return None,
228    })
229}
230
231/// Legacy `url.parse` — a plain object (not a `URL` instance), matching Node's
232/// field set and insertion order.
233fn legacy_parse(input: &str) -> Value {
234    let p = parse_absolute(input);
235    with_host(|h| {
236        let mut m = IndexMap::new();
237        let null = h.null();
238        let opt = |h: &mut crate::host::JsHost, s: &str| {
239            if s.is_empty() {
240                h.null()
241            } else {
242                h.new_str(s)
243            }
244        };
245        match p {
246            Some(p) => {
247                let auth = if p.username.is_empty() {
248                    String::new()
249                } else if p.password.is_empty() {
250                    p.username.clone()
251                } else {
252                    format!("{}:{}", p.username, p.password)
253                };
254                m.insert("protocol".into(), h.new_str(p.protocol.clone()));
255                m.insert("slashes".into(), Value::Bool(true));
256                m.insert("auth".into(), opt(h, &auth));
257                m.insert("host".into(), h.new_str(p.host()));
258                m.insert("port".into(), opt(h, &p.port));
259                m.insert("hostname".into(), h.new_str(p.hostname.clone()));
260                m.insert("hash".into(), opt(h, &p.hash));
261                m.insert("search".into(), opt(h, &p.search));
262                m.insert("query".into(), opt(h, p.search.trim_start_matches('?')));
263                m.insert("pathname".into(), h.new_str(p.pathname.clone()));
264                m.insert(
265                    "path".into(),
266                    h.new_str(format!("{}{}", p.pathname, p.search)),
267                );
268                m.insert("href".into(), h.new_str(p.href()));
269            }
270            None => {
271                m.insert("protocol".into(), null.clone());
272                m.insert("slashes".into(), null.clone());
273                m.insert("auth".into(), null.clone());
274                m.insert("host".into(), null.clone());
275                m.insert("port".into(), null.clone());
276                m.insert("hostname".into(), null.clone());
277                m.insert("hash".into(), null.clone());
278                m.insert("search".into(), null.clone());
279                m.insert("query".into(), null.clone());
280                m.insert("pathname".into(), h.new_str(input));
281                m.insert("path".into(), h.new_str(input));
282                m.insert("href".into(), h.new_str(input));
283            }
284        }
285        h.new_object(m)
286    })
287}
288
289/// `URL` instance methods (component reads are plain data properties).
290pub fn instance_call(recv: &Value, method: &str, _args: &[Value]) -> Result<Value, String> {
291    match method {
292        "toString" | "toJSON" => Ok(with_host(|h| match h.get(recv) {
293            Some(JsObj::Object(p)) => p.get("href").cloned().unwrap_or(Value::Undef),
294            _ => Value::Undef,
295        })),
296        _ => Err(crate::host::type_error(&format!(
297            "url.{method} is not a function"
298        ))),
299    }
300}
301
302// ── file:/legacy URL helpers ─────────────────────────────────────────────────
303
304/// The `href` string of a value: for a native `URL` its stored `href`, else the
305/// value coerced to a string (so both `URL` objects and strings are accepted).
306fn url_href(v: &Value) -> String {
307    with_host(|h| match h.get(v) {
308        Some(JsObj::Object(p)) => match p.get("@@native").map(|x| h.str_of(x)).as_deref() {
309            Some("URL") => p.get("href").map(|x| h.str_of(x)).unwrap_or_default(),
310            _ => h.str_of(v),
311        },
312        _ => h.str_of(v),
313    })
314}
315
316/// `fileURLToPath` core: `file://[host]/path` → decoded `/path`.
317fn file_url_to_path(args: &[Value]) -> Result<String, String> {
318    let v = args.first().cloned().unwrap_or(Value::Undef);
319    let href = url_href(&v);
320    let rest = href
321        .strip_prefix("file://")
322        .ok_or_else(|| crate::host::type_error("The URL must be of scheme file"))?;
323    // The authority runs up to the first '/'; the remainder is the path.
324    let path = match rest.find('/') {
325        Some(0) => rest,
326        Some(i) => &rest[i..],
327        None => "/",
328    };
329    Ok(percent_decode(path))
330}
331
332/// `pathToFileURL(path)` → a `URL` instance whose href is `file://` + the
333/// percent-encoded (path-set) path.
334fn path_to_file_url(path: &str) -> Value {
335    let enc = encode_path_component(path);
336    let pathname = if enc.starts_with('/') {
337        enc
338    } else {
339        format!("/{enc}")
340    };
341    let parts = Parts {
342        protocol: "file:".into(),
343        username: String::new(),
344        password: String::new(),
345        hostname: String::new(),
346        port: String::new(),
347        pathname,
348        search: String::new(),
349        hash: String::new(),
350    };
351    build(&parts)
352}
353
354/// `domainToASCII` (`ascii = true`) / `domainToUnicode` — via the punycode codec.
355fn punycode_domain(args: &[Value], ascii: bool) -> Value {
356    let method = if ascii { "toASCII" } else { "toUnicode" };
357    match super::punycode::call(method, args) {
358        Some(Ok(v)) => v,
359        _ => with_host(|h| h.new_str("")),
360    }
361}
362
363/// `urlToHttpOptions(URL)` → `{ protocol, hostname, hash, search, pathname, path,
364/// href[, port][, auth] }`, mirroring Node's field set and IPv6 bracket-stripping.
365fn url_to_http_options(v: &Value) -> Value {
366    let get = |key: &str| -> String {
367        with_host(|h| match h.get(v) {
368            Some(JsObj::Object(p)) => p.get(key).map(|x| h.str_of(x)).unwrap_or_default(),
369            _ => String::new(),
370        })
371    };
372    let protocol = get("protocol");
373    let mut hostname = get("hostname");
374    if hostname.starts_with('[') && hostname.ends_with(']') && hostname.len() >= 2 {
375        hostname = hostname[1..hostname.len() - 1].to_string();
376    }
377    let hash = get("hash");
378    let search = get("search");
379    let pathname = get("pathname");
380    let href = get("href");
381    let port = get("port");
382    let username = get("username");
383    let password = get("password");
384    let path = format!("{pathname}{search}");
385    let auth = if username.is_empty() && password.is_empty() {
386        None
387    } else {
388        Some(format!(
389            "{}:{}",
390            percent_decode(&username),
391            percent_decode(&password)
392        ))
393    };
394    let port_num = if port.is_empty() {
395        None
396    } else {
397        port.parse::<f64>().ok()
398    };
399    with_host(|h| {
400        let mut m = IndexMap::new();
401        m.insert("protocol".into(), h.new_str(protocol));
402        m.insert("hostname".into(), h.new_str(hostname));
403        m.insert("hash".into(), h.new_str(hash));
404        m.insert("search".into(), h.new_str(search));
405        m.insert("pathname".into(), h.new_str(pathname));
406        m.insert("path".into(), h.new_str(path));
407        m.insert("href".into(), h.new_str(href));
408        if let Some(n) = port_num {
409            m.insert("port".into(), Value::Float(n));
410        }
411        if let Some(a) = auth {
412            m.insert("auth".into(), h.new_str(a));
413        }
414        h.new_object(m)
415    })
416}
417
418/// Percent-decode a URL component (`%XX` → byte, then UTF-8 lossy). Unlike the
419/// form decoder this leaves `+` literal (a file path may legitimately contain it).
420fn percent_decode(s: &str) -> String {
421    let b = s.as_bytes();
422    let mut out: Vec<u8> = Vec::with_capacity(b.len());
423    let mut i = 0;
424    while i < b.len() {
425        if b[i] == b'%' && i + 2 < b.len() {
426            if let (Some(hi), Some(lo)) = (hex_val(b[i + 1]), hex_val(b[i + 2])) {
427                out.push((hi << 4) | lo);
428                i += 3;
429                continue;
430            }
431        }
432        out.push(b[i]);
433        i += 1;
434    }
435    String::from_utf8_lossy(&out).into_owned()
436}
437
438/// Percent-encode a path for a `file:` URL: keep the unreserved + sub-delim set
439/// and `/ : @`, encode everything else (space, `# ? %` `< > "` etc.).
440fn encode_path_component(s: &str) -> String {
441    let mut out = String::with_capacity(s.len());
442    for &b in s.as_bytes() {
443        let keep = b.is_ascii_alphanumeric()
444            || matches!(
445                b,
446                b'/' | b'-'
447                    | b'.'
448                    | b'_'
449                    | b'~'
450                    | b'!'
451                    | b'$'
452                    | b'&'
453                    | b'\''
454                    | b'('
455                    | b')'
456                    | b'*'
457                    | b'+'
458                    | b','
459                    | b';'
460                    | b'='
461                    | b':'
462                    | b'@'
463            );
464        if keep {
465            out.push(b as char);
466        } else {
467            out.push('%');
468            out.push(hex_upper(b >> 4));
469            out.push(hex_upper(b & 0x0f));
470        }
471    }
472    out
473}
474
475// ── legacy url.resolve — RFC 3986 §5 reference resolution ─────────────────────
476
477/// A URI split into its five RFC-3986 components.
478struct UriRef {
479    scheme: Option<String>,
480    authority: Option<String>,
481    path: String,
482    query: Option<String>,
483    fragment: Option<String>,
484}
485
486/// Split a URI reference into its components (RFC 3986 Appendix B), by hand.
487fn split_uri(input: &str) -> UriRef {
488    let mut rest = input;
489    // scheme: leading ALPHA *(ALPHA/DIGIT/+/-/.) then ':' — but only if that ':'
490    // precedes the first '/', '?' or '#'.
491    let mut scheme = None;
492    if let Some(colon) = rest.find(':') {
493        let cand = &rest[..colon];
494        let scheme_ok = !cand.is_empty()
495            && cand.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
496            && cand
497                .chars()
498                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
499            && cand.find(['/', '?', '#']).is_none();
500        if scheme_ok {
501            scheme = Some(cand.to_string());
502            rest = &rest[colon + 1..];
503        }
504    }
505    let mut fragment = None;
506    if let Some(h) = rest.find('#') {
507        fragment = Some(rest[h + 1..].to_string());
508        rest = &rest[..h];
509    }
510    let mut query = None;
511    if let Some(q) = rest.find('?') {
512        query = Some(rest[q + 1..].to_string());
513        rest = &rest[..q];
514    }
515    let mut authority = None;
516    if let Some(r) = rest.strip_prefix("//") {
517        let end = r.find('/').unwrap_or(r.len());
518        authority = Some(r[..end].to_string());
519        rest = &r[end..];
520    }
521    UriRef {
522        scheme,
523        authority,
524        path: rest.to_string(),
525        query,
526        fragment,
527    }
528}
529
530/// Merge a relative path onto a base (RFC 3986 §5.2.3).
531fn merge_paths(base: &UriRef, ref_path: &str) -> String {
532    if base.authority.is_some() && base.path.is_empty() {
533        format!("/{ref_path}")
534    } else {
535        match base.path.rfind('/') {
536            Some(i) => format!("{}{ref_path}", &base.path[..=i]),
537            None => ref_path.to_string(),
538        }
539    }
540}
541
542/// Drop the last path segment of `output` (used by `..` handling).
543fn remove_last_segment(output: &mut String) {
544    match output.rfind('/') {
545        Some(pos) => output.truncate(pos),
546        None => output.clear(),
547    }
548}
549
550/// Remove `.`/`..` dot-segments from a path (RFC 3986 §5.2.4).
551fn remove_dot_segments(path: &str) -> String {
552    let mut input = path.to_string();
553    let mut output = String::new();
554    while !input.is_empty() {
555        if let Some(r) = input.strip_prefix("../") {
556            input = r.to_string();
557        } else if let Some(r) = input.strip_prefix("./") {
558            input = r.to_string();
559        } else if let Some(r) = input.strip_prefix("/./") {
560            input = format!("/{r}");
561        } else if input == "/." {
562            input = "/".to_string();
563        } else if let Some(r) = input.strip_prefix("/../") {
564            input = format!("/{r}");
565            remove_last_segment(&mut output);
566        } else if input == "/.." {
567            input = "/".to_string();
568            remove_last_segment(&mut output);
569        } else if input == "." || input == ".." {
570            input.clear();
571        } else {
572            let start = usize::from(input.starts_with('/'));
573            let end = input[start..]
574                .find('/')
575                .map(|i| start + i)
576                .unwrap_or(input.len());
577            output.push_str(&input[..end]);
578            input.drain(..end);
579        }
580    }
581    output
582}
583
584/// RFC 3986 §5.2.2 transform-references: resolve `r` against `base`.
585fn resolve_ref(base: &UriRef, r: &UriRef) -> UriRef {
586    if r.scheme.is_some() {
587        return UriRef {
588            scheme: r.scheme.clone(),
589            authority: r.authority.clone(),
590            path: remove_dot_segments(&r.path),
591            query: r.query.clone(),
592            fragment: r.fragment.clone(),
593        };
594    }
595    let (authority, path, query) = if r.authority.is_some() {
596        (
597            r.authority.clone(),
598            remove_dot_segments(&r.path),
599            r.query.clone(),
600        )
601    } else if r.path.is_empty() {
602        let q = if r.query.is_some() {
603            r.query.clone()
604        } else {
605            base.query.clone()
606        };
607        (base.authority.clone(), base.path.clone(), q)
608    } else if r.path.starts_with('/') {
609        (
610            base.authority.clone(),
611            remove_dot_segments(&r.path),
612            r.query.clone(),
613        )
614    } else {
615        (
616            base.authority.clone(),
617            remove_dot_segments(&merge_paths(base, &r.path)),
618            r.query.clone(),
619        )
620    };
621    UriRef {
622        scheme: base.scheme.clone(),
623        authority,
624        path,
625        query,
626        fragment: r.fragment.clone(),
627    }
628}
629
630/// Recompose a URI from its components (RFC 3986 §5.3).
631fn recompose(u: &UriRef) -> String {
632    let mut s = String::new();
633    if let Some(sc) = &u.scheme {
634        s.push_str(sc);
635        s.push(':');
636    }
637    if let Some(a) = &u.authority {
638        s.push_str("//");
639        s.push_str(a);
640    }
641    s.push_str(&u.path);
642    if let Some(q) = &u.query {
643        s.push('?');
644        s.push_str(q);
645    }
646    if let Some(f) = &u.fragment {
647        s.push('#');
648        s.push_str(f);
649    }
650    s
651}
652
653/// Legacy `url.resolve(from, to)` — RFC 3986 reference resolution end-to-end.
654fn legacy_resolve(from: &str, to: &str) -> String {
655    recompose(&resolve_ref(&split_uri(from), &split_uri(to)))
656}
657
658// ── URLSearchParams ──────────────────────────────────────────────────────────
659//
660// A `URLSearchParams` is a plain object tagged `@@native = "URLSearchParams"`
661// whose ordered `[key, value]` pairs live in a hidden `@@pairs` array (each entry
662// a 2-element `[key, value]` array of strings). All string coercion happens up
663// front; methods mutate a plain `Vec<(String, String)>` and write it back.
664
665/// Method names dispatched through `search_params_call` (for `instance_has_method`
666/// wiring in `stdlib::mod`; `@@iterator` makes `[...params]` / `for..of` work).
667pub const SEARCH_PARAMS_METHODS: &[&str] = &[
668    "get",
669    "getAll",
670    "has",
671    "set",
672    "append",
673    "delete",
674    "keys",
675    "values",
676    "entries",
677    "forEach",
678    "toString",
679    "sort",
680    "@@iterator",
681];
682
683/// Build a `URLSearchParams` native object from ordered key/value pairs.
684fn make_search_params(pairs: &[(String, String)]) -> Value {
685    with_host(|h| {
686        let items: Vec<Value> = pairs
687            .iter()
688            .map(|(k, v)| {
689                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
690                h.new_array(kv)
691            })
692            .collect();
693        let arr = h.new_array(items);
694        let mut m = IndexMap::new();
695        m.insert("@@native".into(), h.new_str("URLSearchParams"));
696        m.insert("@@pairs".into(), arr);
697        h.new_object(m)
698    })
699}
700
701/// Read the ordered `(key, value)` pairs out of a `URLSearchParams`.
702fn pairs_of(recv: &Value) -> Vec<(String, String)> {
703    with_host(|h| {
704        let items: Vec<Value> = match h.get(recv) {
705            Some(JsObj::Object(p)) => match p.get("@@pairs").and_then(|a| h.get(a)) {
706                Some(JsObj::Array(items)) => items.clone(),
707                _ => Vec::new(),
708            },
709            _ => Vec::new(),
710        };
711        items
712            .iter()
713            .map(|it| match h.get(it) {
714                Some(JsObj::Array(kv)) => {
715                    let kv = kv.clone();
716                    let k = kv.first().map(|x| h.str_of(x)).unwrap_or_default();
717                    let v = kv.get(1).map(|x| h.str_of(x)).unwrap_or_default();
718                    (k, v)
719                }
720                _ => (h.str_of(it), String::new()),
721            })
722            .collect()
723    })
724}
725
726/// Overwrite a `URLSearchParams`' backing `@@pairs` array.
727fn set_pairs(recv: &Value, pairs: &[(String, String)]) {
728    with_host(|h| {
729        let items: Vec<Value> = pairs
730            .iter()
731            .map(|(k, v)| {
732                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
733                h.new_array(kv)
734            })
735            .collect();
736        let arr = h.new_array(items);
737        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
738            p.insert("@@pairs".into(), arr);
739        }
740    });
741}
742
743/// `new URLSearchParams([init])` — from a query string, an object, an iterable of
744/// `[key, value]` pairs, another `URLSearchParams`, or empty.
745pub fn construct_search_params(args: &[Value]) -> Result<Value, String> {
746    let pairs = match args.first() {
747        None => Vec::new(),
748        Some(v) if matches!(v, Value::Undef) || with_host(|h| h.is_null(v)) => Vec::new(),
749        Some(v) => pairs_from_init(v),
750    };
751    Ok(make_search_params(&pairs))
752}
753
754fn pairs_from_init(v: &Value) -> Vec<(String, String)> {
755    // Copy of another URLSearchParams.
756    if super::native_tag(v).as_deref() == Some("URLSearchParams") {
757        return pairs_of(v);
758    }
759    // Query string (a leading `?` is stripped, matching the URL/WHATWG parser).
760    if let Some(s) = with_host(|h| h.as_str(v)) {
761        return parse_query(s.strip_prefix('?').unwrap_or(&s));
762    }
763    with_host(|h| match h.get(v) {
764        // Iterable of `[key, value]` pairs.
765        Some(JsObj::Array(items)) => {
766            let items = items.clone();
767            items
768                .iter()
769                .map(|it| match h.get(it) {
770                    Some(JsObj::Array(kv)) => {
771                        let kv = kv.clone();
772                        let k = kv.first().map(|x| h.str_of(x)).unwrap_or_default();
773                        let val = kv.get(1).map(|x| h.str_of(x)).unwrap_or_default();
774                        (k, val)
775                    }
776                    _ => (h.str_of(it), String::new()),
777                })
778                .collect()
779        }
780        // Plain object: own enumerable entries (hidden `@@` keys excluded).
781        Some(JsObj::Object(p)) => {
782            let entries: Vec<(String, Value)> = p
783                .iter()
784                .filter(|(k, _)| !k.starts_with("@@"))
785                .map(|(k, val)| (k.clone(), val.clone()))
786                .collect();
787            entries
788                .into_iter()
789                .map(|(k, val)| (k, h.str_of(&val)))
790                .collect()
791        }
792        _ => Vec::new(),
793    })
794}
795
796/// `URLSearchParams` instance methods.
797pub fn search_params_call(recv: &Value, method: &str, args: &[Value]) -> Result<Value, String> {
798    match method {
799        "get" => {
800            let name = arg_str(args, 0);
801            match pairs_of(recv).into_iter().find(|(k, _)| *k == name) {
802                Some((_, v)) => Ok(with_host(|h| h.new_str(v))),
803                None => Ok(with_host(|h| h.null())),
804            }
805        }
806        "getAll" => {
807            let name = arg_str(args, 0);
808            let vals: Vec<String> = pairs_of(recv)
809                .into_iter()
810                .filter(|(k, _)| *k == name)
811                .map(|(_, v)| v)
812                .collect();
813            Ok(with_host(|h| {
814                let items = vals.into_iter().map(|v| h.new_str(v)).collect();
815                h.new_array(items)
816            }))
817        }
818        "has" => {
819            let name = arg_str(args, 0);
820            let pairs = pairs_of(recv);
821            let found = if args.len() > 1 {
822                let val = arg_str(args, 1);
823                pairs.iter().any(|(k, v)| *k == name && *v == val)
824            } else {
825                pairs.iter().any(|(k, _)| *k == name)
826            };
827            Ok(Value::Bool(found))
828        }
829        "append" => {
830            let mut pairs = pairs_of(recv);
831            pairs.push((arg_str(args, 0), arg_str(args, 1)));
832            set_pairs(recv, &pairs);
833            Ok(Value::Undef)
834        }
835        "set" => {
836            let name = arg_str(args, 0);
837            let val = arg_str(args, 1);
838            let mut pairs = pairs_of(recv);
839            // Set the first pair named `name` to `val`, remove any others; append
840            // if none existed (WHATWG `set`).
841            let mut seen = false;
842            pairs.retain_mut(|(k, v)| {
843                if *k == name {
844                    if seen {
845                        false
846                    } else {
847                        *v = val.clone();
848                        seen = true;
849                        true
850                    }
851                } else {
852                    true
853                }
854            });
855            if !seen {
856                pairs.push((name, val));
857            }
858            set_pairs(recv, &pairs);
859            Ok(Value::Undef)
860        }
861        "delete" => {
862            let name = arg_str(args, 0);
863            let mut pairs = pairs_of(recv);
864            if args.len() > 1 {
865                let val = arg_str(args, 1);
866                pairs.retain(|(k, v)| !(*k == name && *v == val));
867            } else {
868                pairs.retain(|(k, _)| *k != name);
869            }
870            set_pairs(recv, &pairs);
871            Ok(Value::Undef)
872        }
873        "sort" => {
874            let mut pairs = pairs_of(recv);
875            // Stable sort by key, comparing UTF-16 code units (WHATWG `sort`).
876            pairs.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));
877            set_pairs(recv, &pairs);
878            Ok(Value::Undef)
879        }
880        "toString" => {
881            let s = pairs_of(recv)
882                .iter()
883                .map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
884                .collect::<Vec<_>>()
885                .join("&");
886            Ok(with_host(|h| h.new_str(s)))
887        }
888        "keys" => {
889            let pairs = pairs_of(recv);
890            Ok(with_host(|h| {
891                let items = pairs.into_iter().map(|(k, _)| h.new_str(k)).collect();
892                h.alloc(JsObj::Iter { items, idx: 0 })
893            }))
894        }
895        "values" => {
896            let pairs = pairs_of(recv);
897            Ok(with_host(|h| {
898                let items = pairs.into_iter().map(|(_, v)| h.new_str(v)).collect();
899                h.alloc(JsObj::Iter { items, idx: 0 })
900            }))
901        }
902        "entries" | "@@iterator" => {
903            let pairs = pairs_of(recv);
904            Ok(with_host(|h| {
905                let items = pairs
906                    .into_iter()
907                    .map(|(k, v)| {
908                        let kv = vec![h.new_str(k), h.new_str(v)];
909                        h.new_array(kv)
910                    })
911                    .collect();
912                h.alloc(JsObj::Iter { items, idx: 0 })
913            }))
914        }
915        "forEach" => {
916            let cb = args.first().cloned().unwrap_or(Value::Undef);
917            let this_arg = args.get(1).cloned();
918            // Materialize pairs (releasing the host borrow) before re-entrant invoke.
919            for (k, v) in pairs_of(recv) {
920                let (value, name) = with_host(|h| (h.new_str(v), h.new_str(k)));
921                crate::host::invoke(&cb, vec![value, name, recv.clone()], this_arg.clone())?;
922            }
923            Ok(Value::Undef)
924        }
925        _ => Err(crate::host::type_error(&format!(
926            "urlSearchParams.{method} is not a function"
927        ))),
928    }
929}
930
931/// Parse an `application/x-www-form-urlencoded` string into ordered pairs.
932fn parse_query(q: &str) -> Vec<(String, String)> {
933    q.split('&')
934        .filter(|s| !s.is_empty())
935        .map(|seg| match seg.split_once('=') {
936            Some((k, v)) => (form_decode(k), form_decode(v)),
937            None => (form_decode(seg), String::new()),
938        })
939        .collect()
940}
941
942/// Decode one `application/x-www-form-urlencoded` component (`+` → space,
943/// `%XX` → byte, then UTF-8 lossy).
944fn form_decode(s: &str) -> String {
945    let b = s.as_bytes();
946    let mut out: Vec<u8> = Vec::with_capacity(b.len());
947    let mut i = 0;
948    while i < b.len() {
949        match b[i] {
950            b'+' => {
951                out.push(b' ');
952                i += 1;
953            }
954            b'%' if i + 2 < b.len() => match (hex_val(b[i + 1]), hex_val(b[i + 2])) {
955                (Some(hi), Some(lo)) => {
956                    out.push((hi << 4) | lo);
957                    i += 3;
958                }
959                _ => {
960                    out.push(b'%');
961                    i += 1;
962                }
963            },
964            c => {
965                out.push(c);
966                i += 1;
967            }
968        }
969    }
970    String::from_utf8_lossy(&out).into_owned()
971}
972
973/// Encode one `application/x-www-form-urlencoded` component: space → `+`, the
974/// unreserved set `A-Za-z0-9 * - . _` verbatim, every other byte percent-encoded.
975fn form_encode(s: &str) -> String {
976    let mut out = String::with_capacity(s.len());
977    for &b in s.as_bytes() {
978        match b {
979            b' ' => out.push('+'),
980            b'*' | b'-' | b'.' | b'_' => out.push(b as char),
981            _ if b.is_ascii_alphanumeric() => out.push(b as char),
982            _ => {
983                out.push('%');
984                out.push(hex_upper(b >> 4));
985                out.push(hex_upper(b & 0x0f));
986            }
987        }
988    }
989    out
990}
991
992fn hex_val(c: u8) -> Option<u8> {
993    match c {
994        b'0'..=b'9' => Some(c - b'0'),
995        b'a'..=b'f' => Some(c - b'a' + 10),
996        b'A'..=b'F' => Some(c - b'A' + 10),
997        _ => None,
998    }
999}
1000
1001fn hex_upper(n: u8) -> char {
1002    char::from_digit(n as u32, 16).unwrap().to_ascii_uppercase()
1003}