🧊

gajus/eslint-plugin-jsdocのコードを読む Part.3

gajus/eslint-plugin-jsdocのコードを読む Part.2 | Memory ice cubes https://leaysgur.github.io/posts/2024/02/22/140322/

これの続き。

最後に、eslint-plugin-jsdocがヘビーに依存しているjsdoccommentと、そのjsdoccommentがさらに依存してるjsdoc-type-pratt-parsercomment-parserを読んでいく。

@es-joy/jsdoccomment

es-joy/jsdoccomment https://github.com/es-joy/jsdoccomment

READMEによると、昔はESLint本体に実装されてたSourceCode#getJSDocComment()の代替として生まれたライブラリとのこと。

End-of-Life for Built-in JSDoc Support in ESLint - ESLint - Pluggable JavaScript Linter https://eslint.org/blog/2018/11/jsdoc-end-of-life/

ちなみに、eslint-plugin-jsdocで使われてたAPIは以下のとおり。

  • parseComment
  • findJSDocComment
  • getJSDocComment
  • getDecorator
  • getReducedASTNode
  • commentHandler

いきなりだが、parseComment()以外はREADMEにすら書かれてない・・・。

というわけで嫌な予感がすごいので、全部ではなくiterateJsdoc()で使われてるやつだけ読むことにする。

getJSDocComment(sourceCode, node, settings)

https://github.com/es-joy/jsdoccomment/blob/6aae5ea306015096e3d58cd22257e5222c54e3b4/src/jsdoccomment.js#L356

  • getReducedASTNode(node, sourceCode)した上で
  • findJSDocComment(reducedNode, sourceCode, settings)

getReducedASTNode()がやってるのは、よくわからないけど、元のノードから何かを削ったもの・・・?

例のごとくテストもない。

https://github.com/es-joy/jsdoccomment/blob/6aae5ea306015096e3d58cd22257e5222c54e3b4/src/jsdoccomment.js#L192 https://github.com/es-joy/jsdoccomment/blob/6aae5ea306015096e3d58cd22257e5222c54e3b4/test/jsdoccomment.js

findJSDocComment()がやってるのは、

  • currentNodeに引数で受け取ったeslint.Rule.Nodeなノードを設定
    • それがデコレータだったら先頭のデコレータを設定
  • sourceCode.getTokenBefore(currentNode, { includeComments: true })で、自ノードより上方のトークンを1つだけ取得
    • それが(だったら、もう1つ手前を見る
  • トークンがない、コメントではない場合はnullを返して終わり
  • 行コメントだった場合は、もう1つ前のトークンも見て繰り返す
  • 複数行コメントが引けたらその中身をチェックするフェーズへ進む
    • その複数行コメントの中身が、\*\sにマッチするかチェック
    • つまり/** JSDoc *かどうか
  • さらに(min|max)Linesに収まる範囲にあったかどうかチェック
    • (せっかくトークンで探したのに、行数を見てる?なんのために?)
  • すべてOKなら、estree.Comment型が返る

というわけで、特定のASTノードに対して、最も近くにある単一のコメントトークンを返してる。

  • 行コメント以外は遡らない(= ただの複数行コメントも即候補になる)
  • 単一のコメントだけを返す

このあたりは、TSのJSDoc紐づけとは異なる挙動。TSはもっと貪欲に複数のコメントを紐づける。

ちなみに、@deprecatedになったESLint本家のコードはこちら。

https://github.com/eslint/eslint/blob/8e13a6beb587e624cc95ae16eefe503ad024b11b/lib/source-code/source-code.js#L480

行数チェックは1以下かどうかだけ、つまり直上の行か、同じ行の直前かどうかを見てる。これはたしかにトークンではわからないし納得。 そもそもJSDocコメントがあるかどうか探すべきノードも、クラスか関数しか見てなかったらしい。

parseComment()

https://github.com/es-joy/jsdoccomment/blob/6aae5ea306015096e3d58cd22257e5222c54e3b4/src/parseComment.js#L150

  • 第1引数は、さっき取得したestree.Commentを想定していつつも、実態は{ value: string }さえあれば良いらしい
    • もしくはただのstringでも(にしてもなんだこの冗長なコードは・・・)
  • 第2引数はオプショナルなインデント文字列らしいが、""しか指定されてないし、デフォルト値と一緒
  • comment-parserparse()を呼んで、comment-parserのコメントBlock型を得る
  • そのままではインラインタグがパースされていないので、descriptiontags[].descriptionは別途パースして返してる
    • 正規表現のお化けだった

というわけで、getJSDocComment()で取得したestree.Commentvalueのコメント文字列をパースして、単一のJSDocブロックを表す構造体にしてる。

  • ESTreeのASTとは違う(というかコメントはASTにない)し、Commentトークンとも違う、正規化されたただのオブジェクト
  • ただしcomment-parserが定義してるJSDocブロックそのままではなく、インラインタグもパース済である

というあたりがポイントか。

commentHandler()

https://github.com/es-joy/jsdoccomment/blob/6aae5ea306015096e3d58cd22257e5222c54e3b4/src/commentHandler.js#L19

ここで定義してる独自ASTは、JsdocBlock, JsdocTagなどなど。

https://github.com/es-joy/jsdoccomment/tree/main?tab=readme-ov-file#eslint-ast-produced-for-comment-parser-nodes-jsdocblock-jsdoctag-and-jsdocdescriptionline

eslint-plugin-jsdocでは、contexts[].commentで指定して最後の絞り込み処理を行うために使われてたやつ。

comment-parser

syavorsky/comment-parser: Generic JSDoc-like comment parser. https://github.com/syavorsky/comment-parser

jsdoccommentを介してだけでなく、eslint-plugin-jsdocでも直接依存していて、いくつかのルールで使われてる。

このライブラリ自体は、

  • 構文解析とかをやってるわけではなく、単純な文字列操作をしてるだけ
    • 行ごとに区切って、空白で区切って、正規表現で、etc…
    • だから先述の問題みたいなのもあるというわけね
  • ESTree ASTのことは知らず、正規化された独自オブジェクトにパースするだけ
    • JSDocコメント向けではなく、あくまで汎用的な構造化コメントのパーサー
  • 複数のコメントをまとめてパースすることもできる

他パッケージへの依存もなく、内容も1000行ほどとシンプルだが、そのおかげでjsdoccommentの仕事が増えてる感じ。

さっき見たparse()が返すBlock型は、このようなイメージ。

// https://github.com/syavorsky/comment-parser/blob/0d210d3ddc5863137850716b2c581f27cc9de617/src/primitives.ts#L16
export interface Block {
  description: string;
  tags: Spec[];
  source: Line[];
  problems: Problem[];
}

export interface Spec {
  tag: string;
  name: string;
  default?: string;
  type: string;
  optional: boolean;
  description: string;
  problems: Problem[];
  source: Line[];
}

ここに、inlineTagsというプロパティをjsdoccommentによって追加したものを、eslint-plugin-jsdocの各ルールは受け取る(使うかどうかはルール次第)。

https://github.com/es-joy/jsdoccomment/blob/6aae5ea306015096e3d58cd22257e5222c54e3b4/src/index.js#L8-L28

jsdoc-type-pratt-parser

jsdoc-type-pratt-parser/jsdoc-type-pratt-parser: A pratt parser for jsdoc types https://github.com/jsdoc-type-pratt-parser/jsdoc-type-pratt-parser

Prattパーサーという単語も初耳だったが、そういうアルゴリズムがあるらしい。

Operator-precedence parser - Wikipedia https://en.wikipedia.org/wiki/Operator-precedence_parser#Pratt_parsing

なんしか、JSDocコメント内で書かれる{}内の型の指定の部分をパースしてASTにするのに使われてるらしい。

依存はないがトータルで4000行くらいのコードベース・・・!

JSDocだけでなく、ClosureやTypeScriptの型指定も扱えるようになってるとのこと。 eslint-plugin-jsdocでも、たしかにclosureの型記法に対応してる風のコードがある・・・。

用途としては、jsdoccommentがESTree風ASTを生成するときに、ついでに型指定部分をパースしてる。

jsdoccommentの独自ASTであるJsdocTagの中にある、parsedTypeというキーがそれ。

https://github.com/es-joy/jsdoccomment/blob/6aae5ea306015096e3d58cd22257e5222c54e3b4/src/commentParserToESTree.js#L78

すべては、eslint-plugin-jsdoccontexts[].commentの絞り込みでesqueryを使うために。

あと実は、jsdoccommentexport * from 'jsdoc-type-pratt-parser'されてて、

  • stringify
  • parse
  • tryParse
  • traverse

これらが、eslint-plugin-jsdocの各ルールで使われてる。

コードだけでなく依存関係も本当に追いづらい・・・。

まとめ

Part.1-3の総まとめ。

  • eslint-plugin-jsdocが扱うのは、
    • ソース内のすべてのコメント(種別を問わない)
    • ソース内のすべてのノードに紐づく直近単一のJSDocコメント、および、ノードに紐づかないすべてのJSDocコメント
    • 特定のノードに紐づく、直近単一のJSDocコメント
  • ルール側は、iterateJsdoc()によって各種コンテキストや設定がチェックされ、呼ばれるのを待つ
    • contexts[].(context|comment)や、各ルール側が想定するノードなどに応じて決まる
    • ここの実装が重そう
  • 呼ばれたときは、以下のもの(抜粋)が渡されるので、用途に応じて使う
    • jsdoc: jsdoccommentがパースしたBlock型とパース済のインラインタグ
    • jsdocNode: estree.Commentトークン
    • node: estree.AST.Node | null
  • ESTreeにない独自JSDoc ASTは、jsdoccommentjsdoc-type-pratt-parserが定義してる

というのが大まかな挙動かな。

そして大事なのが、まとめてはみたものの、60%くらいしか理解できてる自信はないってところ。

さて、これをどうやってOxcに持っていこうか・・・。