少し時間が空いたけどもまだまだ続く。
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)
その名が示すとおり、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.
*/
各プリンター(今回はestree)とメイン側の後処理を交互に呼び合って進んでいくことはわかってる、が。
AstPath
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(akaFastPath) · Issue #10785 · prettier/prettier https://github.com/prettier/prettier/issues/10785
まあJS以外の言語にも対応してるが故に、汎用的なVisitorが必要になるわけで、そうして行き着いた結果って感じかな。
callPluginPrintFunction(path, options, mainPrint)
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は、
expandFirstArgexpandLastArgassignmentLayout
というものだったが、詳細はまだわからない。
ログを見る限り、このあたりのコードは昔からあまり変更も入ってないので、本当にわかる人が限られてそう。
printComments(path, doc, options)
estreeのプリンターを見にいく前に、メイン側を先に。
コメントの扱いには、
- 各ノードごとに、一緒に処理されるもの
- コメント単独で処理されるもの
という2パターンがあるらしく、その後者を処理してる。
その判定をしてるwillPrintOwnComments(astPath)によると、各ノードごとに処理されてるのは、JSXElementとその他いくつかのノードとのこと。
判定のネストが深くて読みづらい・・・。
printCommentsSeparately(path, options)
printComments()の中身であり、AstPathのeach(callback, "comments")で、各Commentを処理していく。
当然、commentsプロパティの生えてないノードは処理しない。
コメントにはleadingとtrailingがあるが、これらは排他なbooleanであり、どっちかのみ出力される。
もしくは、どちらもfalseであるdanglingなものも存在するけど、それについてはここでは扱ってない。(後述)
それぞれ、
printLeadingComment(path, options)printTrailingComment(path, options, printedPreviousTrailingComment)
というように、引数が違えば返り値も違うようで、trailingのほうはひと手間かかってそう。
Doc化できたら、元々のノードのDocを挟んで返す。[leading, doc, trailing]という感じに。
元々のノードがlabelというDocコマンドになってたら、それを継承して返すようなことをしてた。
printLeadingComment(path, options)
ここでの返り値は、estreeのprintComment(path, options)した結果に、改行を条件によって追加したDocの配列。
改行が追加される条件は、前後行に何があるかで決まる・・・という感じのコードが書いてある気がする。
estreeのprintComment()を呼ぶところで、ASTにコメントを紐づけるフェーズで生やしたCommentのprintedがtrueに更新される。
printTrailingComment(path, options, printedPreviousTrailingComment)
trailingなコメントを連続して処理する場合、printedPreviousTrailingCommentに値が入ってくるようになってた。
というわけで、この関数が返す値がそのままこのprintedPreviousTrailingCommentとして使われる。
返り値としては、ただの配列か、lineSuffix()というDocコマンドになってる。
printAstToDoc(ast, options)の続き
callPluginPrintFunction()をawaitしたあとは、ensureAllCommentsPrinted(options)という確認をしておわり。
すべてのコメントに対して、!comment.printed && !printedComments.has(comment))であることを確認してる。
そしてちゃんとDoc化されてることがわかったら、comment.printedをdeleteする!
printedCommentsとは
printedは知ってたけど、printedCommentsって何?なぜダブルチェックしてるんやろ?って思い、少し調べておくと。
まずはprepareToPrint()のコメント紐付けの前に、new Set()で初期化されてる。
参照されてるのは、さっきのensureAllCommentsPrinted()だけ。
add()されてるのはというと、printIgnored(path, options)という新手のユーティリティ。
どこで使われてるかというと、
estreeのプリンターのメインの入り口であるprint()の初手で呼んでるprintWithoutParentheses()のこの冒頭の部分。
function printWithoutParentheses(path, options, print, args) {
if (isIgnored(path)) {
return printIgnored(path, options);
}
// ...
}
isIgnored()を見るに、prettier-ignoreなコメントがついたノードをそのまま処理してる感じか・・・?
これに合致したprintIgnored()の中で、printedComments.add()されてる。
コメントのDoc化まわりの落穂拾い
まだすべてが明らかになったわけではない。
printDanglingComment(path, options, danglingCommentOptions)
printLeadingComment()やprintTrailingComment()は、printComments()もといprintCommentsSeparately()で使われてた。
しかしここでDoc化されたのは、
JSXElementなどに関係ないコメントでleadingかtrailingなものだけ
つまり、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化する部分で使われてた。
この対応自体は、() => () => {}みたく、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化についていうと、
- 大多数の基本的な
leadingとtrailingなコメントcallPluginPrintFunction()でのprintComment()で対応willPrintOwnComments()によって判定される一部のものは除外
- それ以外の
danglingなコメントprintDanglingComment()を各所で使って対応
- それ以外の特殊なケース
printCommentsSeparately()とprintComments()で個別に対応
わかってたけど、このロジックをすっきり理解するのは、やっぱ無理。ロジックじゃなくてヒューリスティックな歴史の積み重ねって言うほうが正しい。