Skip to main content

atmos/os_lib/css/
parser.rs

1// 分割: css.rs より機械的に移動(2026-07-16 リファクタ フェーズ3)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5pub fn parse_css(css: &str) -> StyleSheet {
6    // 1. CSSコメントを除去
7    let css_clean = strip_css_comments(css);
8
9    // 2. トップレベルの {} ブロックを正しく分割(ネスト対応)
10    let mut rules = Vec::new();
11    let mut keyframes: Vec<Keyframes> = Vec::new();
12    let mut counter_styles: Vec<CounterStyleDef> = Vec::new();
13    let mut font_faces: Vec<FontFaceDef> = Vec::new();
14    // `@layer` の宣言順(後で宣言されたレイヤーほど通常宣言の優先度が高い)。
15    // 同名レイヤーは初出の位置にマージする(仕様通り)。匿名レイヤー(名前無し)は
16    // 常に新規スロットを割り当てる。
17    let mut layer_names: Vec<String> = Vec::new();
18    let blocks = split_css_blocks(&css_clean);
19
20    for (selector_part, decl_part) in blocks {
21        let selector_part = selector_part.trim();
22        let decl_part = decl_part.trim();
23        if selector_part.is_empty() || decl_part.is_empty() {
24            continue;
25        }
26
27        if selector_part.starts_with('@') {
28            // @keyframes / @font-face → パースして保持。@charset → スキップ。
29            // @media → メディアクエリを評価して内部ルールを再帰パース。
30            let lower = selector_part.to_lowercase();
31            if lower.starts_with("@font-face") {
32                if let Some(def) = parse_font_face_def(decl_part) {
33                    font_faces.push(def);
34                }
35            } else if lower.starts_with("@keyframes") || lower.starts_with("@-webkit-keyframes") {
36                let name = selector_part
37                    .trim_start_matches("@-webkit-keyframes")
38                    .trim_start_matches("@keyframes")
39                    .trim()
40                    .to_string();
41                if !name.is_empty() {
42                    if let Some(kf) = parse_keyframes(&name, decl_part) {
43                        keyframes.push(kf);
44                    }
45                }
46            } else if lower.starts_with("@media") {
47                // 【2026-07-24変更】以前はここで`media_query_matches`が偽の場合
48                // ブロックを丸ごと除外していたが、CSS解析はコンテンツ変化時の
49                // 一度きりしか走らないため、その一度きりの評価時点のウィンドウ幅
50                // が実際の表示時と異なると(起動直後のプレースホルダー表示中等)、
51                // モバイルファーストCSSのナビゲーション等が永久に消えたままになる
52                // バグの原因だった。常に内部ルールを保持し、`media_query`として
53                // タグ付けしてカスケード時に動的判定する。
54                let query = selector_part
55                    .trim_start_matches("@media")
56                    .trim()
57                    .to_lowercase();
58                let inner_rules = parse_css_declarations(decl_part);
59                for mut r in inner_rules {
60                    r.media_query = Some(match r.media_query {
61                        Some(existing) => alloc::format!("{} and {}", existing, query),
62                        None => query.clone(),
63                    });
64                    rules.push(r);
65                }
66            } else if lower.starts_with("@container") {
67                // 【2026-09-03】`@container`(コンテナクエリ)。
68                //
69                // `@media` と同じく**解析時に評価して落とさない**。
70                // 基準がビューポートではなく問い合わせコンテナなので、
71                // コンテナ寸法はレイアウトが決まるまで分からず、解析時点の
72                // 評価は必ず誤る(`@media` を静的評価してページが白紙化した
73                // 2026-07-24 の失敗と同じ轍を踏まないこと)。
74                // ルールは常に保持し、カスケード時に動的判定する。
75                //
76                // 保持形式は `名前|条件`(名前省略時は `|条件`)。
77                let prelude = selector_part.trim_start_matches("@container").trim();
78                let (name, cond) =
79                    super::container_query::split_name_and_condition(prelude);
80                let tag = alloc::format!("{}|{}", name.unwrap_or_default(), cond);
81                let inner_rules = parse_css_declarations(decl_part);
82                for mut r in inner_rules {
83                    // 入れ子の `@container` は両方を満たす必要がある。
84                    r.container_query = Some(match r.container_query {
85                        Some(existing) => alloc::format!("{}&&{}", existing, tag),
86                        None => tag.clone(),
87                    });
88                    rules.push(r);
89                }
90            } else if lower.starts_with("@supports") {
91                // `not`/`and`/`or` の論理結合は評価するが、葉の `(property: value)`
92                // 自体は「このエンジンが構文解析できる宣言は常に対応している」扱いの
93                // 簡略方針(`:where()` を `:is()` と同一視する簡略化と同じ方針)。
94                // 以前は条件式を一切評価せず常に内部ルールを適用していたため、
95                // `@supports not (display: grid) { ...フォールバック... }` という
96                // 定番のプログレッシブエンハンスメント・パターンで、本来適用しては
97                // いけないフォールバックブロックまで常に適用されてしまうバグが
98                // あった。
99                let cond = selector_part.trim_start_matches("@supports").trim().to_lowercase();
100                if supports_condition_matches(&cond) {
101                    let inner_rules = parse_css_declarations(decl_part);
102                    rules.extend(inner_rules);
103                }
104            } else if lower.starts_with("@counter-style") {
105                let name = selector_part
106                    .trim_start_matches("@counter-style")
107                    .trim()
108                    .to_string();
109                if !name.is_empty() {
110                    if let Some(def) = parse_counter_style_def(&name, decl_part) {
111                        counter_styles.push(def);
112                    }
113                }
114            } else if lower.starts_with("@layer") {
115                // `@layer name { ... }` / 匿名 `@layer { ... }`。
116                // `@layer name1, name2;`(波括弧無しの順序宣言のみの構文)は
117                // split_css_blocks が `{}` ブロックしか認識しないため非対応。
118                let name = selector_part.trim_start_matches("@layer").trim().to_string();
119                let layer_idx = if name.is_empty() {
120                    layer_names.push(alloc::format!("<anon-{}>", layer_names.len()));
121                    layer_names.len() - 1
122                } else if let Some(pos) = layer_names.iter().position(|n| *n == name) {
123                    pos
124                } else {
125                    layer_names.push(name);
126                    layer_names.len() - 1
127                };
128                // 内容はトップレベルの CSS と同じ規則(ネスト・@media 等含む)で
129                // 再帰的にパースし、結果の全ルールにこのレイヤー番号を付与する。
130                let inner_sheet = parse_css(decl_part);
131                for mut r in inner_sheet.rules {
132                    r.layer = Some(layer_idx);
133                    rules.push(r);
134                }
135                keyframes.extend(inner_sheet.keyframes);
136                counter_styles.extend(inner_sheet.counter_styles);
137                font_faces.extend(inner_sheet.font_faces);
138            }
139            continue;
140        }
141
142        // トップレベルの comma だけで分割する(`:is(a, b)` 等の関数疑似クラス内の
143        // comma で誤分割してルール全体が壊れないように、括弧の深さを見る)。
144        let selectors: Vec<Selector> = split_top_level_commas(selector_part)
145            .into_iter()
146            .filter_map(|s| parse_selector(s.trim()))
147            .collect();
148        if selectors.is_empty() {
149            continue;
150        }
151
152        // CSS Nesting Module Level 1(`.a { color:red; &:hover{...} }`)の簡易実装。
153        // ネストしたルールは `&` を親セレクタのテキストに置換(`&` が無ければ親の後ろに
154        // 半角スペースで連結=子孫結合子)してデシュガーし、トップレベルの `rules` に
155        // 積む。`:is()` によるラップ等の厳密な仕様は行わず単純な文字列連結で近似する
156        // (本エンジンの `:is()`/`:where()` 簡略化方針と同じ割り切り)。
157        let parent_selector_texts: Vec<String> = split_top_level_commas(selector_part)
158            .into_iter()
159            .map(|s| s.trim().to_string())
160            .collect();
161        let declarations = parse_nested_body(&parent_selector_texts, decl_part, &mut rules, None);
162        if declarations.is_empty() {
163            continue;
164        }
165
166        rules.push(Rule {
167            selectors,
168            declarations,
169            layer: None,
170            media_query: None,
171            container_query: None,
172        });
173    }
174
175    StyleSheet {
176        rules,
177        keyframes,
178        counter_styles,
179        font_faces,
180    }
181}
182
183/// `@font-face { font-family: 'X'; src: url(a) format('woff2'), url(b) format('truetype'); }`の
184/// 宣言ブロックから`font-family`名と、読込可能な形式(TTF/OTF)を優先したURLを1件抽出する。
185fn parse_font_face_def(decl_part: &str) -> Option<FontFaceDef> {
186    let mut family = None;
187    let mut best_url: Option<String> = None;
188    let mut best_is_preferred_format = false;
189    // `font-weight` 未指定は CSS 既定の 400。
190    let mut weight: u16 = 400;
191    for decl in decl_part.split(';') {
192        let decl = decl.trim();
193        let Some((prop, val)) = decl.split_once(':') else {
194            continue;
195        };
196        let prop = prop.trim().to_lowercase();
197        let val = val.trim();
198        if prop == "font-family" {
199            let name = val.trim_matches(|c| c == '\'' || c == '"').trim();
200            if !name.is_empty() {
201                family = Some(name.to_string());
202            }
203        } else if prop == "font-weight" {
204            // `100`〜`900` の数値のみ扱う。`bold` は 700、`normal` は 400。
205            // 範囲指定(`400 900`)は先頭を採る。
206            let first = val.split_whitespace().next().unwrap_or("");
207            weight = match first.to_lowercase().as_str() {
208                "bold" => 700,
209                "normal" => 400,
210                other => other.parse::<u16>().unwrap_or(400),
211            };
212        } else if prop == "src" {
213            // カンマ区切りの複数候補(`local()`/`url() format()`)から
214            // `url(...)`とそれに続く`format('...')`ヒントをペアで抜き出す。
215            for part in val.split(',') {
216                let part = part.trim();
217                let Some(after_url) = part.strip_prefix("url(") else {
218                    continue;
219                };
220                let Some((url_raw, rest)) = after_url.split_once(')') else {
221                    continue;
222                };
223                let url = url_raw.trim_matches(|c| c == '\'' || c == '"').trim();
224                if url.is_empty() {
225                    continue;
226                }
227                let rest_lower = rest.to_lowercase();
228                let is_preferred = rest_lower.contains("truetype")
229                    || rest_lower.contains("opentype")
230                    || rest_lower.contains("woff2");
231                if best_url.is_none() || (is_preferred && !best_is_preferred_format) {
232                    best_url = Some(url.to_string());
233                    best_is_preferred_format = is_preferred;
234                }
235            }
236        }
237    }
238    let family = family?;
239    Some(FontFaceDef {
240        family,
241        src_url: best_url,
242        weight,
243    })
244}
245
246/// `@counter-style <name> { system: cyclic|numeric; symbols: a b c; suffix: ". "; }` をパースする。
247/// `symbols` はクォート付き文字列(`"a" "b"`)またはクォート無し空白区切り両方を許容する。
248/// `system` 未指定は `cyclic` 扱い。`negative`/`range`/`pad`/`prefix`/`fallback` は非対応で無視。
249pub(crate) fn parse_counter_style_def(name: &str, decl_part: &str) -> Option<CounterStyleDef> {
250    let mut system = String::from("cyclic");
251    let mut symbols: Vec<String> = Vec::new();
252    let mut suffix = String::from(". ");
253    for decl in decl_part.split(';') {
254        let mut parts = decl.splitn(2, ':');
255        let key = parts.next()?.trim().to_lowercase();
256        let Some(val) = parts.next() else { continue };
257        let val = val.trim();
258        match key.as_str() {
259            "system" => system = val.to_lowercase(),
260            "symbols" => {
261                symbols = split_top_level_commas(val)
262                    .into_iter()
263                    .flat_map(|seg| seg.split_whitespace())
264                    .map(|s| String::from(s.trim_matches(|c| c == '"' || c == '\'')))
265                    .filter(|s| !s.is_empty())
266                    .collect();
267            }
268            "suffix" => {
269                suffix = String::from(val.trim_matches(|c| c == '"' || c == '\''));
270            }
271            _ => {}
272        }
273    }
274    if symbols.is_empty() {
275        return None;
276    }
277    Some(CounterStyleDef {
278        name: String::from(name),
279        system,
280        symbols,
281        suffix,
282    })
283}
284
285/// @keyframes ブロックの中身(`0% { ... } 50% { ... } to { ... }`)をパース。
286pub(crate) fn parse_keyframes(name: &str, body: &str) -> Option<Keyframes> {
287    let blocks = split_css_blocks(body);
288    let mut frames: Vec<(f32, Vec<Declaration>)> = Vec::new();
289    for (sel, decl_part) in blocks {
290        // セレクタはカンマ区切りの複数オフセット(`0%, 50%` 等)を取り得る。
291        let decls = parse_declaration_list(&decl_part);
292        if decls.is_empty() {
293            continue;
294        }
295        for off_tok in sel.split(',') {
296            let tok = off_tok.trim().to_lowercase();
297            let offset = match tok.as_str() {
298                "from" => 0.0,
299                "to" => 1.0,
300                _ => match tok.trim_end_matches('%').trim().parse::<f32>() {
301                    Ok(p) => p / 100.0,
302                    Err(_) => continue,
303                },
304            };
305            if (0.0..=1.0).contains(&offset) {
306                frames.push((offset, decls.clone()));
307            }
308        }
309    }
310    if frames.is_empty() {
311        return None;
312    }
313    frames.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal));
314    Some(Keyframes {
315        name: name.to_string(),
316        frames,
317    })
318}
319
320/// `name: value; ...` 形式の宣言リストをパース(!important 対応)。
321pub(crate) fn parse_declaration_list(decl_part: &str) -> Vec<Declaration> {
322    decl_part
323        .split(';')
324        .filter_map(|decl| {
325            let mut kv = decl.splitn(2, ':');
326            let name = kv.next()?.trim();
327            let raw_value = kv.next()?.trim();
328            let (value, important) = match raw_value.rfind('!') {
329                Some(pos)
330                    if raw_value
331                        .get(pos + 1..)
332                        .unwrap_or("")
333                        .trim()
334                        .eq_ignore_ascii_case("important") =>
335                {
336                    (raw_value.get(..pos).unwrap_or("").trim(), true)
337                }
338                _ => (raw_value, false),
339            };
340            if name.is_empty() || value.is_empty() {
341                return None;
342            }
343            Some(Declaration {
344                name: String::from(name),
345                value: String::from(value),
346                important,
347            })
348        })
349        .collect()
350}
351
352/// CSSコメント `/* ... */` を除去する。
353/// バイト単位でコメント区切り(`/*`/`*/`、いずれも1バイトASCII)だけを検出し、それ以外の
354/// バイトはそのままコピーする。UTF-8の継続バイト(0x80-0xBF)がASCIIの`/`/`*`と衝突すること
355/// は無いため、この方式でマルチバイト文字(絵文字や `★` 等)を破壊せずに処理できる。
356/// 旧実装は `bytes[i] as char` で1バイトずつchar化しており、マルチバイト文字が
357/// Latin-1的に誤変換される致命的なバグがあった(例: `★`(E2 98 85) → 'â' + 制御文字2つ)。
358pub(crate) fn strip_css_comments(css: &str) -> String {
359    if !css.contains("/*") {
360        return css.to_string();
361    }
362    let bytes = css.as_bytes();
363    let mut out = Vec::with_capacity(css.len());
364    let mut i = 0;
365    while i < bytes.len() {
366        if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' {
367            // コメント開始 → */ まで飛ばす
368            i += 2;
369            while i + 1 < bytes.len() {
370                if bytes[i] == b'*' && bytes[i + 1] == b'/' {
371                    i += 2;
372                    break;
373                }
374                i += 1;
375            }
376        } else {
377            out.push(bytes[i]);
378            i += 1;
379        }
380    }
381    String::from_utf8(out).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
382}
383
384/// `@supports <condition>` の真偽を判定する。`not`/`and`/`or` の論理結合は
385/// 実際に評価するが、葉の `(property: value)`/`selector(...)` 自体は
386/// 「このエンジンが構文解析できる宣言は常に対応している」扱いとする簡略方針
387/// (`media_query_matches` の「未知のクエリは通過」方針と対になる設計)。
388pub fn supports_condition_matches(cond: &str) -> bool {
389    let cond = cond.trim();
390    if cond.is_empty() {
391        return true;
392    }
393    if cond.contains(" and ") && !cond.contains(" or ") {
394        return cond.split(" and ").all(|p| supports_condition_matches(p.trim()));
395    }
396    if cond.contains(" or ") && !cond.contains(" and ") {
397        return cond.split(" or ").any(|p| supports_condition_matches(p.trim()));
398    }
399    if let Some(rest) = cond.strip_prefix("not ") {
400        return !supports_condition_matches(rest.trim());
401    }
402    if let Some(stripped) = cond.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
403        return supports_condition_matches(stripped.trim());
404    }
405    // 葉: `property: value` や `selector(...)` 等。
406    true
407}
408
409/// トップレベルの CSS ブロックを `{` / `}` の深さを追跡して正しく分割する
410/// @media クエリが現在のビューポートにマッチするか評価。
411pub fn media_query_matches(query: &str) -> bool {
412    let vw = VIEWPORT_HINT_W.load(AtomicOrd::Relaxed);
413    let vh = VIEWPORT_HINT_H.load(AtomicOrd::Relaxed);
414    // "screen", "all" → 常にマッチ
415    if query == "screen" || query == "all" || query.is_empty() {
416        return true;
417    }
418    // "print" → スキップ
419    if query == "print" {
420        return false;
421    }
422    // "not print" → マッチ
423    if query == "not print" {
424        return true;
425    }
426    // カンマ区切り: いずれかがマッチすれば true
427    if query.contains(',') {
428        return query.split(',').any(|q| media_query_matches(q.trim()));
429    }
430    // " and " 区切りの複合条件(`screen and (min-width: 600px)` や、レンジ指定でよく使われる
431    // `(min-width: 600px) and (max-width: 900px)` 等): 全条件が真の場合のみマッチ。
432    // 旧実装は「screen」「and」という固定文字列を先頭からのみ1回ずつ trim_start_matches していたため、
433    // `screen and (...)` の直後に来る条件がそもそも判定式まで届かず、単一条件ですら常に
434    // 素通り(trueへフォールバック)してしまっていた。再帰的に分割することで両方のケースを直す。
435    if query.contains(" and ") {
436        return query.split(" and ").all(|part| media_query_matches(part.trim()));
437    }
438    // "screen" 単体の media type 修飾子を取り除く(`screen (min-width: 600px)` のような
439    // 稀な書き方にも耐性を持たせる)。
440    let inner = query.trim_start_matches("screen").trim();
441    // 括弧を除去して再評価
442    let cond = inner.trim_matches(|c| c == '(' || c == ')');
443    // max-width: Npx
444    if let Some(rest) = cond
445        .strip_prefix("max-width:")
446        .or_else(|| cond.strip_prefix("max-width :"))
447    {
448        let px: i32 = rest
449            .trim()
450            .trim_end_matches("px")
451            .trim()
452            .parse()
453            .unwrap_or(i32::MAX);
454        return vw <= px;
455    }
456    // min-width: Npx
457    if let Some(rest) = cond
458        .strip_prefix("min-width:")
459        .or_else(|| cond.strip_prefix("min-width :"))
460    {
461        let px: i32 = rest
462            .trim()
463            .trim_end_matches("px")
464            .trim()
465            .parse()
466            .unwrap_or(0);
467        return vw >= px;
468    }
469    // max-height: Npx
470    if let Some(rest) = cond
471        .strip_prefix("max-height:")
472        .or_else(|| cond.strip_prefix("max-height :"))
473    {
474        let px: i32 = rest
475            .trim()
476            .trim_end_matches("px")
477            .trim()
478            .parse()
479            .unwrap_or(i32::MAX);
480        return vh <= px;
481    }
482    // min-height: Npx
483    if let Some(rest) = cond
484        .strip_prefix("min-height:")
485        .or_else(|| cond.strip_prefix("min-height :"))
486    {
487        let px: i32 = rest
488            .trim()
489            .trim_end_matches("px")
490            .trim()
491            .parse()
492            .unwrap_or(0);
493        return vh >= px;
494    }
495    // orientation: landscape / portrait
496    if let Some(rest) = cond.strip_prefix("orientation:") {
497        let o = rest.trim();
498        if o == "landscape" {
499            return vw >= vh;
500        }
501        if o == "portrait" {
502            return vh > vw;
503        }
504    }
505    // `resolution`/`min-resolution`/`max-resolution`(HiDPI/retina 向けアセット
506    // 出し分けの定番。`@media (min-resolution: 2dppx)` 等)が丸ごと未対応で、
507    // `min-width`/`max-width` と同様の数値レンジ特性にもかかわらず末尾の
508    // 「未知のクエリは通過」フォールバックに落ちていた。この処理系のフレーム
509    // バッファは等倍密度(1dppx = 96dpi)固定として扱う。
510    let parse_dppx = |s: &str| -> Option<f32> {
511        let s = s.trim();
512        if let Some(n) = s.strip_suffix("dppx") {
513            n.trim().parse::<f32>().ok()
514        } else if let Some(n) = s.strip_suffix("dpi") {
515            n.trim().parse::<f32>().ok().map(|v| v / 96.0)
516        } else if let Some(n) = s.strip_suffix("dpcm") {
517            n.trim().parse::<f32>().ok().map(|v| v / 37.795_28)
518        } else if let Some(n) = s.strip_suffix('x') {
519            n.trim().parse::<f32>().ok()
520        } else {
521            None
522        }
523    };
524    const DEVICE_DPPX: f32 = 1.0;
525    if let Some(rest) = cond.strip_prefix("min-resolution:") {
526        return parse_dppx(rest).is_some_and(|r| DEVICE_DPPX >= r);
527    }
528    if let Some(rest) = cond.strip_prefix("max-resolution:") {
529        return parse_dppx(rest).is_some_and(|r| DEVICE_DPPX <= r);
530    }
531    if let Some(rest) = cond.strip_prefix("resolution:") {
532        return parse_dppx(rest).is_some_and(|r| (DEVICE_DPPX - r).abs() < 0.001);
533    }
534    // `monochrome`/`min-monochrome`/`max-monochrome`(モノクロフレームバッファの
535    // 階調ビット数。カラー表示なら常に0)も同型のフォールバックバグだった。
536    if let Some(rest) = cond.strip_prefix("min-monochrome:") {
537        return rest.trim().parse::<i32>().is_ok_and(|n| n <= 0);
538    }
539    if let Some(rest) = cond.strip_prefix("max-monochrome:") {
540        return rest.trim().parse::<i32>().is_ok_and(|n| n >= 0);
541    }
542    if let Some(rest) = cond.strip_prefix("monochrome:") {
543        return rest.trim().parse::<i32>() == Ok(0);
544    }
545    if cond.trim() == "monochrome" {
546        return false;
547    }
548    // prefers-color-scheme: dark / light(PREFERS_DARK は既定 light、システムテーマとの
549    // 実連動は未配線)。以前は未知クエリとして常にマッチしており、dark/light 両方の
550    // 上書きルールが同時に適用されてしまう不具合があったため、明示的に判定する。
551    if let Some(rest) = cond.strip_prefix("prefers-color-scheme:") {
552        let scheme = rest.trim();
553        let is_dark = PREFERS_DARK.load(AtomicOrd::Relaxed);
554        if scheme == "dark" {
555            return is_dark;
556        }
557        if scheme == "light" {
558            return !is_dark;
559        }
560    }
561    // prefers-reduced-motion: この処理系にアニメーション抑制機構は無いため常に
562    // no-preference(reduce しない)として扱う。
563    if let Some(rest) = cond.strip_prefix("prefers-reduced-motion:") {
564        let pref = rest.trim();
565        return pref == "no-preference";
566    }
567    // `prefers-contrast`(Media Queries Level 5)が丸ごと未対応で、`pointer`/
568    // `hover` 等と同じ「末尾の未知クエリ通過フォールバック」に落ち、
569    // `@media (prefers-contrast: more)` と `@media (prefers-contrast: less)`
570    // が同時に真になってしまうバグだった。この OS にはコントラスト設定切替が
571    // 無いため `prefers-reduced-motion` と同じ方針で常に no-preference。
572    if let Some(rest) = cond.strip_prefix("prefers-contrast:") {
573        let pref = rest.trim();
574        return pref == "no-preference";
575    }
576    // `pointer`/`any-pointer`/`hover`/`any-hover`(Media Queries Level 4)が
577    // 丸ごと未対応で、末尾の「未知のクエリは通過させる」フォールバックに落ちて
578    // `@media (pointer: fine)` と `@media (pointer: coarse)` の両方が同時に
579    // 真になってしまうバグだった(互いに矛盾するはずの分岐が同時適用される)。
580    // この OS は USB HID マウス+キーボードを前提とするため、精密ポインタ・
581    // ホバー可能として固定で答える。
582    if let Some(rest) = cond.strip_prefix("any-pointer:") {
583        return rest.trim() == "fine";
584    }
585    if let Some(rest) = cond.strip_prefix("pointer:") {
586        return rest.trim() == "fine";
587    }
588    if let Some(rest) = cond.strip_prefix("any-hover:") {
589        return rest.trim() == "hover";
590    }
591    if let Some(rest) = cond.strip_prefix("hover:") {
592        return rest.trim() == "hover";
593    }
594    // `pointer`/`hover` と同じ「未知のクエリは通過」フォールバックが原因で
595    // 相互排他のはずの分岐が同時に真になる同型のバグが `scripting`/
596    // `forced-colors`/`inverted-colors`/`color-gamut` にもあった。
597    // この処理系は常に JS を実行する(`<noscript>` を常に非表示にする既存の
598    // 判断と同じ真実源)・強制配色モードや色反転機能は無い・
599    // フレームバッファは sRGB 相当という前提で固定回答する。
600    if let Some(rest) = cond.strip_prefix("scripting:") {
601        return rest.trim() == "enabled";
602    }
603    if let Some(rest) = cond.strip_prefix("forced-colors:") {
604        return rest.trim() == "none";
605    }
606    if let Some(rest) = cond.strip_prefix("inverted-colors:") {
607        return rest.trim() == "none";
608    }
609    if let Some(rest) = cond.strip_prefix("color-gamut:") {
610        return rest.trim() == "srgb";
611    }
612    // 同じ「未知のクエリは通過」フォールバックが原因の同型バグの続き。
613    // `update`(画面更新能力。`none`/`slow`/`fast`)はこの処理系の VSync
614    // ページフリップ機構により `fast` として固定回答。`grid`(ビットマップ
615    // 画面か文字グリッド端末か。`0`/`1`)はフレームバッファを持つビットマップ
616    // 画面のため常に `0`。`prefers-reduced-transparency`/`prefers-reduced-data`
617    // はこの OS に対応する設定切替機構が無いため `prefers-reduced-motion`
618    // と同じ方針で常に `no-preference`。
619    if let Some(rest) = cond.strip_prefix("update:") {
620        return rest.trim() == "fast";
621    }
622    if let Some(rest) = cond.strip_prefix("grid:") {
623        return rest.trim() == "0";
624    }
625    if let Some(rest) = cond.strip_prefix("prefers-reduced-transparency:") {
626        return rest.trim() == "no-preference";
627    }
628    if let Some(rest) = cond.strip_prefix("prefers-reduced-data:") {
629        return rest.trim() == "no-preference";
630    }
631    true // それ以外の未知のクエリは通過させる
632}
633
634/// @media ブロック内の CSS を再帰的にパースして Rule リストを返す。
635pub(crate) fn parse_css_declarations(css: &str) -> Vec<Rule> {
636    let mut rules = Vec::new();
637    let blocks = split_css_blocks(css);
638    for (selector_part, decl_part) in blocks {
639        let selector_part = selector_part.trim();
640        let decl_part = decl_part.trim();
641        if selector_part.is_empty() || decl_part.is_empty() {
642            continue;
643        }
644        if selector_part.starts_with('@') {
645            continue;
646        }
647        // トップレベルの comma だけで分割する(`:is(a, b)` 等の関数疑似クラス内の
648        // comma で誤分割してルール全体が壊れないように、括弧の深さを見る)。
649        let selectors: Vec<Selector> = split_top_level_commas(selector_part)
650            .into_iter()
651            .filter_map(|s| parse_selector(s.trim()))
652            .collect();
653        if selectors.is_empty() {
654            continue;
655        }
656        // CSS Nesting は非対応。ネストしたルールの `{`/`}` や内部の `;` が同じブロック内の
657        // 正当な宣言を巻き添えで壊さないよう、`{` 以降(ネストしたルール全体)を切り捨てる
658        // (上の `parse_css` 本体の同種処理と同じ方針)。
659        let decl_part = match decl_part.find('{') {
660            Some(pos) => decl_part.get(..pos).unwrap_or(decl_part).trim(),
661            None => decl_part,
662        };
663        let declarations: Vec<Declaration> = decl_part
664            .split(';')
665            .filter_map(|decl| {
666                let mut kv = decl.splitn(2, ':');
667                let name = kv.next()?.trim();
668                let raw_value = kv.next()?.trim();
669                let (value, important) = match raw_value.rfind('!') {
670                    Some(pos)
671                        if raw_value
672                            .get(pos + 1..)
673                            .unwrap_or("")
674                            .trim()
675                            .eq_ignore_ascii_case("important") =>
676                    {
677                        (raw_value.get(..pos).unwrap_or("").trim(), true)
678                    }
679                    _ => (raw_value, false),
680                };
681                if name.is_empty() || value.is_empty() {
682                    return None;
683                }
684                Some(Declaration {
685                    name: String::from(name),
686                    value: String::from(value),
687                    important,
688                })
689            })
690            .collect();
691        if !declarations.is_empty() {
692            rules.push(Rule {
693                selectors,
694                declarations,
695                layer: None,
696                media_query: None,
697            container_query: None,
698            });
699        }
700    }
701    rules
702}
703
704pub(crate) fn split_css_blocks(css: &str) -> Vec<(String, String)> {
705    let mut blocks = Vec::new();
706    let mut depth = 0;
707    let mut selector_start = 0;
708    let mut block_start = 0;
709    let bytes = css.as_bytes();
710
711    for i in 0..bytes.len() {
712        match bytes[i] {
713            b'{' => {
714                if depth == 0 {
715                    block_start = i + 1;
716                }
717                depth += 1;
718            }
719            b'}' => {
720                depth -= 1;
721                if depth <= 0 {
722                    depth = 0;
723                    if let (Some(selector), Some(body)) = (
724                        css.get(selector_start..block_start.saturating_sub(1)),
725                        css.get(block_start..i),
726                    ) {
727                        blocks.push((selector.to_string(), body.to_string()));
728                    }
729                    selector_start = i + 1;
730                }
731            }
732            _ => {}
733        }
734    }
735
736    blocks
737}
738
739// DOM ツリーにスタイルを適用する