use crate::{Keyframe, StyleAtRuleBlock, StyleDeclaration, StyleRule};
use noxid_css_syntax::properties::{PropertyLookup, table};
use noxid_ir::{ComponentDefinition, SemanticAttribute, SemanticId, SemanticViewNode};
use noxid_source::{Diagnostic, Span};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Clone, Debug)]
struct ViewElement {
tag: String,
classes: BTreeSet<String>,
id: Option<String>,
attributes: BTreeSet<String>,
opaque_class: bool,
opaque_id: bool,
parent: Option<usize>,
}
#[derive(Default)]
struct ViewModel {
elements: Vec<ViewElement>,
}
impl ViewModel {
fn collect(nodes: &[SemanticViewNode]) -> Self {
let mut model = Self::default();
model.walk(nodes, None);
model
}
fn walk(&mut self, nodes: &[SemanticViewNode], parent: Option<usize>) {
for node in nodes {
match node {
SemanticViewNode::Element {
tag,
attributes,
children,
..
} => {
let index = self.elements.len();
let mut element = ViewElement {
tag: tag.to_ascii_lowercase(),
classes: BTreeSet::new(),
id: None,
attributes: BTreeSet::new(),
opaque_class: false,
opaque_id: false,
parent,
};
for attribute in attributes {
match attribute {
SemanticAttribute::Static { name, value, .. } => {
let lowered = name.to_ascii_lowercase();
element.attributes.insert(lowered.clone());
match lowered.as_str() {
"class" => element
.classes
.extend(value.split_whitespace().map(str::to_string)),
"id" => element.id = Some(value.trim().to_string()),
_ => {}
}
}
SemanticAttribute::Binding { name, .. }
| SemanticAttribute::TwoWayBinding { name, .. }
| SemanticAttribute::Event { name, .. } => {
let lowered = name.to_ascii_lowercase();
element.attributes.insert(lowered.clone());
match lowered.as_str() {
"class" => element.opaque_class = true,
"id" => element.opaque_id = true,
_ => {}
}
}
}
}
self.elements.push(element);
self.walk(children, Some(index));
}
SemanticViewNode::ComponentInvocation { children, .. } => {
self.walk(children, parent);
}
SemanticViewNode::Conditional { children, .. }
| SemanticViewNode::For { children, .. } => self.walk(children, parent),
SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
for case in cases {
self.walk(&case.children, parent);
}
}
SemanticViewNode::Text { .. }
| SemanticViewNode::Binding { .. }
| SemanticViewNode::Slot { .. } => {}
}
}
}
fn siblings(&self, index: usize) -> Vec<usize> {
let parent = self.elements[index].parent;
(0..self.elements.len())
.filter(|candidate| *candidate != index && self.elements[*candidate].parent == parent)
.collect()
}
fn ancestors(&self, index: usize) -> Vec<usize> {
let mut output = Vec::new();
let mut cursor = self.elements[index].parent;
while let Some(current) = cursor {
output.push(current);
cursor = self.elements[current].parent;
}
output
}
fn descriptors(&self) -> Vec<String> {
let mut output = BTreeSet::new();
for element in &self.elements {
output.insert(element.tag.clone());
for class in &element.classes {
output.insert(format!(".{class}"));
}
if let Some(id) = &element.id {
output.insert(format!("#{id}"));
}
}
output.into_iter().collect()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Combinator {
Descendant,
Child,
NextSibling,
LaterSibling,
}
#[derive(Clone, Debug, Default)]
struct Compound {
tag: Option<String>,
classes: Vec<String>,
ids: Vec<String>,
attributes: Vec<String>,
pseudo_classes: usize,
pseudo_elements: usize,
universal: bool,
}
#[derive(Clone, Debug)]
struct Complex {
steps: Vec<(Compound, Option<Combinator>)>,
global: bool,
}
type Specificity = (usize, usize, usize);
impl Complex {
fn specificity(&self) -> Specificity {
self.steps.iter().fold((0, 0, 0), |total, (compound, _)| {
(
total.0 + compound.ids.len(),
total.1
+ compound.classes.len()
+ compound.attributes.len()
+ compound.pseudo_classes,
total.2 + usize::from(compound.tag.is_some()) + compound.pseudo_elements,
)
})
}
fn has_pseudo(&self) -> bool {
self.steps
.iter()
.any(|(compound, _)| compound.pseudo_classes + compound.pseudo_elements > 0)
}
}
fn parse_selector_list(selector: &str) -> Vec<(String, Complex)> {
split_top_level(selector, ',')
.into_iter()
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| (part.to_string(), parse_complex(part)))
.collect()
}
fn parse_complex(selector: &str) -> Complex {
let global = selector.contains(":global(");
let mut steps: Vec<(Compound, Option<Combinator>)> = Vec::new();
let mut buffer = String::new();
let mut pending: Option<Combinator> = None;
let mut paren = 0usize;
let mut bracket = 0usize;
let mut quote: Option<char> = None;
let mut chars = selector.chars().peekable();
let flush = |buffer: &mut String,
steps: &mut Vec<(Compound, Option<Combinator>)>,
combinator: Option<Combinator>| {
let trimmed = buffer.trim().to_string();
buffer.clear();
if trimmed.is_empty() {
return;
}
steps.push((parse_compound(&trimmed), combinator));
};
while let Some(ch) = chars.next() {
if let Some(active) = quote {
buffer.push(ch);
if ch == active {
quote = None;
}
continue;
}
match ch {
'\'' | '"' => {
quote = Some(ch);
buffer.push(ch);
}
'(' => {
paren += 1;
buffer.push(ch);
}
')' => {
paren = paren.saturating_sub(1);
buffer.push(ch);
}
'[' => {
bracket += 1;
buffer.push(ch);
}
']' => {
bracket = bracket.saturating_sub(1);
buffer.push(ch);
}
'>' | '+' | '~' if paren == 0 && bracket == 0 => {
flush(&mut buffer, &mut steps, pending.take());
pending = Some(match ch {
'>' => Combinator::Child,
'+' => Combinator::NextSibling,
_ => Combinator::LaterSibling,
});
}
_ if ch.is_whitespace() && paren == 0 && bracket == 0 => {
if !buffer.trim().is_empty() {
let mut lookahead = chars.clone();
let mut next = lookahead.next();
while matches!(next, Some(candidate) if candidate.is_whitespace()) {
next = lookahead.next();
}
if matches!(next, Some('>') | Some('+') | Some('~')) {
continue;
}
flush(&mut buffer, &mut steps, pending.take());
pending = Some(Combinator::Descendant);
}
}
_ => buffer.push(ch),
}
}
flush(&mut buffer, &mut steps, pending.take());
let mut ordered: Vec<(Compound, Option<Combinator>)> = Vec::new();
for index in (0..steps.len()).rev() {
let combinator = steps[index].1;
ordered.push((steps[index].0.clone(), combinator));
}
Complex {
steps: ordered,
global,
}
}
fn parse_compound(text: &str) -> Compound {
let mut compound = Compound::default();
let chars = text.chars().collect::<Vec<_>>();
let mut index = 0usize;
let mut tag = String::new();
while index < chars.len() {
match chars[index] {
'*' => {
compound.universal = true;
index += 1;
}
'.' => {
index += 1;
compound.classes.push(read_identifier(&chars, &mut index));
}
'#' => {
index += 1;
compound.ids.push(read_identifier(&chars, &mut index));
}
'[' => {
let start = index + 1;
let mut depth = 1usize;
index += 1;
while index < chars.len() && depth > 0 {
match chars[index] {
'[' => depth += 1,
']' => depth -= 1,
_ => {}
}
index += 1;
}
let inner = chars[start..index.saturating_sub(1)]
.iter()
.collect::<String>();
let name = inner
.split(['=', '~', '|', '^', '$', '*'])
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
compound.attributes.push(name);
}
':' => {
let double = chars.get(index + 1) == Some(&':');
index += if double { 2 } else { 1 };
let _name = read_identifier(&chars, &mut index);
if chars.get(index) == Some(&'(') {
let mut depth = 1usize;
index += 1;
while index < chars.len() && depth > 0 {
match chars[index] {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
index += 1;
}
}
if double {
compound.pseudo_elements += 1;
} else {
compound.pseudo_classes += 1;
}
}
ch if ch.is_alphanumeric() || ch == '-' || ch == '_' || !ch.is_ascii() => {
tag.push(ch);
index += 1;
}
_ => index += 1,
}
}
if !tag.is_empty() {
compound.tag = Some(tag.to_ascii_lowercase());
}
compound
}
fn read_identifier(chars: &[char], index: &mut usize) -> String {
let start = *index;
while *index < chars.len() {
let ch = chars[*index];
if ch.is_alphanumeric() || ch == '-' || ch == '_' || !ch.is_ascii() {
*index += 1;
} else if ch == '\\' && *index + 1 < chars.len() {
*index += 2;
} else {
break;
}
}
chars[start..*index].iter().collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MatchMode {
Possible,
Certain,
}
fn compound_matches(compound: &Compound, element: &ViewElement, mode: MatchMode) -> bool {
if let Some(tag) = &compound.tag
&& tag != &element.tag
{
return false;
}
let opaque_class = element.opaque_class && mode == MatchMode::Possible;
let opaque_id = element.opaque_id && mode == MatchMode::Possible;
for class in &compound.classes {
if !opaque_class && !element.classes.contains(class) {
return false;
}
}
for id in &compound.ids {
if opaque_id {
continue;
}
if element.id.as_deref() != Some(id.as_str()) {
return false;
}
}
for attribute in &compound.attributes {
if attribute.is_empty() || attribute.starts_with("data-noxid-") {
continue;
}
if !element.attributes.contains(attribute) {
return false;
}
}
true
}
fn matches_from(
model: &ViewModel,
complex: &Complex,
step: usize,
index: usize,
mode: MatchMode,
) -> bool {
let Some((compound, combinator)) = complex.steps.get(step) else {
return true;
};
if !compound_matches(compound, &model.elements[index], mode) {
return false;
}
let Some(combinator) = combinator else {
return complex.steps.len() == step + 1
|| matches_from(model, complex, step + 1, index, mode);
};
let candidates = match combinator {
Combinator::Descendant => model.ancestors(index),
Combinator::Child => model.elements[index].parent.into_iter().collect(),
Combinator::NextSibling | Combinator::LaterSibling => model.siblings(index),
};
candidates
.into_iter()
.any(|candidate| matches_from(model, complex, step + 1, candidate, mode))
}
fn matched_elements(model: &ViewModel, complex: &Complex, mode: MatchMode) -> BTreeSet<usize> {
(0..model.elements.len())
.filter(|index| matches_from(model, complex, 0, *index, mode))
.collect()
}
struct FlatRule {
id: SemanticId,
selector: String,
selector_span: Span,
context: String,
matched: BTreeSet<usize>,
certain: BTreeSet<usize>,
specificity: Specificity,
has_pseudo: bool,
declarations: Vec<StyleDeclaration>,
}
pub(crate) fn check(
component: &ComponentDefinition,
rules: &[StyleRule],
vocabulary: &Vocabulary,
diagnostics: &mut Vec<Diagnostic>,
) {
let model = ViewModel::collect(&component.view);
let style_id = SemanticId::style(&component.name);
let mut flat = Vec::new();
visit_rules(
&model,
rules,
"",
&mut flat,
diagnostics,
&style_id,
vocabulary,
);
check_unused(&model, &flat, diagnostics);
check_shadowing(&flat, diagnostics);
check_nesting(&model, &flat, diagnostics);
}
#[allow(clippy::too_many_arguments)]
fn visit_rules(
model: &ViewModel,
rules: &[StyleRule],
context: &str,
flat: &mut Vec<FlatRule>,
diagnostics: &mut Vec<Diagnostic>,
style_id: &SemanticId,
vocabulary: &Vocabulary,
) {
for rule in rules {
match rule {
StyleRule::Qualified {
id,
selector,
declarations,
span,
..
} => {
check_declarations(declarations, id, diagnostics);
check_strict_declarations(vocabulary, declarations, diagnostics);
check_contrast(vocabulary, selector, declarations, diagnostics);
let parsed = parse_selector_list(selector);
let global = parsed.iter().any(|(_, complex)| complex.global);
let mut matched = BTreeSet::new();
let mut certain = BTreeSet::new();
let mut specificity = (0, 0, 0);
let mut has_pseudo = false;
for (_, complex) in &parsed {
matched.extend(matched_elements(model, complex, MatchMode::Possible));
certain.extend(matched_elements(model, complex, MatchMode::Certain));
specificity = specificity.max(complex.specificity());
has_pseudo |= complex.has_pseudo();
}
flat.push(FlatRule {
id: id.clone(),
selector: selector.clone(),
selector_span: *span,
context: context.to_string(),
matched: if global { BTreeSet::new() } else { matched },
certain: if global { BTreeSet::new() } else { certain },
specificity,
has_pseudo: has_pseudo || global,
declarations: declarations.clone(),
});
}
StyleRule::AtRule {
name,
prelude,
block,
..
} => {
let nested = format!("{context}@{name} {}|", prelude.trim());
match block {
Some(StyleAtRuleBlock::Rules(rules)) => visit_rules(
model,
rules,
&nested,
flat,
diagnostics,
style_id,
vocabulary,
),
Some(StyleAtRuleBlock::Declarations(declarations)) => {
check_declarations(declarations, style_id, diagnostics);
check_strict_declarations(vocabulary, declarations, diagnostics);
}
None => {}
}
}
StyleRule::Keyframes { id, frames, .. } => {
for Keyframe { declarations, .. } in frames {
check_declarations(declarations, id, diagnostics);
check_strict_declarations(vocabulary, declarations, diagnostics);
}
}
}
}
}
fn check_declarations(
declarations: &[StyleDeclaration],
owner: &SemanticId,
diagnostics: &mut Vec<Diagnostic>,
) {
let table = table();
for declaration in declarations {
if declaration.important {
diagnostics.push(
Diagnostic::error(
"CSS_IMPORTANT_FORBIDDEN",
format!(
"`!important` is not allowed on `{}` in a component style block; scoping already isolates this component's rules, so `!important` can only hide a specificity mistake. Delete `!important`, and if the rule really must win, give its selector the specificity it needs.",
declaration.name
),
declaration.span,
)
.with_symbol(declaration.id.to_string()),
);
}
match table.lookup(&declaration.name) {
PropertyLookup::Known(def) => {
if let Err(rejection) = table.check_value(def, &declaration.value) {
diagnostics.push(
Diagnostic::error(
"CSS_INVALID_VALUE",
format!(
"{}; `{}` accepts {}.",
rejection.describe(&declaration.name),
declaration.name,
def.legal_values()
),
declaration.span,
)
.with_symbol(declaration.id.to_string())
.with_field("property", declaration.name.clone()),
);
}
}
PropertyLookup::Custom | PropertyLookup::Vendor => {}
PropertyLookup::Unknown => {
let suggestion = table
.nearest(&declaration.name)
.map(|nearest| format!(" Write `{nearest}` instead."))
.unwrap_or_default();
diagnostics.push(
Diagnostic::error(
"CSS_UNKNOWN_PROPERTY",
format!(
"unknown CSS property `{}`.{suggestion} Custom properties (`--name`) and vendor-prefixed properties are always legal; every other property must be one the compiler's property table knows.",
declaration.name
),
declaration.span,
)
.with_symbol(declaration.id.to_string())
.with_field("property", declaration.name.clone()),
);
}
}
let _ = owner;
}
}
fn check_unused(model: &ViewModel, flat: &[FlatRule], diagnostics: &mut Vec<Diagnostic>) {
let descriptors = model.descriptors();
for rule in flat {
let parsed = parse_selector_list(&rule.selector);
for (text, complex) in &parsed {
if complex.global {
continue;
}
if !matched_elements(model, complex, MatchMode::Possible).is_empty() {
continue;
}
let nearest = nearest_descriptor(text, &descriptors);
let advice = match nearest {
Some(candidate) => {
format!("the nearest element this view renders is `{candidate}`")
}
None => "this view renders no elements".to_string(),
};
diagnostics.push(
Diagnostic::error(
"CSS_UNUSED_SELECTOR",
format!(
"selector `{text}` matches no element this component's view can render, across every `#if`, `#match`, and `#for` branch; {advice}. Rewrite the selector to name an element the view renders, wrap it in `:global(...)`, or move the rule to a global stylesheet if it targets markup this component does not own."
),
rule.selector_span,
)
.with_symbol(rule.id.to_string())
.with_field("selector", text.clone()),
);
}
}
}
fn nearest_descriptor(selector: &str, descriptors: &[String]) -> Option<String> {
let target = selector.trim();
descriptors
.iter()
.map(|candidate| (edit_distance(target, candidate), candidate.clone()))
.min_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)))
.map(|(_, candidate)| candidate)
}
fn check_shadowing(flat: &[FlatRule], diagnostics: &mut Vec<Diagnostic>) {
for (later_index, later) in flat.iter().enumerate() {
if later.has_pseudo || later.certain.is_empty() {
continue;
}
let later_properties = later
.declarations
.iter()
.map(|declaration| declaration.name.to_ascii_lowercase())
.collect::<BTreeSet<_>>();
for earlier in flat.iter().take(later_index) {
if earlier.has_pseudo
|| earlier.context != later.context
|| earlier.certain.is_empty()
|| !earlier.certain.is_subset(&later.certain)
|| !earlier.matched.is_subset(&later.matched)
|| earlier.specificity > later.specificity
{
continue;
}
for declaration in &earlier.declarations {
let name = declaration.name.to_ascii_lowercase();
if !later_properties.contains(&name) {
continue;
}
diagnostics.push(
Diagnostic::error(
"CSS_SHADOWED_DECLARATION",
format!(
"`{}` declared on `{}` is fully overridden by `{}` on the later rule `{}`, which matches the same elements at equal or higher specificity, so this declaration can never take effect. Delete it, or narrow one of the two selectors so they no longer overlap.",
declaration.name, earlier.selector, declaration.name, later.selector
),
declaration.span,
)
.with_symbol(declaration.id.to_string())
.with_field("shadowedBy", later.id.to_string()),
);
}
}
}
}
const FLEX_ITEM_PROPERTIES: &[&str] = &[
"flex",
"flex-grow",
"flex-shrink",
"flex-basis",
"order",
"align-self",
];
const GRID_ITEM_PROPERTIES: &[&str] = &[
"grid-area",
"grid-column",
"grid-column-start",
"grid-column-end",
"grid-row",
"grid-row-start",
"grid-row-end",
"justify-self",
];
const FLEX_CONTAINER_PROPERTIES: &[&str] =
&["flex-direction", "flex-wrap", "flex-flow", "place-items"];
const GRID_CONTAINER_PROPERTIES: &[&str] = &[
"grid-template",
"grid-template-columns",
"grid-template-rows",
"grid-template-areas",
"grid-auto-columns",
"grid-auto-rows",
"grid-auto-flow",
];
fn check_nesting(model: &ViewModel, flat: &[FlatRule], diagnostics: &mut Vec<Diagnostic>) {
let mut declared_display = BTreeMap::<usize, String>::new();
for rule in flat {
for declaration in &rule.declarations {
if declaration.name.eq_ignore_ascii_case("display") {
let value = declaration.value.trim().to_ascii_lowercase();
for index in &rule.certain {
declared_display.insert(*index, value.clone());
}
}
}
}
for rule in flat {
if rule.certain.is_empty() || rule.certain != rule.matched {
continue;
}
for declaration in &rule.declarations {
let property = declaration.name.to_ascii_lowercase();
let (needs_parent, wanted, family) =
if FLEX_ITEM_PROPERTIES.contains(&property.as_str()) {
(true, ["flex", "inline-flex"].as_slice(), "flex")
} else if GRID_ITEM_PROPERTIES.contains(&property.as_str()) {
(true, ["grid", "inline-grid"].as_slice(), "grid")
} else if FLEX_CONTAINER_PROPERTIES.contains(&property.as_str()) {
(false, ["flex", "inline-flex"].as_slice(), "flex")
} else if GRID_CONTAINER_PROPERTIES.contains(&property.as_str()) {
(false, ["grid", "inline-grid"].as_slice(), "grid")
} else {
continue;
};
let mut offender = None;
let mut provable = true;
for index in &rule.certain {
let subject = if needs_parent {
model.elements[*index].parent
} else {
Some(*index)
};
let Some(subject) = subject else {
provable = false;
break;
};
let Some(display) = declared_display.get(&subject) else {
provable = false;
break;
};
if wanted.iter().any(|value| display == value) {
provable = false;
break;
}
offender.get_or_insert((subject, display.clone()));
}
let (Some((subject, display)), true) = (offender, provable) else {
continue;
};
let owner = describe_element(&model.elements[subject]);
let message = if needs_parent {
format!(
"`{property}` only has an effect on a {family} item, but this block declares `display: {display}` on the parent `{owner}`. Declare `display: {family}` on `{owner}`, or delete `{property}`."
)
} else {
format!(
"`{property}` only has an effect on a {family} container, but this block declares `display: {display}` on `{owner}`. Declare `display: {family}` on `{owner}`, or delete `{property}`."
)
};
diagnostics.push(
Diagnostic::error("CSS_INVALID_NESTING", message, declaration.span)
.with_symbol(declaration.id.to_string())
.with_field("property", property.clone()),
);
}
}
}
fn describe_element(element: &ViewElement) -> String {
if let Some(class) = element.classes.iter().next() {
return format!(".{class}");
}
if let Some(id) = &element.id {
return format!("#{id}");
}
element.tag.clone()
}
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;
for (index, ch) in value.char_indices() {
if let Some(active) = quote {
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 edit_distance(left: &str, right: &str) -> usize {
let left = left.chars().collect::<Vec<_>>();
let right = right.chars().collect::<Vec<_>>();
let mut previous = (0..=right.len()).collect::<Vec<_>>();
let mut current = vec![0usize; right.len() + 1];
for (row, left_char) in left.iter().enumerate() {
current[0] = row + 1;
for (column, right_char) in right.iter().enumerate() {
let cost = usize::from(left_char != right_char);
current[column + 1] = (previous[column] + cost)
.min(previous[column + 1] + 1)
.min(current[column] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right.len()]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TokenNeed {
Color,
Spacing,
Radius,
Shadow,
Font,
Duration,
Breakpoint,
ZIndex,
}
impl TokenNeed {
fn label(self) -> &'static str {
match self {
Self::Color => "Color",
Self::Spacing => "Spacing",
Self::Radius => "Radius",
Self::Shadow => "Shadow",
Self::Font => "Font",
Self::Duration => "Duration",
Self::Breakpoint => "Breakpoint",
Self::ZIndex => "ZIndex",
}
}
fn categories(self) -> &'static [&'static str] {
match self {
Self::Color => &["Color"],
Self::Spacing => &["Spacing", "Length"],
Self::Radius => &["Radius", "Length"],
Self::Shadow => &["Shadow", "Elevation"],
Self::Font => &["Font", "FontFamily", "Typography"],
Self::Duration => &["Duration"],
Self::Breakpoint => &["Breakpoint"],
Self::ZIndex => &["ZIndex"],
}
}
}
pub(crate) struct Vocabulary {
pub(crate) strict: bool,
pub(crate) system: String,
pub(crate) tokens: Vec<(String, String, String)>,
}
impl Vocabulary {
pub(crate) fn from_design(design: &noxid_design_ir::DesignProgram) -> Self {
let strict = design.systems.iter().any(|system| system.strict);
let system = design
.systems
.iter()
.find(|system| system.strict)
.or_else(|| design.systems.first())
.map(|system| system.name.clone())
.unwrap_or_default();
let tokens = design
.systems
.iter()
.flat_map(|system| {
system.tokens.iter().map(|token| {
(
token.name.clone(),
token.category.clone(),
token.value.clone(),
)
})
})
.collect();
Self {
strict,
system,
tokens,
}
}
fn declared(&self, need: TokenNeed) -> Vec<&str> {
self.tokens
.iter()
.filter(|(_, category, _)| need.categories().contains(&category.as_str()))
.map(|(name, _, _)| name.as_str())
.collect()
}
fn color_value(&self, name: &str) -> Option<&str> {
self.tokens
.iter()
.find(|(token, category, _)| token == name && category == "Color")
.map(|(_, _, value)| value.as_str())
}
fn offer(&self, need: TokenNeed) -> String {
let declared = self.declared(need);
let kind = need.label();
if declared.is_empty() {
format!(
"`{}` declares no {kind} token yet; declare one in its `tokens` block and reference it as `token(<name>)`",
self.system
)
} else {
format!(
"write one of `{}`'s declared {kind} tokens instead: {}",
self.system,
declared
.iter()
.map(|name| format!("`token({name})`"))
.collect::<Vec<_>>()
.join(", ")
)
}
}
}
fn token_need(property: &str, component: &str) -> Option<TokenNeed> {
use noxid_css_syntax::properties::{LiteralKind, classify_literal, is_structural_literal};
if is_structural_literal(component) {
return None;
}
let property = property.to_ascii_lowercase();
let kind = classify_literal(component);
if property.contains("shadow") {
return Some(TokenNeed::Shadow);
}
if property == "font-family" || property == "font" {
return matches!(kind, LiteralKind::String | LiteralKind::Identifier)
.then_some(TokenNeed::Font);
}
if property == "z-index" {
return matches!(kind, LiteralKind::Integer).then_some(TokenNeed::ZIndex);
}
match kind {
LiteralKind::Color => Some(TokenNeed::Color),
LiteralKind::Time => Some(TokenNeed::Duration),
LiteralKind::Length if property.contains("radius") => Some(TokenNeed::Radius),
LiteralKind::Length => Some(TokenNeed::Spacing),
_ => None,
}
}
pub(crate) fn check_strict_declarations(
vocabulary: &Vocabulary,
declarations: &[StyleDeclaration],
diagnostics: &mut Vec<Diagnostic>,
) {
use noxid_css_syntax::properties::split_components;
if !vocabulary.strict {
return;
}
for declaration in declarations {
if declaration.name.starts_with("--") {
continue;
}
for component in split_components(&declaration.value) {
let Some(need) = token_need(&declaration.name, component) else {
continue;
};
diagnostics.push(
Diagnostic::error(
"CSS_TOKEN_REQUIRED",
format!(
"`{}` is a raw {} value, and `design {} strict` requires every {} to be a declared token: {}.",
component,
need.label(),
vocabulary.system,
need.label(),
vocabulary.offer(need)
),
declaration.span,
)
.with_symbol(declaration.id.to_string())
.with_field("tokenKind", need.label()),
);
}
}
}
pub(crate) fn check_strict_media_prelude(
vocabulary: &Vocabulary,
name: &str,
prelude: &str,
span: Span,
owner: &SemanticId,
diagnostics: &mut Vec<Diagnostic>,
) {
if !vocabulary.strict || !name.eq_ignore_ascii_case("media") {
return;
}
let lowered = prelude.to_ascii_lowercase();
let mut rest = lowered.as_str();
while let Some(index) = rest.find("width") {
let after = &rest[index + "width".len()..];
let Some(colon) = after.find(':') else {
break;
};
let value = after[colon + 1..]
.split(')')
.next()
.unwrap_or("")
.trim()
.to_string();
if !value.is_empty() && !value.starts_with("token(") {
diagnostics.push(
Diagnostic::error(
"CSS_TOKEN_REQUIRED",
format!(
"`{value}` is a raw Breakpoint value, and `design {} strict` requires every width query to name a declared Breakpoint token: {}.",
vocabulary.system,
vocabulary.offer(TokenNeed::Breakpoint)
),
span,
)
.with_symbol(owner.to_string())
.with_field("tokenKind", "Breakpoint"),
);
}
rest = &after[colon + 1..];
}
}
pub(crate) fn check_contrast(
vocabulary: &Vocabulary,
selector: &str,
declarations: &[StyleDeclaration],
diagnostics: &mut Vec<Diagnostic>,
) {
fn token_name(value: &str) -> Option<&str> {
value
.trim()
.strip_prefix("token(")?
.strip_suffix(')')
.map(str::trim)
}
let mut foreground: Option<(&StyleDeclaration, &str)> = None;
let mut background: Option<(&StyleDeclaration, &str)> = None;
for declaration in declarations {
let lowered = declaration.name.to_ascii_lowercase();
let Some(name) = token_name(&declaration.value) else {
continue;
};
match lowered.as_str() {
"color" => foreground = Some((declaration, name)),
"background" | "background-color" => background = Some((declaration, name)),
_ => {}
}
}
let (Some((declaration, foreground)), Some((_, background))) = (foreground, background) else {
return;
};
let (Some(foreground_value), Some(background_value)) = (
vocabulary.color_value(foreground),
vocabulary.color_value(background),
) else {
return;
};
let Some(ratio) = noxid_design_ir::contrast_ratio(foreground_value, background_value) else {
return;
};
if ratio >= noxid_design_ir::CONTRAST_AA {
return;
}
diagnostics.push(
Diagnostic::error(
"CSS_CONTRAST_INSUFFICIENT",
format!(
"`{selector}` sets `color: token({foreground})` ({foreground_value}) on `token({background})` ({background_value}), a contrast ratio of {ratio:.2}:1, below the WCAG AA minimum of {:.1}:1 for normal text. Pick a declared Color token that clears AA against `{background}`, or change one of the two tokens' values.",
noxid_design_ir::CONTRAST_AA
),
declaration.span,
)
.with_symbol(declaration.id.to_string())
.with_field("ratio", format!("{ratio:.2}")),
);
}