🧊

Prettier のコードを読む Part.7

少し時間が空いたけどもまだまだ続く。

Prettier のコードを読む Part.6 | Memory ice cubes https://leaysgur.github.io/posts/2024/09/18/152100/

今回からは少しフィールドが変わる予定。

おさらい

Part.4からPart.6までは、PrettierがDocを用意するために必要なASTと、その各ノードに関連づけられたコメントの扱いについて見てきた。

ここからは、そのASTとコメントが揃った状態で、いよいよDoc化の部分に進んでいく。

printAstToDoc(ast, options)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/ast-to-doc.js#L31

その名が示すとおり、Doc化を行うこの関数を深掘りしていくのがここからの主旨。

printAstToDoc(ast, options)では、まずprepareToPrint(ast, options)を呼んでて、これは前回の記事までで読んだコメントをASTに紐づける処理だった。

今回はその直後から。

埋め込みコード用の処理なんかはいったん無視しておくと、ここのコメントにもあるように、

/**
 * Takes an abstract syntax tree (AST) and recursively converts it to a
 * document (series of printing primitives).
 *
 * This is done by descending down the AST recursively. The recursion
 * involves two functions that call each other:
 *
 * 1. mainPrint(), which is defined as an inner function here.
 *    It basically takes care of node caching.
 * 2. callPluginPrintFunction(), which checks for some options, and
 *    ultimately calls the print() function provided by the plugin.
 *
 * The plugin function will call mainPrint() again for child nodes
 * of the current node. mainPrint() will do its housekeeping, then call
 * the plugin function again, and so on.
 *
 * All the while, these functions pass a "path" variable around, which
 * is a stack-like data structure (AstPath) that maintains the current
 * state of the recursion. It is called "path", because it represents
 * the path to the current node through the Abstract Syntax Tree.
 */

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/ast-to-doc.js#L10-L30

各プリンター(今回はestree)とメイン側の後処理を交互に呼び合って進んでいくことはわかってる、が。

AstPath

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/common/ast-path.js

Doc化を行う際に使ってる、いわばAST用のVisitorというやつだろうか。

ASTをルートから降りていきつつ、

  • そのパス(.programとか配列なら.0とか)と
  • 当該ノード

が交互に並ぶ、不思議なデータ構造だった。

AstPath {
  stack: [
    { type: 'Program' },
    'body',
    [
      { type: 'VariableDeclaration' }
    ],
    0,
    { type: 'VariableDeclaration' },
    'declarations',
    [
      { type: 'VariableDeclarator' }
    ],
    0,
    { type: 'VariableDeclarator' },
    'init',
    { type: 'Literal' }
  ]
}

ノードのプロパティを省略してみるとこういう具合。

stackという配列が動的になっていて、call(callback, ...keys)each(callback, ...keys)などの一部でstack.push()してる。

その都度で、親を見たり遡ったりもできるようになってるけど、必ずしもこんな構造である必要はないのかもしれない?

Remove AstPath(aka FastPath) · Issue #10785 · prettier/prettier https://github.com/prettier/prettier/issues/10785

まあJS以外の言語にも対応してるが故に、汎用的なVisitorが必要になるわけで、そうして行き着いた結果って感じかな。

callPluginPrintFunction(path, options, mainPrint)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/ast-to-doc.js#L94

Doc化のエントリーポイントであり、

  • printer.print()mainPrint()を渡しつつ、キャッチボールの開始宣言
  • printComments()

この2つが主な仕事。

awaitされてるけど、callPluginPrintFunction()自体はasyncもついてないし、Promiseが返ることもないが、なんか外部プラグイン用にそうなってるらしい。

mainPrint() > mainPrintInternal()

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/ast-to-doc.js#L56 https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/ast-to-doc.js#L68

mainPrint(selector, args)は、selectorのそれぞれをAstPath.call(mainPrintInternal(args), ...selector)してるだけで、実体はmainPrintInternal()のほう。

mainPrintInternal()は、Docをキャッシュしつつ、callPluginPrintFunction()を呼び直すだけに見える。

いちおうキャッシュなしでも動作は変わらないようだが、テストの実行はめちゃめちゃ遅くなったので、頻繁に通るコードパスであることは間違いない。

argsがない場合のみキャッシュするようになっているのは、argsによってDocの構造が変わる可能性があるからかな?argsもキャッシュキーに含めてないのは、何か事情があるのだろうか。

ざっとgrepしてわかったargsは、

  • expandFirstArg
  • expandLastArg
  • assignmentLayout

というものだったが、詳細はまだわからない。

ログを見る限り、このあたりのコードは昔からあまり変更も入ってないので、本当にわかる人が限られてそう。

printComments(path, doc, options)

estreeのプリンターを見にいく前に、メイン側を先に。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/print.js#L198

コメントの扱いには、

  • 各ノードごとに、一緒に処理されるもの
  • コメント単独で処理されるもの

という2パターンがあるらしく、その後者を処理してる。

その判定をしてるwillPrintOwnComments(astPath)によると、各ノードごとに処理されてるのは、JSXElementとその他いくつかのノードとのこと。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/comments/printer-methods.js#L68

判定のネストが深くて読みづらい・・・。

printCommentsSeparately(path, options)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/print.js#L158

printComments()の中身であり、AstPatheach(callback, "comments")で、各Commentを処理していく。

当然、commentsプロパティの生えてないノードは処理しない。

コメントにはleadingtrailingがあるが、これらは排他なbooleanであり、どっちかのみ出力される。

もしくは、どちらもfalseであるdanglingなものも存在するけど、それについてはここでは扱ってない。(後述)

それぞれ、

  • printLeadingComment(path, options)
  • printTrailingComment(path, options, printedPreviousTrailingComment)

というように、引数が違えば返り値も違うようで、trailingのほうはひと手間かかってそう。

Doc化できたら、元々のノードのDocを挟んで返す。[leading, doc, trailing]という感じに。

元々のノードがlabelというDocコマンドになってたら、それを継承して返すようなことをしてた。

printLeadingComment(path, options)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/print.js#L29

ここでの返り値は、estreeprintComment(path, options)した結果に、改行を条件によって追加したDocの配列。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/print/comment.js#L8

改行が追加される条件は、前後行に何があるかで決まる・・・という感じのコードが書いてある気がする。

estreeprintComment()を呼ぶところで、ASTにコメントを紐づけるフェーズで生やしたCommentprintedtrueに更新される。

printTrailingComment(path, options, printedPreviousTrailingComment)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/print.js#L64

trailingなコメントを連続して処理する場合、printedPreviousTrailingCommentに値が入ってくるようになってた。 というわけで、この関数が返す値がそのままこのprintedPreviousTrailingCommentとして使われる。

返り値としては、ただの配列か、lineSuffix()というDocコマンドになってる。

printAstToDoc(ast, options)の続き

callPluginPrintFunction()awaitしたあとは、ensureAllCommentsPrinted(options)という確認をしておわり。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/ast-to-doc.js#L52

すべてのコメントに対して、!comment.printed && !printedComments.has(comment))であることを確認してる。

そしてちゃんとDoc化されてることがわかったら、comment.printeddeleteする!

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/print.js#L206

printedCommentsとは

printedは知ってたけど、printedCommentsって何?なぜダブルチェックしてるんやろ?って思い、少し調べておくと。

まずはprepareToPrint()のコメント紐付けの前に、new Set()で初期化されてる。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/ast-to-doc.js#L133

参照されてるのは、さっきのensureAllCommentsPrinted()だけ。

add()されてるのはというと、printIgnored(path, options)という新手のユーティリティ。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/print-ignored.js

どこで使われてるかというと、

estreeのプリンターのメインの入り口であるprint()の初手で呼んでるprintWithoutParentheses()のこの冒頭の部分。

function printWithoutParentheses(path, options, print, args) {
  if (isIgnored(path)) {
    return printIgnored(path, options);
  }

  // ...
}

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/print/index.js#L21

isIgnored()を見るに、prettier-ignoreなコメントがついたノードをそのまま処理してる感じか・・・?

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/utils/is-ignored.js

これに合致したprintIgnored()の中で、printedComments.add()されてる。

コメントのDoc化まわりの落穂拾い

まだすべてが明らかになったわけではない。

printDanglingComment(path, options, danglingCommentOptions)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/print.js#L119

printLeadingComment()printTrailingComment()は、printComments()もといprintCommentsSeparately()で使われてた。

しかしここでDoc化されたのは、

  • JSXElementなどに関係ないコメントで
  • leadingtrailingなものだけ

つまり、leadingでもtrailingでもない、danglingなコメントを処理できていない。

そこで、JSXElementやその他に関する処理と同様に、各Doc化の工程で必要に応じて処理されるようになってるらしい。

というわけで、printDanglingComment()が使われてるところを調べてみると、

  • language-js/print/estree.js
  • language-js/print/ternary.js
  • language-js/print/function.js
  • language-js/print/module.js
  • language-js/print/block.js
  • language-js/print/call-arguments.js
  • language-js/print/mapped-type.js
  • language-js/print/class.js
    • { marker }
  • language-js/print/function-parameters.js
    • { filter }
  • language-js/print/arrow-function.js
    • { filter }
  • language-js/print/component.js
    • { filter }
  • language-js/print/jsx.js
    • { indent }
  • language-js/print/type-parameters.js
    • { indent }
  • language-js/print/object.js
    • { indent }
  • language-js/print/array.js
    • { indent }

と、結構な数のファイルから、いろんな引数で呼ばれてた。 これがおそらく、danglingなコメントが書かれる可能性のある場所ってことかな?

で、もし処理漏れがあった時のために、ensureAllCommentsPrinted()で確認してるというわけか。

printCommentsSeparately()

printComments()の内部で呼ばれてたやつが、まさかexportされてるとは思わんやん・・。

たった1箇所、arrow-functionをDoc化する部分で使われてた。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/print/arrow-function.js#L84

この対応自体は、() => () => {}みたく、ArrowFunctionExpressionが連続するところに関係してるっぽかったが・・・、詳細は謎。

printComments()

お察しのとおり、これもexportされてて、各地で使われてる。

  • language-js/print/property.js
  • language-js/print/member-chain.js
  • language-js/print/class.js
  • language-js/print/binaryish.js
  • language-js/print/type-annotation.js
  • language-js/print/jsx.js

やはり想像してた以上に、例外的な対応が多くなってしまうのだなあ。

ここまでのまとめ

  • printAstToDoc(ast, options)で、ASTのDoc化が行われる
  • callPluginPrintFunction()が、再帰で以下を呼び続け、Docを生成していく
    • estree.print(astPath, options, mainPrint, args)
      • mainPrint() > mainPrintInternal() > callPluginPrintFunction() > …
    • printComment(atPath, doc, options)

コメントのDoc化についていうと、

  • 大多数の基本的なleadingtrailingなコメント
    • callPluginPrintFunction()でのprintComment()で対応
    • willPrintOwnComments()によって判定される一部のものは除外
  • それ以外のdanglingなコメント
    • printDanglingComment()を各所で使って対応
  • それ以外の特殊なケース
    • printCommentsSeparately()printComments()で個別に対応

わかってたけど、このロジックをすっきり理解するのは、やっぱ無理。ロジックじゃなくてヒューリスティックな歴史の積み重ねって言うほうが正しい。