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