A Stylesheet and the Cascade
A stylesheet is a list of rules, and a rule is a selector plus some
declarations. Parsing it is the least interesting parser in this guide: split
on {, split on ;, split on :.
The interesting part is that several rules match the same element and say different things, and something has to decide. That decision is what the C in CSS stands for, and it is the only genuinely subtle thing about the language.
The rule
Two comparisons, in order.
- Specificity. How precisely does the selector describe the element?
- Source order. If specificity ties, the later rule wins.
That is the whole cascade, and everything people say about CSS being unpredictable comes from misremembering the first one.
The program
const std = @import("std");
const dom = @import("html.zig");
/// Three counts, compared left to right. Not one number: the familiar
/// 100/10/1 weighting is a simplification that breaks, and the check-yourself
/// on the chapter shows where.
pub const Specificity = struct {
ids: u16 = 0,
classes: u16 = 0,
tags: u16 = 0,
pub fn beats(a: Specificity, b: Specificity) bool {
if (a.ids != b.ids) return a.ids > b.ids;
if (a.classes != b.classes) return a.classes > b.classes;
return a.tags > b.tags;
}
};
pub const Selector = struct {
tag: ?[]const u8 = null,
class: ?[]const u8 = null,
id: ?[]const u8 = null,
pub fn specificity(s: Selector) Specificity {
return .{
.ids = if (s.id != null) 1 else 0,
.classes = if (s.class != null) 1 else 0,
.tags = if (s.tag != null) 1 else 0,
};
}
/// Every component present must match. An absent component matches
/// anything, which is why `*` needs no special case.
pub fn matches(s: Selector, node: dom.Node) bool {
if (node.kind != .element) return false;
if (s.tag) |t| {
if (!std.ascii.eqlIgnoreCase(node.name, t)) return false;
}
if (s.id) |want| {
const got = node.attr("id") orelse return false;
if (!std.mem.eql(u8, got, want)) return false;
}
if (s.class) |want| {
const got = node.attr("class") orelse return false;
var classes = std.mem.tokenizeAny(u8, got, " ");
var found = false;
while (classes.next()) |c| {
if (std.mem.eql(u8, c, want)) found = true;
}
if (!found) return false;
}
return true;
}
};
pub const Declaration = struct { property: []const u8, value: []const u8 };
pub const Rule = struct {
selector: Selector,
decls: [8]Declaration = undefined,
decl_count: u8 = 0,
/// Position in the stylesheet. Ties in specificity are broken by this,
/// which is the entire reason "put your overrides last" works.
order: u16 = 0,
};
fn parseSelector(text: []const u8) Selector {
var s: Selector = .{};
var rest = std.mem.trim(u8, text, " \t\n");
if (rest.len == 0) return s;
// Tag name, if the selector does not start with a marker.
if (rest[0] != '.' and rest[0] != '#' and rest[0] != '*') {
const end = std.mem.findAny(u8, rest, ".#") orelse rest.len;
s.tag = rest[0..end];
rest = rest[end..];
} else if (rest[0] == '*') {
rest = rest[1..];
}
while (rest.len > 0) {
const marker = rest[0];
rest = rest[1..];
const end = std.mem.findAny(u8, rest, ".#") orelse rest.len;
const name = rest[0..end];
if (marker == '.') s.class = name else s.id = name;
rest = rest[end..];
}
return s;
}
pub fn parse(source: []const u8, out: []Rule) ![]Rule {
var n: usize = 0;
var rest = source;
while (std.mem.findScalar(u8, rest, '{')) |open| {
const close = std.mem.findScalar(u8, rest, '}') orelse break;
if (n == out.len) return error.TooManyRules;
var rule: Rule = .{ .selector = parseSelector(rest[0..open]), .order = @intCast(n) };
var decls = std.mem.splitScalar(u8, rest[open + 1 .. close], ';');
while (decls.next()) |decl| {
const colon = std.mem.findScalar(u8, decl, ':') orelse continue;
if (rule.decl_count == rule.decls.len) break;
rule.decls[rule.decl_count] = .{
.property = std.mem.trim(u8, decl[0..colon], " \t\n"),
.value = std.mem.trim(u8, decl[colon + 1 ..], " \t\n"),
};
rule.decl_count += 1;
}
out[n] = rule;
n += 1;
rest = rest[close + 1 ..];
}
return out[0..n];
}
/// The cascade: every declaration that matches, with the winner decided by
/// specificity first and source order second. There is no third tiebreak,
/// because two identical selectors in the same sheet are ordered by position.
pub fn cascade(rules: []const Rule, node: dom.Node, property: []const u8) ?[]const u8 {
var best: ?Declaration = null;
var best_spec: Specificity = .{};
var best_order: u16 = 0;
for (rules) |rule| {
if (!rule.selector.matches(node)) continue;
for (rule.decls[0..rule.decl_count]) |decl| {
if (!std.mem.eql(u8, decl.property, property)) continue;
const spec = rule.selector.specificity();
const wins = best == null or spec.beats(best_spec) or
(!best_spec.beats(spec) and rule.order >= best_order);
if (wins) {
best = decl;
best_spec = spec;
best_order = rule.order;
}
}
}
return if (best) |d| d.value else null;
}
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &stdout_writer.interface;
const stylesheet =
\\p { color: black; margin: 0 }
\\.warning { color: orange }
\\p.warning { color: red; margin: 8 }
\\#urgent { color: crimson }
\\p { margin: 4 }
;
var rules_buf: [16]Rule = undefined;
const rules = try parse(stylesheet, &rules_buf);
try out.print("{d} rules\n", .{rules.len});
var text: [32]u8 = undefined;
for (rules) |rule| {
const s = rule.selector.specificity();
var w: std.Io.Writer = .fixed(&text);
if (rule.selector.tag) |t| try w.print("{s}", .{t});
if (rule.selector.class) |c| try w.print(".{s}", .{c});
if (rule.selector.id) |i| try w.print("#{s}", .{i});
try out.print(" order {d} specificity {d},{d},{d} {s}\n", .{
rule.order, s.ids, s.classes, s.tags, w.buffered(),
});
}
const html = "<p class=\"warning\" id=\"urgent\">Careful</p><p>Ordinary</p>";
var nodes: [16]dom.Node = undefined;
const tree = try dom.parseInto(html, &nodes);
try out.writeAll("\nwhat each element gets\n");
var child = nodes[tree.root].first;
while (child) |c| : (child = nodes[c].next) {
const node = nodes[c];
if (node.kind != .element) continue;
try out.print(" <{s} class={s} id={s}>\n", .{
node.name,
node.attr("class") orelse "-",
node.attr("id") orelse "-",
});
for ([_][]const u8{ "color", "margin" }) |property| {
try out.print(" {s: <7} {s}\n", .{ property, cascade(rules, node, property) orelse "(unset)" });
}
}
try out.flush();
}What just happened
<p class="warning" id="urgent"> got color: crimson. Four rules matched
it, saying black, orange, red and crimson. #urgent won because an id beats
any number of classes, and that is specificity doing its job.
The same element got margin: 8, from p.warning. Only two rules mention
margin: p at specificity 0,0,1 and p.warning at 0,1,1. The class component
decides it.
The plain <p> got margin: 4, from the last rule. Two rules with the
identical selector p both set margin. Specificity ties, so source order
breaks it, and the later one wins. This is the mechanism behind every “just put
your overrides at the bottom” instruction anybody has ever given you.
Specificity is three numbers, not one. The output shows them separately:
0,1,1 for p.warning, 1,0,0 for #urgent. They are compared left to
right, like version numbers, so any id beats any number of classes.
Check yourself
You will often see specificity described as a single score: 100 for an id, 10 for a class, 1 for a tag, add them up. Where does that break?
At eleven classes. .a.b.c.d.e.f.g.h.i.j.k scores 110 under that scheme, which
beats an id’s 100, and in a real browser it does not: the comparison is
component by component, so 1,0,0 beats 0,11,0 no matter how large the
middle number gets.
The weighting is a teaching shortcut that happens to give the right answer for every realistic stylesheet, which is why it survives. It is worth knowing it is a shortcut, because the moment you build something that computes specificity, using one number is a bug rather than a simplification. The implementation above uses a struct with three fields for that reason.
What this one leaves out
Inheritance. Some properties, color among them, are inherited by children
that do not set them, and others, margin among them, are not. That list is
per-property and defined by the spec, and the next chapter needs it, because a
paragraph inside a styled div takes its colour from somewhere.
Combinators. div p matches a p with a div ancestor, and div > p
requires it to be the parent. Both need the tree rather than the node, which
makes matching a walk upwards rather than a test in isolation, and is why
selector matching in real engines runs right-to-left: start from the element
you have and walk up, rather than finding every div and searching down.
!important, which sits above specificity entirely, and the reason it
exists at all is user stylesheets: a reader who needs larger text has to be
able to overrule the page.
If you have written C
The parsing is strtok on braces and semicolons and holds no surprises. The
part worth thinking about is the matching, because it is the hot loop of a
browser.
Naively, styling a document is every rule against every element, which is
rules times nodes and is far too slow for a real stylesheet against a real
page. Engines instead index the rules by their rightmost component into
hash maps keyed on tag, class and id, so an element only ever tests the handful
of rules that could possibly match it. That is why the shape of your selectors
affects performance at all, and why a selector ending in * is the one that
costs.
The other C-specific concern is that every string here points into the stylesheet source, so the source must outlive the parsed rules. That is the same borrowing decision as everywhere else in this track, and in a browser it is why a stylesheet’s bytes are kept alive for as long as the page is.
Next: layout, which turns this tree of styled nodes into rectangles with positions.