Skip to main content

logicaffeine_language/lgx/
css.rs

1//! Component-scoped CSS for the `Style:` section — a real tokenizer, not string surgery.
2//!
3//! [`scope`] stamps the component's scope class onto the SUBJECT compound of every complex
4//! selector (`.card h1` → `.card h1.lgx-todos`). Every element the component's own view
5//! renders carries that class (stamped at compile time), and a child component's elements
6//! do not, so a scoped rule can match only this component's elements — no wrapper element,
7//! no leakage in either direction. `:global(…)` opts a compound out of scoping.
8//!
9//! Group at-rules (`@media`, `@supports`, `@container`, `@layer`, `@scope`,
10//! `@starting-style`) have a nested RULE LIST for a body, so scoping recurses into them;
11//! every other block at-rule (`@keyframes`, `@font-face`, `@page`, `@property`, …) passes
12//! through VERBATIM (a keyframe's `0%`/`to` are offsets, not selectors), and a statement
13//! at-rule (`@import …;`) is untouched. A prefixed at-rule would be invalid CSS a browser
14//! silently drops — which is why the prelude of an at-rule is never treated as a selector.
15//! Declarations are copied byte for byte; comments are removed.
16//!
17//! Every offset an error reports is a byte offset into the stylesheet as written, at any depth:
18//! comments are blanked in place rather than cut out, and a group at-rule's body is parsed at its
19//! own position.
20
21/// Conditional-group at-rules whose body is a nested rule list.
22pub const GROUP_AT_RULES: &[&str] = &[
23    "media",
24    "supports",
25    "container",
26    "layer",
27    "scope",
28    "starting-style",
29];
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum CssErrorKind {
33    /// A `{` without its `}` or a `}` without its `{`.
34    UnbalancedBraces,
35    /// A `"…"`/`'…'` string or a `(`…`)` that never closes.
36    UnterminatedString,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct CssError {
41    pub kind: CssErrorKind,
42    /// Byte offset of the offending character.
43    pub at: usize,
44}
45
46impl CssError {
47    pub fn message(&self) -> String {
48        match self.kind {
49            CssErrorKind::UnbalancedBraces => "The braces in this stylesheet do not balance: every rule is `selector { declarations }` and every `{` needs its `}`.".into(),
50            CssErrorKind::UnterminatedString => "A string or `(` in this stylesheet never closes.".into(),
51        }
52    }
53}
54
55/// One item of a rule list.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum CssItem {
58    /// `prelude { body }` — a style rule; `body` is the raw declaration text.
59    Rule { prelude: String, body: String },
60    /// `@name prelude { body }` or `@name prelude;` (`body: None`). `body_at` is the byte offset
61    /// of the body's first character in the stylesheet. The body keeps its comments BLANKED (each
62    /// byte replaced by NUL) rather than removed, so the offsets of anything parsed out of it are
63    /// still offsets into the stylesheet; [`parse_items`] of the body reads it.
64    AtRule {
65        name: String,
66        prelude: String,
67        body: Option<String>,
68        body_at: usize,
69    },
70}
71
72/// Split a stylesheet into its top-level items (comments removed).
73pub fn parse_items(css: &str) -> Result<Vec<CssItem>, CssError> {
74    parse_items_at(css, 0)
75}
76
77/// [`parse_items`] for text that begins at byte `base` of the stylesheet, so every offset it
78/// reports is an offset into the whole stylesheet.
79fn parse_items_at(css: &str, base: usize) -> Result<Vec<CssItem>, CssError> {
80    let at = |e: CssError| CssError {
81        kind: e.kind,
82        at: e.at + base,
83    };
84    let items = parse_blanked(&blank_comments(css).map_err(at)?, base).map_err(at)?;
85    Ok(items.into_iter().map(|item| unblank(item)).collect())
86}
87
88/// The items of `stripped` (comments already blanked), with each at-rule body's offset absolute.
89fn parse_blanked(stripped: &str, base: usize) -> Result<Vec<CssItem>, CssError> {
90    let stripped = stripped.to_string();
91    let b = stripped.as_bytes();
92    let mut items = Vec::new();
93    let mut i = 0;
94    while i < b.len() {
95        while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == BLANK as u8) {
96            i += 1;
97        }
98        if i >= b.len() {
99            break;
100        }
101        if b[i] == b'}' {
102            return Err(CssError {
103                kind: CssErrorKind::UnbalancedBraces,
104                at: i,
105            });
106        }
107        // Prelude runs to the first top-level `{` (a rule / block at-rule) or `;` (a statement at-rule).
108        let prelude_start = i;
109        let mut depth_paren = 0usize;
110        loop {
111            if i >= b.len() {
112                // A prelude with no block and no `;` — a bare `@import …` at EOF, or garbage.
113                let text = trim_blank(&stripped[prelude_start..]);
114                if text.is_empty() {
115                    break;
116                }
117                if let Some(rest) = text.strip_prefix('@') {
118                    let (name, prelude) = split_at_name(rest);
119                    items.push(CssItem::AtRule {
120                        name,
121                        prelude,
122                        body: None,
123                        body_at: base + i,
124                    });
125                    break;
126                }
127                return Err(CssError {
128                    kind: CssErrorKind::UnbalancedBraces,
129                    at: prelude_start,
130                });
131            }
132            match b[i] {
133                b'"' | b'\'' => i = skip_string(&stripped, i)?,
134                b'(' => {
135                    depth_paren += 1;
136                    i += 1;
137                }
138                b')' => {
139                    depth_paren = depth_paren.saturating_sub(1);
140                    i += 1;
141                }
142                b';' if depth_paren == 0 => {
143                    let text = trim_blank(&stripped[prelude_start..i]);
144                    if let Some(rest) = text.strip_prefix('@') {
145                        let (name, prelude) = split_at_name(rest);
146                        items.push(CssItem::AtRule {
147                            name,
148                            prelude,
149                            body: None,
150                            body_at: base + i,
151                        });
152                    }
153                    // A stray `;` between rules is dropped.
154                    i += 1;
155                    break;
156                }
157                b'{' if depth_paren == 0 => {
158                    let prelude = trim_blank(&stripped[prelude_start..i]).to_string();
159                    let body_start = i + 1;
160                    let body_end = matching_brace(&stripped, i)?;
161                    let body = stripped[body_start..body_end].to_string();
162                    if let Some(rest) = prelude.strip_prefix('@') {
163                        let (name, prelude) = split_at_name(rest);
164                        items.push(CssItem::AtRule {
165                            name,
166                            prelude,
167                            body: Some(body),
168                            body_at: base + body_start,
169                        });
170                    } else {
171                        items.push(CssItem::Rule { prelude, body });
172                    }
173                    i = body_end + 1;
174                    break;
175                }
176                _ => i += 1,
177            }
178        }
179    }
180    Ok(items)
181}
182
183/// Scope a stylesheet to `class` (the bare class name, no leading dot).
184/// Copy the whole CHARACTER at byte `at` into `out`, and answer the next character's byte index.
185///
186/// ★ THIS SCANNER USED TO PUSH `b[i] as char`, WHICH IS LATIN-1. Every byte of a multi-byte
187/// character became a `char` of its own, so `.café` came out `.café` — VALID CSS that matches
188/// nothing. The stylesheet loads, the rule is emitted, the page renders unstyled, and nothing
189/// anywhere is an error; the class in the MARKUP is untouched, because markup is copied rather
190/// than scanned, so the two halves of one name silently disagree.
191///
192/// The structure this scanner looks for — `,` `(` `)` `[` `]` `"` `'` — is all ASCII, so it can
193/// keep indexing bytes; only the ACCUMULATION had to learn that a character is not a byte.
194fn push_char_at(src: &str, at: usize, out: &mut String) -> usize {
195    match src[at..].chars().next() {
196        Some(c) => {
197            out.push(c);
198            at + c.len_utf8()
199        }
200        None => at + 1,
201    }
202}
203
204pub fn scope(css: &str, class: &str) -> Result<String, CssError> {
205    let items = parse_items(css)?;
206    Ok(emit_items(&items, class)?.join(" "))
207}
208
209fn emit_items(items: &[CssItem], class: &str) -> Result<Vec<String>, CssError> {
210    let mut out = Vec::new();
211    for item in items {
212        match item {
213            CssItem::Rule { prelude, body } => {
214                let selectors = split_top_level(prelude, ',');
215                let scoped: Vec<String> = selectors
216                    .iter()
217                    .map(|s| scope_selector(s.trim(), class))
218                    .collect();
219                out.push(format!("{} {{{}}}", scoped.join(", "), body));
220            }
221            CssItem::AtRule {
222                name,
223                prelude,
224                body: None,
225                ..
226            } => {
227                out.push(if prelude.is_empty() {
228                    format!("@{name};")
229                } else {
230                    format!("@{name} {prelude};")
231                });
232            }
233            CssItem::AtRule {
234                name,
235                prelude,
236                body: Some(body),
237                body_at,
238            } => {
239                let head = if prelude.is_empty() {
240                    format!("@{name}")
241                } else {
242                    format!("@{name} {prelude}")
243                };
244                if GROUP_AT_RULES.contains(&name.to_ascii_lowercase().as_str()) {
245                    let inner = emit_items(&parse_items_at(body, *body_at)?, class)?;
246                    out.push(format!("{head} {{ {} }}", inner.join(" ")));
247                } else {
248                    out.push(format!("{head} {{{}}}", body.replace(BLANK, "")));
249                }
250            }
251        }
252    }
253    Ok(out)
254}
255
256/// Scope one complex selector: stamp `.class` on the last non-`:global` compound and unwrap
257/// every `:global(…)`.
258pub fn scope_selector(selector: &str, class: &str) -> String {
259    let parts = split_compounds(selector);
260    // Index of the compound to stamp: the last one that is not `:global(...)`.
261    let subject = parts.iter().rposition(|(compound, _)| !is_global(compound));
262    let mut out = String::new();
263    for (idx, (compound, combinator)) in parts.iter().enumerate() {
264        let text = if is_global(compound) {
265            unwrap_global(compound)
266        } else if Some(idx) == subject {
267            stamp(compound, class)
268        } else {
269            compound.clone()
270        };
271        out.push_str(&text);
272        if let Some(c) = combinator {
273            if c.trim().is_empty() {
274                out.push(' ');
275            } else {
276                out.push(' ');
277                out.push_str(c.trim());
278                out.push(' ');
279            }
280        }
281    }
282    out
283}
284
285fn is_global(compound: &str) -> bool {
286    compound.starts_with(":global(")
287}
288
289fn unwrap_global(compound: &str) -> String {
290    let inner = &compound[":global(".len()..];
291    match inner.rfind(')') {
292        Some(k) => format!("{}{}", &inner[..k], &inner[k + 1..]),
293        None => inner.to_string(),
294    }
295}
296
297/// Insert `.class` into a compound before its first pseudo (`:`/`::`) at the top level.
298fn stamp(compound: &str, class: &str) -> String {
299    if compound == "*" {
300        return format!(".{class}");
301    }
302    let b = compound.as_bytes();
303    let mut depth = 0i32;
304    let mut i = 0;
305    while i < b.len() {
306        match b[i] {
307            b'(' | b'[' => depth += 1,
308            b')' | b']' => depth -= 1,
309            b':' if depth == 0 => {
310                let (head, tail) = compound.split_at(i);
311                return format!("{head}.{class}{tail}");
312            }
313            _ => {}
314        }
315        i += 1;
316    }
317    format!("{compound}.{class}")
318}
319
320/// Split a complex selector into `(compound, combinator-after)` pairs. Combinators are
321/// whitespace (descendant), `>`, `+`, `~`; parens, brackets and strings are opaque.
322fn split_compounds(selector: &str) -> Vec<(String, Option<String>)> {
323    let b = selector.as_bytes();
324    let mut parts: Vec<(String, Option<String>)> = Vec::new();
325    let mut current = String::new();
326    let mut depth = 0i32;
327    let mut i = 0;
328    while i < b.len() {
329        let c = b[i];
330        if depth == 0
331            && (c == b' ' || c == b'\t' || c == b'\n' || c == b'>' || c == b'+' || c == b'~')
332        {
333            // Gather the whole combinator run.
334            let start = i;
335            while i < b.len()
336                && (b[i] == b' '
337                    || b[i] == b'\t'
338                    || b[i] == b'\n'
339                    || b[i] == b'>'
340                    || b[i] == b'+'
341                    || b[i] == b'~')
342            {
343                i += 1;
344            }
345            let comb = selector[start..i].to_string();
346            if current.is_empty() {
347                // Leading whitespace: ignore.
348                continue;
349            }
350            if i >= b.len() {
351                // Trailing whitespace: ignore.
352                break;
353            }
354            parts.push((std::mem::take(&mut current), Some(comb)));
355            continue;
356        }
357        match c {
358            b'(' | b'[' => depth += 1,
359            b')' | b']' => depth -= 1,
360            b'"' | b'\'' => {
361                let q = c;
362                current.push(c as char);
363                i += 1;
364                while i < b.len() && b[i] != q {
365                    i = push_char_at(selector, i, &mut current);
366                }
367            }
368            _ => {}
369        }
370        if i < b.len() {
371            i = push_char_at(selector, i, &mut current);
372        } else {
373            i += 1;
374        }
375    }
376    if !current.is_empty() {
377        parts.push((current, None));
378    }
379    parts
380}
381
382/// Split on a delimiter at paren/bracket/string depth zero.
383fn split_top_level(s: &str, delim: char) -> Vec<String> {
384    let mut out = Vec::new();
385    let mut current = String::new();
386    let mut depth = 0i32;
387    let mut chars = s.chars().peekable();
388    while let Some(c) = chars.next() {
389        match c {
390            '(' | '[' => {
391                depth += 1;
392                current.push(c);
393            }
394            ')' | ']' => {
395                depth -= 1;
396                current.push(c);
397            }
398            '"' | '\'' => {
399                current.push(c);
400                for n in chars.by_ref() {
401                    current.push(n);
402                    if n == c {
403                        break;
404                    }
405                }
406            }
407            _ if c == delim && depth == 0 => out.push(std::mem::take(&mut current)),
408            _ => current.push(c),
409        }
410    }
411    if !current.trim().is_empty() || out.is_empty() {
412        out.push(current);
413    }
414    out.into_iter().filter(|s| !s.trim().is_empty()).collect()
415}
416
417fn split_at_name(rest: &str) -> (String, String) {
418    let rest = rest.trim();
419    let end = rest
420        .find(|c: char| c.is_ascii_whitespace() || c == '(')
421        .unwrap_or(rest.len());
422    (rest[..end].to_string(), rest[end..].trim().to_string())
423}
424
425/// The byte a blanked comment is filled with. It is not whitespace and not structure, so every
426/// scan passes over it exactly as it passed over the comment, and [`unblank`] removes it from what
427/// is emitted — the output is what cutting the comment out would have produced.
428const BLANK: char = '\u{0}';
429
430/// The stylesheet with every comment's bytes replaced by [`BLANK`], one for one, so an offset in
431/// the result is the same offset in the source. An unterminated comment runs to the end.
432fn blank_comments(css: &str) -> Result<String, CssError> {
433    let mut out = String::with_capacity(css.len());
434    let b = css.as_bytes();
435    let mut i = 0;
436    while i < b.len() {
437        if css[i..].starts_with("/*") {
438            let end = css[i + 2..].find("*/").map_or(b.len(), |k| i + 2 + k + 2);
439            out.extend(std::iter::repeat(BLANK).take(end - i));
440            i = end;
441            continue;
442        }
443        if b[i] == b'"' || b[i] == b'\'' {
444            let end = skip_string(css, i)?;
445            out.push_str(&css[i..end]);
446            i = end;
447            continue;
448        }
449        let ch = css[i..].chars().next().unwrap();
450        out.push(ch);
451        i += ch.len_utf8();
452    }
453    Ok(out)
454}
455
456/// `s` without the whitespace and blanked comments around it.
457fn trim_blank(s: &str) -> &str {
458    s.trim_matches(|c: char| c.is_whitespace() || c == BLANK)
459}
460
461/// An item with the blanked comments taken out of its text.
462fn unblank(item: CssItem) -> CssItem {
463    let clean = |s: String| {
464        if s.contains(BLANK) {
465            s.replace(BLANK, "")
466        } else {
467            s
468        }
469    };
470    match item {
471        CssItem::Rule { prelude, body } => CssItem::Rule {
472            prelude: clean(prelude).trim().to_string(),
473            body: clean(body),
474        },
475        CssItem::AtRule {
476            name,
477            prelude,
478            body,
479            body_at,
480        } => CssItem::AtRule {
481            name: clean(name),
482            prelude: clean(prelude).trim().to_string(),
483            body,
484            body_at,
485        },
486    }
487}
488
489/// Skip the string opening at `at`; returns the index past its closing quote.
490fn skip_string(s: &str, at: usize) -> Result<usize, CssError> {
491    let b = s.as_bytes();
492    let q = b[at];
493    let mut i = at + 1;
494    while i < b.len() {
495        if b[i] == b'\\' {
496            i += 2;
497            continue;
498        }
499        if b[i] == q {
500            return Ok(i + 1);
501        }
502        if b[i] == b'\n' {
503            break;
504        }
505        i += 1;
506    }
507    Err(CssError {
508        kind: CssErrorKind::UnterminatedString,
509        at,
510    })
511}
512
513/// Index of the `}` matching the `{` at `open` (strings and parens opaque).
514fn matching_brace(s: &str, open: usize) -> Result<usize, CssError> {
515    let b = s.as_bytes();
516    let mut depth = 0usize;
517    let mut i = open;
518    while i < b.len() {
519        match b[i] {
520            b'"' | b'\'' => {
521                i = skip_string(s, i)?;
522                continue;
523            }
524            b'{' => depth += 1,
525            b'}' => {
526                depth -= 1;
527                if depth == 0 {
528                    return Ok(i);
529                }
530            }
531            _ => {}
532        }
533        i += 1;
534    }
535    Err(CssError {
536        kind: CssErrorKind::UnbalancedBraces,
537        at: open,
538    })
539}