手写递归下降 Parser:把 token 拼成 AST

本节目标

上一节我们得到了 token 流。这一步叫语法分析(Syntactic Analysis):按照“语法规则”把 token 组装成一棵 AST。

我们用的写法叫递归下降:为每一种语法结构写一个函数,函数里再调用更细的函数。例如“表达式”分三层:

表达式 expression  =  赋值 assignment
赋值   assignment  =  Identifier '=' 表达式  |  加减 additive
加减   additive    =  乘除 multiplicative (('+'|'-') 乘除)*
乘除   multiplicative = 一元 unary (('*'|'/') 一元)*
一元   unary      =  ('-')? 基础 primary
基础   primary    =  数字 | 字符串 | 标识符 | 调用 call | '(' 表达式 ')'
调用   call       =  Identifier '(' (表达式 (',' 表达式)*)? ')'

优先级怎么体现? 看调用顺序:additive 调用 multiplicative,所以 * 比 + 先结合——1 + 2 * 3 自然解析成 1 + (2 * 3)。这就是“递归下降天然表达优先级”。

动手:一个能解析算术/变量/调用的 Parser

// 运行环境:Node.js 18+(无需任何依赖,依赖上一节的 tokenize)
// 运行方式:把整段保存为 app-l4.cjs,终端执行  node app-l4.cjs
// (这里把 tokenize 一起带上,保证单文件可运行)

function tokenize(src) {
  const tokens = []
  let i = 0, buf = '', state = 'idle'
  const isDigit = (c) => c >= '0' && c <= '9'
  const isIdentStart = (c) => /[A-Za-z_$]/.test(c)
  const isIdentPart = (c) => /[A-Za-z0-9_$]/.test(c)
  const isSpace = (c) => c === ' ' || c === '\n' || c === '\t' || c === '\r'
  const flush = (type) => { if (buf) { tokens.push({ type, value: buf }); buf = '' } }
  while (i < src.length) {
    const c = src[i]
    if (c === '/' && src[i + 1] === '/') { flush(state === 'idle' ? null : state); while (i < src.length && src[i] !== '\n') i++; state = 'idle'; continue }
    if (isSpace(c)) { flush(state === 'idle' ? null : state); state = 'idle'; i++; continue }
    if (isDigit(c)) { state = 'num'; buf += c; i++; continue }
    if (isIdentStart(c)) { state = 'ident'; buf += c; i++; continue }
    if (isIdentPart(c) && state !== 'idle') { buf += c; i++; continue }
    const two = src.slice(i, i + 2)
    const multiOps = ['==', '===', '&&', '||', '<=', '>=', '!=', '!==']
    if (multiOps.includes(two)) { flush(state === 'idle' ? null : state); tokens.push({ type: 'op', value: two }); state = 'idle'; i += 2; continue }
    if ('+-*/%=<>!'.includes(c)) { flush(state === 'idle' ? null : state); tokens.push({ type: 'op', value: c }); state = 'idle'; i++; continue }
    if (c === '(' || c === ')') { flush(state === 'idle' ? null : state); tokens.push({ type: 'paren', value: c }); state = 'idle'; i++; continue }
    flush(state === 'idle' ? null : state); tokens.push({ type: 'op', value: c }); state = 'idle'; i++; continue
  }
  flush(state === 'idle' ? null : state)
  const KEYWORDS = ['let', 'const', 'var', 'function', 'return', 'if', 'else']
  return tokens.map((t) => t.type === 'ident' && KEYWORDS.includes(t.value) ? { type: 'keyword', value: t.value } : t)
}

// ---- 递归下降解析器 ----
class Parser {
  constructor(tokens) { this.tokens = tokens; this.pos = 0 }
  peek() { return this.tokens[this.pos] }
  next() { return this.tokens[this.pos++] }
  expect(type, value) {
    const t = this.next()
    if (!t || t.type !== type || (value !== undefined && t.value !== value))
      throw new Error('语法错误:期望 ' + (value || type) + ',但得到 ' + JSON.stringify(t))
    return t
  }

  // 程序 = 多条语句
  parseProgram() {
    const body = []
    while (this.pos < this.tokens.length) body.push(this.parseStatement())
    return { type: 'Program', body }
  }
  parseStatement() {
    const t = this.peek()
    let stmt
    if (t && t.type === 'keyword' && ['let', 'const', 'var'].includes(t.value))
      stmt = this.parseVarDecl()
    else
      stmt = { type: 'ExpressionStatement', expression: this.parseExpression() }
    // 消费可选的语句结束符 ;(不强制,兼容无分号写法)
    if (this.peek() && this.peek().type === 'op' && this.peek().value === ';') this.next()
    return stmt
  }
  parseVarDecl() {
    const kind = this.next().value
    const name = this.expect('ident').value
    this.expect('op', '=')
    const init = this.parseExpression()
    return { type: 'VariableDeclaration', kind, id: { type: 'Identifier', name }, init }
  }

  // 表达式入口:先试赋值,否则按加减处理
  parseExpression() { return this.parseAssignment() }
  parseAssignment() {
    const left = this.parseAdditive()
    const t = this.peek()
    if (t && t.type === 'op' && t.value === '=' && left.type === 'Identifier') {
      this.next()
      return { type: 'AssignmentExpression', operator: '=', left, right: this.parseExpression() }
    }
    return left
  }
  // 加减(+ -),左结合
  parseAdditive() {
    let node = this.parseMultiplicative()
    while (this.peek() && this.peek().type === 'op' && ['+', '-'].includes(this.peek().value)) {
      const op = this.next().value
      node = { type: 'BinaryExpression', operator: op, left: node, right: this.parseMultiplicative() }
    }
    return node
  }
  // 乘除(* /),左结合,优先级高于加减
  parseMultiplicative() {
    let node = this.parseUnary()
    while (this.peek() && this.peek().type === 'op' && ['*', '/'].includes(this.peek().value)) {
      const op = this.next().value
      node = { type: 'BinaryExpression', operator: op, left: node, right: this.parseUnary() }
    }
    return node
  }
  parseUnary() {
    if (this.peek() && this.peek().type === 'op' && this.peek().value === '-') {
      this.next()
      return { type: 'UnaryExpression', operator: '-', argument: this.parseUnary() }
    }
    return this.parsePrimary()
  }
  parsePrimary() {
    const t = this.next()
    if (!t) throw new Error('语法错误:表达式不完整')
    if (t.type === 'num') return { type: 'Literal', value: Number(t.value) }
    if (t.type === 'string') return { type: 'Literal', value: t.value }
    if (t.type === 'ident') {
      if (this.peek() && this.peek().type === 'paren' && this.peek().value === '(') {
        this.next() // 吃掉 (
        const args = []
        if (!(this.peek() && this.peek().type === 'paren' && this.peek().value === ')')) {
          args.push(this.parseExpression())
          while (this.peek() && this.peek().type === 'op' && this.peek().value === ',') { this.next(); args.push(this.parseExpression()) }
        }
        this.expect('paren', ')')
        return { type: 'CallExpression', callee: { type: 'Identifier', name: t.value }, arguments: args }
      }
      return { type: 'Identifier', name: t.value }
    }
    if (t.type === 'paren' && t.value === '(') {
      const e = this.parseExpression()
      this.expect('paren', ')')
      return e
    }
    throw new Error('语法错误:无法解析 ' + JSON.stringify(t))
  }
}

// === 调用示例 ===
function parse(src) { return new Parser(tokenize(src)).parseProgram() }

const code = 'let x = 1 + 2 * 3; foo(10)'
const ast = parse(code)
console.log(JSON.stringify(ast, null, 2))
// 关键结构(节选):
// BinaryExpression(+) 的 right 是 BinaryExpression(*),说明 * 先于 + 结合:
//   left: Literal(1)
//   right: BinaryExpression(*)  -> left: Literal(2), right: Literal(3)
// 以及 CallExpression(foo, args:[Literal(10)])

名词解释

递归下降(Recursive Descent):一种手写语法分析的方法——为每一种语法结构写一个解析函数,函数内部再调用更细的函数去解析它的子结构。“递归”指的是这些函数常常互相调用(多为间接递归)。它是最容易让人“看懂”的解析写法。

运算符优先级(Precedence):不同运算符结合的先后顺序(例如 * 先于 +)。在递归下降里,优先级不是靠查表,而是靠“谁调用谁”自然表达:让 additive 去调用 multiplicative,就意味着 * 会在更内层先结合。

课后练习

练习 1:1 + 2 * 3 经过本节的 Parser 后,最外层的 BinaryExpression 节点的 operator 是什么?它的 right 子树根节点又是什么类型?

答案:最外层 operator 是 +;它的 right 是一棵 BinaryExpression,其 operator 为 *。这正说明 *(在内层)先于 +(在外层)结合,符合数学优先级。

练习 2:如果把 parseAdditive 与 parseMultiplicative 的调用关系反过来(让 additive 不再调用 multiplicative,而是反过来),会发生什么?

答案:优先级会颠倒——* 会比 + 后结合。原本 1 + 2 * 3 会被错误解析成 (1 + 2) * 3,计算结果从 7 变成 9。这恰好证明:递归下降里“调用方向”直接决定了运算符优先级。

本节小结(观点与完整描述)

本节你写出的是编译原理里最经典、也最“肉眼可读”的语法分析器:递归下降 = 为每种语法写个函数,函数间互相调用。你不必死记那套文法,只要理解一件事——优先级是“喊谁来帮忙”喊出来的:additive 调用 multiplicative,所以 * 自然先于 + 结合。再配上 peek / next / expect 这三个小工具(看一眼、吃掉、断言并吃掉),你就能从 token 流里拼出任意复杂的树。loc 字段还能在出错时带上“第几行第几列”。但也要清醒:手写解析器只适合玩具语言;真实 JS 的边界情况多到吓人,所以下一节起我们把“解析”这步交给 acorn / @babel/parser,把精力留在真正有价值的第 ② 步——转换。