一次有趣的 Biome Contribute 经历

前言

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.ungramcss.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 中找到了这次要参考的节点处理,分别是 JsImportCallExpressionJsImportAssertion

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

所以这里不能简单的将 TsImportTypeimport 函数参数类型简单写成 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 需要强行限制成 assertwith 。后面 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 未来越来越好吧~

相关推荐
桂月二二35 分钟前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062062 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb2 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角2 小时前
CSS 颜色
前端·css
硬汉嵌入式2 小时前
《安富莱嵌入式周报》第349期:VSCode正式支持Matlab调试,DIY录音室级麦克风,开源流体吊坠,物联网在军工领域的应用,Unicode字符压缩解压
vscode·matlab·开源
说私域3 小时前
社群裂变+2+1链动新纪元:S2B2C小程序如何重塑企业客户管理版图?
大数据·人工智能·小程序·开源
浪浪山小白兔3 小时前
HTML5 新表单属性详解
前端·html·html5
lee5763 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579653 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
limit for me4 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架