use crate::compat::{format, String, Vec};
use crate::style::css::{CssDeclaration, CssParser};
#[derive(Debug, Clone, Copy)]
pub struct TracedSheet<'a> {
pub name: &'a str,
pub css: &'a str,
pub priority: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComputedOrigin {
pub property: String,
pub value: String,
pub selector: String,
pub sheet: String,
pub priority: u8,
}
impl ComputedOrigin {
pub fn describe(&self) -> String {
format!(
"{}: {} (from `{}` in sheet `{}`, priority {})",
self.property, self.value, self.selector, self.sheet, self.priority
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceNote {
UnparsedSelector {
selector: String,
sheet: String,
},
UnknownProperty {
property: String,
value: String,
sheet: String,
},
InvalidValue {
property: String,
value: String,
reason: String,
sheet: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StyleTrace {
pub origins: Vec<ComputedOrigin>,
pub notes: Vec<TraceNote>,
}
impl StyleTrace {
pub fn origin_of(&self, property: &str) -> Option<&ComputedOrigin> {
self.origins.iter().find(|origin| origin.property == property)
}
pub fn has(&self, property: &str) -> bool {
self.origin_of(property).is_some()
}
}
pub fn trace_cascade(
kind: &str,
class: Option<&str>,
id: Option<&str>,
state: Option<crate::style::selector::PseudoState>,
sheets: &[TracedSheet<'_>],
) -> StyleTrace {
let mut ordered: Vec<&TracedSheet<'_>> = sheets.iter().collect();
ordered.sort_by_key(|sheet| sheet.priority);
let mut origins: Vec<ComputedOrigin> = Vec::new();
let mut notes: Vec<TraceNote> = Vec::new();
for sheet in ordered {
let Ok(rules) = CssParser::parse_rules(sheet.css) else {
continue;
};
for rule in rules {
let Some(selector) = CssParser::parse_selector(&rule.selector_text) else {
notes.push(TraceNote::UnparsedSelector {
selector: rule.selector_text.clone(),
sheet: String::from(sheet.name),
});
continue;
};
if !selector.matches(kind, class, id, state) {
continue;
}
for declaration in &rule.declarations {
if !CssParser::is_known_property(&declaration.property) {
notes.push(TraceNote::UnknownProperty {
property: declaration.property.clone(),
value: declaration.value.clone(),
sheet: String::from(sheet.name),
});
continue;
}
let mut scratch = crate::style::WidgetStyle::default();
if let Err(reason) =
CssParser::apply_declarations(core::slice::from_ref(declaration), &mut scratch)
{
notes.push(TraceNote::InvalidValue {
property: declaration.property.clone(),
value: declaration.value.clone(),
reason,
sheet: String::from(sheet.name),
});
continue;
}
record(&mut origins, declaration, &rule.selector_text, sheet);
}
}
}
origins.sort_by(|left, right| left.property.cmp(&right.property));
notes.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
StyleTrace { origins, notes }
}
fn record(
origins: &mut Vec<ComputedOrigin>,
declaration: &CssDeclaration,
selector_text: &str,
sheet: &TracedSheet<'_>,
) {
let origin = ComputedOrigin {
property: declaration.property.clone(),
value: declaration.value.clone(),
selector: String::from(selector_text),
sheet: String::from(sheet.name),
priority: sheet.priority,
};
if let Some(existing) = origins.iter_mut().find(|o| o.property == origin.property) {
*existing = origin;
} else {
origins.push(origin);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_matching_rule_reports_its_origin() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet {
name: "base",
css: "Button { background-color: #ff0000; }",
priority: 0,
}],
);
let origin = trace.origin_of("background-color").expect("the rule set the property");
assert_eq!(origin.value, "#ff0000");
assert_eq!(origin.selector, "Button");
assert_eq!(origin.sheet, "base");
assert_eq!(origin.priority, 0);
}
#[test]
fn a_higher_priority_sheet_wins() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[
TracedSheet { name: "base", css: "Button { color: #111111; }", priority: 0 },
TracedSheet { name: "theme", css: "Button { color: #222222; }", priority: 5 },
],
);
let origin = trace.origin_of("color").expect("a rule set the property");
assert_eq!(origin.value, "#222222", "the higher-priority sheet's value must win");
assert_eq!(origin.sheet, "theme");
}
#[test]
fn the_trace_agrees_with_the_applied_value() {
use crate::style::WidgetStyle;
let css = "Button { background-color: #abcdef; }";
let mut applied = WidgetStyle::default();
CssParser::apply_declaration_text("background-color: #abcdef", &mut applied)
.expect("a known property applies");
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet { name: "s", css, priority: 0 }],
);
assert_eq!(applied.background_color, Some(crate::core::Color::rgb(0xab, 0xcd, 0xef)));
assert_eq!(trace.origin_of("background-color").expect("traced").value, "#abcdef");
}
#[test]
fn an_unparsed_selector_is_noted() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet { name: "s", css: ": { color: #111111; }", priority: 0 }],
);
assert!(trace.origins.is_empty());
assert_eq!(trace.notes.len(), 1);
assert!(matches!(trace.notes[0], TraceNote::UnparsedSelector { .. }));
}
#[test]
fn only_matching_rules_are_traced() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet {
name: "s",
css: "Label { color: #111111; } Button { color: #222222; }",
priority: 0,
}],
);
assert_eq!(trace.origins.len(), 1);
assert_eq!(trace.origin_of("color").expect("traced").value, "#222222");
}
#[test]
fn the_last_declaration_of_a_property_wins() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet {
name: "s",
css: "Button { color: #111111; color: #222222; }",
priority: 0,
}],
);
assert_eq!(trace.origin_of("color").expect("traced").value, "#222222");
}
#[test]
fn the_origins_are_sorted_by_property() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet {
name: "s",
css: "Button { text-color: #ffffff; background-color: #000000; }",
priority: 0,
}],
);
let names: Vec<&str> = trace.origins.iter().map(|o| o.property.as_str()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "the origins must be sorted for a stable report");
}
#[test]
fn an_unknown_property_is_noted_and_not_a_winner() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet {
name: "s",
css: "Button { text-sahdow: 1px; color: #111111; }",
priority: 0,
}],
);
assert!(!trace.has("text-sahdow"), "an ignored property must not be reported as applied");
assert!(trace.has("color"), "the valid declaration still wins");
assert_eq!(trace.notes.len(), 1, "{:?}", trace.notes);
match &trace.notes[0] {
TraceNote::UnknownProperty { property, value, sheet } => {
assert_eq!(property, "text-sahdow");
assert_eq!(value, "1px");
assert_eq!(sheet, "s");
}
other => panic!("expected an unknown-property note, got {other:?}"),
}
}
#[test]
fn a_refused_value_is_noted_with_its_reason_and_loses_to_a_valid_one() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet {
name: "s",
css: "Button { color: #111111; color: notacolour; }",
priority: 0,
}],
);
assert_eq!(
trace.origin_of("color").expect("a rule set the property").value,
"#111111",
"the refused declaration must not win"
);
assert_eq!(trace.notes.len(), 1, "{:?}", trace.notes);
match &trace.notes[0] {
TraceNote::InvalidValue { property, value, reason, sheet } => {
assert_eq!(property, "color");
assert_eq!(value, "notacolour");
assert_eq!(sheet, "s");
assert!(reason.contains("color"), "the parser's reason must travel: {reason}");
}
other => panic!("expected an invalid-value note, got {other:?}"),
}
}
#[test]
fn the_notes_are_deterministically_ordered() {
let sheets = [TracedSheet {
name: "s",
css: "Button { aaa-property: 1; zzz-property: 2; }",
priority: 0,
}];
let first = trace_cascade("Button", None, None, None, &sheets);
let second = trace_cascade("Button", None, None, None, &sheets);
assert_eq!(first.notes, second.notes, "two runs must render the same report");
}
#[test]
fn describe_names_the_source() {
let trace = trace_cascade(
"Button",
None,
None,
None,
&[TracedSheet { name: "theme", css: "Button { color: #123456; }", priority: 3 }],
);
let rendered = trace.origin_of("color").expect("traced").describe();
assert!(rendered.contains("#123456"), "{rendered}");
assert!(rendered.contains("theme"), "{rendered}");
}
}