少し時間が空いたけどもまだまだ続く。
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
は、
expandFirstArg
expandLastArg
assignmentLayout
というものだったが、詳細はまだわからない。
ログを見る限り、このあたりのコードは昔からあまり変更も入ってないので、本当にわかる人が限られてそう。
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()
で個別に対応
わかってたけど、このロジックをすっきり理解するのは、やっぱ無理。ロジックじゃなくてヒューリスティックな歴史の積み重ねって言うほうが正しい。