前言
Biome 是个基于 Rust 开发的前端工具链(github 地址: github.com/biomejs/bio...%25EF%25BC%258C%25E7%259B%25AE%25E5%2589%258D%25E4%25B8%25BB%25E8%25A6%2581%25E6%258F%2590%25E4%25BE%259B%25E4%25BA%2586 "https://github.com/biomejs/biome)%EF%BC%8C%E7%9B%AE%E5%89%8D%E4%B8%BB%E8%A6%81%E6%8F%90%E4%BE%9B%E4%BA%86") formatter 以及 linter 这两种功能,在功能层面上类似于 Prettier 和 ESLint。但由于 Biome 基于 Rust 开发,因此它对比 JS 开发的一些工具链先天具备不错的性能优势,这里不做展开介绍。
作为 Web 项目的工具链,Biome 支持基于 JavaScript 或者 TypeScript 开发项目的 formatter 和 linter 能力,因此 Biome 支持对于 TypeScript 以及 JavaScript 这类语言的 Parser 能力,顺便一提,Biome 同时也支持 CSS 以及 JSON 文件的 formatter(参考: biomejs.dev/reference/c... 配置详情),因此 Biome 也同时提供了 CSS 和 JSON 的 parser。
从一个 issue 开始
好几个月前,我在 Biome 的仓库发现了一个 issue: github.com/biomejs/bio...
这个 issue 的意思很简单,大概是这个老哥在开发自己项目的时候,用了 Biome 去做 formatter,然后发现自己用到了 TypeScript v5.3 的一个特性: www.typescriptlang.org/docs/handbo...
这个特性源自于 TC 39 的一个 proposal: github.com/tc39/propos... 。简单介绍一下这个 proposal,它提供了一种 import 属性,用于增加 JS 在 import 文件内容的时候能附带一些额外的信息,同样这个 proposal 给 import 引入了一个 with
关键字,在用法上可以参考如下 case:
typescript
import json from './bar.json' with { type: 'json' };
可以看到通过 with
这个关键字可以前置知道这个导入类型这个 json,对于 dynamic import 同样也提供了对应的语法:
typescript
import('bar.json', { with: { type: 'json' } });
目前这个 proposal 按照最新消息进展是已经到了 stage-4
,同时 TypeScript 在 v5.3 支持了这个 proposal (参考: www.typescriptlang.org/docs/handbo...%25EF%25BC%258C%25E5%259F%25BA%25E4%25BA%258E%25E8%25BF%2599%25E4%25B8%25AA "https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-3.html#import-attributes)%EF%BC%8C%E5%9F%BA%E4%BA%8E%E8%BF%99%E4%B8%AA") proposal,TS 在 v5.3 对 Import types 中的 resolution-mode
支持了一种新的 import 写法,TS 引入的写法如下:
typescript
export type TypeFromRequire =
import("pkg", { with: { "resolution-mode": "require" } }).TypeFromRequire;
export type TypeFromImport =
import("pkg", { with: { "resolution-mode": "import" } }).TypeFromImport;
Ok,issue 中的小哥就是用到了 TypeScript v5.3 支持这种全新的 Import Types 写法,他自己提供的 playground 示例如下:
typescript
export type Fs = typeof import('fs', { with: { 'resolution-mode': 'import' } })
他写了一段如上的代码,然后使用 biome 去对代码进行 formatter,发现代码寄了,于是给 biome 提了个 issue。
于是笔者看了下这个 issue 下面的一些回复,以及当时这段代码在 biome playground 的 Parser 出来的 AST 结构:
在当时是可以很清楚看到 Biome 的 Parser 对于 TsImportType 这个数据类型的支持,里面并没有对 import attributes 的处理,这个对应的 {with: { 'resolution-mode': 'import'}}
被处理成了一个 JSBogus 结构(可以理解为一般 Biome 把 Syntax Error 会处理成这种结构)。
那么这个问题就应该比较清晰了,我们只用让 Biome 的 Parser 支持 TsImportType
对于 import attributes 这个数据结构处理就行了,同时添加上对应数据结构的 formatter 处理逻辑,这个问题应该就解决了。
Commit biome_parser 1.0
在搞清楚怎么解决问题之后,笔者直接打开 biome 代码仓库开搞,这里在开始搞之前,我们可以先看一眼 crates/biome_parser/CONTRIBUTING.md
贡献文档,如果你是第一次贡献,主仓库根目录下的 CONTRBUTING.md
也记得先看一眼。
因为笔者是老司机了,所以直接切到 biome 的 js.ungram
这个文件下面来,这个文件在 xtask/codegen
目录下,这个目录的一些 *.ungram
文件能自动生成 Biome Parser 需要处理的语法的 Rust 数据结构,注意这里只生成对应的数据结构,具体语法要如何处理需要我们自己去 biome_parser
这个 crates
中去完善对应的逻辑。
在这个目录下,我们可以看到除了 js.ungram
,还有 json.ungram
、css.ungram
,说明 Biome 支持这些不同语法的 parser,目前 Biome 没有对 TypeScript 和 JavaScript 做 *.ungram
文件的区分,都统一放在了 js.ungram
文件中。
笔者在最开始做这个 PR 的时候,先去 js.ungram
中参考了一下 JS Import Attributes 的语法结构,因为 Import Attributes 这个 proposal Biome 在还是 Rome 的时候就已经支持了hhh,于是我们可以看看 biome_parser
是怎么处理以下的 dynaic import 和正常的 import attributes 语法结构:
python
import('bar.json', { with: { type: 'json' } });
import a from "mod" assert { type: "json" };
参考这两种语法的处理方式,我们可以比较容易的模仿出这次我们要支持的 import type resolution-mode
语法支持处理。笔者一开始的想法还是比较不错的,这同样也为后面的失败埋下了伏笔,当然这里是后话,这里先按下不表。
于是笔者先从 Playgound 中找到了这次要参考的节点处理,分别是 JsImportCallExpression
和 JsImportAssertion
。
在 js.ungram
中可以分别看到这两个 SyntaxNode 的结构:
python
JsCallArguments = '(' args: JsCallArgumentList ')'
JsImportCallExpression =
'import'
arguments: JsCallArguments
// import a from "mod" assert { type: "json" }
// ^^^^^^^^^^^^^^^^^^^^^^^
JsImportAssertion =
assertion_kind: ('assert' | 'with')
'{'
assertions: JsImportAssertionEntryList
'}'
JsImportAssertionEntryList = (AnyJsImportAssertionEntry (',' AnyJsImportAssertionEntry)* ','?)
AnyJsImportAssertionEntry =
JsImportAssertionEntry
| JsBogusImportAssertionEntry
// import a from "mod" assert { type: "json" }
// ^^^^^^^^^^^^
JsImportAssertionEntry =
key: ('ident' | 'js_string_literal')
':'
value: 'js_string_literal'
这里可以看到,对于 dynmaic import 用到的 JsImportCallExpression,它实际上用的就是个 import()
然后括号中衔接一个 JsCallArgumentList
,用户在写的时候可以给这个 import 传多个参数,biome_parser 都是可以处理的:
python
import("a.json", "b.json", { with: {type: 'json'} });
参考了这里的写法,笔者比较容易的改出了一个版本的 Biome Parser 需要的 TsImportType
,先看 Biome 这里原始的数据结构:
css
// a: import("./test").T<typeof X>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// a: import("./test")<string>
// ^^^^^^^^^^^^^^^^^^^^^^^^^
TsImportType =
'typeof'?
'import'
'('
argument: 'js_string_literal'
')'
qualifier_clause: TsImportTypeQualifier?
type_arguments: TsTypeArguments?
这种结构显然是没办法处理前面提到的 Import type
附带 attributes 参数的情况,因为这里可以看到,argument
只能接收一个单独的 js_string
的参数。
对于前面提到的多个参数的 Case 是没办法处理的,所以这里我直接参考了 JsImportCallExpression
中将 arguments
处理成 JsCallArguments
的写法,这样就可以接收多个参数了,于是对于 js.ungram
改动很简单:
less
TsImportType =
'typeof'?
'import'
- '('
- argument: 'js_string_literal'
- ')'
+ arguments: JsCallArguments
qualifier_clause: TsImportTypeQualifier?
type_arguments: TsTypeArguments?
直接将 ungram 文件中的 argument 改成一个 JsCallArguments
。改完之后,跑一次 xtask
的 codegen 去生成对应的数据结构的 Rust 内容,这部分内容在 biome_parser/CONTRIBUTING.md
文件中有介绍:
just gen-grammar js
生成完对应的数据结构之后,然后就可以到 biome_js_parser
里面去修改对应的 parser 处理逻辑,biome 中关于 TypeScript types 相关的 parser 都在 biome_js_parser/src/syntax/typescript/types.rs
文件中,在这个文件中,可以直接找到对于 TsImportTypes 的 parser 逻辑:
ini
// test ts ts_import_type
// type A = typeof import("test");
// type B = import("test");
// type C = typeof import("test").a.b.c.d.e.f;
// type D = import("test")<string>;
// type E = import("test").C<string>;
fn parse_ts_import_type(p: &mut JsParser, context: TypeContext) -> ParsedSyntax {}
这里最原始的逻辑就是简单读到 TsImportType 的 argument 的时候,当作一个 string 字面量来处理,这里我们的修改操作也比较简单,就是当读到 argument 的时候,把 argument 当作 JsCallArgument 去处理,具体改动参考如下:
这里贴了关于 argument 的处理逻辑,这次修改也主要是围绕这块在改,将 string 的处理逻辑改成处理 JsCallArguments,同时处理一些边界 Case,例如 TsImportType 的 import 参数只能接收1个或2个参数,即正常情况下传一个参数,有 import attributes 的情况下,是两个参数。因此对于 0 个以及大于 2 个参数的情况都要做 parser error 处理。
Ok,简单完成处理逻辑之后,再补上对应的测试逻辑,最早在写这一版 parser 的时候,Biome 的 js parser 测试是写在代码注释里面然后再通过 codegen 生成对应的测试文件:
在 parse_ts_import_type
上补充完这次 commit 需要的测试,再补上一些关于边界 Case 报错的 Test Case:
然后生成测试文件,以及对应测试 Case 的 Parser 结果的 snapshot 内容。
在完成 Parser 的修改之后,我们同时还需要对 TsImportType 这个数据结构的 formatter 内容做一些调整,因为实际上提出这个 issue 的老哥实际上是在 formatter 的时候遇到的问题,所以我们这里直接把原本对于 string argument 的 formatter 改成对于 JsCallArgument 的 format 就行:
实际上因为 TsImportType 的语法结构已经被改了,formatter 这个地方如果不跟着一起调整,是会有 Rust 的编译错误的。改完 formatter 之后,我们直接把老哥 issue 里面不过的 test case 作为 formatter test case 来跑:
然后生成对应的测试 snapshot ,目测来看都没啥问题了。
PR 1.0
在上面的代码 + 测试工作都完成之后,这里就可以开 PR 了,于是笔者开了第一个 PR(PR 地址: github.com/biomejs/bio...):
这里可以看出,笔者的这版本 PR 还是比较没素质的,只在 Summary 里面填写了对应的 issue close 信息,里面没有更多的信息补充,这里还被 Biome 作者大哥批评过 PR 的描述信息不够完善,这里作为一个反面例子给读者们参考一下。
我们需要在 PR 里面简单描述清楚自己 PR 所解决的问题,以及自己 PR 的修改思路。
这个 PR 被 Conacols 简单 Review 了一下,因为 PR 本体里面的测试 Case 以及代码在目前看来都还算比较顺利,因此这个 MR 基本没太多后续就被 merge 了进去:
同时这个 PR 在版本 v1.9.4 中正式发布了:
Biome v1.9.4 的完整 Relase Note 参考: github.com/biomejs/bio...
新的 issue 出现
以为发布完之后一切都 Ok 了,然后大概在一周之后,我又在仓库发现了一个新的 issue: github.com/biomejs/bio...
笔者最开始发现这个 issue 比较晚,直到后面又有类似的 issue 出来:
这些 issue 都反馈了 v1.9.4 的一个问题,大概就是 biome formatter 在 v1.9.4 之后会出现一种有问题的 formatter 情况,举个例子,如果用户代码中存在一段比较长的 TsImportType
类型:
ini
type LongType = typeof import("./longlonglonglonglonglonglonglonglonglong");
Biome 的 Formatter 会出来一个坏的结果:
他的 formatter 结果会在 import 函数的第一个参数后面加上一个逗号,而这个逗号实际上在 TypeScript 语法中是不符合规范的(BTW,JS 中是可以允许在函数后面加一个可有可无的逗号的,但 TypeScript 则是不允许的)。
在参考了 github.com/biomejs/bio... 这个 issue 中老哥提的意见之后,发现之前提的那个 pr 会导致目前这些 issue 反馈的问题:
这里导致这个问题的原因其实比较简单,因为笔者在上一个 PR 中将 TsImportType
的 Argument 由唯一的 JsStringLiteral
改成了可以接收多个参数的 JsCallArguments
,而且笔者在 Formatter 的时候直接用了 Biome 写好的 JsCallArguments 方法去 formatter 这里:
这里实际上是存在一定风险的,例如这里触发的这个 Case,在 JS 中 import expression 的参数接收 JsCallArguments,并且对于最后可有可无的逗号都不会抛错,因此在处理 JSCallArguments 的 format 的时候,biome 对于长的超出限制的 import 参数,会在换行的时候去默认在后面补个逗号,这里的行为和 Prettier 是一致的。
关于 Formatter 处理 JSCallArguments 逻辑可以参考文件: crates/biome_js_formatter/src/js/expressions/call_arguments.rs
。
所以这里不能简单的将 TsImportType
的 import
函数参数类型简单写成 JsCallArguments
。
研究 TypeScript Parser
这里的主要灵感思路来源于 issue (github.com/biomejs/bio...%25E9%2587%258C%25E9%259D%25A2%25E4%25B8%2580%25E4%25B8%25AA%25E8%2580%2581%25E5%2593%25A5%25E7%25BB%2599%25E7%259A%2584%25E5%25BB%25BA%25E8%25AE%25AE%25EF%25BC%258C%25E4%25BB%2596%25E7%25BB%2599%25E7%25AC%2594%25E8%2580%2585%25E6%258C%2587%25E8%25B7%25AF%25E4%25BA%2586%25E4%25B8%2580%25E4%25B8%258B "https://github.com/biomejs/biome/issues/4421)%E9%87%8C%E9%9D%A2%E4%B8%80%E4%B8%AA%E8%80%81%E5%93%A5%E7%BB%99%E7%9A%84%E5%BB%BA%E8%AE%AE%EF%BC%8C%E4%BB%96%E7%BB%99%E7%AC%94%E8%80%85%E6%8C%87%E8%B7%AF%E4%BA%86%E4%B8%80%E4%B8%8B") TypeScript Parser 对于 TsImportType
这个数据类型的处理源码: github.com/microsoft/T...
直接打开这块的代码处理逻辑来参考一下:
这里我们带入一个完整的 TsImportType 带有 import attributes 的处理,我们可以看到这块逻辑 TypeScript 处理的比较"暴力",它对于以下代码:
css
a: typeof import('mod', { with: { "resolution-mode": 'import' } });
对于 import 后面的第二参数以及第二个参数前面的逗号,都是直接写死的,并不是当成一个列表在处理。同时 with
这个关键字是个固定写死的词(最早 import-attributes
提案中的关键字这里叫 assert
,所以可以看到上面的 Parser 同时对 WithKeyWord
以及 AssertKeyWord
进行了处理)。
简单概述一下,上面代码中的 ,{ with: {
和 }}
在 parser 的时候都是当作固定编码(hardcoded token)在处理,而中间则就是个普通的 ObjectExpression,我们按照 Biome Parser 中对于 Object Expression 的处理来参考处理这部分即可。
Commit biome_parser 2.0
Ok。这里我们开始做第二个版本的 Biome Parser 的修改,还是老规矩,先直接改 js.ungram
,这次直接把 arguments 的 JsCallArguments
给替换掉,上一节提到这块的硬编码会比较多,笔者可以直接写到 .ungram
里面来,因此这里我们自己造一个 TsImportTypeArguments
:
这个 TsImportTypesArguments
的内容如下:
可以看到,我们将 Import 的第一个参数修改为 AnyTsType
(这里也是对齐 TypeScript Parser),实际上 Parser 处理 ImportType
时它的第一个是可以接收除了 String 之外的其他对象的,例如写:
ini
type A = typeof import(1);
这里也是合理的,不过 TypeChecker 会对这里的类型抛错,但这里就不是 Parser 处理的了。
然后第二个参数 ungram 直接来一波硬编码:
arduino
(',' TsImportTypeAssertionBlock )?
这里的这个语法表示括号里面的内容是 Optional 的,然后 TsImportTypeAssertionBlock
我们直接做一波 Object 的语法编写,同时对于参数的 key 需要强行限制成 assert
和 with
。后面 value 再做一波 Object 的处理,这里笔者是直接把 Biome 支持 Import Attributes 这个提案时的 JsImportAssertion
拿过来复用了一波:
python
// import a from "mod" assert { type: "json" }
// ^^^^^^^^^^^^^^^^^^^^^^^
JsImportAssertion =
assertion_kind: ('assert' | 'with')
'{'
assertions: JsImportAssertionEntryList
'}'
所以这里 TsImportTypeAssertion
也完成了,那么 ungram
的工作基本完成了,因为新加了几个语法结构,在 xtask/codegen
的 kind 文件中,我们也要把这几个类型补充上去:
补充完之后,执行一下 codegen 来生成对应的数据结构:
just gen-grammar js
这个时候 biome_js_syntax
以及 biome_js_factory
这两个 crates 都会有这次新增的数据结构生成,然后我们直接去编写 biome_js_parser
相关 parser 逻辑,这里在完成 ungram
编写之后,代码逻辑就很清晰了。
首先,找到 parse_ts_import_type
这个方法,然后把处理 import
参数为 JsImportCallArguments
的部分修改掉:
这里可以看到因为在 js.ungram
文件中我们将 arguments 替换成了 TsImportTypeArguments
,这里我们写个方法 parse_ts_import_type_arguments
去处理:
对于逗号硬编码的地方,这里加个判断逻辑处理掉,如果逗号后面没有参数了,这里抛个 parse error 出来(即新 issue 中用户反馈的问题)。
然后对于 import 的第二个参数,在 js.ungram
中定义为 TsImportTypeAssertionBlock
,写个 parse_ts_import_type
方法来处理:
这个方法主要处理 { TsImportTypeAssertion }
这堆语法,同时对于一些语法的边界情况做一个 syntax error 的抛错处理。因此这个方法,可以看到很简单,我们只用处理前后 {
和 }
即可。
最后 TsImportTypeAssertion
放到 parser_ts_import_type_assertion
中处理即可,这个语法结构主要处理最后 with: { 'resolution-mode': 'import' }
这块语法:
这个代码处理就就比较简单, 直接 parser 掉关键字 with
或者 assert
(参考 TypeScript Parser)。然后对于 ImportAssertionList
处理直接参考之前 Biome 中对 JsImportAssertion
侧的 parser 逻辑即可。
那么这样我们基本的 Parser 逻辑都添加完了,然后开始补充一波测试,这里也感谢 issue 里的 simon 老哥提供了一波很给力的测试 Case:
Error Case 也添加一波:
补充完对应的测试文件之后,我们直接测试二连:
bash
cargo test
cargo insta review
检查一下对应的 Parser Snapshot 生成的 AST 数据结构是否符合预期,如果没问题就直接 commit 上去。
Parser 处理完成之后,对于新增加的语法结构我们也要补充对应的 formatter 逻辑,这块其实没太多需要介绍的,如果感兴趣未来可以单独写一篇关于 Biome formatter 的逻辑处理介绍。
这里主要是把之前 TsImportType
中的 import
参数的 JsCallArguments
换成我们的 TsImportTypeArguments
formatter。补一波 fommater 测试:
Ok,目前来看很长的参数也不会 formatter 出后面逗号了,那么这个 issue 算是搞定了,属实有点难绷。
PR 2.0
上面代码 + 测试 + CHANGELOG 都搞定后,笔者又双开了个 PR:
原 PR 请参考: github.com/biomejs/bio... 。
这次 PR 相比笔者之前提的第一个要更清晰一些,在 PR 中笔者描述了解决的问题、主要修改逻辑、修改的参考依据(TypeScript Parser)、以及对应的测试补充。
因为这个 PR 本体内容还算比较大以及修改的地方比较多,这次主要是 ematipico ****老哥在 Review,老哥很专业,给了很多关于测试内容以及 formatter 逻辑的修改意见,最后笔者都一一回应以及调整。
于是这个 PR 也最后喜提 merged:
同时在目前的 Biome Nightly 版本中发布,目前在 Biome Playground 中写相关的 TsImportType 代码,观察 AST 结构已经能看到笔者新增的数据结构已经生效了:
Ok,很 Nice,希望未来也能正常运行!
总结
其实也没太多需要好总结的东西,一次经历分享,期待 Biome 未来越来越好吧~