Biome Formatterのコードを読む Part 2 | Memory ice cubes https://leaysgur.github.io/posts/2024/11/11/165409/
Biome FormatterのIRであるFormatElementの一生についてのあれこれ。
IRであるこれは、整形処理の過程で生まれ、最終的に文字列になる。
文字列にするためには、Documentというstructに対して、Vec<FormatElement>の形式で渡すってことは、前回まででわかってる。
というわけで今回は、どこでどのようにFormatElementは作られるのか、というところを調べたい。
FormatElement enum
コードを持ってくるとこんな感じ。
/// Language agnostic IR for formatting source code.
///
/// Use the helper functions like [crate::builders::space], [crate::builders::soft_line_break] etc.
#[derive(Clone, Eq, PartialEq)]
pub enum FormatElement {
Space,
HardSpace,
Line(LineMode),
ExpandParent,
StaticText {
text: &'static str,
},
DynamicText {
text: Box<str>,
source_position: TextSize,
},
LocatedTokenText {
source_position: TextSize,
slice: TokenText,
},
LineSuffixBoundary,
Interned(Interned),
BestFitting(BestFittingElement),
Tag(Tag),
}
この時点でもう、PrettierのIRとは違った顔ぶれであることがわかる。
最終的な整形結果はPrettier相当だったとしても、その内部表現という意味では異なるのだなあ。
FormatElement::Tag
最も見慣れないやつ。
こっちもコードを持ってくると、こう。
/// A Tag marking the start and end of some content to which some special formatting should be applied.
///
/// Tags always come in pairs of a start and an end tag and the styling defined by this tag
/// will be applied to all elements in between the start/end tags.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Tag {
StartIndent,
EndIndent,
StartAlign(Align),
EndAlign,
StartDedent(DedentMode),
EndDedent(DedentMode),
StartGroup(Group),
EndGroup,
StartConditionalContent(Condition),
EndConditionalContent,
StartIndentIfGroupBreaks(GroupId),
EndIndentIfGroupBreaks(GroupId),
StartFill,
EndFill,
StartEntry,
EndEntry,
StartLineSuffix,
EndLineSuffix,
StartVerbatim(VerbatimKind),
EndVerbatim,
StartLabelled(LabelId),
EndLabelled,
}
つまり、ネストしたデータ構造ではなく、startとendでもって、フラットなデータ構造のまま処理を行いたいということか。
PrettierのIRはネストする前提になってたもんね。
builders::*ヘルパー
FormatElementのdocコメントにも書いてあったけど、あのenumとstructを直接扱うのではなく、ヘルパーAPIを使ってIRを構築していくべしとのこと。
そのためのAPI群がこのbuilders.rsというわけで、preludeでも公開されてる。
- soft_line_break
- hard_line_break
- empty_line
- soft_line_break_or_space
- text
- dynamic_text
- syntax_token_cow_slice
- located_token_text
- line_suffix
- line_suffix_boundary
- labelled
- space
- hard_space
- maybe_space
- indent
- dedent
- dedent_to_root
- align
- block_indent
- soft_block_indent
- soft_block_indent_with_maybe_space
- soft_line_indent_or_space
- soft_line_indent_or_hard_space
- soft_space_or_block_indent
- group
- expand_parent
- if_groups_break
- if_groups_fits_on_line
- indent_if_group_breaks
多い・・・! ここをみると、Prettierのそれに結構近い感じもする(同一ではないけど)。
で、これらのbuildersを使っても、enumのFormatElementが直接生成されるわけではない。
たとえばtext()の型はfn text(text: &'static str) -> StaticTextというように、それぞれを表すstructのまま。
では、いつどうやってenumのFormatElement::StaticTextになるのか?ってところで登場するのが、Formatというtraitである。
Format trait
pub trait Format<Context> {
/// Formats the object using the given formatter.
fn fmt(&self, f: &mut Formatter<Context>) -> FormatResult<()>;
}
自身とFormatterを受け取り、FormatResult<()>を返す。
FormatResult<T>はResult<T, FormatError>なので、つまりはFormatterを使って何かするだけでよく、返り値は特にないってこと。
結果を返さないということは、Formatterに溜め込んでいくスタイルということ。
このtraitがすごく重要なやつで、整形処理に関わるありとあらゆるものが実装する決まり。
StaticTextでのFormat trait
さっきのStaticTextも、もれなくFormat traitを実装してる。
impl<Context> Format<Context> for StaticText {
fn fmt(&self, f: &mut Formatter<Context>) -> FormatResult<()> {
f.write_element(FormatElement::StaticText { text: self.text })
}
}
というコードで、write_element()というそれらしいやつを呼んでるところでenumのFormatElementになってる。
Formatterのwrite_element
Buffer traitの機能であり、自身が抱えてるbufferのwrite_element()を呼び出すもの。
Bufferもtraitなので、その実体はまた別の場所にある。
だいたいのコードで使われてたのはVecBufferというstructで、イメージ通りのただのVecだった。
実装としてもFormatElementをpush()してるだけ。
つまり、Bufferを元に用意されたFormatterのwrite_element()によって、そのBufferにFormatElementが積まれる。
biome_js_formatterでのFormat trait
このFormat traitは、もともとbiome_formatter crateで定義されてる。
text()などのIRヘルパーも、biome_formatter crateで実装されてるし、IRそれ自体もbiome_formatter crateのもの。
なので、biome_formatter crateにおいて、直接Format traitを実装できてた。
ただ整形処理としてのメインとしては、biome_js_formatter crateとして、biome_js_syntax crateで定義されたノードに対して、biome_formatter crateのFormatを実装したい。
しかし、Rustはそれを許さない!というのも、
- A crateにおいて
- B crateで定義されたtraitを
- C crateで定義されたstructに実装
することはできない。Rustの仕様で。
Rustのcoherence/orphan ruleについて | Memory ice cubes https://leaysgur.github.io/posts/2024/11/13/133750/
B crateのtraitを実装できるのは、B crateか、その対象のstructを定義してる元のC crateでだけ。
なので、biome_js_formatter traitにおいて、Format traitを実装するためには、biome_js_formatterでローカルな中間structを別途で用意する必要がある。
そのstructで、biome_js_syntaxやbiomw_rowanのものをラップしてから、Format traitを実装しないといけないのである。
biome_js_formatterでの実装
そういうわけなので、特定のAST/CSTノードにFormat traitを実装するべく、だいたい中間structが用意されてる。
impl Format<JsFormatContext> forでgrepすると見つかる。
で、その中間structには、FormatNodeRuleというこれまたbiome_js_formatter crateで定義されたtraitが実装される。
FormatNodeRule trait(抜粋)はこうなってて、各ノードをどう整形するかが定義されてる。
/// Rule for formatting a JavaScript [AstNode].
pub(crate) trait FormatNodeRule<N>
where
N: AstNode<Language = JsLanguage>,
{
fn fmt(&self, node: &N, f: &mut JsFormatter) -> FormatResult<()> {
if self.is_suppressed(node, f) {
return write!(f, [format_suppressed_node(node.syntax())]);
}
self.fmt_leading_comments(node, f)?;
self.fmt_node(node, f)?;
self.fmt_dangling_comments(node, f)?;
self.fmt_trailing_comments(node, f)
}
/// Formats the node without comments. Ignores any suppression comments.
fn fmt_node(&self, node: &N, f: &mut JsFormatter) -> FormatResult<()> {
let needs_parentheses = self.needs_parentheses(node);
if needs_parentheses {
write!(f, [text("(")])?;
}
self.fmt_fields(node, f)?;
if needs_parentheses {
write!(f, [text(")")])?;
}
Ok(())
}
/// Formats the node's fields.
fn fmt_fields(&self, item: &N, f: &mut JsFormatter) -> FormatResult<()>;
// ...
}
というわけで、各ノードはfmt_fields()を実装するのが義務になっていて、必要に応じてfmt_leading_comments()やneeds_parentheses()を上書きしたりしてる。
fmt_fields()内で、他のノードのfmt()をさらに呼んだりして、処理は進んでいく。ちなみにこのtraitはテンプレらしく、他の言語のFormatterでもだいたい同じ。
この中間structと元のAST/CSTノードを紐付け直すために、AsFormatとかFormatRuleとかまだほかにも実装すべきtraitがあって、このへんが本当に難解だと思った。
だってもう自動生成しちゃってるし。
すべてがFormat trait
IRを作るためのヘルパー、各ノードに限らず、Format traitを実装したものはまだまだある。
例えば、各ノードそれ自体が抱えてる変数名やらSyntaxTokenと呼ばれる類のもの。
これはbiome_rowanで定義されたAST/CSTの構成要素で、biome_js_syntaxがノードを定義するときに使ってるもの。
IRに含まれてたLocatedTokenTextやDynamicTextはこれら専用の存在で、StaticTextとは違って、新たに文字列を生成しないようになってる。
というように、何から何までFormat traitを中心に回ってるし、IRヘルパーのあらゆる引数もFormat traitの存在を前提にしてる。
たとえばこれは、JsVariableDeclarationというノードに対する定義。
#[derive(Debug, Clone, Default)]
pub(crate) struct FormatJsVariableDeclaration;
impl FormatNodeRule<JsVariableDeclaration> for FormatJsVariableDeclaration {
fn fmt_fields(&self, node: &JsVariableDeclaration, f: &mut JsFormatter) -> FormatResult<()> {
let JsVariableDeclarationFields {
await_token,
kind,
declarators,
} = node.as_fields();
if let Some(await_token) = await_token {
write!(f, [await_token.format(), space()])?;
}
write![
f,
[group(&format_args![
kind.format(),
space(),
declarators.format()
])]
]
}
}
FormatJsVariableDeclarationが中間structで、FormatNodeRuleでfmt_fields()だけ実装してる。
format_args! macroは、すべてFormat traitを実装した要素たちを受け入れるようになってるし、constかletかみたいなkind: SyntaxResult<SyntaxToken>でさえもFormat traitを実装してる。
なんならOption<T>までFormat traitを実装してる・・・!
ちなみに、format()はAsFormatというtraitで、各ノードからFormatを実装した中間structを引き出すやつ。
format_with()という抜け道もある
たまたま見つけた。
このformat_with()というヘルパーは、インラインでFormat trait相当の存在を仕立て上げることができる。
なので、いくつかをまとめて変数に格納できるようになって、条件分岐なんかで便利というもの。
impl FormatNodeRule<JsFormalParameter> for FormatJsFormalParameter {
fn fmt_fields(&self, node: &JsFormalParameter, f: &mut JsFormatter) -> FormatResult<()> {
let JsFormalParameterFields {
decorators,
binding,
question_mark_token,
type_annotation,
initializer,
} = node.as_fields();
let content = format_with(|f| {
write![
f,
[
binding.format(),
question_mark_token.format(),
type_annotation.format()
]
]
});
let is_hug_parameter = node
.syntax()
.grand_parent()
.and_then(FormatAnyJsParameters::cast)
.map_or(false, |parameters| {
should_hug_function_parameters(¶meters, f.comments(), false).unwrap_or(false)
});
if is_hug_parameter && decorators.is_empty() {
write![f, [decorators.format(), content]]?;
} else if decorators.is_empty() {
write![f, [decorators.format(), group(&content)]]?;
} else {
write![f, [group(&decorators.format()), group(&content)]]?;
}
write![f, [FormatInitializerClause::new(initializer.as_ref())]]
}
}
ここでいうcontentなるほど。
Format traitを実装せずとも、IRヘルパーとformat_with()だけあれば、それなりのコードを書くことは可能ってことになる・・・?って思ったけど、あらゆる便利関数やヘルパーも、結局はbiome_rowan::SyntaxTokenやbiome_rowan::SyntaxNodeを受け取って、Format traitを実装したstructを返すような感じなので、どこまでいってもbiome_rowanベースだなあって感じ。
https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/token/number.rs#L9 https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/builders.rs#L2507
write! macro
最後に軽くこのmacroのことを。
biome_formatterで定義されてるこのmacroで、その中身はformatterのwrite_fmt()を、引数をArgumentsというstructにして渡すようになってる。
ArgumentsとArgument
https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/arguments.rs#L86 https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/arguments.rs#L12
Argumentは不思議な構造をしてて、std::ffi::c_voidとか生ポインター的なことが書いてあって詳細はよくわからない。けど、いろんなFormat traitを実装してるものを、等しく扱うためのラッパーって感じ。
format()が呼ばれると、自身が抱えるFormat traitを実装したvalueのfmt()を呼び出すだけ。
このArgumentももれなくFormat traitを実装してて、そのfmt()からこのformat()を呼ぶようになってる。
AsFormatのformat()を実装したものが、再帰の過程でArgumentになり、その流れでFormatで実装したfmt()を呼び、その結果BufferかFormatterに貯まる。
おわりに
これがRustか〜まだまだ慣れんな〜って感じ。
そして、IRへの解像度が高まった結論としては、やはりbiome_formatterはBiomeのための共通インフラであるってこと。
Biomeの外の世界からの利用は想定されてないと思う。