
从 CSS 字符串到 AST(一)—— 词法分析器(Lexer)的实现
最近在实习的时候,遇到了一些需求,需要自己去实现 CSS 的解析、(伪)渲染流程。以之为契机,我学习了一下编译相关的知识,其中的第一环就是 Lexer。
本文中的代码均使用 Go 实现,成果已经作为 Go 库 go.baoshuo.dev/csslexer
发布。
- GitHub 仓库:renbaoshuo/go-css-lexer
- 实现标准:CSS Syntax Module Level 3 (W3C Candidate Recommendation Draft; 24 December 2021)
建议在阅读本文前对 CSS 标准内容有一定理解。
词法分析
词法分析(lexical analysis)是计算机科学中将字符序列转换为记号(token,也有译为标记或词元)序列的过程。进行词法分析的程序或者函数叫作词法分析器(lexical analyzer,简称 lexer),也叫扫描器(scanner)。词法分析器一般以函数的形式存在,供语法分析器调用。
——维基百科
词法分析是编译中的第一个步骤。它读入组成源码的字符流,并将他们组织成一个个的词素(lexeme)。有了词素以后,识别并标注它的类型,就可以生成一个 <token-name, attribute-value>
形式的词法单元(token)。这个单元会被传送给下一个步骤 —— 语法分析 —— 进行后续的处理。
在进行词法分析之前,首先要设定好到底有多少种 token 类型,然后再确定每个 token 类型的判断条件和解析方式。
Token 的分类
由 CSS Syntax Module Level 3 中的 4. Tokenization 一节可以得到 CSS 的 token 有以下几种类型:
<ident-token>
<function-token>
<at-keyword-token>
<hash-token>
<string-token>
<bad-string-token>
<url-token>
<bad-url-token>
<delim-token>
<number-token>
<percentage-token>
<dimension-token>
<whitespace-token>
<CDO-token>
<CDC-token>
<colon-token>
<semicolon-token>
<comma-token>
<[-token>
<]-token>
<(-token>
<)-token>
<{-token>
<}-token>
为了解析方便,我们又在标准的 token 类型外拓展了几个 token 类型,得到了下面的 token 表:
<ident-token> IdentToken
<function-token> FunctionToken foo()
<at-keyword-token> AtKeywordToken @foo
<hash-token> HashToken #foo
<string-token> StringToken
<bad-string-token> BadStringToken
<url-token> UrlToken url()
<bad-url-token> BadUrlToken
<delim-token> DelimiterToken
<number-token> NumberToken 3
<percentage-token> PercentageToken 3%
<dimension-token> DimensionToken 3em
<whitespace-token> WhitespaceToken
<CDO-token> CDOToken <!--
<CDC-token> CDCToken -->
<colon-token> ColonToken :
<semicolon-token> SemicolonToken ;
<comma-token> CommaToken ,
<(-token> LeftParenthesisToken (
<)-token> RightParenthesisToken )
<[-token> LeftBracketToken [
<]-token> RightBracketToken ]
<{-token> LeftBraceToken {
<}-token> RightBraceToken }
<EOF-token> EOFToken
CommentToken /* ... */
IncludeMatchToken ~=
DashMatchToken |=
PrefixMatchToken ^=
SuffixMatchToken $=
SubstringMatchToken *=
ColumnToken ||
UnicodeRangeToken
于是乎,我们就有了词法分析的期望目标产物 —— 由这 33 种类型的 token 组成的 token 流。
输入流
工欲善其事,必先利其器。
在实现真正的词法分析流程以前,我们需要编写一套输入流来辅助我们完成读入的操作。
首先,我们给出输入流的定义:
// Input represents a stream of runes read from a source.
type Input struct {
runes []rune // The runes in the input stream.
pos int // The current position in the input stream.
start int // The start position of the current token being read.
err error // Any error encountered while reading the input.
}
这个结构封装了对一个 rune 切片的访问,并维护了当前扫描的位置(pos)和当前正在扫描的 token 的起始位置(start)。
需要注意的是,我们使用 rune
而不是 byte
来存储内容,这样做的原因是为了便于处理代码中包含的 Emoji 等 Unicode 字符。
为了使用方便,这个输入流可以从 string
、[]rune
、[]byte
和 io.Reader
初始化。实现细节可以查看仓库中的 input.go
,各个函数签名如下:
NewInput(input string) *Input
NewInputRunes(runes []rune) *Input
NewInputBytes(input []byte) *Input
NewInputReader(r io.Reader) *Input
接下来,我们需要设计一系列合理的方法,使得这个输入流的使用能够在满足我们的实际需求的同时,还保持简洁的风格。
在 4.2 节的一系列定义中,通过观察不难发现,在解析过程中会不断地出现 consume 和 reconsume 的操作,也就是说,在输入流的末尾会不断地进行 pop_back 和 push_back 的操作。那么我们可以将这些操作转化为「预读」和「后移指针」的操作,以此来减少频繁在流末尾进行的弹出和插入操作。
于是,我们就有了以下两个方法:
func (z *Input) Peek(n int) rune
预读输入流中
pos+n
位置的字符。func (z *Input) Move(n int)
将当前输入流的指针后移
n
位。
经过阅读规范以后,不难发现一个 token 可以由几个不同类别的字符序列组成,比如 16px
就是一个 16
(number sequence) 和一个 px
(ident sequence) 共同组成的 dimension-token。所以我们在解析一个 token 的时候可能会调用多个解析函数,那么就需要在 token 级别做一个固定的输出模式。
于是,我们定义 func (z *Input) Shift() []rune
来弹出当前 token,并更新 Input
实例中的 start
值,以开始下一 token 的解析。
不过后续在解析 url-token 的时候遇到了需要读取当前已经 consume 的内容的情况,于是将 Shift
方法拆分成了 Current
和 Shift
两个不同的方法,以便使用。
除此以外,在解析的时候还有需要在满足某一特定条件下一直 consume 的能力需求,因此又设计了较为通用的 func (z *Input) MoveWhilePredicate(pred func(rune) bool)
方法,来实现这一能力。
加上错误处理逻辑以后,整个 Input
的方法如下:
func (z *Input) PeekErr(pos int) error
func (z *Input) Err() error
func (z *Input) Peek(n int) rune
func (z *Input) Move(n int)
func (z *Input) Current() []rune
func (z *Input) Shift() []rune
func (z *Input) MoveWhilePredicate(pred func(rune) bool)
接下来,我们就可以正式开始 lexer 的编写了。
词法分析器
其实 Lexer 的方法框架设计就相对简单了,下面直接给出定义:
type Lexer struct {
r *Input // The input stream of runes to be lexed.
}
func (l *Lexer) Err() error
func (l *Lexer) Next() (TokenType, []rune)
在 Next
方法中有一个巨大的 switch-case 语句,这里面包含了 4.3.1. Consume a token 中所描述的所有在 token 开始时的情形。我们将会根据一个 token 开始的几个字符(小于等于 3 个)来确定这个 token 的后续部分应该如何解析。
Token 开始处的分类讨论
开始解析 token 的时候一定是在文件流的开头或者上一个 token 刚刚解析完毕的时候,那么此时我们只需要根据对应规则判断 token 类型即可。
首先预读 1 个字符,记为 next
,然后对这个字符进行分类讨论。
EOF
:直接返回 EOF-token。\t
,\n
,\r
,\f
,:根据标准需要将此字符及后续的所有 whitespace 组合成一个 whitespace-token。
/
:如果是/*
则一直读取到*/
或者EOF
作为 comment-token。'
(单引号),"
(双引号):遇到这两种引号,会调用字符串解析函数consumeStringToken()
。该函数会持续读取字符,直到遇到与之匹配的结束引号。在此过程中,它会处理转义字符(如\"
)。如果在中途遇到换行符或文件末尾,则会生成一个 bad-string-token,否则生成一个 string-token。0
~9
的数字字符:如果以数字开头,确定无疑是数字类型,调用数字解析函数consumeNumericToken()
。(
,)
,[
,]
,{
,}
:生成对应的括号字符。function-token 或者 url-token 的情况会在处理 ident-like 的时候另行考虑。+
,.
:这两个字符,再加上-
,都比较特殊。不过-
需要包含一些额外的判断,因此归属于另外一条规则处理。- 解析器会向后预读,通过
nextCharsAreNumber()
判断后续字符是否能构成一个合法的数字(例如+1.5
,.5
)。 - 如果可以,则调用
consumeNumericToken()
将其完整解析为一个 numeric-token。 - 如果不构成数字,则
+
和.
会被当作 delimiter-token。
- 解析器会向后预读,通过
-
:除了像+
一样判断是否有可能进入数字的处理逻辑以外,还需要考虑作为-->
(CDC-token) 和 ident-like 的情况。如果都不是才会被当做 delimiter-token。if l.nextCharsAreNumber() { return l.consumeNumericToken() } if l.r.Peek(1) == '-' && l.r.Peek(2) == '>' { l.r.Move(3) // consume "-->" return CDCToken, l.r.Shift() } if l.nextCharsAreIdentifier() { return l.consumeIdentLikeToken() } l.r.Move(1) return DelimiterToken, l.r.Shift()
<
:如果能构成<!--
,解析为一个 CDO-token,否则解析为 delimiter-token。*
,^
,$
,|
,~
: 这些是属性选择器中的匹配符。- 如果它们后面紧跟
=
,则会组合成一个专有 token:*=
→ substring-match-token^=
→ prefix-match-token$=
→ suffix-match-token~=
→ include-match-token|=
→ dash-match-token
- 特别地,对于
|
,如果能够组成||
,则会成为 column-token。 - 如果没有,则单独作为 delimiter-token。
- 如果它们后面紧跟
@
:如果后续的字符能够组成一个 identifier,那么解析为 at-keyword-token,否则解析为 delimiter-token。,
(逗号):直接生成 comma-token。:
(冒号):直接生成 colon-token。;
(分号):直接生成 semicolon-token。u
或U
:这是一个特殊前缀。如果其后是 + 紧跟着十六进制数字或 ? (例如 U+26 或 u+A?),则调用consumeUnicodeRangeToken()
解析为一个 urange-token。否则,按标识符处理。- 这里有一个坑点,需要在编写 parser 的时候注意,比如
u+a
既是一个合法的 unicode-range,也是一个合法的 selector,需要根据上下文来判定。
- 这里有一个坑点,需要在编写 parser 的时候注意,比如
1 <= c <= 31,
!
,%
,&
,=
,>
,?
,`
, 127:解析为 delimiter-token。其余字符:尝试解析为 ident-like。
整个流程在 lexer.go
的 24-198 行,由于篇幅原因此处就不贴完整代码了。
Token 解析
为了方便,我们为几种逻辑复杂 / 需要重用的 token 解析逻辑进行了封装,产生了如下函数:
- 先 consume 一个数字;
- 如果后续跟一个合法的 name,则 consume 这个 name 作为它的单位,组合为 dimension-token;
- 如果后续跟一个
%
,consume 掉这个%
,产生一个 percentage-token; - 否则产生一个 number-token。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-numeric-token func (l *Lexer) consumeNumericToken() (TokenType, []rune) { l.consumeNumber() if l.nextCharsAreIdentifier() { l.consumeName() return DimensionToken, l.r.Shift() } else if l.r.Peek(0) == '%' { l.r.Move(1) // consume '%' return PercentageToken, l.r.Shift() } return NumberToken, l.r.Shift() }
- 有以下几种情况:
U+0000FF
,+
后面可以跟 1 ~ 6 个 16 进制数字;U+0000??
,+
后面先跟 16 进制数字再跟?
(通配符),总数不超过 6 个;U+0001-0002
,-
两侧可以有 1 ~ 6 个 16 进制数字。
- 这些情况需要各自分类讨论,最后产生一个 urange-token。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#urange func (l *Lexer) consumeUnicodeRangeToken() (TokenType, []rune) { // range start start_length_remaining := 6 for next := l.r.Peek(0); start_length_remaining > 0 && next != EOF && isASCIIHexDigit(next); next = l.r.Peek(0) { l.r.Move(1) // consume the hex digit start_length_remaining-- } if start_length_remaining > 0 && l.r.Peek(0) == '?' { // wildcard range for start_length_remaining > 0 && l.r.Peek(0) == '?' { l.r.Move(1) // consume the '?' start_length_remaining-- } } else if l.r.Peek(0) == '-' && isASCIIHexDigit(l.r.Peek(1)) { // range end l.r.Move(1) // consume the '-' end_length_remaining := 6 for next := l.r.Peek(0); end_length_remaining > 0 && next != EOF && isASCIIHexDigit(next); next = l.r.Peek(0) { l.r.Move(1) // consume the hex digit end_length_remaining-- } } return UnicodeRangeToken, l.r.Shift() }
- 有以下几种情况:
- 先 consume 一个合法的 name;
- 然后判断是否为一个函数的开始,如果是,再判断是否是 url-token,转入特定的解析流程。
- 需要额外注意的是,如果 url 函数的参数是使用单 / 双引号包裹的字符串,那么按照普通函数参数解析即可。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-ident-like-token func (l *Lexer) consumeIdentLikeToken() (TokenType, []rune) { l.consumeName() if l.r.Peek(0) == '(' { l.r.Move(1) // consume the opening parenthesis if equalIgnoringASCIICase(l.r.Current(), urlRunes) { // The spec is slightly different so as to avoid dropping whitespace // tokens, but they wouldn't be used and this is easier. l.consumeWhitespace() next := l.r.Peek(0) if next != '"' && next != '\'' { return l.consumeURLToken() } } return FunctionToken, l.r.Shift() } return IdentToken, l.r.Shift() }
注意这里的实现其实会在含转义的 URL-token 上出现问题,后续通过修改 consumeName 函数的实现,通过返回值判断解决了此问题。
- 简而言之,就是从开始的引号的位置一直匹配到相对应的结束引号位置或者文件末尾;
- 特别地,如果遇到没有转义的换行,那么此时就需要作为 bad-string-token 返回了。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-string-token func (l *Lexer) consumeStringToken() (TokenType, []rune) { until := l.r.Peek(0) // the opening quote, already checked valid by the caller l.r.Move(1) for { next := l.r.Peek(0) if next == until { l.r.Move(1) return StringToken, l.r.Shift() } if next == EOF { return StringToken, l.r.Shift() } if isCSSNewline(next) { return BadStringToken, l.r.Shift() } if next == '\\' { next_next := l.r.Peek(1) if next_next == EOF { l.r.Move(1) // consume the backslash continue } if isCSSNewline(next_next) { l.r.Move(1) l.consumeSingleWhitespace() } else if twoCharsAreValidEscape(next, next_next) { l.r.Move(1) // consume the backslash l.consumeEscape() } else { l.r.Move(1) } } else { l.r.Move(1) // consume the current rune } } }
- 需要按照规范特别注意 bad-url-token 的情况。
- 但此处的实现和规范不同,在
consumeIdentLikeToken()
中我们把 URL 的前导空格全部 consume 掉了,但如果遇到使用引号包裹的 URL 时,这段空格理应单独作为一个 whitespace-token,不过无伤大雅,这样解析也可以,不影响后续的 parse 流程。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-url-token func (l *Lexer) consumeURLToken() (TokenType, []rune) { for { next := l.r.Peek(0) if next == ')' { l.r.Move(1) return UrlToken, l.r.Shift() } if next == EOF { return UrlToken, l.r.Shift() } if isHTMLWhitespace(next) { l.consumeWhitespace() next_next := l.r.Peek(0) if next_next == ')' { l.r.Move(1) // consume the closing parenthesis return UrlToken, l.r.Shift() } if next_next == EOF { return UrlToken, l.r.Shift() } // If the next character is not a closing parenthesis, there's an error and we should mark it as a bad URL token. break } if next == '"' || next == '\'' || isNonPrintableCodePoint(next) { l.r.Move(1) // consume the invalid character break } if next == '\\' { if twoCharsAreValidEscape(next, l.r.Peek(1)) { l.r.Move(1) // consume the backslash l.consumeEscape() continue } else { break } } l.r.Move(1) // consume the current rune } l.consumeBadUrlRemnants() return BadUrlToken, l.r.Shift() }
特定类型字符片段解析
一共有以下几个片段解析的函数:
consumeUntilCommentEnd()
:一直读取到注释结束。// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-comment func (l *Lexer) consumeUntilCommentEnd() { for { next := l.r.Peek(0) if next == EOF { break } if next == '*' && l.r.Peek(1) == '/' { l.r.Move(2) // consume '*/' return } l.r.Move(1) // consume the current rune } }
consumeEscape()
:解析一个转义字符。// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-escaped-code-point func (l *Lexer) consumeEscape() rune { var res rune = 0 next := l.r.Peek(0) if isASCIIHexDigit(next) { l.r.Move(1) res = hexDigitToValue(next) for i := 1; i < 6; i++ { c := l.r.Peek(0) if isASCIIHexDigit(c) { l.r.Move(1) res = res*16 + hexDigitToValue(c) } else { break } } if !isValidCodePoint(res) { res = '\uFFFD' // U+FFFD REPLACEMENT CHARACTER } // If the next input code point is whitespace, consume it as well. l.consumeSingleWhitespace() } else if next != EOF { l.r.Move(1) // consume the escape character res = next } else { res = '\uFFFD' // U+FFFD REPLACEMENT CHARACTER for EOF } return res }
consumeName()
:读取一个 name。// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-name func (l *Lexer) consumeName() { for { next := l.r.Peek(0) if isNameCodePoint(next) { l.r.Move(1) } else if twoCharsAreValidEscape(next, l.r.Peek(1)) { l.r.Move(1) // consume the backslash l.consumeEscape() } else { break } } }
consumeNumber()
:读取一个数字。需要特别注意对科学计数法的处理,以及与调用侧配合正确解析.7
+.7
等 case。// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-number func (l *Lexer) consumeNumber() { next := l.r.Peek(0) // If the next rune is '+' or '-', consume it as part of the number. if next == '+' || next == '-' { l.r.Move(1) } // consume the integer part of the number l.r.MoveWhilePredicate(isASCIIDigit) // float next = l.r.Peek(0) if next == '.' && isASCIIDigit(l.r.Peek(1)) { l.r.Move(1) // consume the '.' l.r.MoveWhilePredicate(isASCIIDigit) } // scientific notation next = l.r.Peek(0) if next == 'e' || next == 'E' { next_next := l.r.Peek(1) if isASCIIDigit(next_next) { l.r.Move(1) // consume 'e' or 'E' l.r.MoveWhilePredicate(isASCIIDigit) } else if (next_next == '+' || next_next == '-') && isASCIIDigit(l.r.Peek(2)) { l.r.Move(2) // consume 'e' or 'E' and the sign l.r.MoveWhilePredicate(isASCIIDigit) } } }
consumeSingleWhitespace()
:读取一个空格。func (l *Lexer) consumeSingleWhitespace() { next := l.r.Peek(0) if next == '\r' && l.r.Peek(1) == '\n' { l.r.Move(2) // consume CRLF } else if isHTMLWhitespace(next) { l.r.Move(1) // consume the whitespace character } }
consumeWhitespace()
:读取多个空格。func (l *Lexer) consumeWhitespace() { for { next := l.r.Peek(0) if isHTMLWhitespace(next) { l.consumeSingleWhitespace() } else if next == EOF { return } else { break } } }
consumeBadUrlRemnants()
:读取 bad-url-token 的剩余部分。// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#consume-the-remnants-of-a-bad-url func (l *Lexer) consumeBadUrlRemnants() { for { next := l.r.Peek(0) if next == ')' { l.r.Move(1) return } if next == EOF { return } if twoCharsAreValidEscape(next, l.r.Peek(1)) { l.r.Move(1) // consume the backslash l.consumeEscape() continue } l.r.Move(1) } }
Identifier 和 Number 的鉴别逻辑
对于 identifier,我们根据以下标准判断接下来的字符是否可能开始一个 identifier 的序列:
- 第一位是 NameStartCodePoint(以英文字母、下划线或非 ASCII 字母开始);或
- 第一位和第二位组合起来可以开始一段转义序列;或
- 以
-
开始的 identifier(再走一遍上面两点的识别流程,同时注意--
的情况)。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#would-start-an-identifier
func (l *Lexer) nextCharsAreIdentifier() bool {
first := l.r.Peek(0)
if isNameStartCodePoint(first) {
return true
}
second := l.r.Peek(1)
if twoCharsAreValidEscape(first, second) {
return true
}
if first == '-' {
return isNameStartCodePoint(second) || second == '-' ||
twoCharsAreValidEscape(second, l.r.Peek(2))
}
return false
}
对于 number,当符合以下条件的时候可以开始一个 number 的序列:
- 第一位是数字;
- 第一位是正负号,第二位是数字;
- 第一位是正负号,第二位是小数点,第三位是数字;
- 第一位是小数点,第二位是数字。
// https://www.w3.org/TR/2021/CRD-css-syntax-3-20211224/#starts-with-a-number
func (l *Lexer) nextCharsAreNumber() bool {
first := l.r.Peek(0)
if isASCIIDigit(first) {
return true
}
second := l.r.Peek(1)
if first == '+' || first == '-' {
if isASCIIDigit(second) {
return true
}
if second == '.' {
third := l.r.Peek(2)
if isASCIIDigit(third) {
return true
}
}
}
if first == '.' {
return isASCIIDigit(second)
}
return false
}
小结
让我们来总结一下 lexer 工作流程:在 lexer 读取到某个 token 的起始点的时候,lexer 预读起始的几个字符,然后辨别 token 的类型。对于大致分类好的 token,根据其更具体的特征预读并消耗掉对应的字符,直到这个 token 结束。
大致的类型辨别是通过 Next()
函数中的那个巨大的 switch-case 语句来完成的。而对于精细的 token 类型的判断,则是 case 中的语句和 consume_token.go
定义的一系列函数来共同完成的。至于 token 内部的字符段的解析,则是 consume.go
中的一系列函数完成的。由此组合,整个 token 的解析过程得以良好运转。
除了文中提到的相关方法以外,在 util.go
中还有一系列的工具函数:
func isASCII(c rune) bool
func isASCIIAlpha(c rune) bool
func isASCIIDigit(c rune) bool
func isASCIIHexDigit(c rune) bool
func isCSSNewline(c rune) bool
func isNameStartCodePoint(r rune) bool
func isNameCodePoint(r rune) bool
func isNonPrintableCodePoint(r rune) bool
func twoCharsAreValidEscape(first, second rune) bool
func isHTMLSpecialWhitespace(c rune) bool
func isHTMLWhitespace(c rune) bool
这些函数的作用可以很容易地由它们的名字得知,故此处不再赘述。
测试
为了验证 lexer 的实现正确性,我们引入了 romainmenke/css-tokenizer-tests 的测试用例来对 lexer 进行测试。具体的测试流程可以参考 lexer_test.go
中的实现。
根据测试结果来看,出现的问题主要集中在与转义字符相关的处理,对于大部分情况已经能够正常解析。截止编写本文之时,测试通过率为 96.53% (167/173),个人认为已经处于可用水平。
后记
文中所述的 lexer 的具体实现已经开源在 renbaoshuo/go-css-lexer,欢迎大家 Star!
搓这个 lexer 花了半个周末的时间,修修补补又消耗了一些时间。也算是在工作之余充实自己的大脑了。后续还可能会针对预读相关的内存访问进行优化(不知道读者有没有发现最多会预读三个字符),以提升处理效率。
文章题图由 Gemini 2.5 Pro Imagen 生成。