[Node] 淡如止水 TypeScript (五):语法错误

0. 回顾

0.1 第一篇回顾

在第一篇中,我们克隆了 TypeScript 源码仓库,并配置了调试环境,
VSCode 断点成功停在了 lib/tsc 的第一行。

我们新建了一个 debug/index.ts 文件,作为编译的源代码,内容如下,

const i: number = 1;

0.2 第二篇回顾

第二篇,我们从 lib/tsc 第一行往下单步调试,
解决了 require 无法跳转到 .ts 文件的问题。

我们一路跟踪 TypeScript 从命令行工具 lib/tsc 到 parser 的过程。

lib/tsc -> ts.executeCommandLine -> executeCommandLineWorker -> performCompilation

performCompilation 包含了 TypeScript 编译的两个主要步骤,

createProgram: 创建 Program 对象
emitFilesAndReportErrorsAndGetExitStatus: 写文件

我们就暂时略过了写文件的逻辑,
第二篇文章的后半部分,后面第三、四篇文章,都在介绍 createProgram

TypeScript 每次编译会创建一个 Program,但有可能会创建多个 SourceFile
用于处理每一个待编译的源文件

createProgram     // 创建一个 Program 对象
processRootFile   // 处理每个文件,创建多个 SourceFile 对象
processSourceFile
getSourceFileFromReferenceWorker
getSourceFile
findSourceFile
host.getSourceFile
createSourceFile  // 来到了 parser 中

0.3 第三篇回顾

第三篇,我们从 parser 中的 createSourceFile 开始往下执行,

createSourceFile
Parser.parseSourceFile
parseSourceFileWorker
(nextToken)
parseList

parseList 又是一个关键函数,它是整个语法解析器的入口。
不过语法分析我们放到了第四篇中来介绍。
第三篇中,我们跟进了 parseList 之前的 nextToken 执行过程。

nextToken()token() 是 TypeScript 词法分析器最常见的两个函数,
词法分析器内部保存了状态,token() 用来返回当前在处理的 token (的种类 SyntaxKind),
nextToken() 逻辑非常复杂,覆盖了词法分析扫描下一个 token 是所有细节。

简略介绍 nextToken() 的基本原理的话,
它首先在字符流中,从当前位置,向后扫描一个字符,然后分情况分析,
比如说遇到了一个英文字符 c,就认为它可能是一个标识符或关键字,
然后就继续往后扫描,直到积累到的字符串不再构成标识符位置,比如扫描到了空格。

这样就会扫描到一个完整的,合法的标识符序列,例如 const,将字符串存为 tokenValue
然后,词法分析器会识别出这是一个关键字,并返回该 token 的种类为 SyntaxKind.ConstKeyword

这就是一个简单示例词法分析的全过程了。

0.4 第四篇回顾

第五篇,我们研究了 TypeScript 的语法分析,
从顶层的语法解析函数 parseList 开始往下调试。

TypeScript 采用了手工编写的递归下降解析方法,
AST 的创建过程,由大量的互相调用的 parseXXX 函数来完成。

最顶层的 parseXXX 函数是 parseList,返回了 AST 的根节点,
AST 的每个子树(子节点)都由相应的 parseXXX 函数返回。

在解析的过程中,解析器还可能会调用 nextToken()token()
用以获取下一个或当前的 token,来填充 AST 节点内容。

parseList
  parseDeclaration
    parseVariableStatement
      parseVariableDeclarationList
        parseVariableDeclaration
          parseIdentifierOrPattern
            parseIdentifier
          parseTypeAnnotation
            parseType
          parseInitializer
            parseAssignmentExpressionOrHigher
      parseSemicolon

实际的解析链路会非常长,以上只是粗略写了一些关键的 parseXXX 函数。
语法分析器会通过前瞻(look ahead)来决定使用哪一个函数进行解析。
即,通过自顶向下构造 AST 的方式,实现了产生式的最左推导。

解析是根据给定的文法,结构化一段线性表示的过程。
TypeScript 语法分析,最终目的是创建一棵 AST。

小结

以上我们回顾了前四篇文章的内容,有几个关键点需要一览,

performCompilation          // 执行编译
  createProgram             // 创建 Program 对象
    Parser.parseSourceFile  // 每个文件单独解析,创建 SourceFile 对象
      parseList             // 返回一个 AST
  emitFilesAndReportErrorsAndGetExitStatus

前四篇中,我们已经对 createProgram 的流程打探清楚了,
从本文开始,我们来 emitFilesAndReportErrorsAndGetExitStatus

其中包含了语法检查,语义检查,写文件等等业务逻辑。

1. 回溯到 performCompilation

书接上文,第四篇中我们已经了解了 parseList
它执行完之后返回到了,parseSourceFileWorker 函数中,位于 src/compiler/parser.ts#L858,

function parseSourceFileWorker(...): SourceFile {
  ...

  sourceFile.statements = parseList(ParsingContext.SourceElements, parseStatement);
  Debug.assert(token() === SyntaxKind.EndOfFileToken);
  
  ...
  return sourceFile;
}

看到 parseList 返回后,后面一句的判断,当前 token 就已经是文件结尾了。

完整的调用链路是这样的,

performCompilation
  createProgram
    forEach processRootFile
      processSourceFile
        getSourceFileFromReferenceWorker
          getSourceFile
            findSourceFile
              host.getSourceFile
                createSourceFile
                  Parser.parseSourceFile
                    parseSourceFileWorker
                      parseList
  emitFilesAndReportErrorsAndGetExitStatus

就这样我们一路回到了 performCompilation
在回溯过程中,forEach processRootFile 还会处理另外一些 TypeScript 内置的 .d.ts 文件,
这里就暂且略过不写了。

createProgram 完了之后,TypeScript 就开始执行 emitFilesAndReportErrorsAndGetExitStatus 了,
调用位置位于 src/tsc/executeCommandLine.ts#L515

function performCompilation(
  ...
) {
  ...
  const program = createProgram(programOptions);
  const exitStatus = emitFilesAndReportErrorsAndGetExitStatus(
      ...
  );
  ...
}

2. 语法错误

2.1 全局搜索报错位置

我们知道 TypeScript 在编译的时候,会提示各种可能的错误,例如语法错误、类型错误。
从头跟踪编译过程,然后找到哪里出错,是一件很繁琐的事情。

为此,我们可以先构造一个错误,然后在 TypeScript 报错的位置打个断点,
再通过 VSCode 反查调用栈,得到出错的链路信息。

修改 debug/index.ts 文件的内容如下,

const 0

然后命令行调用 lib/tsc 编译一下,

$ node bin/tsc debug/index.ts
debug/index.ts:1:7 - error TS1134: Variable declaration expected.

1 const 0
        ~


Found 1 error.

得到了以上报错信息。

现在,我们就可以在 TypeScript src/ 文件夹下搜索错误码 1134 了。

[Node] 淡如止水 TypeScript (五):语法错误_第1张图片

搜到了 src/compiler/diagnosticInformationMap.generated.ts#L110 位置的,
Variable_declaration_expected,它是错误信息的 key 值。
根据这个错误信息,我们就可以找出,代码中哪里抛出了这个错误。

[Node] 淡如止水 TypeScript (五):语法错误_第2张图片

我们在这个位置打个断点。

src/compiler/parser.ts#L2095,

function parsingContextErrors(context: ParsingContext): DiagnosticMessage {
  switch (context) {
    ...
    case ParsingContext.VariableDeclarations: return Diagnostics.Variable_declaration_expected;
    ...
  }
}

启动调试,程序会停在断点处,


[Node] 淡如止水 TypeScript (五):语法错误_第3张图片

2.2 调用栈

我们可以看到 VSCode 左侧的调用栈信息,


[Node] 淡如止水 TypeScript (五):语法错误_第4张图片

有一些步骤似曾相识,我们点开来看,
parseDelimitedList 之前都是前几篇文章中,我们已经分析过的内容。

parseVariableDeclarationList,src/compiler/parser.ts#L5763,
识别出了 const 关键字,正要开始解析后面的 node.declarations 部分。

function parseVariableDeclarationList(inForStatementInitializer: boolean): VariableDeclarationList {
  const node = createNode(SyntaxKind.VariableDeclarationList);

  switch (token()) {
    ...
    case SyntaxKind.ConstKeyword:
      node.flags |= NodeFlags.Const;
      break;
    ...
  }

  nextToken();

  ...
  if (token() === SyntaxKind.OfKeyword && lookAhead(canFollowContextualOfKeyword)) {
    ...
  }
  else {
    ...
    node.declarations = parseDelimitedList(ParsingContext.VariableDeclarations,
      inForStatementInitializer ? parseVariableDeclaration : parseVariableDeclarationAllowExclamation);
    ...
  }

  ...
}

为此调用了 parseDelimitedList,src/compiler/parser.ts#L2115,

function parseDelimitedList(...): NodeArray {
  ...

  while (true) {
    if (isListElement(kind, /*inErrorRecovery*/ false)) {
      ...
      list.push(parseListElement(kind, parseElement));
      ...
    }

    ...

    if (abortParsingListOrMoveToNextToken(kind)) {
      break;
    }
  }

  ...
  const result = createNodeArray(list, listPos);
  ...
  return result;
}

正常的 parseDelimitedList 会调用 parseListElement 完成后续的解析。
但此时 isListElement 的判断失败了,因此走到了 abortParsingListOrMoveToNextToken 函数中来。

src/compiler/parser.ts#L2074

function abortParsingListOrMoveToNextToken(kind: ParsingContext) {
  parseErrorAtCurrentToken(parsingContextErrors(kind));
  ...
}

接着调用了 parsingContextErrors,src/compiler/parser.ts#L2084,

function parsingContextErrors(context: ParsingContext): DiagnosticMessage {
  switch (context) {
    ...
    case ParsingContext.VariableDeclarations: return Diagnostics.Variable_declaration_expected;
    ...
  }
}

这就是上文搜索到包含错误 key Variable_declaration_expected 的函数了。

因此,TypeScript 在解析过程中,如果遇到了预期之外的 token,
就会跑到错误处理分支,根据错误 key 来记录错误消息。


总结

本文简单展示了 TypeScript 处理语法错误的代码逻辑,实际处理过程会更加的复杂,
包括这些错误信息如何最终展示到控制台中,
以及错误消息的格式化展示问题。

但我觉得目前来看,这件事不是特别的紧急,因此先放下,等有机会再详细了解它。

参考

TypeScript v3.7.3

你可能感兴趣的:([Node] 淡如止水 TypeScript (五):语法错误)