JavaScript 语法细节:词法分析器如何判断下一个 Token 是除号还是正则表达式的开始?
问题来源
当我们需要分析转换 JavaScript/TypeScript 源代码的时候,很多时候并不需要分析完整的代码结构,而是利用 JS 的语法特性跳过一些部分,而只分析我们感兴趣的部分。接下来我们提出一个问题:如何在不知道具体 AST 的前提下,在 Lexer 读取到 /
的时候,准确判断这是一个除号 /
,还是一个正则表达式字面量 /regex/
的开头?
解决思路
在 ECMAScript Spec 没有明确提出 Parser 的实现应该如何处理这两者,但是我们应该明确的是下面两个规则:
- 如果我们需要一个
Expression
,那么/
只可能是正则表达式字面量的开头; - 如果我们需要一个
Punctuator
,例如, ; { }
,那么/
只可能是除号相关的开头,包括/
,/=
。
那么接下来,我们只要知道何时我们需要一个 Expression
,就知道接下来是正则还是除法了。
上一个 Token 是中缀运算符,那么下一个
/
一定是Expression
。例如call(f, /=/) // ^
这里不可能是除号,因为除号前面必须紧跟一个完整的
Expression
。其他中缀符号也是同样的道理。a * /=/ b && /=/
即使它语义上是不合理的——我们不能去“乘”或者“且”一个
RegExp
。上一个 Token 是前缀运算符,例如
+ ++
,同样的道理,下一个/
一定是Expression
。上一个 Token 是括号的结束,也就是
) ] }
,这是一个稍微复杂的问题。假如我们武断地认为)
后面的/
一定是除号:(a + b) / 3 // ^ call() / 4 // ^
好像确实没错。但是考虑下面和关键字
if
,while
,for
有关的情况:if (something) /regex/.text(something); //^ while (something) /regex/.text(something); //^ for (let i = 0; i < 1; i++) /regex/.text(something); //^
虽然我们在一个圆括号结束
)
的下一个 Token 开头,但是这里却可以放一个Expression
!事实上这里是任意Statement
的开头。因此,我们还需要考虑关键字附带的括弧的情况。为了判断当前的括弧是否属于
if
,while
,for
关键字,我们还需要处理括号栈的情况。我们后面需要详细讨论括号的问题。
上一个 Token 是
Identifier
或者Literal
。很显然,我们不能有像下面的代码,因为这里出现了连续两个原子Expression
:id /regex/ "string" /regex/ 213 /regex/
因此下一个
/
一定是除号。关键字 +
Expression
。下面这些关键字后可以接Expression
,因此/
应该判断为RegExr
。case, debugger, delete, do, else, in, instanceof, new, return, throw, typeof, void, yield, await
例如
return /regex/; // ^ for (const i in /[0-9]/.exec("123")) {} // ^
括号的难题
圆括号 ()
根据上面的讨论,我们可以把圆括号分为两类:
- 与
Expression
相关的圆括号,包括优先运算的圆括号、call()
调用函数的圆括号、function f() {}
() => {}
参数列表的圆括号。 - 与
if
,while
,for
相关的圆括号。
我们只需要提取出所有第二种的圆括号,就可以判断当前的 )
之后是不是 Expression
了!
方括号 []
JS 中的方括号,都是与 Expression
结合的,例如:
arr[0]
const arr = [1, 2, 3]
因此 ]
表明了一个 Expression
的结尾,后面一定是除号。
花括号 {}
JavaScript 中的花括弧,同样分成两种
表示一个
Block
的花括弧。例如function () {} // ^ { doSomething(); } //^ { a } //^
注意第三种情况
{ a }
会被处理成一个块,而不是对象字面量{ a: a }
。这几种情况,后面都不可以跟除号,因此/
表示一个RegExp
开头。表示一个对象字面量
const object = { a }
这种情况,
{ a }
是一个Expression
,后面不能直接跟Expression
,因此/
表示除号。ESM Import/Export
import { a } from 'something'; export { a } from 'something';
这种情况
}
后面既不能是除号也不能是RegExp
。对于花括弧的情况,我们需要跟踪这个
}
是某个 Block 的{
对应的,还是某个对象字面量对应的。两种情况下,后面的判断情况会不同。这种情况,实际上在不知道 AST 的情况下非常棘手,因为我们遇到一个{
的时候,需要知道当前是不是在一个“把{
解释成字符串字面量”的环境下。我们来观察几种情况:
{} // 字面量 {} //^ { a } // Block //^ { a: 1 } // 字面量 //^
在不知道完整 AST 的前提下,我现在没有什么好办法判断
{
是不是Block
。这导致如果我们总认为}
后面不可以跟/regex/
的情况下,可能会拒绝下面这样完全合理的代码:{ doSomething(); } /regex/.text("something");
而
/regex/
的/
不应该解析为/
。而如果我们认为
}
后方总是可以接/regex/
的话,则const a = { a: 1 } /regex/ 2;
会被认为无效的代码,虽然它只是语义上有问题,而语法上完全没有问题。
假如我们激进的认为,
}
后面的/
一定是/regex/
,这样至少我们可以拒绝掉对象字面量在左手边的除法运算,我认为不失为一种 "trash in trash out" 的简单办法。
应用
NodeJS 的依赖 cjs-module-lexer 中,实现了一个简单的词法分析器,用于提取所有像
module.exports = { a };
exports.b = b;
这样的 CommonJS Export。出于简单和性能的考虑,cjs-module-lexer
没有完全处理 JS 的语法树,而是简单的处理了 Token。由于需要忽略像
"exports.b = b"
这样的假 Export,我们需要跳过字符串。为了准确判断字符串的位置,我们还需要跳过正则表达式中的 "
,也就是需要确定正则表达式的位置。这引出了上述一个关键的问题:遇到 /
的时候,是除号还是正则表达式?
事实上 cjs-module-lexer
在处理花括弧的时候,也没能完美判断 }
后方是正则还是除号,可以尝试在 CJS Module Lexer Playground 下面输入
{
doSomething();
}
/[(]/.text("something");
会得到下面的报错,尽管这是完全合法有意义的 JS。可以参考 acorn 的结果。
"Error: Parse error playground.js0:1:1"
参考资料
https://stackoverflow.com/questions/5519596/when-parsing-javascript-what-determines-the-meaning-of-a-slash