🧊

Biome Formatterのコードを読む Part 3

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

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

コードを持ってくるとこんな感じ。

/// 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

最も見慣れないやつ。

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

こっちもコードを持ってくると、こう。

/// 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::*ヘルパー

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

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

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

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を実装してる。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/builders.rs#L266

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になってる。

Formatterwrite_element

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/formatter.rs#L259

Buffer traitの機能であり、自身が抱えてるbufferwrite_element()を呼び出すもの。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/buffer.rs#L35

Buffertraitなので、その実体はまた別の場所にある。

だいたいのコードで使われてたのはVecBufferというstructで、イメージ通りのただのVecだった。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/buffer.rs#L224

実装としてもFormatElementpush()してるだけ。

つまり、Bufferを元に用意されたFormatterwrite_element()によって、そのBufferFormatElementが積まれる。

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_syntaxbiomw_rowanのものをラップしてから、Format traitを実装しないといけないのである。

biome_js_formatterでの実装

そういうわけなので、特定のAST/CSTノードにFormat traitを実装するべく、だいたい中間structが用意されてる。 impl Format<JsFormatContext> forでgrepすると見つかる。

で、その中間structには、FormatNodeRuleというこれまたbiome_js_formatter crateで定義されたtraitが実装される。

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

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があって、このへんが本当に難解だと思った。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/generated.rs

だってもう自動生成しちゃってるし。

すべてがFormat trait

IRを作るためのヘルパー、各ノードに限らず、Format traitを実装したものはまだまだある。

例えば、各ノードそれ自体が抱えてる変数名やらSyntaxTokenと呼ばれる類のもの。

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

これはbiome_rowanで定義されたAST/CSTの構成要素で、biome_js_syntaxがノードを定義するときに使ってるもの。

IRに含まれてたLocatedTokenTextDynamicTextはこれら専用の存在で、StaticTextとは違って、新たに文字列を生成しないようになってる。

というように、何から何までFormat traitを中心に回ってるし、IRヘルパーのあらゆる引数もFormat traitの存在を前提にしてる。

たとえばこれは、JsVariableDeclarationというノードに対する定義。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/js/declarations/variable_declaration.rs

#[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で、FormatNodeRulefmt_fields()だけ実装してる。

format_args! macroは、すべてFormat traitを実装した要素たちを受け入れるようになってるし、constletかみたいなkind: SyntaxResult<SyntaxToken>でさえもFormat traitを実装してる。

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

なんならOption<T>までFormat traitを実装してる・・・!

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

ちなみに、format()AsFormatというtraitで、各ノードからFormatを実装した中間structを引き出すやつ。

format_with()という抜け道もある

たまたま見つけた。

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_formatter/src/builders.rs#L2302

この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(&parameters, 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())]]
    }
}

https://github.com/biomejs/biome/blob/fa93a147abe64e9c85908d317a8dd1de343ad132/crates/biome_js_formatter/src/js/bindings/formal_parameter.rs#L23

ここでいうcontentなるほど。

Format traitを実装せずとも、IRヘルパーとformat_with()だけあれば、それなりのコードを書くことは可能ってことになる・・・?って思ったけど、あらゆる便利関数やヘルパーも、結局はbiome_rowan::SyntaxTokenbiome_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のことを。

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

biome_formatterで定義されてるこのmacroで、その中身はformatterwrite_fmt()を、引数をArgumentsというstructにして渡すようになってる。

ArgumentsArgument

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を実装したvaluefmt()を呼び出すだけ。

このArgumentももれなくFormat traitを実装してて、そのfmt()からこのformat()を呼ぶようになってる。

AsFormatformat()を実装したものが、再帰の過程でArgumentになり、その流れでFormatで実装したfmt()を呼び、その結果BufferFormatterに貯まる。

おわりに

これがRustか〜まだまだ慣れんな〜って感じ。

そして、IRへの解像度が高まった結論としては、やはりbiome_formatterはBiomeのための共通インフラであるってこと。 Biomeの外の世界からの利用は想定されてないと思う。