🧊

Biome Formatterのコードを読む Part 2

続き。

Biome Formatterのコードを読む Part 1 | Memory ice cubes https://leaysgur.github.io/posts/2024/11/08/161102/

biome_formatterというインフラを、各言語側のFormatterが呼び出す、いわばエントリーポイントであるbiome_formatter::format_node(root, language)の詳細を追っていきたい回。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/lib.rs#L1363

引数のrootlanguage

まず先に引数を。

biome_js_formatterは単に呼び出しを受け流してるだけなので、中身はもっと手前で用意されてる。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/lib.rs#L519

遡ること、各言語ごとFormatterを呼び出すbiome_serviceのここの部分。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_service/src/file_handlers/javascript.rs#L756

optionsは、インデントや行幅やらいわゆるオプションが詰まったJsFormatOptionsというstructであり、その後biome_js_formatter::JsFormatLanJsFormatOptionsになる。

treeは、parse.syntax()で、これはbiome_rowan::SyntaxNode<JsLanguage>を返す。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_rowan/src/syntax/node.rs#L18 https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_rowan/src/ast/mod.rs#L188

つまり、biome_rowanを使って定義されたbiome_js_syntaxのツリーであり、対象のJSファイルのルートのノードということ。

biome_formatter::format_node(root, language)

さて本題。以下はコードを簡略化したやつ。

let (root, source_map) = match language.transform(&root.clone());

let context = language.create_context(&root, source_map);
let format_node = FormatRefWithRule::new(&root, L::FormatRule::default());

let mut state = FormatState::new(context);
let mut buffer = VecBuffer::new(&mut state);

write!(buffer, [format_node])?;

let mut document = Document::from(buffer.into_vec());
document.propagate_expand();

state.assert_formatted_all_tokens(&root);

let context = state.into_context();
let comments = context.comments();

comments.assert_checked_all_suppressions(&root);
comments.assert_formatted_all_comments();

Ok(Formatted::new(document, context))

要点を順に見ていく。

language.transform(root)

JsFormatLanguageは、biome_js_formatterbiome_formatterFormatLanguage traitを実装したもの。何をどうフォーマットしたいかの定義の集合体。

各ノードに対して整形ルールを実装したもの、コンテキストやコメントの扱いなどすべての情報が詰まってる。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/lib.rs#L446

で、transform()は、PrettierでいうASTのpostprocess()相当。

中身はここ。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/syntax_rewriter.rs#L13

Prettierとおなじく、

この2つに対して前処理をして、ツリーをアップデートしてる。

language.create_context(root, source_map)

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/lib.rs#L487

JsFormatContextnew()してて、その内部ではComments::from_node(root)してる。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/comments.rs#L810

このCommentsというstructは、ノードごとに紐づくコメントを取得するためのものだそうな。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/comments.rs#L1038

独自のMap構造になってる。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/comments/map.rs

rootのノードから全走査して、コメントを回収していく。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/comments/builder.rs#L45

Prettierで見てきたのと同じように、コメントの行間から算出するplacementや、DecoratedCommentのような中間データを活用しつつ、コメントに関する情報を集めてる。

plcamentの定義は、biome_js_formatter側にあって、Prettierでも見たあの20連発ifがまたいた・・・。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/comments.rs#L98

このplcace_comment()は、biome_formatter側のflush_comments()という処理から呼ばれてる。

最終的に、ノードを表すキーごとに保持されるSourceCommentという構造体はこうなってる。

/// A comment in the source document.
#[derive(Debug, Clone)]
pub struct SourceComment<L: Language> {
    /// The number of lines appearing before this comment
    pub(crate) lines_before: u32,

    pub(crate) lines_after: u32,

    /// The comment piece
    pub(crate) piece: SyntaxTriviaPieceComments<L>,

    /// The kind of the comment.
    pub(crate) kind: CommentKind,

    /// Whether the comment has been formatted or not.
    #[cfg(debug_assertions)]
    pub(crate) formatted: Cell<bool>,
}

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/comments.rs#L159

CSTになっても、コメントまわりはちっとも楽にならんのだなあ・・・。

で、このコメントのMapは、各ノードをFormatElementに変換する過程で、コンテキスト経由で参照されて、またそれぞれFormatElementになる。

write!(buffer, [format_node])

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/macros.rs#L70

しれっと書かれてるけど、ここでツリーがFormatElementに変換される。

コメントによると、FormatElementを抱えるためのデータ構造であるBufferwrite_fmt()を呼んで、その結果をbufferに書き込んでいくとのこと。

要するに、各ノード向けに実装しておいたformat()がrootから順々に呼ばれて、IRであるFormatElementに変えられ積まれていくってことか。

Document::from(buffer.into_vec())

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/format_element/document.rs#L138

Vec<FormatElement>Documentになる瞬間。

BiomeのDocumentは、elementsだけを保持するstructで、直後に呼ばれるpropagate_expand()しか実装を持ってない。

propagate_expand()もPrettierで見たのと同様に、改行するか1行にまとめるかを反映していくやつ。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/format_element/document.rs#L31

Formatted::new(document, context)

あとはすべての要素がIRになり、Documentに抱えられてるかだけassertしたら、Formatted structにして返す。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/lib.rs#L779

Formattedprint()してるのは

一体いつ?って思ってたけど、呼び出し元だった。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_service/src/file_handlers/javascript.rs#L757

format()を呼んだ後、続けてformatted.print()してる。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/lib.rs#L809

PrinterOptions

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/printer/printer_options/mod.rs#L8

contextからPrinterOptionsというここに関連するオプションだけを抽出して、それでもってprint(document)する。

print(document)

中身はprint_with_indent(document, 0)となっており、実体はこっち。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/printer/mod.rs#L56

詳細はもう割愛するけど、stackとqueueで進行を管理しながら、print_element()で文字列にしていく。

IRに関しては、Prettierのそれと同じものもあれば、足りないものもあれば、独自のやつもあり、まあ違うよなって印象。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/format_element.rs#L19

TagというPrettierには存在しない構成要素もいて、これはstartとendを表すマーカーとのこと。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/format_element/tag.rs#L11

IRとして特定のstructを定義する代わりに、2点のマーカーで済ませてる的な・・・?

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/printer/mod.rs#L163

ほかだと、fits()とかPrinterStateとか、Prettierでも見たような処理が並んでて、基本的な考え方は同一であることが伺えるけど、とにかく正規化されてる印象。

ともあれこれにてPrinted structが取得できて、整形後の文字列とご対面ということになる。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/lib.rs#L841

Playgroundだけみてると、PrettierのIRと同等のものを使ってるようにも見える。(Formatter IRのタブを選んだ時)

しかしこれも、上述のオリジナルなIRを使ってそれ用のfmt::Displayを実装してるだけだった。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/format_element/document.rs#L223

おわりに

一通り読んでみての一番の感想はやっぱ、「コードが多い!」に尽きる。

良くも悪くもJSでぎゅっと書かれてたPrettierに比べると、Rustで正規化の限りを尽くされたBiomeのコードは読むのが大変。いや、Prettierとはベクトルが違うだけやけども。 いわゆるdebuggerもないし、コンパイル遅いし、型はあれどtrait境界をLSPでジャンプできなかったりするし。

まあでも無駄なコードなんかないわけやし、この美しき建造物を讃えよう。どれだけの時間でこの境地にたどり着いたのかは知らんけど、まじすげ〜って感じ。

ただこうまでしてもなおバグは存在するらしく、本当に一大分野なのだなあ。

https://github.com/biomejs/biome/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen+label%3AA-Formatter+label%3AL-JavaScript

そもそもコードを読んだわけ

そもそものモチベーションとしては、OXCでもフォーマッタを実装しようとしていて、その参考もしくはbiome_formatterの利用ができないか?ってところの検証。

それに対する結論としては・・・、

  • ガイドにしたがって、biome_formatter::format_node(root, language)を利用できる?

無理。あらゆるものがbiome_rowanを使ったBiomeのAST/CSTを前提にしてるから。

OXC ASTをBiome AST/CSTに変換するのは、やはり非現実的であろうし、というかソース文字列を渡すほうが早い。 ただそれだとOXCでやる意味もない・・・。

  • biome_formatterから、FormatElementやそのbuilder、およびPrinterだけを借りるのはできる?

未検証なので確信はまだないけど、不可能ではなさそう・・・? 一部のコードがbiome_rowanへの依存を持ってるから怪しいか?

Printerまわりだけでも相当なコード量があるので、そこだけでも省略できたら嬉しいはず。 Biome自体を答え合わせにも使えるので、デバッグも捗りそう?な気がする。

ただこの場合、Formatterとして指定できるオプションなんかはBiomeに縛られることになってしまう。

という感じかな〜。

一番なんとかしたかったコメントを取り回す部分は、どうしたって自前でやるしかなさそうで、これが本当につらい。 個人的には、これ以上あんな実装をこの世に増やすべきではないとさえ感じている・・・。

いっそのこと、Prettier互換を謳うのをやめて、ベストエフォートでミニマムなFormatterを目指すというのは・・・なんて。