🧊

Prettier のコードを読む Part 3

続いてます。

Prettier のコードを読む Part 2 | Memory ice cubes https://leaysgur.github.io/posts/2024/09/02/144240/

今回からは、メインの整形処理で、具体的にどういうことが起こってるのかを追ってく。

おさらい

https://github.com/prettier/prettier/blob/3.3.3/src/main/core.js#L25

整形ロジックの中心であるcoreFormat(text, options)の詳細を調べたかった。

そしてここでは、

  • 整形前コード > AST
  • AST > Doc
  • Doc > 整形後コード

という3段階の工程があることがわかってて、それぞれ具体的にどういうことが行われてるのか。

  // 1
let a
 = 42; // 2

というファイルに対して、prettier t.js --no-semiを実行すると、

// 1
let a = 42 // 2

というコードが出力される。

  • コメントの行頭が揃って
  • 変数宣言が1列になり
  • セミコロンが削除され
  • 最後に空行が追加される

これらの処理がどう行われてるか?ということを調べたい。

まずは、整形前コードからASTの工程。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/core.js#L30

入口はこちら。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/parse.js#L5

resolveParser(options)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/parser-and-printer.js#L49

まずはどのパーサーを使うかを割り出す。といっても、ここに到達した時点で何を使うかは既に決まってるので、単にJSとして参照を解決するだけ。

CLIで、パーサー指定なしで、JSファイルに対して実行した場合は、

  • parser: babel
  • printer: estree

というデフォルト構成になる。

parser.parse(text, options)

https://github.com/prettier/prettier/blob/3.3.3/src/language-js/parse/babel.js

初期化してパース実行まで。

Babelのパーサーは、他のパーサーより比較的いろんなことをやってるように見える。(デフォルトだから?)

パーサーのプラグインやらを整えたら、parse(text, options)でASTにする。

最後にpostprocess(ast, options)してASTを返すのがパーサーのお作法らしい。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/parse/babel.js#L160

postprocess(ast, options)

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/parse/postprocess/index.js#L14

どうやら自称ESTree互換な各ASTの差分を吸収する後処理らしい。

BabelのAST特有な処理もあれば、そうでないものもある。

  • ParenthesizedExpressionにコメントが紐づかず、透過的に扱えるよう位置を偽装
    • /** @type {Foo} */ (foo)のやつね・・・
  • その他にもいくつかのノードに特別な前処理
    • LogicalExpression
    • VariableDeclaration
    • etc…
  • 連続するブロックコメントのマージ
    • すべての行が*ではじまるブロックコメントが隣接してたら

ふむ。

単にパーサーからASTを取得してるだけかと思ってたけど、ASTに対して変更を加えてたことがわかった。

printAstToDoc(ast, options) > prepareToPrint(ast, options)

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

このDoc化の直前にも、またしてもASTに変更を加えてることがわかってる。

https://github.com/prettier/prettier/blob/3.3.3/src/main/ast-to-doc.js#L128

やってることは2つ。

  • ASTの各ノードに対して、Commentを紐づける
  • printer.preprocess(ast, options)があれば実行
    • ESTreeのプリンターにはない

コメントはやはり鬼門である。

attachComments(ast, options)

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

まず、ESTree.Commentに対して下処理をしてて、それがdecorateComment(rootNode, comment, options)というやつ。

decorateComment(node, comment, options)

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

4番目の引数もあって、自身で再帰するときに使ってた。

ここでは、コメントごとに以下の3つを探してる。

  • followingNode: 自分の後にあるノード
  • precedingNode: 自分の前にあるノード
  • enclosingNode: 自分を内包するノード

今回のコード例だと、コメントが2つあって、

  • 1つ目: followingNodeVariableDeclaration、ほかはundefined
  • 2つ目: precedingNodeVariableDeclaration、ほかはundefined

対象のノードは、自分の位置と対象ノードの位置を使って2分探査で探しつつ、同時にコメントを紐づけるにふさわしいかの判定もやってる。

https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/main/comments/attach.js#L18 https://github.com/prettier/prettier/blob/52829385bcc4d785e58ae2602c0b098a643523c9/src/language-js/comments/printer-methods.js#L14

なるほどな!これを理解するのは無理!

次に、拡張したESTree.Commentの配列を回して、ASTのノード側に反映していく。

同時に、comment.placementというものも追加される。

  • ownLine
  • endOfLine
  • remaining

このいずれかになる模様。

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

このaddXxxComment()が呼ばれると、そのノードにcommentsプロパティが生えることはわかった。

ループが終わると、最後にbreakTies()という処理を呼んでる。

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

comment.placementremainingになっていて、その中で既知の12パターンに合致しなかったとき、はぐれコメントとしてマークされるらしい。 それを最終的にどこかのノードに紐づける最後の砦みたいなことをしてる・・・と思う。

ここまでやって、やっと、Doc化するためのASTのできあがり。

@babel/parserattachCommentオプション

というか、このあたりをいじってて初めて知ったけど、BabelのパーサーにはattachCommentというオプションがある。

https://babeljs.io/docs/babel-parser#options

これを有効にすると、特定のノードにleadingCommentstrailingCommentsinnerCommentsというものが生えるようになる・・・。

https://github.com/babel/babel/blob/04485b5796956e38fb5cfdd8bcbc11c2ff2bae7f/packages/babel-parser/src/parser/comments.ts https://github.com/babel/babel/blob/04485b5796956e38fb5cfdd8bcbc11c2ff2bae7f/packages/babel-parser/ast/comment-attachment.md

ただPrettierの場合は、Babel以外のパーサーにも対応するために、自前でやらざるを得ないということか。

あと少し調べてみたところ、どうやらPrettierとBabelとでは、やはり解釈というか挙動が違うようだった。

やっぱコメントの用途をパーサーレベルで解釈するのは無理ってことかね・・・。

続く

この時点でのASTはこんな感じ。

{
  "ast": {
    "type": "File",
    "start": 0,
    "end": 25,
    "loc": {
      "start": {
        "line": 1,
        "column": 0,
        "index": 0
      },
      "end": {
        "line": 4,
        "column": 0,
        "index": 25
      }
    },
    "range": [0, 25],
    "errors": [],
    "program": {
      "type": "Program",
      "start": 0,
      "end": 25,
      "loc": {
        "start": {
          "line": 1,
          "column": 0,
          "index": 0
        },
        "end": {
          "line": 4,
          "column": 0,
          "index": 25
        }
      },
      "range": [0, 25],
      "sourceType": "module",
      "interpreter": null,
      "body": [
        {
          "type": "VariableDeclaration",
          "start": 7,
          "end": 19,
          "loc": {
            "start": {
              "line": 2,
              "column": 0,
              "index": 7
            },
            "end": {
              "line": 3,
              "column": 6,
              "index": 19
            }
          },
          "range": [7, 19],
          "declarations": [
            {
              "type": "VariableDeclarator",
              "start": 11,
              "end": 18,
              "loc": {
                "start": {
                  "line": 2,
                  "column": 4,
                  "index": 11
                },
                "end": {
                  "line": 3,
                  "column": 5,
                  "index": 18
                }
              },
              "range": [11, 18],
              "id": {
                "type": "Identifier",
                "start": 11,
                "end": 12,
                "loc": {
                  "start": {
                    "line": 2,
                    "column": 4,
                    "index": 11
                  },
                  "end": {
                    "line": 2,
                    "column": 5,
                    "index": 12
                  },
                  "identifierName": "a"
                },
                "range": [11, 12],
                "name": "a"
              },
              "init": {
                "type": "NumericLiteral",
                "start": 16,
                "end": 18,
                "loc": {
                  "start": {
                    "line": 3,
                    "column": 3,
                    "index": 16
                  },
                  "end": {
                    "line": 3,
                    "column": 5,
                    "index": 18
                  }
                },
                "range": [16, 18],
                "extra": {
                  "rawValue": 42,
                  "raw": "42"
                },
                "value": 42
              }
            }
          ],
          "kind": "let",
          "trailingComments": [
            {
              "type": "CommentLine",
              "value": " 2",
              "start": 20,
              "end": 24,
              "loc": {
                "start": {
                  "line": 3,
                  "column": 7,
                  "index": 20
                },
                "end": {
                  "line": 3,
                  "column": 11,
                  "index": 24
                }
              },
              "placement": "endOfLine",
              "leading": false,
              "trailing": true,
              "printed": false,
              "nodeDescription": "VariableDeclaration"
            }
          ],
          "leadingComments": [
            {
              "type": "CommentLine",
              "value": " 1",
              "start": 2,
              "end": 6,
              "loc": {
                "start": {
                  "line": 1,
                  "column": 2,
                  "index": 2
                },
                "end": {
                  "line": 1,
                  "column": 6,
                  "index": 6
                }
              },
              "placement": "endOfLine",
              "leading": true,
              "trailing": false,
              "printed": false,
              "nodeDescription": "VariableDeclaration"
            }
          ],
          "comments": [
            {
              "type": "CommentLine",
              "value": " 1",
              "start": 2,
              "end": 6,
              "loc": {
                "start": {
                  "line": 1,
                  "column": 2,
                  "index": 2
                },
                "end": {
                  "line": 1,
                  "column": 6,
                  "index": 6
                }
              },
              "placement": "endOfLine",
              "leading": true,
              "trailing": false,
              "printed": false,
              "nodeDescription": "VariableDeclaration"
            },
            {
              "type": "CommentLine",
              "value": " 2",
              "start": 20,
              "end": 24,
              "loc": {
                "start": {
                  "line": 3,
                  "column": 7,
                  "index": 20
                },
                "end": {
                  "line": 3,
                  "column": 11,
                  "index": 24
                }
              },
              "placement": "endOfLine",
              "leading": false,
              "trailing": true,
              "printed": false,
              "nodeDescription": "VariableDeclaration"
            }
          ]
        }
      ],
      "directives": []
    },
    "tokens": [/* omit */]
  },
  "comments": [/* omit */]
}

これはPlaygroundで表示したpreprocessed ASTというやつ。(tokenscommentsは省略した。)

VariableDeclarationのノードにcommentsが生えてるのがわかる。((leading|trailing)CommentsはBabel特有のやつ)

元のESTree.Commentから増えたのは、

  • placement: string: さっきの3パターンだが、Doc化には使われてなさそう
  • leading: boolean: 前にあるのかどうか
  • trailing: boolean: 後にあるのかどうか
  • printed: boolean: Doc化済フラグ(後でdeleteされる・・・)
  • nodeDescription: string: デバッグ用のメッセージ

というプロパティたち。重要なのはやはりleadingtrailingか。

ちなみに、CLIの--debug-print-astだと、commentsが生える前のASTしか見れない。

ふう。とりあえず、次へ進むか・・・。