Skip to main content

noxid_css_ir/
lib.rs

1mod accuracy;
2
3use noxid_css_syntax::{AtRuleBlock, Declaration, ParsedStyle, Rule, parse};
4use noxid_ir::{SemanticId, SemanticProgram};
5use noxid_source::{Diagnostic, Span, json_escape};
6use std::collections::{BTreeMap, BTreeSet};
7
8#[derive(Clone, Debug, Default)]
9pub struct StyleProgram {
10    pub sheets: Vec<StyleSheet>,
11}
12
13#[derive(Clone, Debug)]
14pub struct StyleSheet {
15    pub id: SemanticId,
16    pub component: SemanticId,
17    pub component_name: String,
18    pub scope: String,
19    pub rules: Vec<StyleRule>,
20    pub span: Span,
21    pub cst_token_count: usize,
22}
23
24#[derive(Clone, Debug)]
25pub enum StyleRule {
26    Qualified {
27        id: SemanticId,
28        selector: String,
29        scoped_selector: String,
30        declarations: Vec<StyleDeclaration>,
31        span: Span,
32    },
33    AtRule {
34        id: SemanticId,
35        name: String,
36        prelude: String,
37        block: Option<StyleAtRuleBlock>,
38        span: Span,
39    },
40    Keyframes {
41        id: SemanticId,
42        name: String,
43        scoped_name: String,
44        vendor_prefix: Option<String>,
45        frames: Vec<Keyframe>,
46        span: Span,
47    },
48}
49
50#[derive(Clone, Debug)]
51pub enum StyleAtRuleBlock {
52    Rules(Vec<StyleRule>),
53    Declarations(Vec<StyleDeclaration>),
54}
55
56#[derive(Clone, Debug)]
57pub struct Keyframe {
58    pub selector: String,
59    pub declarations: Vec<StyleDeclaration>,
60    pub span: Span,
61}
62
63#[derive(Clone, Debug)]
64pub struct StyleDeclaration {
65    pub id: SemanticId,
66    pub name: String,
67    pub value: String,
68    pub important: bool,
69    pub span: Span,
70}
71
72#[derive(Clone, Debug)]
73pub struct LoweredStyles {
74    pub program: StyleProgram,
75    pub diagnostics: Vec<Diagnostic>,
76}
77
78/// How much of the CSS pipeline to run.
79#[derive(Clone, Copy, Debug)]
80pub struct StyleOptions {
81    /// Run the view-is-the-oracle accuracy checks (WO-55). The view is only
82    /// a trustworthy oracle when the rest of the program analysed cleanly:
83    /// an unresolved component invocation, for instance, drops its children
84    /// from the semantic view, and a selector naming one of those children
85    /// would then be refused for a reason that is not the author's mistake.
86    /// Callers that already hold errors pass `false`; the refusals come back
87    /// as soon as those errors are fixed, so nothing is let through silently.
88    pub check_accuracy: bool,
89}
90
91impl Default for StyleOptions {
92    fn default() -> Self {
93        Self {
94            check_accuracy: true,
95        }
96    }
97}
98
99pub fn lower(program: &SemanticProgram) -> LoweredStyles {
100    lower_with_options(
101        program,
102        &noxid_design_ir::DesignProgram::default(),
103        StyleOptions::default(),
104    )
105}
106
107pub fn lower_with_options(
108    program: &SemanticProgram,
109    design: &noxid_design_ir::DesignProgram,
110    options: StyleOptions,
111) -> LoweredStyles {
112    let vocabulary = accuracy::Vocabulary::from_design(design);
113    let mut sheets = Vec::new();
114    let mut diagnostics = Vec::new();
115    for component in &program.components {
116        let Some(style) = &component.style else {
117            continue;
118        };
119        let parsed = parse(&style.source, style.span);
120        diagnostics.extend(parsed.diagnostics.clone());
121        let scope = scope_id(component.id.as_str());
122        let mut keyframes = BTreeMap::new();
123        collect_keyframes(
124            &parsed,
125            &component.name,
126            &scope,
127            &mut keyframes,
128            &mut diagnostics,
129        );
130        let rules = {
131            let mut lowerer = Lowerer {
132                component: &component.name,
133                scope: &scope,
134                keyframes: &keyframes,
135                design,
136                vocabulary: &vocabulary,
137                check_accuracy: options.check_accuracy,
138                rule_ordinal: 0,
139                declaration_ordinal: 0,
140                diagnostics: &mut diagnostics,
141            };
142            lowerer.rules(&parsed.ast.rules)
143        };
144        // The view is the oracle: every accuracy refusal below is provable
145        // from this block plus this component's compiler-visible view tree.
146        if options.check_accuracy {
147            accuracy::check(component, &rules, &vocabulary, &mut diagnostics);
148        }
149        sheets.push(StyleSheet {
150            id: SemanticId::style(&component.name),
151            component: component.id.clone(),
152            component_name: component.name.clone(),
153            scope,
154            rules,
155            span: style.span,
156            cst_token_count: parsed.cst.tokens.len(),
157        });
158    }
159    LoweredStyles {
160        program: StyleProgram { sheets },
161        diagnostics,
162    }
163}
164
165struct Lowerer<'a> {
166    component: &'a str,
167    scope: &'a str,
168    keyframes: &'a BTreeMap<String, String>,
169    design: &'a noxid_design_ir::DesignProgram,
170    vocabulary: &'a accuracy::Vocabulary,
171    /// The strict width-query refusal is an accuracy refusal like the rest,
172    /// so it defers with them while the program holds other errors.
173    check_accuracy: bool,
174    rule_ordinal: usize,
175    declaration_ordinal: usize,
176    diagnostics: &'a mut Vec<Diagnostic>,
177}
178
179impl Lowerer<'_> {
180    fn rules(&mut self, rules: &[Rule]) -> Vec<StyleRule> {
181        rules.iter().map(|rule| self.rule(rule)).collect()
182    }
183
184    fn rule(&mut self, rule: &Rule) -> StyleRule {
185        match rule {
186            Rule::Qualified(rule) => {
187                self.rule_ordinal += 1;
188                let rule_ordinal = self.rule_ordinal;
189                let scoped_selector = match scope_selector_list(&rule.selector, self.scope) {
190                    Ok(selector) => selector,
191                    Err(message) => {
192                        self.diagnostics.push(
193                            Diagnostic::error(
194                                "CSS_INVALID_GLOBAL_SELECTOR",
195                                message,
196                                rule.selector_span,
197                            )
198                            .with_symbol(
199                                SemanticId::css_rule(self.component, rule_ordinal).to_string(),
200                            ),
201                        );
202                        rule.selector.clone()
203                    }
204                };
205                StyleRule::Qualified {
206                    id: SemanticId::css_rule(self.component, rule_ordinal),
207                    selector: rule.selector.clone(),
208                    scoped_selector,
209                    declarations: self.declarations(&rule.declarations, rule_ordinal),
210                    span: rule.span,
211                }
212            }
213            Rule::At(rule) if matches!(rule.name.as_str(), "keyframes" | "-webkit-keyframes") => {
214                let name = rule.prelude.trim().to_string();
215                let scoped_name = self
216                    .keyframes
217                    .get(&name)
218                    .cloned()
219                    .unwrap_or_else(|| format!("{name}--{}", self.scope));
220                let frames = match &rule.block {
221                    Some(AtRuleBlock::Keyframes(frames)) => frames
222                        .iter()
223                        .map(|frame| {
224                            self.rule_ordinal += 1;
225                            let ordinal = self.rule_ordinal;
226                            Keyframe {
227                                selector: frame.selector.clone(),
228                                declarations: self.declarations(&frame.declarations, ordinal),
229                                span: frame.span,
230                            }
231                        })
232                        .collect(),
233                    _ => vec![],
234                };
235                let id_name = if rule.name == "-webkit-keyframes" {
236                    format!("-webkit-{name}")
237                } else {
238                    name.clone()
239                };
240                StyleRule::Keyframes {
241                    id: SemanticId::css_keyframes(self.component, &id_name),
242                    name,
243                    scoped_name,
244                    vendor_prefix: (rule.name == "-webkit-keyframes").then(|| "-webkit-".into()),
245                    frames,
246                    span: rule.span,
247                }
248            }
249            Rule::At(rule) => {
250                self.rule_ordinal += 1;
251                let ordinal = self.rule_ordinal;
252                let block = match &rule.block {
253                    Some(AtRuleBlock::Rules(rules)) => {
254                        Some(StyleAtRuleBlock::Rules(self.rules(rules)))
255                    }
256                    Some(AtRuleBlock::Declarations(declarations)) => Some(
257                        StyleAtRuleBlock::Declarations(self.declarations(declarations, ordinal)),
258                    ),
259                    Some(AtRuleBlock::Keyframes(_)) | None => None,
260                };
261                // Strict mode is checked against what the author wrote,
262                // before the breakpoint token is resolved below.
263                if self.check_accuracy {
264                    accuracy::check_strict_media_prelude(
265                        self.vocabulary,
266                        &rule.name,
267                        &rule.prelude,
268                        rule.span,
269                        &SemanticId::css_at_rule(self.component, &rule.name, ordinal),
270                        self.diagnostics,
271                    );
272                }
273                StyleRule::AtRule {
274                    id: SemanticId::css_at_rule(self.component, &rule.name, ordinal),
275                    name: rule.name.clone(),
276                    // A media or container query is evaluated before custom
277                    // properties resolve, so `token(md)` must become the
278                    // declared width here. Leaving it for the `var()`
279                    // substitution would emit a query that never matches.
280                    prelude: self.resolve_prelude_tokens(&rule.prelude),
281                    block,
282                    span: rule.span,
283                }
284            }
285        }
286    }
287
288    /// A query prelude cannot use `var()`: media and container conditions are
289    /// evaluated before custom properties resolve. `token(name)` therefore
290    /// becomes the declared value in the prelude itself.
291    fn resolve_prelude_tokens(&self, prelude: &str) -> String {
292        let mut output = prelude.to_string();
293        for system in &self.design.systems {
294            for token in &system.tokens {
295                output = output.replace(&format!("token({})", token.name), &token.value);
296            }
297        }
298        output
299    }
300
301    fn declarations(
302        &mut self,
303        declarations: &[Declaration],
304        rule_ordinal: usize,
305    ) -> Vec<StyleDeclaration> {
306        declarations
307            .iter()
308            .map(|declaration| {
309                self.declaration_ordinal += 1;
310                let mut value = declaration.value.clone();
311                if matches!(
312                    declaration.name.to_ascii_lowercase().as_str(),
313                    "animation" | "animation-name" | "-webkit-animation" | "-webkit-animation-name"
314                ) {
315                    for (name, scoped) in self.keyframes {
316                        value = replace_identifier(&value, name, scoped);
317                    }
318                }
319                StyleDeclaration {
320                    id: SemanticId::css_declaration(
321                        self.component,
322                        rule_ordinal,
323                        self.declaration_ordinal,
324                    ),
325                    name: declaration.name.clone(),
326                    value,
327                    important: declaration.important,
328                    span: declaration.span,
329                }
330            })
331            .collect()
332    }
333}
334
335fn collect_keyframes(
336    parsed: &ParsedStyle,
337    component: &str,
338    scope: &str,
339    output: &mut BTreeMap<String, String>,
340    diagnostics: &mut Vec<Diagnostic>,
341) {
342    fn visit(
343        rules: &[Rule],
344        component: &str,
345        scope: &str,
346        output: &mut BTreeMap<String, String>,
347        seen: &mut BTreeSet<String>,
348        diagnostics: &mut Vec<Diagnostic>,
349    ) {
350        for rule in rules {
351            let Rule::At(rule) = rule else {
352                continue;
353            };
354            if matches!(rule.name.as_str(), "keyframes" | "-webkit-keyframes") {
355                let name = rule.prelude.trim();
356                if name.is_empty() || name.chars().any(char::is_whitespace) {
357                    diagnostics.push(
358                        Diagnostic::error(
359                            "CSS_INVALID_KEYFRAMES_NAME",
360                            "keyframes require one identifier name",
361                            rule.span,
362                        )
363                        .with_symbol(SemanticId::style(component).to_string()),
364                    );
365                } else if !seen.insert(format!("{}:{name}", rule.name)) {
366                    diagnostics.push(
367                        Diagnostic::error(
368                            "CSS_DUPLICATE_KEYFRAMES",
369                            format!("duplicate keyframes `{name}`"),
370                            rule.span,
371                        )
372                        .with_symbol(SemanticId::css_keyframes(component, name).to_string()),
373                    );
374                } else {
375                    output
376                        .entry(name.to_string())
377                        .or_insert_with(|| format!("{name}--{scope}"));
378                }
379            }
380            if let Some(AtRuleBlock::Rules(children)) = &rule.block {
381                visit(children, component, scope, output, seen, diagnostics);
382            }
383        }
384    }
385
386    visit(
387        &parsed.ast.rules,
388        component,
389        scope,
390        output,
391        &mut BTreeSet::new(),
392        diagnostics,
393    );
394}
395
396pub fn scope_id(component_id: &str) -> String {
397    format!("noxid-{:08x}", fnv1a(component_id) as u32)
398}
399
400fn fnv1a(value: &str) -> u64 {
401    value.bytes().fold(0xcbf29ce484222325, |hash, byte| {
402        (hash ^ byte as u64).wrapping_mul(0x100000001b3)
403    })
404}
405
406fn scope_selector_list(selector: &str, scope: &str) -> Result<String, String> {
407    split_top_level(selector, ',')
408        .into_iter()
409        .map(|selector| scope_selector(selector.trim(), scope))
410        .collect::<Result<Vec<_>, _>>()
411        .map(|selectors| selectors.join(", "))
412}
413
414fn scope_selector(selector: &str, scope: &str) -> Result<String, String> {
415    let (protected, globals) = protect_globals(selector)?;
416    let mut output = String::new();
417    let mut compound = String::new();
418    let mut paren = 0usize;
419    let mut bracket = 0usize;
420    let mut quote = None;
421    let chars = protected.chars().collect::<Vec<_>>();
422    let mut index = 0;
423    while index < chars.len() {
424        let ch = chars[index];
425        if let Some(active) = quote {
426            compound.push(ch);
427            if ch == '\\' && index + 1 < chars.len() {
428                index += 1;
429                compound.push(chars[index]);
430            } else if ch == active {
431                quote = None;
432            }
433            index += 1;
434            continue;
435        }
436        match ch {
437            '\'' | '"' => {
438                quote = Some(ch);
439                compound.push(ch);
440            }
441            '[' => {
442                bracket += 1;
443                compound.push(ch);
444            }
445            ']' => {
446                bracket = bracket.saturating_sub(1);
447                compound.push(ch);
448            }
449            '(' => {
450                paren += 1;
451                compound.push(ch);
452            }
453            ')' => {
454                paren = paren.saturating_sub(1);
455                compound.push(ch);
456            }
457            '>' | '+' | '~' if paren == 0 && bracket == 0 => {
458                output.push_str(&scope_compound(&compound, scope, &globals));
459                compound.clear();
460                output.push(ch);
461            }
462            value if value.is_whitespace() && paren == 0 && bracket == 0 => {
463                output.push_str(&scope_compound(&compound, scope, &globals));
464                compound.clear();
465                output.push(value);
466            }
467            _ => compound.push(ch),
468        }
469        index += 1;
470    }
471    output.push_str(&scope_compound(&compound, scope, &globals));
472    Ok(restore_globals(&output, &globals))
473}
474
475fn scope_compound(compound: &str, scope: &str, globals: &[String]) -> String {
476    let trimmed = compound.trim();
477    if trimmed.is_empty() {
478        return compound.to_string();
479    }
480    let local = globals
481        .iter()
482        .enumerate()
483        .fold(trimmed.to_string(), |value, (index, _)| {
484            value.replace(&global_marker(index), "")
485        });
486    if local.trim().is_empty() {
487        return compound.to_string();
488    }
489    let insert = first_pseudo_index(trimmed).unwrap_or(trimmed.len());
490    format!(
491        "{}[data-noxid-scope=\"{}\"]{}",
492        &trimmed[..insert],
493        scope,
494        &trimmed[insert..]
495    )
496}
497
498fn first_pseudo_index(selector: &str) -> Option<usize> {
499    let mut bracket = 0usize;
500    let mut quote = None;
501    let mut escaped = false;
502    for (index, ch) in selector.char_indices() {
503        if let Some(active) = quote {
504            if escaped {
505                escaped = false;
506            } else if ch == '\\' {
507                escaped = true;
508            } else if ch == active {
509                quote = None;
510            }
511            continue;
512        }
513        match ch {
514            '\'' | '"' => quote = Some(ch),
515            '[' => bracket += 1,
516            ']' => bracket = bracket.saturating_sub(1),
517            ':' if bracket == 0 => return Some(index),
518            _ => {}
519        }
520    }
521    None
522}
523
524fn protect_globals(selector: &str) -> Result<(String, Vec<String>), String> {
525    let mut output = String::new();
526    let mut globals = Vec::new();
527    let mut index = 0;
528    while let Some(relative) = selector[index..].find(":global(") {
529        let start = index + relative;
530        output.push_str(&selector[index..start]);
531        let content_start = start + ":global(".len();
532        let Some(end) = matching_paren(selector, content_start) else {
533            return Err("unterminated :global(...) selector".into());
534        };
535        let value = selector[content_start..end].trim();
536        if value.is_empty() {
537            return Err(":global(...) cannot be empty".into());
538        }
539        let marker = global_marker(globals.len());
540        globals.push(value.to_string());
541        output.push_str(&marker);
542        index = end + 1;
543    }
544    output.push_str(&selector[index..]);
545    Ok((output, globals))
546}
547
548fn matching_paren(value: &str, content_start: usize) -> Option<usize> {
549    let mut depth = 1usize;
550    let mut quote = None;
551    let mut escaped = false;
552    for (offset, ch) in value[content_start..].char_indices() {
553        if let Some(active) = quote {
554            if escaped {
555                escaped = false;
556            } else if ch == '\\' {
557                escaped = true;
558            } else if ch == active {
559                quote = None;
560            }
561            continue;
562        }
563        match ch {
564            '\'' | '"' => quote = Some(ch),
565            '(' => depth += 1,
566            ')' => {
567                depth -= 1;
568                if depth == 0 {
569                    return Some(content_start + offset);
570                }
571            }
572            _ => {}
573        }
574    }
575    None
576}
577
578fn global_marker(index: usize) -> String {
579    format!("__NOXID_GLOBAL_{index}__")
580}
581
582fn restore_globals(value: &str, globals: &[String]) -> String {
583    globals
584        .iter()
585        .enumerate()
586        .fold(value.to_string(), |output, (index, global)| {
587            output.replace(&global_marker(index), global)
588        })
589}
590
591fn split_top_level(value: &str, delimiter: char) -> Vec<&str> {
592    let mut values = Vec::new();
593    let mut start = 0;
594    let mut paren = 0usize;
595    let mut bracket = 0usize;
596    let mut quote = None;
597    let mut escaped = false;
598    for (index, ch) in value.char_indices() {
599        if let Some(active) = quote {
600            if escaped {
601                escaped = false;
602            } else if ch == '\\' {
603                escaped = true;
604            } else if ch == active {
605                quote = None;
606            }
607            continue;
608        }
609        match ch {
610            '\'' | '"' => quote = Some(ch),
611            '(' => paren += 1,
612            ')' => paren = paren.saturating_sub(1),
613            '[' => bracket += 1,
614            ']' => bracket = bracket.saturating_sub(1),
615            current if current == delimiter && paren == 0 && bracket == 0 => {
616                values.push(&value[start..index]);
617                start = index + ch.len_utf8();
618            }
619            _ => {}
620        }
621    }
622    values.push(&value[start..]);
623    values
624}
625
626fn replace_identifier(value: &str, name: &str, replacement: &str) -> String {
627    let mut output = String::new();
628    let mut index = 0;
629    let mut quote = None;
630    while index < value.len() {
631        let ch = value[index..].chars().next().unwrap_or('\0');
632        if let Some(active) = quote {
633            output.push(ch);
634            index += ch.len_utf8();
635            if ch == '\\' && index < value.len() {
636                let escaped = value[index..].chars().next().unwrap_or('\0');
637                output.push(escaped);
638                index += escaped.len_utf8();
639            } else if ch == active {
640                quote = None;
641            }
642            continue;
643        }
644        if matches!(ch, '\'' | '"') {
645            quote = Some(ch);
646            output.push(ch);
647            index += ch.len_utf8();
648            continue;
649        }
650        if is_identifier_char(ch) {
651            let start = index;
652            index += ch.len_utf8();
653            while index < value.len() && is_identifier_char(value[index..].chars().next().unwrap())
654            {
655                index += value[index..].chars().next().unwrap().len_utf8();
656            }
657            let word = &value[start..index];
658            output.push_str(if word == name { replacement } else { word });
659        } else {
660            output.push(ch);
661            index += ch.len_utf8();
662        }
663    }
664    output
665}
666
667fn is_identifier_char(ch: char) -> bool {
668    ch.is_alphanumeric() || matches!(ch, '-' | '_') || !ch.is_ascii()
669}
670
671impl StyleProgram {
672    pub fn to_json(&self) -> String {
673        format!(
674            "{{\"schemaVersion\":1,\"sheets\":[{}]}}",
675            self.sheets
676                .iter()
677                .map(StyleSheet::to_json)
678                .collect::<Vec<_>>()
679                .join(",")
680        )
681    }
682}
683
684impl StyleSheet {
685    fn to_json(&self) -> String {
686        format!(
687            "{{\"id\":\"{}\",\"component\":\"{}\",\"scope\":\"{}\",\"cstTokenCount\":{},\"rules\":[{}],\"span\":{}}}",
688            self.id,
689            self.component,
690            json_escape(&self.scope),
691            self.cst_token_count,
692            self.rules
693                .iter()
694                .map(StyleRule::to_json)
695                .collect::<Vec<_>>()
696                .join(","),
697            span_json(self.span)
698        )
699    }
700}
701
702impl StyleRule {
703    fn to_json(&self) -> String {
704        match self {
705            Self::Qualified {
706                id,
707                selector,
708                scoped_selector,
709                declarations,
710                span,
711            } => format!(
712                "{{\"kind\":\"rule\",\"id\":\"{}\",\"selector\":\"{}\",\"scopedSelector\":\"{}\",\"declarations\":[{}],\"span\":{}}}",
713                id,
714                json_escape(selector),
715                json_escape(scoped_selector),
716                declarations
717                    .iter()
718                    .map(StyleDeclaration::to_json)
719                    .collect::<Vec<_>>()
720                    .join(","),
721                span_json(*span)
722            ),
723            Self::AtRule {
724                id,
725                name,
726                prelude,
727                block,
728                span,
729            } => format!(
730                "{{\"kind\":\"at-rule\",\"id\":\"{}\",\"name\":\"{}\",\"prelude\":\"{}\",\"block\":{},\"span\":{}}}",
731                id,
732                json_escape(name),
733                json_escape(prelude),
734                block
735                    .as_ref()
736                    .map(StyleAtRuleBlock::to_json)
737                    .unwrap_or_else(|| "null".into()),
738                span_json(*span)
739            ),
740            Self::Keyframes {
741                id,
742                name,
743                scoped_name,
744                vendor_prefix,
745                frames,
746                span,
747            } => format!(
748                "{{\"kind\":\"keyframes\",\"id\":\"{}\",\"name\":\"{}\",\"scopedName\":\"{}\",\"vendorPrefix\":{},\"frames\":[{}],\"span\":{}}}",
749                id,
750                json_escape(name),
751                json_escape(scoped_name),
752                vendor_prefix
753                    .as_ref()
754                    .map(|value| format!("\"{}\"", json_escape(value)))
755                    .unwrap_or_else(|| "null".into()),
756                frames
757                    .iter()
758                    .map(Keyframe::to_json)
759                    .collect::<Vec<_>>()
760                    .join(","),
761                span_json(*span)
762            ),
763        }
764    }
765}
766
767impl StyleAtRuleBlock {
768    fn to_json(&self) -> String {
769        match self {
770            Self::Rules(rules) => format!(
771                "{{\"kind\":\"rules\",\"rules\":[{}]}}",
772                rules
773                    .iter()
774                    .map(StyleRule::to_json)
775                    .collect::<Vec<_>>()
776                    .join(",")
777            ),
778            Self::Declarations(declarations) => format!(
779                "{{\"kind\":\"declarations\",\"declarations\":[{}]}}",
780                declarations
781                    .iter()
782                    .map(StyleDeclaration::to_json)
783                    .collect::<Vec<_>>()
784                    .join(",")
785            ),
786        }
787    }
788}
789
790impl Keyframe {
791    fn to_json(&self) -> String {
792        format!(
793            "{{\"selector\":\"{}\",\"declarations\":[{}],\"span\":{}}}",
794            json_escape(&self.selector),
795            self.declarations
796                .iter()
797                .map(StyleDeclaration::to_json)
798                .collect::<Vec<_>>()
799                .join(","),
800            span_json(self.span)
801        )
802    }
803}
804
805impl StyleDeclaration {
806    fn to_json(&self) -> String {
807        format!(
808            "{{\"id\":\"{}\",\"name\":\"{}\",\"value\":\"{}\",\"important\":{},\"span\":{}}}",
809            self.id,
810            json_escape(&self.name),
811            json_escape(&self.value),
812            self.important,
813            span_json(self.span)
814        )
815    }
816}
817
818fn span_json(span: Span) -> String {
819    format!("{{\"start\":{},\"end\":{}}}", span.start, span.end)
820}
821
822#[cfg(test)]
823mod tests {
824    use super::*;
825    use noxid_ir::{
826        ComponentDefinition, ComponentRenderMode, ComponentRenderPolicy, HydrationMode, StyleBlock,
827    };
828
829    #[test]
830    fn scopes_compounds_globals_nested_rules_and_keyframes() {
831        assert_eq!(
832            scope_selector_list(".panel > button:hover, :global(body) .panel", "noxid-test")
833                .unwrap(),
834            ".panel[data-noxid-scope=\"noxid-test\"] > button[data-noxid-scope=\"noxid-test\"]:hover, body .panel[data-noxid-scope=\"noxid-test\"]"
835        );
836        let source = "@media (min-width: 40rem) { .panel { animation: fade 1s; } } @keyframes fade { from { opacity: 0; } to { opacity: 1; } }";
837        let component = ComponentDefinition {
838            route_metadata: None,
839            route_render: None,
840            render: ComponentRenderPolicy {
841                id: SemanticId::component_render("Test"),
842                mode: ComponentRenderMode::Universal,
843                hydration: HydrationMode::Eager,
844                span: Span::new(0, 0),
845            },
846            route_query: vec![],
847            id: SemanticId::component("Panel"),
848            name: "Panel".into(),
849            middleware: vec![],
850            capabilities: vec![],
851            props: vec![],
852            events: vec![],
853            context_uses: vec![],
854            context_providers: vec![],
855            types: vec![],
856            distinct_types: vec![],
857            machines: vec![],
858            states: vec![],
859            computed: vec![],
860            loaders: vec![],
861            resources: vec![],
862            presence: None,
863            streams: vec![],
864            agents: vec![],
865            actions: vec![],
866            behaviors: vec![],
867            regions: vec![],
868            lifecycle: None,
869            effects: vec![],
870            intent: None,
871            invariants: vec![],
872            requirements: vec![],
873            scenarios: vec![],
874            view: vec![],
875            style: Some(StyleBlock {
876                source: source.into(),
877                span: Span::new(0, source.len()),
878            }),
879            span: Span::new(0, source.len()),
880        };
881        // A structural-lowering test: the component carries no view, so the
882        // accuracy pass has no oracle and is not what this asserts.
883        let lowered = lower_with_options(
884            &SemanticProgram {
885                imports: vec![],
886                functions: vec![],
887                external_modules: vec![],
888                contexts: vec![],
889                types: vec![],
890                distinct_types: vec![],
891                resources: vec![],
892                streams: vec![],
893                agents: vec![],
894                endpoints: vec![],
895                tasks: vec![],
896                queues: vec![],
897                models: vec![],
898                components: vec![component],
899            },
900            &noxid_design_ir::DesignProgram::default(),
901            StyleOptions {
902                check_accuracy: false,
903            },
904        );
905        assert!(lowered.diagnostics.is_empty(), "{:?}", lowered.diagnostics);
906        let json = lowered.program.to_json();
907        assert!(json.contains("css-keyframes:Panel.fade"));
908        assert!(json.contains("fade--noxid-"));
909        assert!(json.contains("animation"));
910    }
911
912    #[test]
913    fn rejects_unbalanced_global_selector() {
914        let error = scope_selector_list(".local :global(.open", "noxid-test").unwrap_err();
915        assert!(error.contains("unterminated"));
916    }
917}