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