🧊

Prettier のコードを読む Part 4

いつまで続くのだろうか。

Prettier のコードを読む Part 3 | Memory ice cubes https://leaysgur.github.io/posts/2024/09/04/143544/

おさらい

整形処理のメイン工程では、ASTをPrettierのIRであるDocに変換するけど、そのためにまずASTにひと手間を加える必要があった。

任意のパーサーでコード文字列をASTにした直後、コメントの情報をASTに追加してた。

このattachComments(ast, options)という関数が実行されると、各ノードにcommentsというプロパティが生える。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/attach.js#L136

この処理の内容を、重点的に読んでいきたい回。

上からざっと行くと、

  • decorateComment()で、コメントに対して近接のノード情報を保存したうえで
  • それらのコメントをループして、今度はノード側にコメントを紐づけていく

この2段階の処理の1つ目を見ていく。

decorateComment(node, comment, options [, enclosingNode])

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/attach.js#L59

コメント曰く、

As efficiently as possible, decorate the comment object with .precedingNode, .enclosingNode, and/or .followingNode properties, at least one of which is guaranteed to be defined.

なるほど。

まず初手でgetSortedChildNodes(node, options)してることから、なんらかの順にソートしたものから探すらしい。

getSortedChildNodes(node, options)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/attach.js#L18

キャッシュを使って動作を高速化してる。

第1引数のnodeは、基本的にはASTのルートとなるtype: Program|Fileになると思うけど、attachComments()の第4引数が使われるケースでは別のノードになる。

まず、printer.canAttachComment()が実装されているかチェックしてるけど、これはestreeのプリンターには必ずあるので無視でいい。

その次、ここのメインである子ノードを取得するところ。

const childNodes = (
  getCommentChildNodes(node, options) ?? [
    ...getChildren(node, {
      getVisitorKeys: createGetVisitorKeysFunction(printerGetVisitorKeys),
    }),
  ]
).flatMap((node) =>
  canAttachComment(node) ? [node] : getSortedChildNodes(node, options),
);

まずは、getCommentChildNodes(node, options)しようとする。

getCommentChildNodes(node, options)

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

// Prevent attaching comments to FunctionExpression in this case:
//     class Foo {
//       bar() // comment
//       {
//         baz();
//       }
//     }

というコメントが残されていて、

  • 以下の特定のパーサーでのみ、
    • typescript
    • flow
    • acorn
    • espree
    • meriyah
    • __babel_estree
  • MethodDefinitionのノードで、
  • valueFunctionExpressionなら
  • etc…

というとき、特別な値が返るようになってた。

それ以外は、getChildren(node, options)する。

ちなみに、BabelのパーサーにはMethodDefinitionというノードはなく、ClassMethodというノードになる。

前回みたpostprocess(ast, options)で各ASTの差異を吸収するくだりは終わったかと思ってたけど、まあやっぱこうなるわな〜。

getChildren(node, options)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/utils/ast-utils.js#L13

getChildren()はつまり、渡されたノード直下にぶらさがってるものを全部並べたいだけ。

オプションでfilter(node)で除外できるようになってるけど、今回はそもそもfilterを渡してないので、除外されるものはない。

filterは、getCursorNode()経由でgetDescendants()getChildren()を呼ぶコードパスでだけ指定されるみたい。 このときは、nodeContainCursor()というカーソルが乗ってるノード以外を除外する指定になる。(こういうのはメソッド分けてくれ + これはもう少しやりようないんか?)

getVisitorKeysでは、

  • BabelのVISITOR_KEYSをベースに、TSとFlowとAngularにも対応しつつ
  • 追加で対応したいキーを追加し
  • 特定のノードでキーを除外する

ということをしてる。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/traverse/visitor-keys.evaluate.js

既存のVisitor実装を使うのではなく、愚直にASTのオブジェクトのキーを個別に見て回ってるってこと。

残るは、canAttachComment(node) ? [node] : getSortedChildNodes(node, options)の部分。

canAttachComment(node)

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

どうやら、コメントを紐づけたくないノードがあるらしい。

  • EmptyStatement
  • TemplateElement
  • Import
  • TSEmptyBodyFunctionExpression
  • ChainExpression

ノードのtypeがこれらに合致する場合はスキップして、代わりに再帰でgetSortedChildNodes()を呼ぶことになる。

が、ざっと見る感じ、

  • ChainExpressionTSEmptyBodyFunctionExpression以外は、空になってそう
  • getCommentChildNodes()MethodDefinitionのためのものなので、完全に無駄
  • getChildren()だけでいいはずだが、ソートとキャッシュのために再帰してる?

というわけで、

  • 基本的にはルートの直下ノードを列挙したい
  • 一部のノードは透過的に無視して、その直下ノードを列挙する

そしてこれらを、ノードのstart位置でソートして返す。ASTのノードの各キーを訪れる順序は、コードの登場位置とは関係ないから。

コメントの近接ノードを割り出す

改めて本題。ルートの直下ノードを列挙したところで、それらを使っていく。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/attach.js#L70

let left = 0;
let right = childNodes.length;
while (left < right) {
  // ...
}

コードでいうとここ。いわゆる二分探査をやっていく。

材料は、

  • ASTのルートの直下ノード
    • Babelの場合はルートがtype: Fileで、直下はtype: Programのみ
  • 各コメント

で、大きく3パターンの分岐があり、二分探査で最も近くのものを探し続ける。

  • コメントがそのノードの内部にある
    • そのノードを親として、decorateComment(nextRootNode, comment, options, thisEnclosingNode)を再帰で呼びなおす
  • コメントがそのノードの前方にある
    • そのノードをprecedingNodeとして確保
  • コメントがそのノードの後方にある
    • そのノードをfollowingNodeとして確保

なので、type: Programなんかの場合は、必ずすべてのコメントが内部にあることになるので、即最初のパターンに入って再帰する。

なんなら、

export const fn = () => {
  if (x) {
    return {
      y: [ 1, /* THIS */ 2 ],
    }
  }
}

こんな風にネストしてる場合は、

  • File
  • Program
  • ExportNamedDeclaration
  • 中略
  • ObjectProperty
  • ArrayExpression

みたく、decorateComment()を何度も再帰するようになってる。

(コメント位置から前後と親を見るほうが速そうに思うけど、できないもんだろうか?)

このコード例の場合は最終的に、

  • enclosingNode: ArrayExpression
  • precedingNode: NumericLiteral(1)
  • followingNode: NumericLiteral(2)

となる。

enclosingNodeTemplateLiteralだったら

テンプレートリテラル内のコメントについて、例外を処理してる。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/attach.js#L108

コメントとリテラル内のExpressionの位置関係から、precedingNodefollowingNodenullに戻してる。

どうやら、こういうコードがあった場合に、

`
${v1}
${/* THIS */
v2}
`

この処理がないとこうなっちゃう。

`
${v1 /* THIS */}
${v2}
`

こうなってほしいのに。

`
${v1}
${/* THIS */ v2}
`

という感じらしい。ニッチすぎる。

ここまでのまとめ

ASTにコメントを紐づけるattachComments()の中でも、まだ準備段階みたいなコードだけで1記事になってしまった。

まとめるとするならば、

  • Commentすべてに対して
  • enclosingNode, followingNode, precedingNodeを見つける
  • ただしすべてのノードが対象になるわけではなく、いくつか例外もある
    • 訪れるキーとして除外されているもの
    • canAttachComment()できないもの
  • enclosingNode: TemplateLiteralの場合は、例外処理がある

というわけで、続く・・・。