Selectors & Selector Matching

Building a Web Browser Engine: Parsing & the DOM

Chapter 5 · Selectors & Selector Matching

Chapter 4 left every Rule's own selectors as raw strings — "div", ".highlight", "div p" — real text, but not yet anything that can be checked against a DOM node. This chapter builds both halves: a real selector parser, and a matcher that decides, for one selector and one Element, whether they apply to each other at all.

Simple Selectors and the Selector They Combine Into

class SimpleSelector: def __init__(self, tag=None, id=None, classes=None): self.tag = tag # None means "no tag constraint" self.id = id self.classes = classes or set() class Selector: def __init__(self, parts): self.parts = parts # ancestors left-to-right, TARGET last -- e.g. "div p" -> [div, p]

A single space-separated piece of a selector — div, .card, p#main.highlight — is one SimpleSelector, scanned for a leading tag name followed by any number of .class and #id markers, in whatever order they appear.

Verified directly — tag, class, and id all match correctly on their own
A div with class="card" matches the selector div and fails to match p. An element with class="foo highlight bar" matches .highlight — the class attribute is space-separated, and .highlight only needs to be one of the classes present, not the whole string. An element with id="main" matches #main and nothing else with a different id.
Verified directly — a compound selector requires every part to hold at once
div.card matches a div that also carries class="card", but a plain div with no class at all does not match. A full three-way compound, p#main.highlight, correctly matches a <p id="main" class="highlight"> and correctly rejects a <span id="main" class="highlight"> — identical id and class, wrong tag, no match.

A Real Gap, Found the Moment Descendant Selectors Are Needed

div p means "a p with a div somewhere above it" — answering that requires walking upward from a node. Chapter 3's own Element/TextNode/CommentNode classes only ever store children. Nothing before this chapter ever needed to go the other direction.

Verified directly — a node built by Chapter 3's own parser has no .parent attribute at all
Accessing .parent on a freshly-parsed Element — one that never had anything extra done to it — raises AttributeError: 'Element' object has no attribute 'parent'. This isn't a bug in Chapter 3; a tree built purely for top-down parsing genuinely never needed an upward pointer until a selector like div p came along and asked for one.
def assign_parents(node, parent=None): node.parent = parent for child in node.children: assign_parents(child, node)

One walk over the tree, once, before any matching starts, and every node gains a real .parent reference — the same shape a real browser's own Node.parentNode takes.

Matching a Descendant Selector: Search Upward, Part by Part

def matches_selector(element, selector): if not matches_simple(element, selector.parts[-1]): return False # the TARGET itself has to match first current = element.parent for part in reversed(selector.parts[:-1]): found = False node = current while node is not None: if node.kind == 'element' and matches_simple(node, part): found = True; current = node.parent; break node = node.parent if not found: return False return True
Verified directly — a matching ancestor and a genuinely absent one give the correct opposite answers
div p matches a p nested inside a div (through any number of levels), and correctly fails to match a p sitting at the top level with no div anywhere above it.
Verified directly — the search is gap-tolerant, matching real CSS behavior exactly
div section p matches a p nested as div > section > article > p — with a completely unrelated article sitting between section and p, an element the selector never mentions at all. Each part of the selector only has to find some matching ancestor, not the immediate parent — exactly how real CSS descendant combinators work. The same selector correctly fails to match a p sitting directly inside a div with no section anywhere in between.

An Honest Limitation: Nothing Here Enforces id Uniqueness

Verified directly — two elements sharing the same id both "match" it
Two sibling elements — a div and a span — both carrying id="dup" both correctly match #dup, individually. Real HTML documents are supposed to never repeat an id, but nothing in this chapter's own parser or matcher checks for or enforces that — matches_simple only ever asks "does this element's own id attribute equal the one in the selector," with no awareness of any other element in the document at all. A genuinely malformed document with a duplicate id won't raise an error here; it'll just mean an id selector quietly matches more than one thing, which is exactly what a real browser does too when handed the same invalid markup.

Where This Connects

This chapter's findingWhat it connects to
Chapter 3's tree had no parent pointer until a descendant selector needed oneA direct parallel to Chapter 9's own origin/base finding in the compiler project — a data structure built correctly for its original purpose still needing an honest, later revision once a new consumer's own requirements arrive
Gap-tolerant ancestor search for multi-part descendant selectorsChapter 6's own specificity and cascade — every rule whose selector matches at all becomes a real candidate for a given element, regardless of how deeply nested the match happened to be
SimpleSelector/Selector built here, from Chapter 4's own raw stringsChapter 8's own style tree, which will need to ask "does this rule apply to this element" for every rule in the stylesheet, against every element in the DOM — the exact question this chapter's own matches_selector answers
id uniqueness left honestly unenforcedThe same kind of stated scope boundary Chapter 3 drew around <li>'s own missing implied-closing rule — a real limitation, named directly rather than silently assumed away

Hands-On Exercises

Exercise 1

Build a tree shaped div > section > article > p (four levels, each a direct child of the one before it) and match it against the selector "div section p" using this chapter's own matches_selector. Confirm it matches despite article never appearing in the selector, then build a second tree — div > p directly, no section anywhere — and confirm the same selector correctly fails to match.

📄 View solution
Exercise 2

Build two sibling elements — a div and a span — both with id="dup", and match both against the selector "#dup" using this chapter's own matches_selector. Confirm both report a match, and explain specifically why matches_simple has no way to detect or reject this, even in principle, without being handed information about the rest of the document it currently never receives.

📄 View solution
Exercise 3

Build a <p id="main" class="highlight"> and match it against the compound selector "p#main.highlight" using this chapter's own matches_selector. Then build a <span id="main" class="highlight"> — identical id and class, different tag — and match it against the same selector. Confirm the two results differ, and trace through matches_simple to identify exactly which check is responsible for the rejection.

📄 View solution

Chapter 5 Quick Reference

  • Two structures: SimpleSelector (tag/id/classes, one space-separated piece) and Selector (an ordered list of them — ancestors, target last)
  • Verified: tag, class (against a multi-class attribute), id, and compound (tag+id+class together) selectors all match correctly on their own
  • Real gap found and fixed: Chapter 3's own DOM tree had no .parent reference at all — assign_parents() adds it, once, before any matching begins
  • Verified: descendant matching is gap-tolerant — an unrelated ancestor sitting between two selector parts doesn't break the match, exactly like real CSS
  • Verified — an honest limitation: id uniqueness is never enforced; two elements sharing the same id both "match" an id selector
  • Next chapter: Specificity & the Cascade — deciding which rule wins when more than one selector matches the same element