Exercise 3: Finding and Fixing the Comma-Selector Bug — Possible Solution
====================================================================
THE TEST
------------------------------
elem =
rule_comma = Rule(['h1', '.warning'], [Declaration('color', 'blue')])
rule_plainTag = Rule(['h1'], [Declaration('color', 'red')])
sheet = Stylesheet([rule_comma, rule_plainTag]) # rule_comma written FIRST
cascade(elem, sheet)['color'] # the buggy version from the chapter
cascade_fixed(elem, sheet)['color'] # the fixed version
RESULT
------------------------------
buggy cascade() -> 'red' (WRONG)
fixed cascade_fixed() -> 'blue' (correct)
REPRODUCING THE BUG
------------------------------
The buggy cascade() loop is:
for sel_text in rule.selectors:
sel = parse_selector(sel_text)
if matches_selector(element, sel):
matches.append((specificity(sel), order, rule.declarations))
break
For rule_comma, rule.selectors is ['h1', '.warning']. The loop checks
'h1' FIRST -- it matches (elem's tag is 'h1') -- registers
specificity('h1') == (0,0,1), and `break`s immediately, NEVER checking
whether '.warning' (the second selector in the list) also matches,
even though it does (elem's class is 'warning') and has a genuinely
higher specificity, (0,1,0).
rule_plainTag registers specificity (0,0,1) too (it's just 'h1'). Now
both rules are tied at (0,0,1) in the sort key -- and the tie is
broken by source order, where rule_plainTag (written SECOND) wins:
red. This directly contradicts real CSS behavior, where a comma-list
selector like "h1, .warning { color: blue; }" behaves as if it were
two separate rules sharing one declaration block -- and the
'.warning' half of it genuinely has higher specificity than a bare
'h1' rule, so it should win outright.
THE FIX
------------------------------
def cascade_fixed(element, stylesheet):
matches = []
for order, rule in enumerate(stylesheet.rules):
best_spec = None
for sel_text in rule.selectors:
sel = parse_selector(sel_text)
if matches_selector(element, sel):
s = specificity(sel)
if best_spec is None or s > best_spec:
best_spec = s
if best_spec is not None:
matches.append((best_spec, order, rule.declarations))
matches.sort(key=lambda m: (m[0], m[1]))
result = {}
for spec, order, decls in matches:
for decl in decls:
result[decl.name] = decl.value
return result
Instead of breaking on the first match, the inner loop now checks
EVERY selector in rule.selectors that matches the element, and keeps
whichever one has the highest specificity via `if best_spec is None or
s > best_spec`. For rule_comma, that means checking both 'h1' (0,0,1)
and '.warning' (0,1,0), and correctly keeping (0,1,0) as the rule's
effective specificity for this element. Now rule_comma, (0,1,0), is
genuinely higher than rule_plainTag's (0,0,1) -- rule_comma wins
outright, blue, and source order is never even consulted.
WHY THIS WORKS AS AN ANSWER
------------------------------
The bug wasn't in specificity() itself -- every specificity()
calculation involved was already correct in isolation. The bug was in
which specificity value cascade() chose to ATTRIBUTE to the rule as a
whole once more than one of its own comma-separated selectors matched
the same element. `break`ing on the first match silently assumes the
first-listed selector in a comma list is "the" one that matched, when
in reality any number of them might match simultaneously, and CSS's
own rule is to use the strongest one. This is a genuinely easy bug to
miss by reasoning alone, since single-selector rules (the overwhelming
majority of any real stylesheet) never trigger it at all -- it only
ever surfaces once a rule with more than one comma-separated selector
is tested against an element that happens to match more than one of
those selectors at once.