深入理解 swc - 第三部分,语法分析

序言

这是深入理解 swc 的第三部分,来探究 swc 的语法分析阶段。语法分析是将源代码转换成抽象语法树(Abstract Syntax Tree,AST)的过程。

上下文无关语法

在开始深入理解 JavaScript 源代码语法分析之前,我们首先需要理解 JavaScript 的语法结构。JavaScript 的语法是由一个名为 "Technical Committee 39"(TC39)的组织定义的,它在 ECMA-262 标准中得到了明确的规定。ECMA-262 是 JavaScript 的官方标准,它采用了一种称为 "上下文无关语法"(Context-Free Grammar,CFG)的形式化语法来明确规定了 JavaScript 的语法规则。

上下文无关语法是一种广泛应用于编程语言的形式化语法,它的基本规则实际上非常直观,而且易于理解。尽管对于所有的细节进行深入的理解可能需要一定的时间,但我们可以通过一个简单的例子来初步感受其工作原理。

我们来看一下 JavaScript 中 if 语句的语法定义:

java 复制代码
IfStatement :
    if ( Expression ) Statement else Statement
    if ( Expression ) Statement [lookahead ≠ else]

在这个例子中,IfStatementExpressionStatement 都是非终结符,它们代表的是可以进一步展开或替换的语法结构。 if()else 这些则是终结符,它们是语法结构中具体的字面符号。

这个规则的含义是,一个 IfStatement 可以由 if ( Expression ) Statement else Statement 这样的结构组成,也可以由 if ( Expression ) Statement 这样的结构组成,但后者的情况下,Statement 后面不能直接跟着 else

所以,一个包含 else 的完整的 IfStatement 可以是 if (x > 0) y = 1; else y = 0;,而不包含 elseIfStatement 可以是 if (x > 0) y = 1;

这就是上下文无关语法在定义 JavaScript 语法时的基本应用。希望这个简单的例子能帮助你理解上下文无关语法的基本工作原理。

语法分析方法

在开始我们的语法分析之前,我们需要先了解几种主要的语法分析方法。这些方法主要分为两大类:自顶向下分析和自底向上分析。

  1. 自顶向下分析(Top-down parsing):自顶向下分析从开始符号出发,按照语法规则逐步扩展产生式,直到派生树的叶子节点与输入符号相匹配。常用的自顶向下分析方法包括:

    • 递归下降分析(Recursive Descent Parsing):递归下降分析通过递归程序实现语法规则。从语法的开始符号出发,根据给定的输入,尝试应用所有可能的匹配规则进行扩展。该方法可能需要回溯,因此在某些情况下可能效率较低。
    • 预测分析(Predictive Parsing):预测分析是一种不需要回溯的递归下降分析。它要求语法为LL(1)文法,即对于每个非终结符和输入符号,只有一个产生式可以应用,这使得分析过程更为高效。
  2. 自底向上分析(Bottom-up parsing):自底向上分析从输入符号出发,逐步将其归约为开始符号。常用的自底向上分析方法包括:

    • 移位归约分析(Shift-Reduce Parsing):移位归约分析通过移位(将输入符号推入栈)和归约(用某个产生式将栈顶符号替换为非终结符)操作来构建抽象语法树。其中,最常见的自底向上分析方法是LR分析,它使用预先生成的解析表来指导分析过程。
    • LR分析(LR Parsing):LR分析是一种广泛使用的自底向上分析方法,其中最常见的变体是SLR(Simple LR)、LALR(Look-Ahead LR)和CLR(Canonical LR)分析。这些分析方法根据预先生成的解析表,使用移位和归约操作来构建抽象语法树。

在JavaScript编译器 swc 中,采用的是递归下降分析方法。递归下降分析是一种自顶向下的分析策略,它构建的是源代码的派生树的左深版本。这种分析策略在编译器设计中广受欢迎,理由如下:

  1. 简单直观:递归下降解析器直观且容易编写和理解。我们只需要为每个非终结符写一个函数,这个函数会尝试各种可能的选择来解析输入。这种方法的直观性使得编译器的开发和维护变得更简单。
  2. 高度灵活:递归下降解析器可以轻松处理那些不能被其他解析技术(如LL或LR)处理的语法规则。它可以处理左递归、非上下文自由语法和语言的其他复杂性质。
  3. 预测解析:如果我们处理的语法是LL(1),即在任何情况下,我们都可以通过查看下一个输入符号来决定采取哪个产生式,那么我们可以创建一个非回溯的递归下降解析器,这是一种高效的解析器。
  4. 错误处理:递归下降解析器在发现错误时可以提供有用的反馈。由于解析器的控制流与语法结构紧密匹配,因此递归下降解析器可以尝试修复错误,或至少提供有关错误性质和位置的详细信息。
  5. 扩展性好:递归下降分析方法对于语法规则的扩展非常友好。如果需要引入新的非终结符号,我们只需要添加对应的解析函数即可。

然而,值得一提的是,递归下降解析也有其局限性。例如,它无法直接处理左递归的语法规则,而且在某些情况下可能需要回溯。但是有一些技术可以帮助我们克服这些局限,如消除左递归和使用预测分析等。

总的来说,递归下降分析是一种强大而灵活的工具,它在处理一些复杂的语法规则方面具有明显的优势。这也是为什么许多现代编译器,包括 swc,选择使用它的原因。

Parser 结构体

swc 的语法分析器是通过一个名为 Parser 的结构体来定义的。下面是其定义:

rust 复制代码
pub struct Parser<I: Tokens> {
    state: State,
    input: Buffer<I>,
}

Parser 结构体中主要存储着两种数据,stateinputstate 表示当前解析器的内部状态,而 input 则表示正在处理的由词法分析器得到的单词序列。

State 结构体的定义如下:

rust 复制代码
struct State {
    labels: Vec<JsWord>,
    /// 赋值表达式的起始位置。
    potential_arrow_start: Option<BytePos>,

    found_module_item: bool,
    /// AST节点的起始位置及其尾部逗号的跨度。
    trailing_commas: AHashMap<BytePos, Span>,
}

State 结构体中包含了一些用于追踪解析过程状态的字段,例如 labels 用于存储标签符号(例如循环的标签),potential_arrow_start 用于记录可能的箭头函数表达式的起始位置,found_module_item 用于标记是否找到模块条目,trailing_commas 用于存储 AST 节点的起始位置及其尾部逗号的跨度。

我们在词法分析部分提到过,swc 中的词法分析器被定义为 Lexer 结构体。在这里,Tokens 类型就是对应的词法分析器,它提供了一个可迭代的 Token 序列。

Buffer 结构体建立在词法分析器之上,用于管理和追踪输入的单词序列。其中 iter 就是传入的词法分析器,prev_span 用于存储上一个 Token 的 Span,curnext 分别用于存储当前和下一个 Token。

rust 复制代码
struct Buffer<I: Tokens> {
    iter: I,
    /// 上一个Span of the previous token.
    prev_span: Span,
    cur: Option<TokenAndSpan>,
    /// Peeked token
    next: Option<TokenAndSpan>,
}

解析 if-else 语句

在上述内容中,我们已经阐述了 JavaScript 中 if 语句的语法定义。接下来,我们将深入探讨 swc 如何通过 parse_if_stmt 函数实现其解析过程。

rust 复制代码
fn parse_if_stmt(&mut self) -> PResult<IfStmt> {
    let start = cur_pos!(self);

    assert_and_bump!(self, "if");
    let if_token = self.input.prev_span();

    expect!(self, '(');
    let ctx = Context {
        ignore_else_clause: false,
        ..self.ctx()
    };
    let test = self
        .with_ctx(ctx)
        .include_in_expr(true)
        .parse_expr()
        .map_err(|err| {
            Error::new(
                err.span(),
                SyntaxError::WithLabel {
                    inner: Box::new(err),
                    span: if_token,
                    note: "Tried to parse the condition for an if statement",
                },
            )
        })?;

    expect!(self, ')');

    let cons = {
        // Prevent stack overflow
        crate::maybe_grow(512 * 1024, 2 * 1024 * 1024, || {
            // Annex B
            if !self.ctx().strict && is!(self, "function") {
                // TODO: report error?
            }
            let ctx = Context {
                ignore_else_clause: false,
                ..self.ctx()
            };
            self.with_ctx(ctx).parse_stmt(false).map(Box::new)
        })?
    };

    let alt = if self.ctx().ignore_else_clause {
        None
    } else {
        let mut cur = None;

        let ctx = Context {
            ignore_else_clause: true,
            ..self.ctx()
        };

        let last = loop {
            if !eat!(self, "else") {
                break None;
            }

            if !is!(self, "if") {
                // As we eat `else` above, we need to parse statement once.
                let last = crate::maybe_grow(512 * 1024, 2 * 1024 * 1024, || {
                    let ctx = Context {
                        ignore_else_clause: false,
                        ..self.ctx()
                    };

                    self.with_ctx(ctx).parse_stmt(false)
                })?;
                break Some(last);
            }

            // We encountered `else if`

            let alt = self.with_ctx(ctx).parse_if_stmt()?;

            match &mut cur {
                Some(cur) => {
                    self.adjust_if_else_clause(cur, Box::new(Stmt::If(alt)));
                }
                _ => {
                    cur = Some(alt);
                }
            }
        };

        match cur {
            Some(mut cur) => {
                if let Some(last) = last {
                    self.adjust_if_else_clause(&mut cur, Box::new(last));
                }
                Some(Stmt::If(cur))
            }
            _ => last,
        }
    }
    .map(Box::new);

    let span = span!(self, start);
    Ok(IfStmt {
        span,
        test,
        cons,
        alt,
    })
}

下面是该函数的主要步骤:

  1. 获取开始位置let start = cur_pos!(self); 获取当前位置信息,这将用于后续生成语法树节点的位置信息。

  2. 解析 if 关键字assert_and_bump!(self, "if"); 确认当前 token 是 if 并移动到下一个 token。

  3. 解析条件表达式 :先期待一个 (,然后解析括号里的表达式作为 if 语句的条件。

  4. 解析 if 语句的主体 :期待一个 ) 结束条件表达式,然后解析后面的语句作为 if 语句的主体。

  5. 解析 else 分支 :如果存在 else 关键字,则继续解析 else 后面的语句。这部分需要特别注意,因为 else 可能后接 if 形成 else if,这是一个嵌套的 if 语句,需要递归处理。

  6. 生成 IfStmt 结构体 :最后生成 IfStmt 结构体,包含了 if 语句的所有信息,包括位置信息、条件表达式、主体语句和 else 分支(如果有的话)。

这个函数的实现中,你可以看到 swc 采用的是递归下降分析的方法,对于每个结构,都有一个对应的 parse_* 函数进行处理。对于嵌套的结构,例如 else if,则通过递归调用 parse_if_stmt 来处理。

处理左递归和语法二义性问题

编程语言中的二义性是指某个语句在语法分析时有多种有效的解释。当编程语言中的语法规则没有明确区分某些结构或表达式时,就容易导致二义性。以下是一些编程语言中容易出现二义性的情况:

  1. 运算符优先级不明确 :当编程语言的语法规则未明确指定运算符的优先级时,可能导致二义性。例如,a + b * c 这个表达式在不考虑优先级的情况下可以被解释为 (a + b) * ca + (b * c)

  2. 运算符结合性不明确 :当编程语言的语法规则未明确指定运算符的结合性时,可能导致二义性。例如,a - b - c 这个表达式在不考虑结合性的情况下可以被解释为 (a - b) - ca - (b - c)

  3. 悬挂 else 问题:这是一种常见的二义性问题,涉及到 if-else 语句的嵌套。例如,以下代码片段:

  4. 语法规则冲突:当编程语言中的一组语法规则彼此冲突时,可能导致二义性。例如,在 JavaScript 中的 return 语句和自动插入分号 (ASI, Automatic Semicolon Insertion) 机制的冲突。

当定义二元运算的上下文无关语法时,我们可能会很自然地想要这样去定义:

markdown 复制代码
AdditiveExpression :
    PrimaryExpression + PrimaryExpression
    PrimaryExpression - PrimaryExpression
    PrimaryExpression * PrimaryExpression
    PrimaryExpression / PrimaryExpression

然而,这种定义方式存在两个主要问题:

  1. 左递归问题 :左递归就是在语法规则的定义中,左侧的非终结符直接或间接地出现在右侧的推导式的开始位置,形成递归,如 AdditiveExpression : AdditiveExpression + PrimaryExpression。这在某些解析技术(例如递归下降解析)中会导致无限循环。

  2. 优先级问题:上述定义会使得所有的操作符都具有相同的优先级,而在实际的编程语言中,不同的操作符具有不同的优先级,如在大多数语言中,乘法和除法操作符的优先级高于加法和减法。

为了解决这些问题,ECMA-262 规范(即 JavaScript 的规范)采用了另一种定义方式,即为每个优先级创建一个新的非终结符,并确定其正确的顺序。例如:

markdown 复制代码
MultiplicativeExpression :
    PrimaryExpression
    | MultiplicativeExpression * PrimaryExpression
    | MultiplicativeExpression / PrimaryExpression

AdditiveExpression :
    MultiplicativeExpression
    | AdditiveExpression + MultiplicativeExpression
    | AdditiveExpression - MultiplicativeExpression

按照这种定义方式,乘法和除法会比加法和减法先进行运算,从而正确地实现了操作符的优先级。此外,通过将每个优先级的表达式定义为一个新的非终结符,这种定义方式避免了左递归的问题。

因此,在解析如 1 + 2 * 3 的表达式时,根据上述的语法定义,解析器会将其解析为 1 + (2 * 3),而不是 (1 + 2) * 3。这是因为 MultiplicativeExpression 的优先级高于 AdditiveExpression,所以解析器会首先解析乘法表达式 2 * 3,然后再解析加法表达式 1 + (2 * 3)

二元表达式的解析流程

尽管 ECMA-262 已经通过上下文无关文法定义解决了左递归和优先级问题,但 swc 并没有按照这一定义逐步解析。相反,它选择通过明确设定算术运算符的优先级来实现递归解析。这种方法使得解析过程更加直观,易于理解,并且方便扩展新的运算符。

rust 复制代码
impl BinaryOp {
    pub fn precedence(self) -> u8 {
        match self {
            BinaryOp::EqEq => 6,
            BinaryOp::NotEq => 6,
            BinaryOp::EqEqEq => 6,
            BinaryOp::NotEqEq => 6,
            BinaryOp::Lt => 7,
            BinaryOp::LtEq => 7,
            BinaryOp::Gt => 7,
            BinaryOp::GtEq => 7,
            BinaryOp::LShift => 8,
            BinaryOp::RShift => 8,
            BinaryOp::ZeroFillRShift => 8,

            BinaryOp::Add => 9,
            BinaryOp::Sub => 9,
            BinaryOp::Mul => 10,
            BinaryOp::Div => 10,
            BinaryOp::Mod => 10,

            BinaryOp::BitOr => 3,
            BinaryOp::BitXor => 4,

            BinaryOp::BitAnd => 5,

            BinaryOp::LogicalOr => 1,

            BinaryOp::LogicalAnd => 2,
            BinaryOp::In => 7,
            BinaryOp::InstanceOf => 7,

            BinaryOp::Exp => 11,

            BinaryOp::NullishCoalescing => 1,
        }
    }
}

swc 对算术运算符优先级的解析在 swc_ecma_parser/src/parser/expr/ops.rs 文件中实现,其工作原理概述如下:

  1. parse_bin_expr 函数开始解析二进制表达式。首先尝试解析一个一元表达式(parse_unary_expr)。如果 if 语句或其他二元操作符(instanceofBinOp)后面有错误,那么会尝试进行错误恢复。否则,它将开始递归地解析二元操作符。

  2. 在函数 parse_bin_op_recursively 中,一个无限循环开始了。这个函数会一直解析,直到遇到一个操作符的优先级低于当前优先级的操作符为止。在循环中,首先调用 parse_bin_op_recursively_inner,该函数判断当前操作符的优先级是否低于最小优先级 min_prec。如果是,那么它就会停止解析并返回结果。否则,就会继续解析右边的表达式,并返回新的左表达式 next_left 和优先级 next_prec

  3. 在函数 parse_bin_op_recursively_inner 中,首先检查当前的操作符。如果它的优先级大于 min_prec,那么解析右操作数。然后,用左操作数、当前操作符和右操作数创建一个新的 BinExpr 并返回。否则,函数返回当前的左操作数和 None

  4. 最后,parse_unary_expr 函数负责解析一元表达式。根据当前的 token,它能解析出前缀或后缀的增量(++)、减量(--)、一元操作符或 await 表达式。

整个过程中,如果遇到优先级较低的操作符,解析器就会停止解析当前的表达式,并将剩余的部分留给调用者进行处理。这就是所谓的操作符优先级解析算法,也被称为 "Pratt" 解析器。

抽象语法树

语法解析器的主要任务是将源代码转换为抽象语法树(AST),它是源代码的层次结构表示,为后续的编译或解释阶段提供了结构化的信息。以 JavaScript 的 if 语句为例,swc 在抽象语法树中将其定义为 IfStmt 结构体。

IfStmt 结构体的定义如下:

rust 复制代码
pub struct IfStmt {
    pub span: Span,
    pub test: Box<Expr>,

    #[cfg_attr(feature = "serde-impl", serde(rename = "consequent"))]
    pub cons: Box<Stmt>,

    #[cfg_attr(feature = "serde-impl", serde(default, rename = "alternate"))]
    pub alt: Option<Box<Stmt>>,
}

每个字段的作用如下:

  • span:这是一个 Span 类型的字段,它包含了此 if 语句在源代码中的位置信息。对于编译器错误报告或者代码生成阶段,这个信息是很重要的。

  • test:这是一个 Box<Expr> 类型的字段,它表示 if 语句的条件表达式。这个表达式的结果将决定是执行 if 语句的 "then" 分支(即 cons 字段),还是 "else" 分支(即 alt 字段,如果存在的话)。

  • cons:这是一个 Box<Stmt> 类型的字段,它表示 if 语句的 "then" 分支,即当 test 的结果为 true 时执行的语句。

  • alt:这是一个 Option<Box<Stmt>> 类型的字段,它表示 if 语句的 "else" 分支,即当 test 的结果为 false 时执行的语句。如果 if 语句没有 "else" 分支,那么这个字段的值就是 None

通过这样的结构,swc 能够准确地表示 JavaScript 中的 if 语句,并且能够很方便地处理 if 语句在语法树中的转换和操作。

相关推荐
dae bal1 小时前
关于RSA和AES加密
前端·vue.js
柳杉1 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog1 小时前
低端设备加载webp ANR
前端·算法
LKAI.1 小时前
传统方式部署(RuoYi-Cloud)微服务
java·linux·前端·后端·微服务·node.js·ruoyi
刺客-Andy2 小时前
React 第七十节 Router中matchRoutes的使用详解及注意事项
前端·javascript·react.js
前端工作日常2 小时前
我对eslint的进一步学习
前端·eslint
禁止摆烂_才浅3 小时前
VsCode 概览尺、装订线、代码块高亮设置
前端·visual studio code
程序员猫哥3 小时前
vue跳转页面的几种方法(推荐)
前端
代码老y4 小时前
十年回望:Vue 与 React 的设计哲学、演进轨迹与生态博弈
前端·vue.js·react.js
一条上岸小咸鱼4 小时前
Kotlin 基本数据类型(五):Array
android·前端·kotlin