Tokenizing and Parsing CSS

Building a Web Browser Engine: Parsing & the DOM

Chapter 4 · Tokenizing and Parsing CSS

CSS's own grammar is a lot simpler than HTML's — no implicit closing rules, no fourteen special-cased element types, no ambiguity about what's a tag versus what's content. One chapter is enough to take CSS source text all the way to a real, structured Stylesheet — the second half of what Chapter 8's own style tree will need, alongside the DOM tree Chapter 3 already built.

Three Small Structures

class Declaration: def __init__(self, property, value): self.property = property self.value = value class Rule: def __init__(self, selectors, declarations): self.selectors = selectors # a LIST -- one rule can match several selectors self.declarations = declarations class Stylesheet: def __init__(self, rules): self.rules = rules

selectors stays a flat list of raw strings in this chapter — ['h1', 'h2'] for h1, h2 { ... } — not yet parsed into anything structured. Chapter 5 is where a selector string actually becomes something that can be matched against a DOM node.

Comments First, Before Anything Else

CSS comments (/* ... */) can legally appear almost anywhere — between rules, inside a declaration block, in the middle of a selector list. Stripping them out in one pass, before any real parsing starts, means nothing downstream ever has to think about them again.

def strip_comments(css): result = [] i = 0 while i < len(css): if css[i:i+2] == '/*': end = css.find('*/', i + 2) i = end + 2 if end != -1 else len(css) else: result.append(css[i]); i += 1 return ''.join(result)

The Real Quirk: CSS Comments Don't Nest

Writing what looks like a comment inside a comment — to temporarily disable a block that already has its own comment in it, say — doesn't do what it looks like it should.

Verified directly — the FIRST "*/" ends the comment, no matter how many "/*" came before it
strip_comments("/* outer /* inner */ still outer */ p { color: red; }") returns " still outer */ p { color: red; }" — not an empty string. The scanner has no concept of comment depth; it opens on the first /* and closes on the very next */ it finds, full stop. Everything after that first closing marker — including the second, now-orphaned */ — is treated as ordinary CSS text.
Verified directly — followed all the way through the parser, this silently corrupts a real selector, with no error anywhere
Feeding that exact string through the full parse_css produces one rule whose own selectors is ['still outer */ p'] — not the clean ['p'] a developer obviously intended. The leftover comment fragment doesn't vanish; it gets swept straight into the next rule's own selector text as ordinary characters. Once Chapter 5 builds real selector matching, a selector string like that will simply never match anything in any real document — no crash, no warning, just CSS that silently does nothing.
Two separate comments in a row is a completely different, perfectly safe thing
parse_css("/* one */ /* two */ p { color: red; }") parses cleanly to a single rule with selectors == ['p'] — exactly as expected. Two consecutive comments, each properly opened and closed on its own, is nothing like one comment written to look nested. The failure above is specifically about a /* appearing before the matching */ of an already-open comment — a structural mistake, not "too many comments."

The Rest of the Grammar

def parse_css(css): css = strip_comments(css) rules = [] i = 0 while i < len(css): open_brace = css.find('{', i) if open_brace == -1: break selector_text = css[i:open_brace].strip() close_brace = css.find('}', open_brace) if close_brace == -1: break decl_text = css[open_brace+1:close_brace] if selector_text: selectors = [s.strip() for s in selector_text.split(',') if s.strip()] rules.append(Rule(selectors, parse_declarations(decl_text))) i = close_brace + 1 return Stylesheet(rules)
Verified directly — a trailing semicolon on the last declaration is genuinely optional
parse_css("p { color: red; font-size: 16px; }") and parse_css("p { color: red; font-size: 16px }") — the second missing its own final semicolon — produce identical declaration lists. Splitting on ; naturally produces a trailing empty chunk when the semicolon is present, which the blank-chunk check simply skips; when it's absent, there's no empty chunk to skip in the first place. The same code handles both shapes without a special case for either.
Verified directly — a multi-token value like "10px 20px" survives intact as one value, not two
parse_css("div { margin: 10px 20px; }") produces exactly one Declaration('margin', '10px 20px'). Splitting only ever happens on ; (between declarations) and the first : within each chunk (between property and value) — nothing inside parse_declarations ever splits on whitespace, so a value that's legitimately several space-separated tokens stays exactly that, one string.
Verified directly — arbitrary indentation and line breaks change nothing about the result
A messily formatted, multi-line version of the same two rules — extra blank lines, inconsistent indentation, spaces around the colon — produces byte-for-byte identical Rule objects to a tightly packed one-liner. Every split point in this chapter's own parser strips whitespace immediately afterward, so formatting is never load-bearing.

Where This Connects

This chapter's findingWhat it connects to
A flat list of raw selector strings on each RuleChapter 5's own selector matching — this chapter deliberately stops at "here is the text," leaving "does this text match this DOM node" as a separate, dedicated problem
CSS comments stripped once, up front, before any rule parsing beginsMirrors Chapter 3's own closetag handling being defensive by construction — both chapters choose to make one part of the pipeline robust early, so nothing downstream has to re-solve the same problem
A corrupted selector from a non-nested comment produces no error anywhereA direct parallel to this course's own Chapter 1 finding about regex and HTML — a parser that silently produces a plausible-looking wrong answer is a harder failure mode to catch than one that crashes loudly
Property names lowercased during parsingChapter 3's own tag-name lowercasing — the same "normalize once, at the source, so every later consumer can assume it's already done" discipline, applied to CSS instead of HTML

Hands-On Exercises

Exercise 1

Parse "h1, /* comment */ h2 { color: red; }" using this chapter's own parse_css — a comment sitting between two comma-separated selectors, not inside a declaration block. Verify the resulting rule's own selectors list, and explain why placing strip_comments before selector-splitting is what makes this work correctly.

📄 View solution
Exercise 2

Parse "p {}" — a rule with a genuinely empty declaration block — using this chapter's own parse_css. Confirm this doesn't raise an error and determine exactly what the resulting Rule's own declarations list contains, then explain which specific check inside parse_declarations is responsible for handling an empty block correctly.

📄 View solution
Exercise 3

Parse "/* one */ /* two */ p { color: red; }" — two separate, properly closed comments in a row — and confirm the resulting selector is the clean ['p'], not a corrupted string the way this chapter's own nested-looking example produced. Explain precisely, in terms of how strip_comments's own scan position advances, why two consecutive comments never trigger the same failure a nested-looking one does.

📄 View solution

Chapter 4 Quick Reference

  • Three structures: Declaration (property/value), Rule (a list of selector strings + a list of declarations), Stylesheet (a list of rules)
  • Verified — the real quirk: CSS comments don't nest; the first */ always ends the comment, no matter how many /* came before it
  • Verified: a non-nested-looking comment silently corrupts the following selector, with no error raised anywhere in the pipeline
  • Verified: a trailing semicolon on the last declaration is genuinely optional — the same code handles both shapes with no special case
  • Verified: a multi-token value like "10px 20px" survives intact as one value, since splitting only ever happens on ; and the first :
  • Verified: arbitrary whitespace and indentation never change the parsed result
  • Next chapter: Selectors & Selector Matching — deciding whether one of this chapter's own raw selector strings actually applies to a given DOM node