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の外の世界からの利用は想定されてないと思う。