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