続き。
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とおなじく、
AnyJsParenthesized
JsLogicalExpression
この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を目指すというのは・・・なんて。