Skip to main content

atmos/os_lib/js/builtins/
web_forms.rs

1// 分割: builtins.rs より機械的に移動(2026-07-16 リファクタ フェーズ2)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5// ===== Storage API(localStorage / sessionStorage)=====
6// localStorage: SylFS に "__localStorage__" として永続化。sessionStorage: in-memory のみ。
7pub(crate) static LOCAL_STORAGE: spin::Mutex<alloc::collections::BTreeMap<String, String>> =
8    spin::Mutex::new(alloc::collections::BTreeMap::new());
9pub(crate) static SESSION_STORAGE: spin::Mutex<alloc::collections::BTreeMap<String, String>> =
10    spin::Mutex::new(alloc::collections::BTreeMap::new());
11pub(crate) static LS_LOADED: core::sync::atomic::AtomicBool =
12    core::sync::atomic::AtomicBool::new(false);
13// `document.cookie`(丸ごと未対応だった)。実ブラウザのような per-origin の
14// 永続 cookie jar・`expires`/`max-age`/`path`/`domain`/`secure`/`samesite`
15// 属性は非対応の簡略実装(in-memory のみ、OS 再起動で消える)。読み書きされる
16// 属性部分は無視し、`name=value` の1件のみを反映する。
17pub(crate) static COOKIES: spin::Mutex<alloc::collections::BTreeMap<String, String>> =
18    spin::Mutex::new(alloc::collections::BTreeMap::new());
19
20pub(crate) const LS_FILENAME: &str = "__localStorage__";
21
22pub(crate) use crate::os_lib::web_storage::{ls_deserialize, ls_serialize};
23
24/// 起動後初回アクセス時に SylFS から localStorage を読み込む(FS 未マウントなら skip)。
25pub(crate) fn ls_ensure_loaded() {
26    if LS_LOADED.load(core::sync::atomic::Ordering::Relaxed) {
27        return;
28    }
29    LS_LOADED.store(true, core::sync::atomic::Ordering::Relaxed);
30    if !crate::kernel::fs::is_mounted() {
31        return;
32    }
33    if let Some(data) = crate::kernel::fs::read_file(LS_FILENAME) {
34        *LOCAL_STORAGE.lock() = ls_deserialize(&data);
35    }
36}
37
38/// localStorage の変更を SylFS に書き出す(FS 未マウントなら skip)。
39pub(crate) fn ls_flush() {
40    if !crate::kernel::fs::is_mounted() {
41        return;
42    }
43    let data = ls_serialize(&LOCAL_STORAGE.lock());
44    // 【2026-08-25 バグ修正】ここは FS_LOCK を取らずに **書いて** いた。
45    //
46    // この関数は JS の setter 経路(ブラウザ本体スレッド)から呼ばれるが、
47    // 画像・フォントのワーカースレッドは同時に `FS_LOCK` を取って
48    // ディスクキャッシュを書いている。番人なしで割り込むと
49    // 空きセクタビットマップが二重確保され、**関係ないファイルが壊れる**。
50    //
51    // 削除と保存はひと続きの書き換えなので、両方を同じ番人の下で行う
52    // (途中で手放すと、消えたまま保存されない瞬間が他スレッドから見える)。
53    let _fs_guard = crate::kernel::fs::FS_LOCK.lock();
54    let fs = crate::kernel::fs::get_fs();
55    // 【2026-07-28】ここで書き込み失敗を捨てると localStorage が**黙って
56    // 永続化されない**(次回起動で消えるのに、その場では成功して見える)。
57    // この関数は `()` を返す JS 側の setter 経路から呼ばれるため例外にはできないが、
58    // 失敗した事実は必ずログへ残す。
59    if let Err(e) = fs.delete_file(LS_FILENAME) {
60        // 未作成なら "file not found" は正常。それ以外は書き換え失敗の予兆。
61        if e != "file not found" {
62            crate::warn!("[JS] localStorage: failed to remove old store: {}", e);
63        }
64    }
65    if !data.is_empty() {
66        if let Err(e) = fs.save_file(LS_FILENAME, &data, "system") {
67            crate::error!("[JS] localStorage: failed to persist ({} bytes): {}", data.len(), e);
68        }
69    }
70}
71
72/// `document.cookie` の getter: `"name=value; name2=value2"` 形式で結合する。
73pub fn cookie_string() -> String {
74    COOKIES
75        .lock()
76        .iter()
77        .map(|(k, v)| alloc::format!("{}={}", k, v))
78        .collect::<alloc::vec::Vec<_>>()
79        .join("; ")
80}
81
82/// `document.cookie = "..."` の setter: 先頭の `name=value` セグメントのみを
83/// 反映し、`; expires=...`/`; path=...` 等の後続属性は無視する(`name` が空、
84/// または `=` が無い場合は何もしない)。
85pub fn cookie_set(s: &str) {
86    let first = s.split(';').next().unwrap_or("");
87    if let Some(eq) = first.find('=') {
88        let name = first.get(..eq).unwrap_or("").trim();
89        let value = first.get(eq + 1..).unwrap_or("").trim();
90        if !name.is_empty() {
91            COOKIES
92                .lock()
93                .insert(String::from(name), String::from(value));
94        }
95    }
96}
97
98/// `cookieStore.get(name)`(Cookie Store API。丸ごと未対応だった)。
99/// `document.cookie` と同じ `COOKIES` を参照する。見つからなければ仕様どおり
100/// `null`。
101pub(crate) fn cookie_store_get(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
102    let name = arg(a, 0).to_js_string();
103    let value = COOKIES.lock().get(&name).cloned();
104    let result = match value {
105        Some(v) => {
106            let o = Obj::plain();
107            o.borrow_mut().props.insert("name".into(), Value::str(&name));
108            o.borrow_mut().props.insert("value".into(), Value::str(v));
109            Value::Object(o)
110        }
111        None => Value::Null,
112    };
113    Ok(resolved_promise(it, result))
114}
115/// `cookieStore.getAll(name?)`(対応する複数形。`name` 省略時は全 Cookie)。
116pub(crate) fn cookie_store_get_all(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
117    let filter = if a.is_empty() { None } else { Some(arg(a, 0).to_js_string()) };
118    let items: Vec<Value> = COOKIES
119        .lock()
120        .iter()
121        .filter(|(k, _)| filter.as_ref().is_none_or(|f| *f == **k))
122        .map(|(k, v)| {
123            let o = Obj::plain();
124            o.borrow_mut().props.insert("name".into(), Value::str(k));
125            o.borrow_mut().props.insert("value".into(), Value::str(v));
126            Value::Object(o)
127        })
128        .collect();
129    Ok(resolved_promise(it, Value::Object(Obj::array(items))))
130}
131/// `cookieStore.set(name, value)`(`cookieStore.set({name, value})` 形式も
132/// 対応)。既存の `cookie_set` と同じ `COOKIES` へ書き込む。
133pub(crate) fn cookie_store_set(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
134    let first = arg(a, 0);
135    let (name, value) = if let Value::Object(_) = &first {
136        (
137            obj_prop(&first, "name").map(|v| v.to_js_string()).unwrap_or_default(),
138            obj_prop(&first, "value").map(|v| v.to_js_string()).unwrap_or_default(),
139        )
140    } else {
141        (first.to_js_string(), arg(a, 1).to_js_string())
142    };
143    if !name.is_empty() {
144        COOKIES.lock().insert(name, value);
145    }
146    Ok(resolved_promise(it, Value::Undefined))
147}
148/// `cookieStore.delete(name)`。
149pub(crate) fn cookie_store_delete(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
150    let name = arg(a, 0).to_js_string();
151    COOKIES.lock().remove(&name);
152    Ok(resolved_promise(it, Value::Undefined))
153}
154
155pub(crate) fn storage_map(tag: &str) -> &'static spin::Mutex<alloc::collections::BTreeMap<String, String>> {
156    if tag == "storage:session" {
157        &SESSION_STORAGE
158    } else {
159        &LOCAL_STORAGE
160    }
161}
162/// `kind` が `localStorage`/`sessionStorage` の `ObjKind::Host` なら、実データを保持する
163/// `storage_map` から現在のキー一覧を返す。`Object.keys(localStorage)`/`for...in localStorage`
164/// が常に空を返していたバグの修正用(実データは `.props` ではなく別マップにあるため)。
165pub fn storage_host_keys(kind: &ObjKind) -> Option<Vec<String>> {
166    if let ObjKind::Host(t) = kind {
167        if t.starts_with("storage:") {
168            if t.as_str() != "storage:session" {
169                ls_ensure_loaded();
170            }
171            return Some(storage_map(t).lock().keys().cloned().collect());
172        }
173    }
174    None
175}
176/// `kind` が `dataset:N`(`element.dataset`)の `ObjKind::Host` なら、対応する
177/// DOM 要素の `data-*` 属性一覧を camelCase キーへ変換して返す。
178/// `Object.keys(el.dataset)`/`for...in el.dataset` が実データ(`data-*` 属性、
179/// `.props` ではなく DOM 要素側の属性ストアにある)を素通しして常に空を
180/// 返していたバグの修正用(`storage_host_keys`(localStorage/sessionStorage)
181/// で修正済みの同型バグ)。
182pub fn dataset_host_keys(kind: &ObjKind, dom: &super::super::dom_bridge::DomBridge) -> Option<Vec<String>> {
183    dataset_host_entries(kind, dom).map(|entries| entries.into_iter().map(|(k, _)| k).collect())
184}
185/// `dataset_host_keys` と同じ判定条件で、camelCase キー→属性値のペア一覧を
186/// 返す(`Object.values`/`Object.entries` 用)。
187pub fn dataset_host_entries(
188    kind: &ObjKind,
189    dom: &super::super::dom_bridge::DomBridge,
190) -> Option<Vec<(String, String)>> {
191    if let ObjKind::Host(t) = kind {
192        if let Some(rest) = t.strip_prefix("dataset:") {
193            let idx: usize = rest.parse().ok()?;
194            return Some(
195                dom.attr_names(idx)
196                    .into_iter()
197                    .filter_map(|n| {
198                        crate::os_lib::dom_dataset::data_attr_to_camel(&n).map(|camel| {
199                            let val = dom.get_attr(idx, &n).unwrap_or_default();
200                            (camel, val)
201                        })
202                    })
203                    .collect(),
204            );
205        }
206    }
207    None
208}
209/// `data-user-id` → `userId`(W3C HTML5 §3.2.6.2 準拠の変換)。
210pub(crate) fn data_attr_to_camel(name: &str) -> String {
211    let full = if name.starts_with("data-") {
212        name.to_string()
213    } else {
214        alloc::format!("data-{}", name)
215    };
216    crate::os_lib::dom_dataset::data_attr_to_camel(&full).unwrap_or_else(|| name.to_string())
217}
218pub(crate) fn this_storage_tag(this: &Value) -> Option<String> {
219    if let Value::Object(o) = this {
220        if let ObjKind::Host(t) = &o.borrow().kind {
221            if t.starts_with("storage:") {
222                return Some(t.clone());
223            }
224        }
225    }
226    None
227}
228pub(crate) fn storage_get_item(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
229    if let Some(tag) = this_storage_tag(&this) {
230        if tag != "storage:session" {
231            ls_ensure_loaded();
232        }
233        let k = arg(a, 0).to_js_string();
234        return Ok(match storage_map(&tag).lock().get(&k) {
235            Some(v) => Value::str(v.clone()),
236            None => Value::Null,
237        });
238    }
239    Ok(Value::Null)
240}
241pub(crate) fn storage_set_item(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
242    if let Some(tag) = this_storage_tag(&this) {
243        if tag != "storage:session" {
244            ls_ensure_loaded();
245        }
246        storage_map(&tag)
247            .lock()
248            .insert(arg(a, 0).to_js_string(), arg(a, 1).to_js_string());
249        if tag != "storage:session" {
250            ls_flush();
251        }
252    }
253    Ok(Value::Undefined)
254}
255pub(crate) fn storage_remove_item(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
256    if let Some(tag) = this_storage_tag(&this) {
257        if tag != "storage:session" {
258            ls_ensure_loaded();
259        }
260        storage_map(&tag).lock().remove(&arg(a, 0).to_js_string());
261        if tag != "storage:session" {
262            ls_flush();
263        }
264    }
265    Ok(Value::Undefined)
266}
267pub(crate) fn storage_clear(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
268    if let Some(tag) = this_storage_tag(&this) {
269        storage_map(&tag).lock().clear();
270        if tag != "storage:session" {
271            ls_flush();
272        }
273    }
274    Ok(Value::Undefined)
275}
276pub(crate) fn storage_key(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
277    if let Some(tag) = this_storage_tag(&this) {
278        if tag != "storage:session" {
279            ls_ensure_loaded();
280        }
281        let i = arg(a, 0).to_number() as usize;
282        return Ok(match storage_map(&tag).lock().keys().nth(i) {
283            Some(k) => Value::str(k.clone()),
284            None => Value::Null,
285        });
286    }
287    Ok(Value::Null)
288}
289
290/// `storage:` host のプロパティ取得(メソッド / length / データキー)。
291pub fn storage_get_prop(tag: &str, key: &str) -> Value {
292    if tag != "storage:session" {
293        ls_ensure_loaded();
294    }
295    match key {
296        "getItem" => nv("getItem", storage_get_item),
297        "setItem" => nv("setItem", storage_set_item),
298        "removeItem" => nv("removeItem", storage_remove_item),
299        "clear" => nv("clear", storage_clear),
300        "key" => nv("key", storage_key),
301        "length" => Value::Number(storage_map(tag).lock().len() as f64),
302        _ => match storage_map(tag).lock().get(key) {
303            Some(v) => Value::str(v.clone()),
304            None => Value::Undefined,
305        },
306    }
307}
308/// `storage:` host へのプロパティ代入(length 以外は setItem 相当)。
309/// `delete localStorage.foo`/`delete sessionStorage.foo`(プロパティ直接削除。`removeItem`
310/// と同義だが、こちらの経路は Host プロキシが `.props` を持たないため以前は黙って
311/// 何も起きなかった)。
312pub fn storage_remove_prop(tag: &str, key: &str) {
313    if tag != "storage:session" {
314        ls_ensure_loaded();
315    }
316    storage_map(tag).lock().remove(key);
317    if tag != "storage:session" {
318        ls_flush();
319    }
320}
321pub fn storage_set_prop(tag: &str, key: &str, val: &Value) {
322    if key != "length" {
323        if tag != "storage:session" {
324            ls_ensure_loaded();
325        }
326        storage_map(tag)
327            .lock()
328            .insert(String::from(key), val.to_js_string());
329        if tag != "storage:session" {
330            ls_flush();
331        }
332    }
333}
334
335// ===== URLSearchParams =====
336// 内部状態は正規化済みクエリ文字列 `_query`("k=v&k=v"、常にエンコード済みの形で保持)。
337// get/set 等の外部インターフェースでは form-urlencoded(+ = 空白, %XX)でデコード/エンコードする。
338pub(crate) fn parse_query(q: &str) -> Vec<(String, String)> {
339    let q = q.strip_prefix('?').unwrap_or(q);
340    q.split('&')
341        .filter(|s| !s.is_empty())
342        .map(|pair| match pair.split_once('=') {
343            Some((k, v)) => (percent_decode(k, true), percent_decode(v, true)),
344            None => (percent_decode(pair, true), String::new()),
345        })
346        .collect()
347}
348/// form-urlencoded(RFC1738 系)でエンコード。空白は `+`、それ以外の非英数字は `%XX`。
349pub(crate) fn form_url_encode(s: &str) -> String {
350    let mut out = String::new();
351    for b in s.as_bytes() {
352        match *b {
353            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
354                out.push(*b as char);
355            }
356            b' ' => out.push('+'),
357            _ => out.push_str(&alloc::format!("%{:02X}", b)),
358        }
359    }
360    out
361}
362pub(crate) fn build_query(pairs: &[(String, String)]) -> String {
363    let parts: Vec<String> = pairs
364        .iter()
365        .map(|(k, v)| format!("{}={}", form_url_encode(k), form_url_encode(v)))
366        .collect();
367    parts.join("&")
368}
369/// `URLSearchParams.prototype.size`(ES2023)の実体。`interp.rs` のプロパティ
370/// getter から呼ばれる(USP は専用 ObjKind を持たない plain object 実装のため、
371/// Map/Set の `size` のように直接カウントを持たず、その都度クエリ文字列をパース
372/// して数える)。
373pub fn usp_entry_count(this: &Value) -> usize {
374    parse_query(&usp_query(this)).len()
375}
376pub(crate) fn usp_query(this: &Value) -> String {
377    if let Value::Object(o) = this {
378        return o
379            .borrow()
380            .props
381            .get("_query")
382            .map(|v| v.to_js_string())
383            .unwrap_or_default();
384    }
385    String::new()
386}
387pub(crate) fn usp_set_query(this: &Value, q: &str) {
388    if let Value::Object(o) = this {
389        let parent = {
390            let mut b = o.borrow_mut();
391            b.props.insert(String::from("_query"), Value::str(q));
392            b.props.get("_parent_url").cloned()
393        };
394        if let Some(Value::Object(po)) = parent {
395            let search_val = if q.is_empty() {
396                String::new()
397            } else {
398                alloc::format!("?{}", q)
399            };
400            po.borrow_mut()
401                .props
402                .insert(String::from("search"), Value::str(search_val));
403        }
404    }
405}
406pub(crate) fn usp_get(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
407    let k = arg(a, 0).to_js_string();
408    let pairs = parse_query(&usp_query(&this));
409    Ok(pairs
410        .iter()
411        .find(|(pk, _)| *pk == k)
412        .map(|(_, v)| Value::str(v.clone()))
413        .unwrap_or(Value::Null))
414}
415pub(crate) fn usp_get_all(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
416    let k = arg(a, 0).to_js_string();
417    let items: Vec<Value> = parse_query(&usp_query(&this))
418        .into_iter()
419        .filter(|(pk, _)| *pk == k)
420        .map(|(_, v)| Value::str(v))
421        .collect();
422    Ok(Value::Object(Obj::array(items)))
423}
424/// `has(name, value)`(ES2023 で追加された第2引数 `value`。省略時は名前だけで判定する
425/// 従来どおりの挙動だが、指定時は名前と値の両方が一致するエントリの有無を返す。
426/// 以前は第2引数を完全に無視しており、`has('a', 'wrong-value')` が誤って `true` に
427/// なるバグだった)。
428pub(crate) fn usp_has(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
429    let k = arg(a, 0).to_js_string();
430    let entries = parse_query(&usp_query(&this));
431    let found = if matches!(arg(a, 1), Value::Undefined) {
432        entries.iter().any(|(pk, _)| *pk == k)
433    } else {
434        let v = arg(a, 1).to_js_string();
435        entries.iter().any(|(pk, pv)| *pk == k && *pv == v)
436    };
437    Ok(Value::Bool(found))
438}
439pub(crate) fn usp_append(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
440    let mut pairs = parse_query(&usp_query(&this));
441    pairs.push((arg(a, 0).to_js_string(), arg(a, 1).to_js_string()));
442    usp_set_query(&this, &build_query(&pairs));
443    Ok(Value::Undefined)
444}
445pub(crate) fn usp_set(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
446    let k = arg(a, 0).to_js_string();
447    let mut pairs: Vec<(String, String)> = parse_query(&usp_query(&this))
448        .into_iter()
449        .filter(|(pk, _)| *pk != k)
450        .collect();
451    pairs.push((k, arg(a, 1).to_js_string()));
452    usp_set_query(&this, &build_query(&pairs));
453    Ok(Value::Undefined)
454}
455/// `delete(name, value)`(ES2023 で追加された第2引数。`has` と同じく省略時は名前だけ
456/// で全削除、指定時は名前と値の両方が一致するエントリのみ削除する。以前は第2引数を
457/// 完全に無視しており、値を問わず同名のエントリを全て消してしまうバグだった)。
458pub(crate) fn usp_delete(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
459    let k = arg(a, 0).to_js_string();
460    let value_filter = if matches!(arg(a, 1), Value::Undefined) {
461        None
462    } else {
463        Some(arg(a, 1).to_js_string())
464    };
465    let pairs: Vec<(String, String)> = parse_query(&usp_query(&this))
466        .into_iter()
467        .filter(|(pk, pv)| match &value_filter {
468            Some(v) => !(*pk == k && pv == v),
469            None => *pk != k,
470        })
471        .collect();
472    usp_set_query(&this, &build_query(&pairs));
473    Ok(Value::Undefined)
474}
475pub(crate) fn usp_to_string(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
476    Ok(Value::str(usp_query(&this)))
477}
478/// `URLSearchParams.prototype.sort()`(ES2020)が丸ごと未対応だった。全エントリを
479/// キー名でソートする(仕様どおり安定ソート。同名キー同士の相対順序は保持する)。
480pub(crate) fn usp_sort(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
481    let mut pairs = parse_query(&usp_query(&this));
482    pairs.sort_by(|a, b| a.0.cmp(&b.0));
483    usp_set_query(&this, &build_query(&pairs));
484    Ok(Value::Undefined)
485}
486/// `URLSearchParams` の既定イテレーション(`entries()` と同義)。`make_iterator()` で
487/// 本物のイテレータ形状(`.next()`)にして返す。
488pub(crate) fn usp_iterator(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
489    let items: Vec<Value> = parse_query(&usp_query(&this))
490        .into_iter()
491        .map(|(k, v)| Value::Object(Obj::array(alloc::vec![Value::str(k), Value::str(v)])))
492        .collect();
493    Ok(Value::Object(make_iterator(items)))
494}
495// `new URLSearchParams(init)` の `init` がオブジェクトの場合、以前は
496// `Value::to_js_string()`(`it: &mut Interp` を持たないため JS 側の
497// カスタム `toString`/イテレータを呼べない、純粋な既定変換)に丸投げしており、
498// 素の `Obj::plain()` はどれも `"[object Object]"` に落ちるため、仕様上有効な
499// 3つの `init` 形式が軒並み壊れていた:`sequence<sequence<USVString>>`
500// (配列のペア列。`to_js_string` は要素をカンマ結合するだけで `=`/`&` に
501// ならない)・`record<USVString, USVString>`(プレーンオブジェクト)・
502// 既存の `URLSearchParams`/`FormData` インスタンス(実ブラウザでは USVString
503// 変換が `toString()` を呼ぶため動く)。
504pub(crate) fn url_search_params_ctor(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
505    let init = arg(a, 0);
506    let q = match &init {
507        Value::Undefined | Value::Null => String::new(),
508        Value::Object(o) => {
509            let is_array = matches!(&o.borrow().kind, ObjKind::Array(_));
510            if is_array {
511                let pairs: alloc::vec::Vec<(String, String)> = this_items(&init)
512                    .iter()
513                    .filter_map(|item| {
514                        let inner = this_items(item);
515                        if inner.len() >= 2 {
516                            Some((inner[0].to_js_string(), inner[1].to_js_string()))
517                        } else {
518                            None
519                        }
520                    })
521                    .collect();
522                build_query(&pairs)
523            } else if let Some(existing) = o.borrow().props.get("_query") {
524                // 既存の URLSearchParams/FormData インスタンス由来。
525                existing.to_js_string()
526            } else {
527                let pairs: alloc::vec::Vec<(String, String)> = o
528                    .borrow()
529                    .props
530                    .iter()
531                    .map(|(k, v)| (k.clone(), v.to_js_string()))
532                    .collect();
533                build_query(&pairs)
534            }
535        }
536        other => other.to_js_string(),
537    };
538    let o = Obj::plain();
539    {
540        let mut b = o.borrow_mut();
541        b.props
542            .insert("_query".into(), Value::str(build_query(&parse_query(&q))));
543        // `.size`(ES2023)は USP 固有の仕様(`FormData` には無い)なので、同じ
544        // `_query` 内部表現を共有する `FormData` と区別するためのマーカー。
545        b.props.insert("_is_usp".into(), Value::Bool(true));
546        // `.sort()`(ES2020)は USP 固有の仕様(`FormData` には無い)。
547        b.props.insert("sort".into(), nv("sort", usp_sort));
548        b.props.insert("get".into(), nv("get", usp_get));
549        b.props.insert("getAll".into(), nv("getAll", usp_get_all));
550        b.props.insert("has".into(), nv("has", usp_has));
551        b.props.insert("append".into(), nv("append", usp_append));
552        b.props.insert("set".into(), nv("set", usp_set));
553        b.props.insert("delete".into(), nv("delete", usp_delete));
554        // `entries`/`keys`/`values`/`forEach`(`FormData` は既にあったが、実は同じ
555        // `_query` 内部表現を使うため丸ごと欠落していた `URLSearchParams` 側にも
556        // そのまま流用できる)。
557        b.props.insert("entries".into(), nv("entries", fd_entries));
558        b.props.insert("keys".into(), nv("keys", fd_keys));
559        b.props.insert("values".into(), nv("values", fd_values));
560        b.props.insert("forEach".into(), nv("forEach", fd_for_each));
561        // `Symbol.iterator` が未登録で `for (const [k,v] of params)`(`entries()` と
562        // 同義の既定イテレーション)が丸ごと非対応だったバグ。`entries()` 自体は
563        // 単純な配列を返す簡略実装だが、`for...of` の汎用カスタムイテレータ駆動
564        // 経路は `.next()` を持つ本物のイテレータ形状を要求するため、`make_iterator()`
565        // で包んだ別実装を用意する。
566        b.props.insert(
567            "Symbol(Symbol.iterator)".into(),
568            nv("[Symbol.iterator]", usp_iterator),
569        );
570        b.props
571            .insert("toString".into(), nv("toString", usp_to_string));
572    }
573    Ok(Value::Object(o))
574}
575
576// ===== FormData =====
577// URLSearchParams と同じ `_query` 内部表現を流用しつつ、entries/keys/values/forEach と
578// form 要素からの初期収集に対応する。値は文字列のみ(File は未対応)。
579pub(crate) fn fd_entries(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
580    let items: Vec<Value> = parse_query(&usp_query(&this))
581        .into_iter()
582        .map(|(k, v)| Value::Object(Obj::array(alloc::vec![Value::str(k), Value::str(v)])))
583        .collect();
584    Ok(Value::Object(Obj::array(items)))
585}
586pub(crate) fn fd_keys(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
587    let items: Vec<Value> = parse_query(&usp_query(&this))
588        .into_iter()
589        .map(|(k, _)| Value::str(k))
590        .collect();
591    Ok(Value::Object(Obj::array(items)))
592}
593pub(crate) fn fd_values(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
594    let items: Vec<Value> = parse_query(&usp_query(&this))
595        .into_iter()
596        .map(|(_, v)| Value::str(v))
597        .collect();
598    Ok(Value::Object(Obj::array(items)))
599}
600pub(crate) fn fd_for_each(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
601    let cb = arg(a, 0);
602    if matches!(cb, Value::Object(_)) {
603        for (k, v) in parse_query(&usp_query(&this)) {
604            it.call_value(
605                &cb,
606                Value::Undefined,
607                &[Value::str(v), Value::str(k), this.clone()],
608            )?;
609        }
610    }
611    Ok(Value::Undefined)
612}
613
614/// <form> 要素配下の送信対象コントロールから (name, value) を収集する。
615/// - name 属性が無いコントロールは無視(HTML 仕様)。
616/// - checkbox/radio は checked のときのみ、value(無ければ "on")を採用。
617/// - select は selected option(無ければ最初の option)の value。
618/// - disabled は除外。
619pub(crate) fn collect_form_data(it: &Interp, form_idx: usize) -> Vec<(String, String)> {
620    let dom = it.dom.borrow();
621    let mut out: Vec<(String, String)> = Vec::new();
622    // ドキュメント全体をルート(node[0])からドキュメント順で走査し、その中から
623    // このフォームに実際に属する(`associated_form`が`form_idx`を指す)
624    // コントロールだけを拾う。以前は`form_idx`を起点にした子孫走査のみだった
625    // ため、`<form>`の外に置かれた`<input form="...">`(HTML5仕様の「form
626    // owner」。`element.form`/`checkValidity()`側は既に対応済み)が送信データ
627    // から漏れていた。`associated_form`は`form`属性優先・無ければ祖先探索という
628    // 正しい優先順位を既に実装しているため、それをそのまま判定に使う。
629    let mut stack: Vec<usize> = alloc::vec![0usize];
630    let mut order: Vec<usize> = Vec::new();
631    while let Some(i) = stack.pop() {
632        order.push(i);
633        if let Some(n) = dom.nodes.get(i) {
634            // 子はドキュメント順を保つため逆順 push。
635            for &c in n.children.iter().rev() {
636                stack.push(c);
637            }
638        }
639    }
640    for &i in order.iter() {
641        let n = match dom.nodes.get(i) {
642            Some(n) => n,
643            None => continue,
644        };
645        if n.is_text || i == form_idx {
646            continue;
647        }
648        let tag = n.tag.as_str();
649        if tag != "input" && tag != "select" && tag != "textarea" {
650            continue;
651        }
652        if dom.associated_form(i) != Some(form_idx) {
653            continue;
654        }
655        // `<fieldset disabled>`祖先による暗黙の無効化も含めて判定する
656        // (詳細は`is_disabled`のコメント参照。以前は要素自身の`disabled`
657        // 属性しか見ておらず、無効化されたフィールドの値が送信データに
658        // 混入していた)。
659        if dom.is_disabled(i) {
660            continue;
661        }
662        let name = dom.get_attr(i, "name").unwrap_or_default();
663        if name.is_empty() {
664            continue;
665        }
666        match tag {
667            "input" => {
668                let ty = dom.get_attr(i, "type").unwrap_or_default().to_lowercase();
669                match ty.as_str() {
670                    "submit" | "button" | "reset" | "image" | "file" => { /* 送信対象外 */ }
671                    "checkbox" | "radio" => {
672                        if dom.has_attr(i, "checked") {
673                            let v = dom
674                                .get_attr(i, "value")
675                                .filter(|s| !s.is_empty())
676                                .unwrap_or_else(|| String::from("on"));
677                            out.push((name, v));
678                        }
679                    }
680                    _ => {
681                        out.push((name, dom.get_attr(i, "value").unwrap_or_default()));
682                    }
683                }
684            }
685            "textarea" => {
686                let v = dom
687                    .get_attr(i, "value")
688                    .unwrap_or_else(|| n.initial_text.clone());
689                out.push((name, v));
690            }
691            "select" => {
692                // `<select multiple>`(仕様上は選択された option 1つにつき1エントリを
693                // 送信する)が丸ごと未対応で、常に単一値しか収集しないバグだった
694                // (複数選択リストボックスという定番 UI パターンで送信データが欠落する)。
695                // `<optgroup>`配下の`<option>`が丸ごと見えなくなっていたバグ
696                // (`select_options`側で2026-07-17に修正済み。以前はここで
697                // 直接の子だけを`n.children.iter()`で見ており、`<select
698                // multiple>`の複数選択送信・フォールバック単一選択のどちらも
699                // カテゴリ分けされた`<optgroup>`内のoptionを見落としていた)。
700                // 既に`<optgroup>`対応済みの`select_options`を再利用する。
701                if dom.has_attr(i, "multiple") {
702                    for c in dom.select_options(i) {
703                        if dom.has_attr(c, "selected") {
704                            let v = dom.get_attr(c, "value").unwrap_or_else(|| {
705                                dom.nodes.get(c).map(|cn| cn.initial_text.clone()).unwrap_or_default()
706                            });
707                            out.push((name.clone(), v));
708                        }
709                    }
710                }
711                // select 自身の value 属性(レンダラが選択値を反映)を優先。
712                else if let Some(v) = dom.get_attr(i, "value").filter(|s| !s.is_empty()) {
713                    out.push((name, v));
714                } else {
715                    // フォールバック: HTML属性`selected`が付いたoptionがあれば
716                    // それを、無ければ仕様どおり構造上最初のoption(HTML標準の
717                    // 「selected未指定なら最初のoptionが暗黙に選択される」規則)
718                    // を使う。以前は`selected`属性を一切見ず常に構造上最初の
719                    // optionを使っていたため、`<option selected>`をHTMLの
720                    // マークアップだけで(JS側の`.value=`代入を経由せず)
721                    // 指定した場合、`FormData`送信値が実際の選択と食い違う
722                    // バグだった(`.value`ゲッターは正しく`selected`を見て
723                    // いたのに、こちらのフォールバックだけ非対称だった。
724                    // `<optgroup>`監査中に自己テストで発見。2026-07-17
725                    // 発見・実装)。
726                    let opts = dom.select_options(i);
727                    let chosen = opts
728                        .iter()
729                        .find(|&&o| dom.has_attr(o, "selected"))
730                        .or_else(|| opts.first());
731                    if let Some(&oi) = chosen {
732                        let v = dom.get_attr(oi, "value").unwrap_or_else(|| {
733                            dom.nodes
734                                .get(oi)
735                                .map(|cn| cn.initial_text.clone())
736                                .unwrap_or_default()
737                        });
738                        out.push((name, v));
739                    }
740                }
741            }
742            _ => {}
743        }
744    }
745    out
746}
747
748pub(crate) fn form_data_ctor(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
749    // 引数が form 要素なら、その送信対象コントロールから初期化。
750    let pairs = match this_dom_idx(&arg(a, 0)) {
751        Some(form_idx) => collect_form_data(it, form_idx),
752        None => Vec::new(),
753    };
754    let o = Obj::plain();
755    {
756        let mut b = o.borrow_mut();
757        b.props
758            .insert("_query".into(), Value::str(build_query(&pairs)));
759        b.props.insert("get".into(), nv("get", usp_get));
760        b.props.insert("getAll".into(), nv("getAll", usp_get_all));
761        b.props.insert("has".into(), nv("has", usp_has));
762        b.props.insert("append".into(), nv("append", usp_append));
763        b.props.insert("set".into(), nv("set", usp_set));
764        b.props.insert("delete".into(), nv("delete", usp_delete));
765        b.props.insert("entries".into(), nv("entries", fd_entries));
766        b.props.insert("keys".into(), nv("keys", fd_keys));
767        b.props.insert("values".into(), nv("values", fd_values));
768        b.props.insert("forEach".into(), nv("forEach", fd_for_each));
769        // `URLSearchParams` と同じ理由で `Symbol.iterator` が未登録だった
770        // (`for (const [k,v] of formData)` が丸ごと非対応)。
771        b.props.insert(
772            "Symbol(Symbol.iterator)".into(),
773            nv("[Symbol.iterator]", usp_iterator),
774        );
775        b.props
776            .insert("toString".into(), nv("toString", usp_to_string));
777    }
778    Ok(Value::Object(o))
779}
780
781/// new URL(href): location 風の成分 + searchParams を持つオブジェクト。
782// `new URL(url, base)` の第2引数 `base` が丸ごと無視されており、
783// `new URL('/path', 'https://example.com')` のような相対URL解決という URL API
784// の最も基本的な使い方が一切機能しないバグだった(絶対URL文字列をそのまま渡す
785// 使い方しか動いていなかった)。`fetch`/XHR の相対URL解決に既に使われている
786// 既存の `resolve_url(base, url)` をそのまま再利用する。
787/// `URL.canParse(url, base?)` の実体。この処理系の `new URL()`/`resolve_url`
788/// は寛容な簡略実装で不正な入力でも例外を投げないため、代わりに「解決結果が
789/// `scheme:` 形式を持つか」で判定する(WHATWG のスキーム構文
790/// `[a-zA-Z][a-zA-Z0-9+.-]*:` に準拠した簡易チェック)。
791pub(crate) fn url_can_parse(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
792    let raw = arg(a, 0).to_js_string();
793    let base = match arg(a, 1) {
794        Value::Undefined => String::new(),
795        v => v.to_js_string(),
796    };
797    let href = if base.is_empty() { raw } else { resolve_url(&base, &raw) };
798    Ok(Value::Bool(has_url_scheme(&href)))
799}
800/// `URL.parse(url, base?)`(ES2024/WHATWG。丸ごと未対応だった)。
801/// `try { return new URL(...) } catch { return null }` という定番イディオムを
802/// 1メソッドで完結させる(`URL.canParse` は真偽値のみを返す存在確認、
803/// こちらは実際に使う `URL` インスタンスまたは `null` を返す)。この処理系の
804/// `new URL()` 自体は不正な URL でも例外を投げず素通しする簡略実装のため、
805/// 有効性判定は `canParse` と同じ `has_url_scheme` を流用する。
806pub(crate) fn url_parse(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
807    let raw = arg(a, 0).to_js_string();
808    let base = match arg(a, 1) {
809        Value::Undefined => String::new(),
810        v => v.to_js_string(),
811    };
812    let href = if base.is_empty() { raw } else { resolve_url(&base, &raw) };
813    if !has_url_scheme(&href) {
814        return Ok(Value::Null);
815    }
816    url_ctor(it, Value::Undefined, a)
817}
818pub(crate) fn has_url_scheme(s: &str) -> bool {
819    if !s.contains(':') {
820        return false;
821    }
822    // 生の byte インデックスでの文字列スライス(`clippy::string_slice` で
823    // deny 対象)を避けるため、`:` の手前までを `chars()` で走査する。
824    let mut chars = s.chars().take_while(|&c| c != ':');
825    match chars.next() {
826        Some(c) if c.is_ascii_alphabetic() => {}
827        _ => return false,
828    }
829    chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
830}
831pub(crate) fn url_ctor(it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
832    let raw = arg(a, 0).to_js_string();
833    let base = if matches!(arg(a, 1), Value::Undefined) {
834        String::new()
835    } else {
836        arg(a, 1).to_js_string()
837    };
838    let href = if base.is_empty() {
839        raw
840    } else {
841        resolve_url(&base, &raw)
842    };
843    let comps = location_components(&href);
844    let search = comps
845        .iter()
846        .find(|(k, _)| *k == "search")
847        .map(|(_, v)| v.clone())
848        .unwrap_or_default();
849    let sp = url_search_params_ctor(it, Value::Undefined, &[Value::str(search)])?;
850    let o = Obj::plain();
851    if let Value::Object(sp_obj) = &sp {
852        sp_obj.borrow_mut().props.insert(String::from("_parent_url"), Value::Object(o.clone()));
853    }
854    {
855        let mut b = o.borrow_mut();
856        for (k, v) in &comps {
857            b.props.insert(String::from(*k), Value::str(v.clone()));
858        }
859        b.props.insert("searchParams".into(), sp);
860        // `location_to_string` を使い回していたため、`url.searchParams.set(...)` で
861        // クエリ文字列を変更しても `url.toString()`/暗黙の文字列化(`fetch(url)` 等)
862        // が構築時点の `href` スナップショットのまま変わらないバグだった。
863        b.props
864            .insert("toString".into(), nv("toString", url_to_string));
865        // `URL.prototype.toJSON()`(仕様上 `toString()` と同じ href 文字列列を返す
866        // だけの薄いエイリアス)が丸ごと未対応だった。`JSON.stringify` は `toJSON`
867        // メソッドの有無を見るため、無いと `URL` オブジェクトの内部プロパティ
868        // (`protocol`/`host` 等)がそのままシリアライズされてしまう
869        // (`JSON.stringify(new URL('https://a.com/'))` が `"https://a.com/"` では
870        // なくオブジェクト丸ごとの JSON になる)静かな破壊バグだった。
871        b.props
872            .insert("toJSON".into(), nv("toJSON", url_to_string));
873        // `href` は上のループで construction 時点の静的スナップショットとして
874        // `.props` に入れていたが、`toString()`/`toJSON()` は `searchParams` から
875        // 毎回再構成するため、`url.searchParams.set(...)` 後に `url.href` と
876        // `String(url)` が食い違う(`href` だけ古いまま)バグだった。`href` を
877        // 通常のデータプロパティからアクセサへ差し替え、`url_to_string` と同じ
878        // 再構成ロジックを都度呼ぶことで両者を一致させる。
879        b.accessors.insert(
880            String::from("href"),
881            super::super::value::Accessor {
882                get: Some(nv("href", url_to_string)),
883                // `url.href = '...'`(URL全体を再パースして全コンポーネントを
884                // 書き換える定番の再代入パターン)が丸ごと未対応だった。
885                // セッターが無いため代入が黙って無視され、`.protocol`/`.host`/
886                // `.pathname`等が古いまま食い違うバグ。既存の`url_ctor`と
887                // 全く同じ「`location_components()`で分解して`.props`へ詰め、
888                // `searchParams`を作り直す」ロジックを再利用する`url_set_href`
889                // をそのまま登録する。
890                set: Some(nv("href", url_set_href)),
891            },
892        );
893        b.props.shift_remove("href");
894        // `url.pathname = '...'`/`.search = '...'`/`.hash = '...'`(コンポーネント
895        // 単位の再代入。`href`丸ごと差し替えほど頻度は高くないが定番)が丸ごと
896        // 未対応だった。以前はこれらが単なるプレーンデータプロパティのままで、
897        // 代入自体は(プロパティ書き込みとして)通ってしまうが、(1) 先頭の
898        // `/`/`?`/`#`が正規化されない、(2) 特に`search`は`url.toString()`/
899        // `href`ゲッターが`searchParams`だけを見て`search`プロパティ自体は
900        // 一切読まないため、代入しても`toString()`/`href`に何も反映されない
901        // (`searchParams`が同期されない)、という2種類の静かな不整合があった。
902        // アクセサへ差し替え、正規化と(`search`のみ)`searchParams`再構築を行う。
903        for (key, getter, setter) in [
904            ("pathname", nv("pathname", url_get_pathname) , nv("pathname", url_set_pathname)),
905            ("search", nv("search", url_get_search), nv("search", url_set_search)),
906            ("hash", nv("hash", url_get_hash), nv("hash", url_set_hash)),
907        ] {
908            // `href`と異なりこちらは`.props[key]`をそのまま裏の実体として使う
909            // (アクセサはデータプロパティより優先されるため、`.props`側の
910            // 初期値を消さずに残しておいても`get`/`set`はちゃんと呼ばれる)。
911            // ここで`.props.remove`してしまうと、代入されるまでゲッターが
912            // 常に空文字列を返す新規バグを生むため、あえて残す。
913            b.accessors.insert(
914                String::from(key),
915                super::super::value::Accessor { get: Some(getter), set: Some(setter) },
916            );
917        }
918        // `protocol`/`host`/`hostname`/`port`の個別再代入も同じ理由で丸ごと
919        // 未対応だった。`origin`(`toString()`/`href`は使わないが仕様上の
920        // 読み取り専用プロパティとして参照される定番の値)は`protocol`/
921        // `host`から導出されるため、この4つのうちどれを代入しても
922        // `url_rebuild_host_and_origin`で`host`/`origin`を再計算し直す
923        // 必要がある。
924        for (key, getter, setter) in [
925            ("protocol", nv("protocol", url_get_protocol), nv("protocol", url_set_protocol)),
926            ("host", nv("host", url_get_host), nv("host", url_set_host)),
927            ("hostname", nv("hostname", url_get_hostname), nv("hostname", url_set_hostname)),
928            ("port", nv("port", url_get_port), nv("port", url_set_port)),
929        ] {
930            b.accessors.insert(
931                String::from(key),
932                super::super::value::Accessor { get: Some(getter), set: Some(setter) },
933            );
934        }
935    }
936    Ok(Value::Object(o))
937}
938
939/// `protocol`/`hostname`/`port`のいずれかが変更された後、それらから導出される
940/// `host`(`hostname`+`:`+`port`)と`origin`(`protocol`+`//`+`host`)を
941/// 再計算して`.props`へ書き戻す。`host`セッター自身は`hostname`/`port`へ分割
942/// した後にこの関数を呼ぶ。
943fn url_rebuild_host_and_origin(b: &mut Obj) {
944    let protocol = b.props.get("protocol").map(|v| v.to_js_string()).unwrap_or_default();
945    let hostname = b.props.get("hostname").map(|v| v.to_js_string()).unwrap_or_default();
946    let port = b.props.get("port").map(|v| v.to_js_string()).unwrap_or_default();
947    let host = if port.is_empty() { hostname.clone() } else { alloc::format!("{}:{}", hostname, port) };
948    let origin = if protocol.is_empty() { String::new() } else { alloc::format!("{}//{}", protocol, host) };
949    b.props.insert("host".into(), Value::str(host));
950    b.props.insert("origin".into(), Value::str(origin));
951}
952pub(crate) fn url_get_protocol(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
953    Ok(url_prop_or_empty(&this, "protocol"))
954}
955pub(crate) fn url_set_protocol(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
956    if let Value::Object(o) = &this {
957        let raw = arg(a, 0).to_js_string();
958        let v = if raw.ends_with(':') { raw } else { alloc::format!("{}:", raw) };
959        let mut b = o.borrow_mut();
960        b.props.insert("protocol".into(), Value::str(v));
961        url_rebuild_host_and_origin(&mut b);
962    }
963    Ok(Value::Undefined)
964}
965pub(crate) fn url_get_hostname(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
966    Ok(url_prop_or_empty(&this, "hostname"))
967}
968pub(crate) fn url_set_hostname(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
969    if let Value::Object(o) = &this {
970        let raw = arg(a, 0).to_js_string();
971        let mut b = o.borrow_mut();
972        b.props.insert("hostname".into(), Value::str(raw));
973        url_rebuild_host_and_origin(&mut b);
974    }
975    Ok(Value::Undefined)
976}
977pub(crate) fn url_get_port(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
978    Ok(url_prop_or_empty(&this, "port"))
979}
980pub(crate) fn url_set_port(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
981    if let Value::Object(o) = &this {
982        let raw = arg(a, 0).to_js_string();
983        let mut b = o.borrow_mut();
984        b.props.insert("port".into(), Value::str(raw));
985        url_rebuild_host_and_origin(&mut b);
986    }
987    Ok(Value::Undefined)
988}
989pub(crate) fn url_get_host(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
990    Ok(url_prop_or_empty(&this, "host"))
991}
992/// `url.host = 'hostname:port'`。`hostname`/`port`へ分割して保存し直す
993/// (`hostname`/`port`個別セッターと`host`の内部表現を一致させるため)。
994pub(crate) fn url_set_host(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
995    if let Value::Object(o) = &this {
996        let raw = arg(a, 0).to_js_string();
997        let (hostname, port) = match raw.split_once(':') {
998            Some((h, p)) => (String::from(h), String::from(p)),
999            None => (raw, String::new()),
1000        };
1001        let mut b = o.borrow_mut();
1002        b.props.insert("hostname".into(), Value::str(hostname));
1003        b.props.insert("port".into(), Value::str(port));
1004        url_rebuild_host_and_origin(&mut b);
1005    }
1006    Ok(Value::Undefined)
1007}
1008
1009fn url_prop_or_empty(this: &Value, key: &str) -> Value {
1010    if let Value::Object(o) = this {
1011        if let Some(v) = o.borrow().props.get(key) {
1012            return v.clone();
1013        }
1014    }
1015    Value::str("")
1016}
1017pub(crate) fn url_get_pathname(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
1018    Ok(url_prop_or_empty(&this, "pathname"))
1019}
1020pub(crate) fn url_set_pathname(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1021    if let Value::Object(o) = &this {
1022        let raw = arg(a, 0).to_js_string();
1023        let v = if raw.is_empty() || raw.starts_with('/') {
1024            raw
1025        } else {
1026            alloc::format!("/{}", raw)
1027        };
1028        o.borrow_mut().props.insert("pathname".into(), Value::str(v));
1029    }
1030    Ok(Value::Undefined)
1031}
1032pub(crate) fn url_get_hash(_it: &mut Interp, this: Value, _a: &[Value]) -> Result<Value, Value> {
1033    Ok(url_prop_or_empty(&this, "hash"))
1034}
1035pub(crate) fn url_set_hash(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1036    if let Value::Object(o) = &this {
1037        let raw = arg(a, 0).to_js_string();
1038        let frag = raw.strip_prefix('#').unwrap_or(&raw);
1039        let v = if frag.is_empty() { String::new() } else { alloc::format!("#{}", frag) };
1040        o.borrow_mut().props.insert("hash".into(), Value::str(v));
1041    }
1042    Ok(Value::Undefined)
1043}
1044/// `url.search`ゲッター。
1045/// 【安全性・2026-07-23】`toString()`/`href`と同じく`searchParams`から毎回
1046/// 再構成する版(`usp_to_string`経由で`searchParams`オブジェクトを参照する
1047/// 実装)を試した際、`u.searchParams.set(...); u.search`という呼び出し順序で
1048/// QEMU起動がハングする現象が一度観測されていたが、後日の徹底調査で「QEMU
1049/// 起動ハングはビルドごとのヒープレイアウトに依存する非決定的現象であり、
1050/// 特定のコード内容には紐づかない」と判明した(詳細はspec/walkthrough.md
1051/// 「QEMU起動ハングの二分探索」参照)。この修正自体は`url_to_string`と
1052/// 同じ既に安全実績のあるパターン(`o.borrow()`一度きり、別オブジェクトへの
1053/// 単純な委譲)であり内容起因のリスクは無いと判断し、再適用する。
1054pub(crate) fn url_get_search(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1055    if let Value::Object(o) = &this {
1056        let sp = o.borrow().props.get("searchParams").cloned();
1057        if let Some(sp_val) = sp {
1058            let query = usp_to_string(it, sp_val, a)?.to_js_string();
1059            let v = if query.is_empty() { String::new() } else { alloc::format!("?{}", query) };
1060            return Ok(Value::str(v));
1061        }
1062    }
1063    Ok(url_prop_or_empty(&this, "search"))
1064}
1065/// `url.search = '...'`。`searchParams`が「唯一の真実源」(`toString()`/`href`は
1066/// `search`プロパティ自体でなく`searchParams`から毎回再構成する)ため、
1067/// `search`プロパティの更新だけでなく`searchParams`自体も作り直す必要がある。
1068pub(crate) fn url_set_search(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1069    if let Value::Object(o) = &this {
1070        let raw = arg(a, 0).to_js_string();
1071        let query = raw.strip_prefix('?').unwrap_or(&raw).to_string();
1072        let sp = url_search_params_ctor(it, Value::Undefined, &[Value::str(query.clone())])?;
1073        let v = if query.is_empty() { String::new() } else { alloc::format!("?{}", query) };
1074        let mut b = o.borrow_mut();
1075        b.props.insert("search".into(), Value::str(v));
1076        b.props.insert("searchParams".into(), sp);
1077    }
1078    Ok(Value::Undefined)
1079}
1080
1081/// `url.href = newHref`(`URL.prototype.href`セッター)。`url_ctor`と同じ
1082/// 分解ロジックを、新規オブジェクトを作る代わりに既存の`this`へ上書き
1083/// する形で再利用する(オブジェクト identity は保つ)。
1084pub(crate) fn url_set_href(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1085    if let Value::Object(o) = &this {
1086        let new_href = arg(a, 0).to_js_string();
1087        let comps = location_components(&new_href);
1088        let search = comps
1089            .iter()
1090            .find(|(k, _)| *k == "search")
1091            .map(|(_, v)| v.clone())
1092            .unwrap_or_default();
1093        let sp = url_search_params_ctor(it, Value::Undefined, &[Value::str(search)])?;
1094        let mut b = o.borrow_mut();
1095        for (k, v) in &comps {
1096            b.props.insert(String::from(*k), Value::str(v.clone()));
1097        }
1098        b.props.insert("searchParams".into(), sp);
1099    }
1100    Ok(Value::Undefined)
1101}
1102
1103/// `URL.prototype.toString()`。`href` の静的スナップショットを返すのではなく、
1104/// `searchParams` の現在の内容から毎回クエリ文字列を再構成する(`protocol`/`host`/
1105/// `pathname`/`hash` は construction 時点のまま。この処理系は `pathname`/`hash` への
1106/// 代入自体は未対応なので、それらのプロパティ再代入との不整合は生じない)。
1107pub(crate) fn url_to_string(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1108    if let Value::Object(o) = &this {
1109        let (protocol, host, pathname, hash, sp) = {
1110            let b = o.borrow();
1111            (
1112                b.props.get("protocol").map(|v| v.to_js_string()).unwrap_or_default(),
1113                b.props.get("host").map(|v| v.to_js_string()).unwrap_or_default(),
1114                b.props.get("pathname").map(|v| v.to_js_string()).unwrap_or_default(),
1115                b.props.get("hash").map(|v| v.to_js_string()).unwrap_or_default(),
1116                b.props.get("searchParams").cloned(),
1117            )
1118        };
1119        let query = match sp {
1120            Some(sp_val) => usp_to_string(it, sp_val, a)?.to_js_string(),
1121            None => String::new(),
1122        };
1123        let mut s = alloc::format!("{}//{}{}", protocol, host, pathname);
1124        if !query.is_empty() {
1125            s.push('?');
1126            s.push_str(&query);
1127        }
1128        s.push_str(&hash);
1129        return Ok(Value::str(s));
1130    }
1131    Ok(Value::str(String::new()))
1132}
1133
1134// ===== URLPattern API (HTML Standard) =====
1135/// `new URLPattern(pattern, baseURL?)` または `new URLPattern({ pathname, hostname, ... })`
1136pub(crate) fn url_pattern_ctor(_it: &mut Interp, _t: Value, a: &[Value]) -> Result<Value, Value> {
1137    let arg0 = arg(a, 0);
1138    let mut pattern_pathname = String::from("*");
1139    let mut pattern_hostname = String::from("*");
1140    let mut pattern_protocol = String::from("*");
1141
1142    match &arg0 {
1143        Value::Object(o) => {
1144            let b = o.borrow();
1145            if let Some(p) = b.props.get("pathname") {
1146                pattern_pathname = p.to_js_string();
1147            }
1148            if let Some(h) = b.props.get("hostname") {
1149                pattern_hostname = h.to_js_string();
1150            }
1151            if let Some(pr) = b.props.get("protocol") {
1152                pattern_protocol = pr.to_js_string();
1153            }
1154        }
1155        _ => {
1156            let pat_str = arg0.to_js_string();
1157            if !pat_str.is_empty() {
1158                if pat_str.contains("://") {
1159                    let href = pat_str;
1160                    if let Some((proto, rest)) = href.split_once("://") {
1161                        pattern_protocol = proto.to_string();
1162                        match rest.split_once('/') {
1163                            Some((host, path)) => {
1164                                pattern_hostname = host.to_string();
1165                                pattern_pathname = alloc::format!("/{}", path);
1166                            }
1167                            None => {
1168                                pattern_hostname = rest.to_string();
1169                                pattern_pathname = String::from("/");
1170                            }
1171                        }
1172                    }
1173                } else {
1174                    pattern_pathname = pat_str;
1175                }
1176            }
1177        }
1178    }
1179
1180    let o = Obj::plain();
1181    {
1182        let mut b = o.borrow_mut();
1183        b.props.insert("pathname".into(), Value::str(pattern_pathname));
1184        b.props.insert("hostname".into(), Value::str(pattern_hostname));
1185        b.props.insert("protocol".into(), Value::str(pattern_protocol));
1186        b.props.insert("test".into(), nv("test", url_pattern_test));
1187        b.props.insert("exec".into(), nv("exec", url_pattern_exec));
1188    }
1189    Ok(Value::Object(o))
1190}
1191
1192/// パスパターン(`/users/:id`のような`:name`名前付きセグメントを含む)を
1193/// ターゲットパスとスラッシュ区切りのセグメント単位で照合する。
1194/// 以前は`match_pattern_part`がパターン全体を1個の文字列として扱い、
1195/// `pattern.starts_with(':')`(パターン全体の先頭が`:`の場合のみ)しか
1196/// 見ていなかったため、`/users/:id`のように名前付きセグメントが途中に
1197/// ある一般的なパターンが常にマッチ失敗し、かつ`groups`(`URLPattern`の
1198/// 名前付きキャプチャ結果、`res.pathname.groups.id`等でアクセスされる)
1199/// も一切生成されないバグだった。一致した場合は`(名前, 値)`のキャプチャ
1200/// 一覧を返す。
1201fn match_pathname_pattern(pattern: &str, target: &str) -> Option<Vec<(String, String)>> {
1202    if pattern == "*" || pattern.is_empty() {
1203        return Some(Vec::new());
1204    }
1205    if pattern == target {
1206        return Some(Vec::new());
1207    }
1208    if pattern.contains('*') && !pattern.contains(':') {
1209        let parts: Vec<&str> = pattern.split('*').collect();
1210        if parts.len() == 2 && target.starts_with(parts[0]) && target.ends_with(parts[1]) {
1211            return Some(Vec::new());
1212        }
1213        return None;
1214    }
1215    let pat_segs: Vec<&str> = pattern.split('/').collect();
1216    let tgt_segs: Vec<&str> = target.split('/').collect();
1217    if pat_segs.len() != tgt_segs.len() {
1218        return None;
1219    }
1220    let mut groups = Vec::new();
1221    for (p, t) in pat_segs.iter().zip(tgt_segs.iter()) {
1222        if let Some(name) = p.strip_prefix(':') {
1223            groups.push((name.to_string(), t.to_string()));
1224        } else if *p == "*" {
1225            // ワイルドカードセグメント: 何にでもマッチ
1226        } else if p != t {
1227            return None;
1228        }
1229    }
1230    Some(groups)
1231}
1232
1233fn match_pattern_part(pattern: &str, target: &str) -> bool {
1234    if pattern == "*" || pattern.is_empty() {
1235        return true;
1236    }
1237    if pattern == target {
1238        return true;
1239    }
1240    if pattern.starts_with(':') || pattern == "*" {
1241        return true;
1242    }
1243    if pattern.contains('*') {
1244        let parts: Vec<&str> = pattern.split('*').collect();
1245        if parts.len() == 2 {
1246            return target.starts_with(parts[0]) && target.ends_with(parts[1]);
1247        }
1248    }
1249    false
1250}
1251
1252pub(crate) fn url_pattern_test(it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1253    let res = url_pattern_exec(it, this, a)?;
1254    Ok(Value::Bool(!matches!(res, Value::Null)))
1255}
1256
1257pub(crate) fn url_pattern_exec(_it: &mut Interp, this: Value, a: &[Value]) -> Result<Value, Value> {
1258    let input = arg(a, 0);
1259    let (pat_pathname, pat_hostname, pat_protocol) = match &this {
1260        Value::Object(o) => {
1261            let b = o.borrow();
1262            (
1263                b.props.get("pathname").map(|v| v.to_js_string()).unwrap_or_else(|| "*".into()),
1264                b.props.get("hostname").map(|v| v.to_js_string()).unwrap_or_else(|| "*".into()),
1265                b.props.get("protocol").map(|v| v.to_js_string()).unwrap_or_else(|| "*".into()),
1266            )
1267        }
1268        _ => return Ok(Value::Null),
1269    };
1270
1271    let mut target_pathname = String::new();
1272    let mut target_hostname = String::new();
1273    let mut target_protocol = String::new();
1274
1275    match &input {
1276        Value::Object(o) => {
1277            let b = o.borrow();
1278            target_pathname = b.props.get("pathname").map(|v| v.to_js_string()).unwrap_or_default();
1279            target_hostname = b.props.get("hostname").map(|v| v.to_js_string()).unwrap_or_default();
1280            target_protocol = b.props.get("protocol").map(|v| v.to_js_string()).unwrap_or_default();
1281        }
1282        _ => {
1283            let url_str = input.to_js_string();
1284            if url_str.contains("://") {
1285                if let Some((proto, rest)) = url_str.split_once("://") {
1286                    target_protocol = proto.to_string();
1287                    match rest.split_once('/') {
1288                        Some((host, path)) => {
1289                            target_hostname = host.to_string();
1290                            target_pathname = alloc::format!("/{}", path);
1291                        }
1292                        None => {
1293                            target_hostname = rest.to_string();
1294                            target_pathname = String::from("/");
1295                        }
1296                    }
1297                }
1298            } else {
1299                target_pathname = url_str;
1300            }
1301        }
1302    }
1303
1304    let path_match = match_pathname_pattern(&pat_pathname, &target_pathname);
1305    let match_host = match_pattern_part(&pat_hostname, &target_hostname);
1306    let match_proto = match_pattern_part(&pat_protocol, &target_protocol);
1307
1308    if let (Some(groups), true, true) = (path_match, match_host, match_proto) {
1309        let result_obj = Obj::plain();
1310        {
1311            let mut b = result_obj.borrow_mut();
1312            let pathname_obj = Obj::plain();
1313            {
1314                let mut pb = pathname_obj.borrow_mut();
1315                pb.props.insert("input".into(), Value::str(target_pathname));
1316                let groups_obj = Obj::plain();
1317                {
1318                    let mut gb = groups_obj.borrow_mut();
1319                    for (name, value) in &groups {
1320                        gb.props.insert(name.clone(), Value::str(value.clone()));
1321                    }
1322                }
1323                pb.props.insert("groups".into(), Value::Object(groups_obj));
1324            }
1325            b.props.insert("pathname".into(), Value::Object(pathname_obj));
1326        }
1327        Ok(Value::Object(result_obj))
1328    } else {
1329        Ok(Value::Null)
1330    }
1331}
1332