使用 TypeScript 编写代码分析器(从头开始)
Exercism是一个在线平台,旨在帮助您通过实践和指导提高编码技能。
Exercism为您提供了涵盖众多语言轨道的数千个练习。一旦您开始学习某个语言轨道,就会看到一组核心练习需要您完成。每个练习都是一个充满趣味的挑战,旨在帮助您更深入地了解该语言的特性。
在撰写本文时,我是JavaScript和TypeScript轨道的维护者,最近我们一直致力于实现部分体验的自动化。
本文将介绍如何使用ESTree兼容工具进行 AST 解析和遍历。本文将重点介绍JavaScript和TypeScript代码中最常见的某些 token 类型。
它教您如何自己探索这些树,并参考代码示例和实际生产实现。
阅读本文时,请思考一下你自己的 JavaScript 和 TypeScript 代码。一旦你理解了浏览器(以及类似的工具eslint
)如何解析你的代码,你或许就能更好地理解这门语言是如何定义和构建的。
🚧 所有GitHub链接均为
master
链接,这意味着从撰写本文到您点击它们,内容可能会发生变化。但是,为了确保代码示例有意义,分析器代码库链接指向的是特定的提交 (9ff332b
)。这意味着您看到的代码可能与当前使用的代码不同。
目录
📝 练习
在本文中,我将为练习编写一个分析器gigasecond
,适用于 TypeScript 和 JavaScript 轨道。描述只有两行:
给定一个时刻,确定一千兆秒过去之后的时刻。
一千兆秒等于
10^9
(1,000,000,000) 秒。
规范数据提示了我需要编写的代码,但幸运的是,练习在JavaScript和TypeScript轨道中都实现了。
JavaScript 实现
JavaScript 实现要求我们编写一个名为的导出,gigasecond
该导出返回Date
比输入晚一千兆秒的时间Date
。
// Test case from the JavaScript track
test('tells a gigasecond anniversary since midnight', () => {
const gs = gigasecond(new Date(Date.UTC(2015, 8, 14)));
const expectedDate = new Date(Date.UTC(2047, 4, 23, 1, 46, 40));
expect(gs).toEqual(expectedDate);
});
TypeScript 实现
TypeScript 实现要求我们编写一个默认导出,Gigasecond
该导出是一个类,该类具有一个date()
函数,该函数返回一个Date
比构造函数晚一千兆秒的函数Date
。
// Test case from the TypeScript track
it('tells a gigasecond anniversary since midnight', () => {
const gs = new Gigasecond(new Date(Date.UTC(2015, 8, 14)))
const expectedDate = new Date(Date.UTC(2047, 4, 23, 1, 46, 40))
expect(gs.date()).toEqual(expectedDate)
})
💯 最佳解决方案
在着手为这两种实现编写分析器之前,我必须先确定最佳解决方案。如果我知道预期的代码结果,我就可以尝试识别它并从那里开始工作。
JavaScript 解决方案
JavaScript 中的实现很简单。它使用Date
构造函数Date#getTime
和一个常量来生成适当的结果。
const GIGASECOND_IN_MS = (10 ** 9) * 1000
export function gigasecond(date) {
return new Date(date.getTime() + GIGASECOND_IN_MS)
}
必须注意这里的特殊性:
- 最佳方法是将
GIGASECOND_IN_MS
值提取为顶级常量 - 常数的值 (
(10 ** 9) * 1000
) 可以以多种同样有效的形式最优地写出。然而,写出数字本身却被认为是一种错误。以下所有形式都应该被认为是最优的:10 ** 12
1e9 * 1e3
1e12
,Math.pow(10, 9) * 1000
Math.pow(10, 12)
Date#valueOf
并非最佳选择。它被标记为“此方法通常由 JavaScript 内部调用,而不是在代码中显式调用。”,尽管它在功能上是等效的。- 最后,
Date.parse(date)
它不是一个好的选择,因为它应该只处理字符串。它返回的值与getTime
给定日期时相同,是因为该日期被强制转换为字符串,然后进行解析。
TypeScript 解决方案
TypeScript 实现需要一个class
as default export
,并有一个 method date()
。该算法与 JavaScript 解决方案完全相同,但需要类型注释。
const GIGASECOND_IN_MS = (10 ** 9) * 1000
export default class Gigasecond {
private readonly futureDate: Readonly<Date>
constructor(date: Readonly<Date>) {
this.futureDate = Object.freeze(new Date(date.getTime() + GIGASECOND_IN_MS))
}
date(): Readonly<Date> {
return this.futureDate
}
}
除了前面描述的 JavaScript 变体和规则外,计算还可以在constructor
(如上所示)或函数中进行date
。在函数中,计算过程如下:
const GIGASECOND_IN_MS = (10 ** 9) * 1000
export default class Gigasecond {
constructor(private readonly date: Date) { }
date(): Date {
return new Date(date.getTime() + GIGASECOND_IN_MS)
}
}
👩🏽💻分析代码
现在是时候实际编写分析器了。我们将首先关注 JavaScript 实现。由于目前已有 JavaScript 分析器在运行,并且这些分析器是开源的,因此本示例将使用javascript-analyzer
存储库中的实用程序和基类。
💬 抽象语法树
JavaScript 分析器将处理代码解决方案的抽象语法树 (AST) 。虽然还有其他方法可以编写分析器,但就本文而言,我们主要讨论 AST 解析。
yarn add @typescript-eslint/parser @typescript-eslint/typescript-estree
TypeScript ESLint 团队构建了一个很棒的解析器,它输出一个ESTree —— 一种指定格式的 token 和输入代码的信息。它可以兼容JavaScript
和TypeScript
,因此非常适合我们的用例。我更喜欢使用这种类型的树,因为它的规范允许与其他工具互操作。
处理parser
eslint 配置,然后调用使用 TypeScript 编译代码并将结果转换为与ESTreetypescript-estree
匹配的包。您可以前往AST Explorer亲自尝试,方法是将上面的示例代码粘贴到输入字段并选择。⚠注意:此处的解析器版本通常与最新的解析器不同步。@typescript-eslint/parser
你可能会想:为什么不使用TypeScript 编译器 API 呢?它内置了 TypeScript 语言的 AST 解析功能。它还公开了许多辅助函数(例如
isIdentifer
)。原因是输出结果并非ESTree 规范格式,这意味着您将无法使用其他工具来处理它。实际上,TypeScript-ESTree 包实际上在底层使用了编译器,然后将其转换为规范,这意味着没有锁定。您不会受到编译器更改的约束。
🏃🏽💨运行解析器
现在包已经到位,让我们来解析输入代码。
import { parse, TSESTreeOptions } from "@typescript-eslint/typescript-estree";
const options: TSESTreeOptions = {
comment: false,
jsx: false
}
const program = parse(source, options)
// => Program({ body: [...], sourceType: "module", tokens: [...] })
这给了我们和你在AST Explorer中看到的相同的输出,在根目录下有 aProgram
和 its body
。我们不需要其他字段,但这tokens
很有趣。它列出了在构建树时从源头解析的标记。
💎 你可以在
parsers/AstParser.ts
🔎 寻找主入口点
我们正在寻找一个名为 的函数gigasecond
。我们知道以下内容:
- 必须编辑
export
- 它的名字必须是
gigasecond
输入代码声明了一个函数,就像上面的最优解一样,因此树中保存了一个,FunctionDeclaration
其中Identifier
:
export function gigasecond(date) {
// ...
}
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "gigasecond"
},
generator: false,
expression: false,
async: false,
params: [ ... ],
// The body is a block { ... }
body: {
type: "BlockStatement",
body: [ ... ]
}
}
这是它可以搜索的内容。在 AST 中搜索的最常见方法是遍历该树。从某个节点(通常是根/程序)开始,然后访问每个项目。
我们知道我们的解析与和兼容EStree
,就像可以识别(和转换)代码一样。eslint
eslint
prettier
# Not a dev dependency!
yarn add eslint
import { TSESTree } from "@typescript-eslint/typescript-estree"
import { traverse } from 'eslint/lib/util/traverser'
traverse(program, {
enter(node: TSESTree.Node) {
// ...
}
})
编写这段代码时,TypeScript 会报错说这个库没有类型,不幸的是,在编写时仍然如此。不过,你可以复制declarations.d.ts
我写的这段代码来获得类型补全。
该方法将在程序中的每个节点enter
上调用。在代码块内部,“TraverserContext” 暴露了两个方法:enter
this.skip()
:跳过该节点的进一步遍历,这意味着它不会访问当前节点的任何其他键(以及子键);this.break()
:完全停止遍历。
现在找到入口点就很简单了。
import { TSESTree, AST_NODE_TYPES } from "@typescript-eslint/typescript-estree"
let entry: TSESTree.FunctionDeclaration | undefined = undefined
traverse(program, {
enter(node: TSESTree.Node) {
switch (node.type) {
// function name() {}
case AST_NODE_TYPES.FunctionDeclaration:
if (node.id && node.id.name === 'gigasecond') {
entry = node
this.break()
}
break;
}
}
})
entry
// => FunctionDeclaration({
// id: { type: "Identifier", name: "gigasecond" }, ...
// })
不幸的是,上面的遍历器只FunctionDeclaration
在等效代码中使用了或ArrowFunctionExpression
时失败了FunctionExpression
。稍后会详细介绍。
🔎 查找顶级常量
代码找到了两个组件中的第一个。现在还需要找到第二个。它是一个顶级组件const
,但名称未知。
const GIGASECOND_IN_MS = (10 ** 9) * 1000
{
type: "VariableDeclaration",
declarations: [
{
type: "VariableDeclarator",
id: {
type: "Identifier",
name: "GIGASECOND_IN_MS"
},
init: {
type: "BinaryExpression",
operator: "*",
// Left is another BinaryExpression with **
left: { ... },
// Right is a Literal
right: { ... }
}
}
],
kind: "const"
}
这里没有什么特别有用的。考虑到它需要接受的数据种类繁多,我不能依赖init
它的特定类型。它的名称也不固定,因为它没有被export
编辑,因此没有经过测试。
然而,这里有几个限制会有所帮助:
- 它必须是顶级常量
- 无法命名
gigasecond
- 在最优解中,实际上只有一个不是的顶级常数
entry
,
type FoundConst = { kind: TSESTree.VariableDeclaration['kind'] }
& TSESTree.VariableDeclarator
let bigNumber: FoundConst | undefined = undefined
traverse(program, {
enter(node: TSESTree.Node) {
switch (node.type) {
// const NAME = ...
case AST_NODE_TYPES.VariableDeclaration:
const declaration = node.declarations.find(
(declaration) => declaration.id && declaration.id.name !== 'gigasecond')
)
if (declaration) {
bigNumber = { kind: node.kind, ...declaration }
this.break()
}
break;
default:
// This doesn't declare a variable, so skip the node
this.skip()
}
}
})
稍后,我可以检查bigNumber['kind']
并确保它是const
,或者附加一条const
我喜欢的消息。
算法
现在我找到了entry
要点,我可以弄清楚参数的名称是什么了(date
)。因为我还知道顶层常量,所以我知道常量的名称是什么GIGASECOND_IN_MS
。
new Date(...)
这里没什么特别的。它是一个new
表达式,以表达式作为第一个参数。
{
type: "NewExpression",
callee: {
type: "Identifier",
name: "Date"
},
arguments: [ ... ]
}
let newDate: TSESTree.NewExpression | undefined = undefined
traverse(program, {
enter(node: TSESTree.Node) {
switch (node.type) {
// new Date(...)
case AST_NODE_TYPES.NewExpression:
if (
node.callee.type === AST_NODE_TYPES.Identifier
&& node.callee.name === 'Date'
) {
newDate = node;
this.break()
}
break;
default:
// This doesn't declare a variable, so skip the node
this.skip()
}
}
})
内部表达式的类型为BinaryExpression
。在 EStree 兼容输出中,带有两个部分的 和 运算符(例如+
、-
、*
)是二元表达式,而带有一个部分的 和 运算符(例如~
和!
)是一元表达式。
date.getTime() + GIGASECOND_IN_MS
{
type: "BinaryExpression",
operator: "+",
left: {
type: "CallExpression",
callee: {
type: "MemberExpression",
object: {
type: "Identifier",
name: "date"
},
property: {
type: "Identifier",
name: "getTime"
}
},
arguments: []
},
right: {
type: "Identifier",
name: "GIGASECOND_IN_MS"
}
}
很多东西我们已经见过了,还有一些新类型。让我们来看看。
对象的属性
当解析器遇到对象属性访问器(object.property
)时,它会被解析为MemberExpression
。根据写法的不同,属性可能是Identifier
或Literal
。
date.getTime
// ^ ^
// object property
// | |
// | identifier (name = getTime)
// identifier (name = date)
date['getTime']
// ^ ^
// object property
// | |
// | literal (value = getTime)
// identifier (name = date)
“执行”属性
如果 后面有括号MemberExpression
,则整个表达式将被解析为 的子表达式。标识符CallExpression
后面的括号也是如此。
date.getTime ( )
// ---------| ^ |
// ^ | argument(s) of call expression
// member expression
// |
// -------------|
// call expression
gigasecond(INPUT)
// ------| ^ |
// ^ | argument of call expression
// identifier |
// |
// --------------|
// call expression
匹配标识符
我需要查找并匹配源代码提供的两个标识符:
- 千兆秒第一个参数(用于
arg.getTime()
) - 顶级常量(用于
time + CONSTANT
)
const argumentName = entry.id.name
// => "gigasecond"
const constantName = bigNumber.id.name
// => "GIGASECOND_IN_MS"
let optimalExpression: boolean = false
// NOTE: passing in the newDate as root, so this is a subtree traversal!
traverse(newDate, {
enter(node: TSESTree.Node) {
switch (node.type) {
// new Date(x.z() + y)
case AST_NODE_TYPES.BinaryExpression:
this.break()
if (node.operator !== '+') {
optimalExpression = false;
return;
}
// This allows the order to be reversed
const leftType = node.left.type
const constSide = leftType === AST_NODE_TYPES.Identifier
? node.left
: node.right
const expressionSide = leftType === AST_NODE_TYPES.CallExpression
? node.left
: node.right
if (constSide === expressionSide) {
// throw new Error("not optimal! this is not x.z() + y")
optimalExpression = false
return
}
if (constSide.id.name !== constantName) {
optimalExpression = false
return
}
const { object, property } = expressionSide.callee
optimalExpression =
object.type === AST_NODE_TYPES.Identifier
&& object.name === argumentName
&& ((
property.type === AST_NODE_TYPES.Identifier
&& property.name === 'getTime'
) || (
property.type === AST_NODE_TYPES.Literal
&& property.value === 'getTime'
))
break;
}
}
})
💎 您可以在以下位置找到它:
✅ 自动指导
当所有这些部分组合在一起时,它就是千兆秒级的分析仪。还有一些事情需要检查:
- 是否
bigNumber.kind
等于"const"
?如果不等于,请添加注释 - 使用其中一个推导式是否值得
GIGASECOND_IN_MS
?如果没有,请添加注释。 - 只有一个参数吗
gigasecond
?确保它不是一个...splat
参数,并且没有value = "default"
。 gigasecond
实际上是导出的吗?是export
内联的吗?如果不是,请添加注释。
由于第一个(kind
相等性检查)已经提到,而第二个与调用的内部new Date(...)
表达式非常相似,因此我省略了如何实现它们。如果您需要一些灵感,可以查看千兆秒分析器源代码。第三个是测试entry
for parameters
。
至于export
s,这些由 处理💎 extract_export
,但我会向您展示它的要点。
📦 测试导出
导出JavaScript
基本上TypeScript
有三种类型。使用核心语言功能(即使用关键字)的导出是最简单的:
export const inline = {}
export default class InlineClass {}
export default defaultSpecifier
export { specifier }
export { specifier as renamed }
它们default
export
有自己的令牌类型ExportDefaultDeclaration
。
{
type: "ExportDefaultDeclaration",
declaration: {
// ...
}
}
没有default
修饰符的属于 类型ExportNamedDeclaration
。
{
type: "ExportNamedDeclaration",
declaration: {
// ...
}
}
属性declaration
是稍微有点棘手的地方。内联export
语句,无论是否是默认的,后面都会跟着相同的标记类型,就像它们没有export
关键字一样,类似于用括号将表达式括起来的方式CallExpression
。
内联导出
这意味着第一个例子是VariableDeclaration
带有单个的VariableDeclaractor
:id
是Identifier
带有的name = "inline"
,init
是带有的ObjectExpression
。同样,第二个例子是ClassDeclaration
带有的,作为id
带有Identifier
的name = "InlineClass"
。
说明符导出
第三个具有declaration
类型。这Identifier
与exportsname = "defaultSpecifier"
类似inline
。
然而,第四个和第五个却没有属性。declaration
相反,它们有一个specifiers
属性,在本例中,只有一个项目:
{
type: "ExportSpecifier",
local: {
type: "Identifier",
name: "specifier"
}
exported: {
type: "Identifier",
name: "specifier" // or "renamed"
}
}
使用local
属性来确定导出什么(内部名称是什么)以及exported
属性如何导入(导出名称是什么)。
CommonJS 导出
最后,有些导出不使用关键字,而是使用(就我而言) defunct module.exports
。
module.exports = singleExport
module.exports = { specifier }
module.exports.default = defaultExport
module.exports.renamed = specifier
由于这些不使用关键字,因此它们被解释为ExpressionStatement
s,因为它们是AssignmentExpression
s。以下是重要属性和表示的简要概述表:
表达 | 类型 | 支柱 | 价值 |
---|---|---|---|
module.exports.renamed = specifier |
AssignmentExpression |
||
operator |
"=" |
||
Identifier |
right |
"specifier" |
|
MemberExpression |
left |
🔽 module.exports.renamed 🔽 |
|
module.exports.renamed |
MemberExpression |
||
Identifier |
property |
"renamed" |
|
MemberExpression |
object |
🔽 module.exports 🔽 |
|
module.exports |
MemberExpression |
||
Identifier |
property |
"exports" |
|
Identifier |
object |
"module" |
还有使用 的变体object['accessor']
,其中accessor
不是 而是Identifier
,Literal
但除此之外都是一样的。
🔀 测试变体
如前所述,在 JavaScript 和 TypeScript 中有很多方法可以编写函数。分析器的源代码💎 extract_main_method
中有一个实用方法。它可以检测以下变体:
function name() {}
// FunctionDeclaration
const name = () => {}
// ArrowFunctionExpression
const name = function() {}
// FunctionExpression
export default {
name: () => {}
}
// ExportDefaultDeclaration + ObjectExpression + (Arrow)FunctionExpression
以及 TypeScript 特定的变体(但它们适用于两者)
class Foo {
name() {}
}
// MethodDefinition + FunctionExpression
class Foo {
static name = () => {}
static name = function name() {}
}
// ClassProperty + (Arrow)FunctionExpression
遍历 TypeScript 树
正如你所注意到的,到目前为止,我们所做的只是检查 JavaScript 代码,并展示了它是如何被解析和遍历的。为了使该解决方案能够解析 TypeScript 代码,我们需要对遍历器进行一些修改,并测试一些额外的属性。
🔑 访客钥匙
当traverser
遍历树时,它会根据一组称为 的键来决定“遍历🚶🏽”哪些节点visitor keys
。由于TypeScript
是的超集JavaScript
,因此它具有相同的键,甚至更多。
import { visitorKeys } from "@typescript-eslint/parser/dist/visitor-keys"
traverse(root, {
enter(node: Node) {
// ...
},
visitorKeys
})
如果您查看该文件的源代码,您会发现它实际上导入了eslint
访问者键(以便访问所有 JavaScript 键)并添加了特定的 TypeScript 键。
📖 类型注释
这些很有趣。
class Gigasecond {
constructor(private readonly date: Date) { }
}
{
type: "ClassDeclaration",
id: {
type: "Identifier",
name: "Gigasecond"
},
// everything inside the class { body }
body: {
type: "ClassBody",
body: [
{
// a constructor is a regular method definition...
type: "MethodDefinition",
key: {
type: "Identifier",
name: "constructor"
},
value: {
type: "FunctionExpression",
params: [{ /*...*/ }],
generator: false,
expression: false,
async: false,
body: { /*...*/ }
}
computed: false,
static: false, // (typescript static keyword)
// ... but with a special kind
kind: 'constructor'
}
]
}
}
上述内容与 JavaScript 等效内容相比没有特殊属性,但这是因为源代码中除了 之外没有params
类型注释constructor
:
{
type: "Identifier",
name: "date",
// : ...
typeAnnotation: {
type: "TSTypeAnnotation",
typeAnnotation: {
// Readonly
type: "TSTypeReference",
typeName: {
type: "Identifier"
name: "Readonly"
},
// <...>
typeParameters: {
type: "TSTypeParameterInstantiation",
// Each type between the < brackets >
params: [
{
type: "TSTypeReference",
typeName: {
type: "Identifier",
name: "Date"
}
}
]
}
}
}
}
一些关键的观察结果:
- 类型注释有自己的访问者键
typeAnnotation
, - 所有 TypeScript 节点都以 开头
TS
, - 泛型类型只是
TSTypeReference
同时具有一个typeName
和一个或多个的typeParameters
。 - 剥离类型几乎和删除
typeAnnotation
键一样简单,这几乎与babelpreset-typescript
所做的完全一样。
类属性
在 TypeScript 中,你可以使用诸如private
和 之类的关键字来注释类属性readonly
。此外,它们还可以具有类型(Date
在本例中为 )。
class Gigasecond {
private readonly futureDate: Date
}
{
type: "ClassProperty",
key: {
type: "Identifier",
name: "futureDate"
},
computed: false,
static: false, // static keyword
readonly: true, // readonly keyword
accessibility: "private", // private, protected, public keywords
typeAnnotation: {
type: "TSTypeAnnotation",
typeAnnotation: {
type: "TSTypeReference",
typeName: {
type: "Identifier",
name: "Date"
}
}
}
}
TypeScript 关键字private
和直接readonly
修饰ClassProperty
,但类型又在 上typeAnnotation
。如果源代码中省略了类型注释(即:隐式any
),则typeAnnotation
AST 中不存在该键。
↩ 返回类型
我们现在要讨论的最后一种类型是函数return
类型。大多数其他类型注解只是此类型以及之前提到的类型的变体。
class Gigasecond {
date(): Date {
// ...
}
}
{
type: "MethodDefinition",
key: {
type: "Identifier",
name: "date"
},
value: {
type: "FunctionExpression",
generator: false,
expression: false,
async: false,
body: { /*...*/ },
params: [],
returnType: {
type: "TSTypeAnnotation",
typeAnnotation: {
type: "TSTypeReference",
typeName: {
type: "Identifier",
name: "Date"
}
}
}
},
computed: false,
static: false, // static keyword
kind: "method"
}
你可能已经注意到了,typeAnnotation
不在上。MethodDefinition
这是因为类上的方法定义实际上是将函数表达式绑定(): Date { ... }
到标识符 上date
。
在 上,FunctionExpression
您可以找到之前未遇到过的类型注释。其结构与的returnType
相同。typeAnnotation
ClassProperty
结论
将代码解释为抽象语法树并寻找某些属性需要大量的树遍历;因为某些 AST 解析器的输出格式有规范,所以您可以自己编写工具。
gigasecond
如果学生提供的是与最优解完全相同的变体,则本文内容(格式不同)将用于自动批准练习。即使学生提供的不是最优解,分析器的结果也有足够的空间提供有意义的评论。
参考
分析器参考
- AstParser
#parse
extractExport
extractMainMethod
findMemberCall
findNewExpression
findTopLevelConstants
isBinaryExpression
isIdentifier
isLiteral
锻炼存储库
problem-specifications/gigasecond
|规范数据javascript/gigasecond
typescript/gigasecond
javascript-analyzer/gigasecond
javascript-analyzer