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