mod accuracy;
use noxid_css_syntax::{AtRuleBlock, Declaration, ParsedStyle, Rule, parse};
use noxid_ir::{SemanticId, SemanticProgram};
use noxid_source::{Diagnostic, Span, json_escape};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug, Default)]
pub struct StyleProgram {
pub sheets: Vec<StyleSheet>,
}
#[derive(Clone, Debug)]
pub struct StyleSheet {
pub id: SemanticId,
pub component: SemanticId,
pub component_name: String,
pub scope: String,
pub rules: Vec<StyleRule>,
pub span: Span,
pub cst_token_count: usize,
}
#[derive(Clone, Debug)]
pub enum StyleRule {
Qualified {
id: SemanticId,
selector: String,
scoped_selector: String,
declarations: Vec<StyleDeclaration>,
span: Span,
},
AtRule {
id: SemanticId,
name: String,
prelude: String,
block: Option<StyleAtRuleBlock>,
span: Span,
},
Keyframes {
id: SemanticId,
name: String,
scoped_name: String,
vendor_prefix: Option<String>,
frames: Vec<Keyframe>,
span: Span,
},
}
#[derive(Clone, Debug)]
pub enum StyleAtRuleBlock {
Rules(Vec<StyleRule>),
Declarations(Vec<StyleDeclaration>),
}
#[derive(Clone, Debug)]
pub struct Keyframe {
pub selector: String,
pub declarations: Vec<StyleDeclaration>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct StyleDeclaration {
pub id: SemanticId,
pub name: String,
pub value: String,
pub important: bool,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct LoweredStyles {
pub program: StyleProgram,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Clone, Copy, Debug)]
pub struct StyleOptions {
pub check_accuracy: bool,
}
impl Default for StyleOptions {
fn default() -> Self {
Self {
check_accuracy: true,
}
}
}
pub fn lower(program: &SemanticProgram) -> LoweredStyles {
lower_with_options(
program,
&noxid_design_ir::DesignProgram::default(),
StyleOptions::default(),
)
}
pub fn lower_with_options(
program: &SemanticProgram,
design: &noxid_design_ir::DesignProgram,
options: StyleOptions,
) -> LoweredStyles {
let vocabulary = accuracy::Vocabulary::from_design(design);
let mut sheets = Vec::new();
let mut diagnostics = Vec::new();
for component in &program.components {
let Some(style) = &component.style else {
continue;
};
let parsed = parse(&style.source, style.span);
diagnostics.extend(parsed.diagnostics.clone());
let scope = scope_id(component.id.as_str());
let mut keyframes = BTreeMap::new();
collect_keyframes(
&parsed,
&component.name,
&scope,
&mut keyframes,
&mut diagnostics,
);
let rules = {
let mut lowerer = Lowerer {
component: &component.name,
scope: &scope,
keyframes: &keyframes,
design,
vocabulary: &vocabulary,
check_accuracy: options.check_accuracy,
rule_ordinal: 0,
declaration_ordinal: 0,
diagnostics: &mut diagnostics,
};
lowerer.rules(&parsed.ast.rules)
};
if options.check_accuracy {
accuracy::check(component, &rules, &vocabulary, &mut diagnostics);
}
sheets.push(StyleSheet {
id: SemanticId::style(&component.name),
component: component.id.clone(),
component_name: component.name.clone(),
scope,
rules,
span: style.span,
cst_token_count: parsed.cst.tokens.len(),
});
}
LoweredStyles {
program: StyleProgram { sheets },
diagnostics,
}
}
struct Lowerer<'a> {
component: &'a str,
scope: &'a str,
keyframes: &'a BTreeMap<String, String>,
design: &'a noxid_design_ir::DesignProgram,
vocabulary: &'a accuracy::Vocabulary,
check_accuracy: bool,
rule_ordinal: usize,
declaration_ordinal: usize,
diagnostics: &'a mut Vec<Diagnostic>,
}
impl Lowerer<'_> {
fn rules(&mut self, rules: &[Rule]) -> Vec<StyleRule> {
rules.iter().map(|rule| self.rule(rule)).collect()
}
fn rule(&mut self, rule: &Rule) -> StyleRule {
match rule {
Rule::Qualified(rule) => {
self.rule_ordinal += 1;
let rule_ordinal = self.rule_ordinal;
let scoped_selector = match scope_selector_list(&rule.selector, self.scope) {
Ok(selector) => selector,
Err(message) => {
self.diagnostics.push(
Diagnostic::error(
"CSS_INVALID_GLOBAL_SELECTOR",
message,
rule.selector_span,
)
.with_symbol(
SemanticId::css_rule(self.component, rule_ordinal).to_string(),
),
);
rule.selector.clone()
}
};
StyleRule::Qualified {
id: SemanticId::css_rule(self.component, rule_ordinal),
selector: rule.selector.clone(),
scoped_selector,
declarations: self.declarations(&rule.declarations, rule_ordinal),
span: rule.span,
}
}
Rule::At(rule) if matches!(rule.name.as_str(), "keyframes" | "-webkit-keyframes") => {
let name = rule.prelude.trim().to_string();
let scoped_name = self
.keyframes
.get(&name)
.cloned()
.unwrap_or_else(|| format!("{name}--{}", self.scope));
let frames = match &rule.block {
Some(AtRuleBlock::Keyframes(frames)) => frames
.iter()
.map(|frame| {
self.rule_ordinal += 1;
let ordinal = self.rule_ordinal;
Keyframe {
selector: frame.selector.clone(),
declarations: self.declarations(&frame.declarations, ordinal),
span: frame.span,
}
})
.collect(),
_ => vec![],
};
let id_name = if rule.name == "-webkit-keyframes" {
format!("-webkit-{name}")
} else {
name.clone()
};
StyleRule::Keyframes {
id: SemanticId::css_keyframes(self.component, &id_name),
name,
scoped_name,
vendor_prefix: (rule.name == "-webkit-keyframes").then(|| "-webkit-".into()),
frames,
span: rule.span,
}
}
Rule::At(rule) => {
self.rule_ordinal += 1;
let ordinal = self.rule_ordinal;
let block = match &rule.block {
Some(AtRuleBlock::Rules(rules)) => {
Some(StyleAtRuleBlock::Rules(self.rules(rules)))
}
Some(AtRuleBlock::Declarations(declarations)) => Some(
StyleAtRuleBlock::Declarations(self.declarations(declarations, ordinal)),
),
Some(AtRuleBlock::Keyframes(_)) | None => None,
};
if self.check_accuracy {
accuracy::check_strict_media_prelude(
self.vocabulary,
&rule.name,
&rule.prelude,
rule.span,
&SemanticId::css_at_rule(self.component, &rule.name, ordinal),
self.diagnostics,
);
}
StyleRule::AtRule {
id: SemanticId::css_at_rule(self.component, &rule.name, ordinal),
name: rule.name.clone(),
prelude: self.resolve_prelude_tokens(&rule.prelude),
block,
span: rule.span,
}
}
}
}
fn resolve_prelude_tokens(&self, prelude: &str) -> String {
let mut output = prelude.to_string();
for system in &self.design.systems {
for token in &system.tokens {
output = output.replace(&format!("token({})", token.name), &token.value);
}
}
output
}
fn declarations(
&mut self,
declarations: &[Declaration],
rule_ordinal: usize,
) -> Vec<StyleDeclaration> {
declarations
.iter()
.map(|declaration| {
self.declaration_ordinal += 1;
let mut value = declaration.value.clone();
if matches!(
declaration.name.to_ascii_lowercase().as_str(),
"animation" | "animation-name" | "-webkit-animation" | "-webkit-animation-name"
) {
for (name, scoped) in self.keyframes {
value = replace_identifier(&value, name, scoped);
}
}
StyleDeclaration {
id: SemanticId::css_declaration(
self.component,
rule_ordinal,
self.declaration_ordinal,
),
name: declaration.name.clone(),
value,
important: declaration.important,
span: declaration.span,
}
})
.collect()
}
}
fn collect_keyframes(
parsed: &ParsedStyle,
component: &str,
scope: &str,
output: &mut BTreeMap<String, String>,
diagnostics: &mut Vec<Diagnostic>,
) {
fn visit(
rules: &[Rule],
component: &str,
scope: &str,
output: &mut BTreeMap<String, String>,
seen: &mut BTreeSet<String>,
diagnostics: &mut Vec<Diagnostic>,
) {
for rule in rules {
let Rule::At(rule) = rule else {
continue;
};
if matches!(rule.name.as_str(), "keyframes" | "-webkit-keyframes") {
let name = rule.prelude.trim();
if name.is_empty() || name.chars().any(char::is_whitespace) {
diagnostics.push(
Diagnostic::error(
"CSS_INVALID_KEYFRAMES_NAME",
"keyframes require one identifier name",
rule.span,
)
.with_symbol(SemanticId::style(component).to_string()),
);
} else if !seen.insert(format!("{}:{name}", rule.name)) {
diagnostics.push(
Diagnostic::error(
"CSS_DUPLICATE_KEYFRAMES",
format!("duplicate keyframes `{name}`"),
rule.span,
)
.with_symbol(SemanticId::css_keyframes(component, name).to_string()),
);
} else {
output
.entry(name.to_string())
.or_insert_with(|| format!("{name}--{scope}"));
}
}
if let Some(AtRuleBlock::Rules(children)) = &rule.block {
visit(children, component, scope, output, seen, diagnostics);
}
}
}
visit(
&parsed.ast.rules,
component,
scope,
output,
&mut BTreeSet::new(),
diagnostics,
);
}
pub fn scope_id(component_id: &str) -> String {
format!("noxid-{:08x}", fnv1a(component_id) as u32)
}
fn fnv1a(value: &str) -> u64 {
value.bytes().fold(0xcbf29ce484222325, |hash, byte| {
(hash ^ byte as u64).wrapping_mul(0x100000001b3)
})
}
fn scope_selector_list(selector: &str, scope: &str) -> Result<String, String> {
split_top_level(selector, ',')
.into_iter()
.map(|selector| scope_selector(selector.trim(), scope))
.collect::<Result<Vec<_>, _>>()
.map(|selectors| selectors.join(", "))
}
fn scope_selector(selector: &str, scope: &str) -> Result<String, String> {
let (protected, globals) = protect_globals(selector)?;
let mut output = String::new();
let mut compound = String::new();
let mut paren = 0usize;
let mut bracket = 0usize;
let mut quote = None;
let chars = protected.chars().collect::<Vec<_>>();
let mut index = 0;
while index < chars.len() {
let ch = chars[index];
if let Some(active) = quote {
compound.push(ch);
if ch == '\\' && index + 1 < chars.len() {
index += 1;
compound.push(chars[index]);
} else if ch == active {
quote = None;
}
index += 1;
continue;
}
match ch {
'\'' | '"' => {
quote = Some(ch);
compound.push(ch);
}
'[' => {
bracket += 1;
compound.push(ch);
}
']' => {
bracket = bracket.saturating_sub(1);
compound.push(ch);
}
'(' => {
paren += 1;
compound.push(ch);
}
')' => {
paren = paren.saturating_sub(1);
compound.push(ch);
}
'>' | '+' | '~' if paren == 0 && bracket == 0 => {
output.push_str(&scope_compound(&compound, scope, &globals));
compound.clear();
output.push(ch);
}
value if value.is_whitespace() && paren == 0 && bracket == 0 => {
output.push_str(&scope_compound(&compound, scope, &globals));
compound.clear();
output.push(value);
}
_ => compound.push(ch),
}
index += 1;
}
output.push_str(&scope_compound(&compound, scope, &globals));
Ok(restore_globals(&output, &globals))
}
fn scope_compound(compound: &str, scope: &str, globals: &[String]) -> String {
let trimmed = compound.trim();
if trimmed.is_empty() {
return compound.to_string();
}
let local = globals
.iter()
.enumerate()
.fold(trimmed.to_string(), |value, (index, _)| {
value.replace(&global_marker(index), "")
});
if local.trim().is_empty() {
return compound.to_string();
}
let insert = first_pseudo_index(trimmed).unwrap_or(trimmed.len());
format!(
"{}[data-noxid-scope=\"{}\"]{}",
&trimmed[..insert],
scope,
&trimmed[insert..]
)
}
fn first_pseudo_index(selector: &str) -> Option<usize> {
let mut bracket = 0usize;
let mut quote = None;
let mut escaped = false;
for (index, ch) in selector.char_indices() {
if let Some(active) = quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == active {
quote = None;
}
continue;
}
match ch {
'\'' | '"' => quote = Some(ch),
'[' => bracket += 1,
']' => bracket = bracket.saturating_sub(1),
':' if bracket == 0 => return Some(index),
_ => {}
}
}
None
}
fn protect_globals(selector: &str) -> Result<(String, Vec<String>), String> {
let mut output = String::new();
let mut globals = Vec::new();
let mut index = 0;
while let Some(relative) = selector[index..].find(":global(") {
let start = index + relative;
output.push_str(&selector[index..start]);
let content_start = start + ":global(".len();
let Some(end) = matching_paren(selector, content_start) else {
return Err("unterminated :global(...) selector".into());
};
let value = selector[content_start..end].trim();
if value.is_empty() {
return Err(":global(...) cannot be empty".into());
}
let marker = global_marker(globals.len());
globals.push(value.to_string());
output.push_str(&marker);
index = end + 1;
}
output.push_str(&selector[index..]);
Ok((output, globals))
}
fn matching_paren(value: &str, content_start: usize) -> Option<usize> {
let mut depth = 1usize;
let mut quote = None;
let mut escaped = false;
for (offset, ch) in value[content_start..].char_indices() {
if let Some(active) = quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == active {
quote = None;
}
continue;
}
match ch {
'\'' | '"' => quote = Some(ch),
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(content_start + offset);
}
}
_ => {}
}
}
None
}
fn global_marker(index: usize) -> String {
format!("__NOXID_GLOBAL_{index}__")
}
fn restore_globals(value: &str, globals: &[String]) -> String {
globals
.iter()
.enumerate()
.fold(value.to_string(), |output, (index, global)| {
output.replace(&global_marker(index), global)
})
}
fn split_top_level(value: &str, delimiter: char) -> Vec<&str> {
let mut values = Vec::new();
let mut start = 0;
let mut paren = 0usize;
let mut bracket = 0usize;
let mut quote = None;
let mut escaped = false;
for (index, ch) in value.char_indices() {
if let Some(active) = quote {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == active {
quote = None;
}
continue;
}
match ch {
'\'' | '"' => quote = Some(ch),
'(' => paren += 1,
')' => paren = paren.saturating_sub(1),
'[' => bracket += 1,
']' => bracket = bracket.saturating_sub(1),
current if current == delimiter && paren == 0 && bracket == 0 => {
values.push(&value[start..index]);
start = index + ch.len_utf8();
}
_ => {}
}
}
values.push(&value[start..]);
values
}
fn replace_identifier(value: &str, name: &str, replacement: &str) -> String {
let mut output = String::new();
let mut index = 0;
let mut quote = None;
while index < value.len() {
let ch = value[index..].chars().next().unwrap_or('\0');
if let Some(active) = quote {
output.push(ch);
index += ch.len_utf8();
if ch == '\\' && index < value.len() {
let escaped = value[index..].chars().next().unwrap_or('\0');
output.push(escaped);
index += escaped.len_utf8();
} else if ch == active {
quote = None;
}
continue;
}
if matches!(ch, '\'' | '"') {
quote = Some(ch);
output.push(ch);
index += ch.len_utf8();
continue;
}
if is_identifier_char(ch) {
let start = index;
index += ch.len_utf8();
while index < value.len() && is_identifier_char(value[index..].chars().next().unwrap())
{
index += value[index..].chars().next().unwrap().len_utf8();
}
let word = &value[start..index];
output.push_str(if word == name { replacement } else { word });
} else {
output.push(ch);
index += ch.len_utf8();
}
}
output
}
fn is_identifier_char(ch: char) -> bool {
ch.is_alphanumeric() || matches!(ch, '-' | '_') || !ch.is_ascii()
}
impl StyleProgram {
pub fn to_json(&self) -> String {
format!(
"{{\"schemaVersion\":1,\"sheets\":[{}]}}",
self.sheets
.iter()
.map(StyleSheet::to_json)
.collect::<Vec<_>>()
.join(",")
)
}
}
impl StyleSheet {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"component\":\"{}\",\"scope\":\"{}\",\"cstTokenCount\":{},\"rules\":[{}],\"span\":{}}}",
self.id,
self.component,
json_escape(&self.scope),
self.cst_token_count,
self.rules
.iter()
.map(StyleRule::to_json)
.collect::<Vec<_>>()
.join(","),
span_json(self.span)
)
}
}
impl StyleRule {
fn to_json(&self) -> String {
match self {
Self::Qualified {
id,
selector,
scoped_selector,
declarations,
span,
} => format!(
"{{\"kind\":\"rule\",\"id\":\"{}\",\"selector\":\"{}\",\"scopedSelector\":\"{}\",\"declarations\":[{}],\"span\":{}}}",
id,
json_escape(selector),
json_escape(scoped_selector),
declarations
.iter()
.map(StyleDeclaration::to_json)
.collect::<Vec<_>>()
.join(","),
span_json(*span)
),
Self::AtRule {
id,
name,
prelude,
block,
span,
} => format!(
"{{\"kind\":\"at-rule\",\"id\":\"{}\",\"name\":\"{}\",\"prelude\":\"{}\",\"block\":{},\"span\":{}}}",
id,
json_escape(name),
json_escape(prelude),
block
.as_ref()
.map(StyleAtRuleBlock::to_json)
.unwrap_or_else(|| "null".into()),
span_json(*span)
),
Self::Keyframes {
id,
name,
scoped_name,
vendor_prefix,
frames,
span,
} => format!(
"{{\"kind\":\"keyframes\",\"id\":\"{}\",\"name\":\"{}\",\"scopedName\":\"{}\",\"vendorPrefix\":{},\"frames\":[{}],\"span\":{}}}",
id,
json_escape(name),
json_escape(scoped_name),
vendor_prefix
.as_ref()
.map(|value| format!("\"{}\"", json_escape(value)))
.unwrap_or_else(|| "null".into()),
frames
.iter()
.map(Keyframe::to_json)
.collect::<Vec<_>>()
.join(","),
span_json(*span)
),
}
}
}
impl StyleAtRuleBlock {
fn to_json(&self) -> String {
match self {
Self::Rules(rules) => format!(
"{{\"kind\":\"rules\",\"rules\":[{}]}}",
rules
.iter()
.map(StyleRule::to_json)
.collect::<Vec<_>>()
.join(",")
),
Self::Declarations(declarations) => format!(
"{{\"kind\":\"declarations\",\"declarations\":[{}]}}",
declarations
.iter()
.map(StyleDeclaration::to_json)
.collect::<Vec<_>>()
.join(",")
),
}
}
}
impl Keyframe {
fn to_json(&self) -> String {
format!(
"{{\"selector\":\"{}\",\"declarations\":[{}],\"span\":{}}}",
json_escape(&self.selector),
self.declarations
.iter()
.map(StyleDeclaration::to_json)
.collect::<Vec<_>>()
.join(","),
span_json(self.span)
)
}
}
impl StyleDeclaration {
fn to_json(&self) -> String {
format!(
"{{\"id\":\"{}\",\"name\":\"{}\",\"value\":\"{}\",\"important\":{},\"span\":{}}}",
self.id,
json_escape(&self.name),
json_escape(&self.value),
self.important,
span_json(self.span)
)
}
}
fn span_json(span: Span) -> String {
format!("{{\"start\":{},\"end\":{}}}", span.start, span.end)
}
#[cfg(test)]
mod tests {
use super::*;
use noxid_ir::{
ComponentDefinition, ComponentRenderMode, ComponentRenderPolicy, HydrationMode, StyleBlock,
};
#[test]
fn scopes_compounds_globals_nested_rules_and_keyframes() {
assert_eq!(
scope_selector_list(".panel > button:hover, :global(body) .panel", "noxid-test")
.unwrap(),
".panel[data-noxid-scope=\"noxid-test\"] > button[data-noxid-scope=\"noxid-test\"]:hover, body .panel[data-noxid-scope=\"noxid-test\"]"
);
let source = "@media (min-width: 40rem) { .panel { animation: fade 1s; } } @keyframes fade { from { opacity: 0; } to { opacity: 1; } }";
let component = ComponentDefinition {
route_metadata: None,
route_render: None,
render: ComponentRenderPolicy {
id: SemanticId::component_render("Test"),
mode: ComponentRenderMode::Universal,
hydration: HydrationMode::Eager,
span: Span::new(0, 0),
},
route_query: vec![],
id: SemanticId::component("Panel"),
name: "Panel".into(),
middleware: vec![],
capabilities: vec![],
props: vec![],
events: vec![],
context_uses: vec![],
context_providers: vec![],
types: vec![],
distinct_types: vec![],
machines: vec![],
states: vec![],
computed: vec![],
loaders: vec![],
resources: vec![],
presence: None,
streams: vec![],
agents: vec![],
actions: vec![],
behaviors: vec![],
regions: vec![],
lifecycle: None,
effects: vec![],
intent: None,
invariants: vec![],
requirements: vec![],
scenarios: vec![],
view: vec![],
style: Some(StyleBlock {
source: source.into(),
span: Span::new(0, source.len()),
}),
span: Span::new(0, source.len()),
};
let lowered = lower_with_options(
&SemanticProgram {
imports: vec![],
functions: vec![],
external_modules: vec![],
contexts: vec![],
types: vec![],
distinct_types: vec![],
resources: vec![],
streams: vec![],
agents: vec![],
endpoints: vec![],
tasks: vec![],
queues: vec![],
models: vec![],
components: vec![component],
},
&noxid_design_ir::DesignProgram::default(),
StyleOptions {
check_accuracy: false,
},
);
assert!(lowered.diagnostics.is_empty(), "{:?}", lowered.diagnostics);
let json = lowered.program.to_json();
assert!(json.contains("css-keyframes:Panel.fade"));
assert!(json.contains("fade--noxid-"));
assert!(json.contains("animation"));
}
#[test]
fn rejects_unbalanced_global_selector() {
let error = scope_selector_list(".local :global(.open", "noxid-test").unwrap_err();
assert!(error.contains("unterminated"));
}
}