続き。
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)の詳細を追っていきたい回。
引数のrootとlanguage
まず先に引数を。
biome_js_formatterは単に呼び出しを受け流してるだけなので、中身はもっと手前で用意されてる。
遡ること、各言語ごとFormatterを呼び出すbiome_serviceのここの部分。
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_formatterでbiome_formatterのFormatLanguage traitを実装したもの。何をどうフォーマットしたいかの定義の集合体。
各ノードに対して整形ルールを実装したもの、コンテキストやコメントの扱いなどすべての情報が詰まってる。
で、transform()は、PrettierでいうASTのpostprocess()相当。
中身はここ。
Prettierとおなじく、
AnyJsParenthesizedJsLogicalExpression
この2つに対して前処理をして、ツリーをアップデートしてる。
language.create_context(root, source_map)
JsFormatContextをnew()してて、その内部ではComments::from_node(root)してる。
このCommentsというstructは、ノードごとに紐づくコメントを取得するためのものだそうな。
独自のMap構造になってる。
rootのノードから全走査して、コメントを回収していく。
Prettierで見てきたのと同じように、コメントの行間から算出するplacementや、DecoratedCommentのような中間データを活用しつつ、コメントに関する情報を集めてる。
plcamentの定義は、biome_js_formatter側にあって、Prettierでも見たあの20連発ifがまたいた・・・。
この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>,
}
CSTになっても、コメントまわりはちっとも楽にならんのだなあ・・・。
で、このコメントのMapは、各ノードをFormatElementに変換する過程で、コンテキスト経由で参照されて、またそれぞれFormatElementになる。
write!(buffer, [format_node])
しれっと書かれてるけど、ここでツリーがFormatElementに変換される。
コメントによると、FormatElementを抱えるためのデータ構造であるBufferのwrite_fmt()を呼んで、その結果をbufferに書き込んでいくとのこと。
要するに、各ノード向けに実装しておいたformat()がrootから順々に呼ばれて、IRであるFormatElementに変えられ積まれていくってことか。
Document::from(buffer.into_vec())
Vec<FormatElement>がDocumentになる瞬間。
BiomeのDocumentは、elementsだけを保持するstructで、直後に呼ばれるpropagate_expand()しか実装を持ってない。
propagate_expand()もPrettierで見たのと同様に、改行するか1行にまとめるかを反映していくやつ。
Formatted::new(document, context)
あとはすべての要素がIRになり、Documentに抱えられてるかだけassertしたら、Formatted structにして返す。
Formattedをprint()してるのは
一体いつ?って思ってたけど、呼び出し元だった。
format()を呼んだ後、続けてformatted.print()してる。
PrinterOptions
contextからPrinterOptionsというここに関連するオプションだけを抽出して、それでもってprint(document)する。
print(document)
中身はprint_with_indent(document, 0)となっており、実体はこっち。
詳細はもう割愛するけど、stackとqueueで進行を管理しながら、print_element()で文字列にしていく。
IRに関しては、Prettierのそれと同じものもあれば、足りないものもあれば、独自のやつもあり、まあ違うよなって印象。
TagというPrettierには存在しない構成要素もいて、これはstartとendを表すマーカーとのこと。
IRとして特定のstructを定義する代わりに、2点のマーカーで済ませてる的な・・・?
ほかだと、fits()とかPrinterStateとか、Prettierでも見たような処理が並んでて、基本的な考え方は同一であることが伺えるけど、とにかく正規化されてる印象。
ともあれこれにてPrinted structが取得できて、整形後の文字列とご対面ということになる。
Playgroundだけみてると、PrettierのIRと同等のものを使ってるようにも見える。(Formatter IRのタブを選んだ時)
しかしこれも、上述のオリジナルなIRを使ってそれ用のfmt::Displayを実装してるだけだった。
おわりに
一通り読んでみての一番の感想はやっぱ、「コードが多い!」に尽きる。
良くも悪くもJSでぎゅっと書かれてたPrettierに比べると、Rustで正規化の限りを尽くされたBiomeのコードは読むのが大変。いや、Prettierとはベクトルが違うだけやけども。
いわゆるdebuggerもないし、コンパイル遅いし、型はあれどtrait境界をLSPでジャンプできなかったりするし。
まあでも無駄なコードなんかないわけやし、この美しき建造物を讃えよう。どれだけの時間でこの境地にたどり着いたのかは知らんけど、まじすげ〜って感じ。
ただこうまでしてもなおバグは存在するらしく、本当に一大分野なのだなあ。
そもそもコードを読んだわけ
そもそものモチベーションとしては、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を目指すというのは・・・なんて。