🧊

JSDocをサポートするということ Attach & Find編

JSDocをサポートするということ Parse編 | Memory ice cubes https://leaysgur.github.io/posts/2024/04/26/145407

これの続きです。

JSツーリングの一環として、JSDocをサポートするためにはいくつか壁があり、まずそのコメント自体のパースが大変だという話を書いた。

今回は、パースが大変ならその前段も例に漏れず大変なのだという話です。

いやほんと、大変なのよこのコメント文字列をサポートするのは。

JSDocの持ち主は?

JSDocをパースするからには何らかの目的があるはずで、それがLinterであるならば、ASTのノードとの関係が重要になってくる。

たとえば、引数に対して@paramがちゃんと書かれてるか?とか。JSDoc TSなら型情報を取得してるしなおのこと重要。

端的に言うと、こういうコードがあったときに、このJSDocコメントが紐づく対象はどのノード?ということ。

/** 
 * My greet function!
 * @param {string} name Your name.
 * @returns {void} Logged to console.
 */
const greet = (name) => console.log(`Hello, ${name}!`);

直感的には、このgreetという関数定義に対するもの、って思うはず。

だが、それをコードとして実装するために、厳密に言語化するのが難しい。

ASTで見ると

このJSコードを、試しにESTreeのASTに変換してみると、大まかにこうなる。

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "greet",
          },
          "init": {
            "type": "ArrowFunctionExpression",
            "id": null,
            "params": [
              {
                "type": "Identifier",
                "name": "name",
              }
            ],
            "body": {
              "type": "CallExpression",
              "callee": {
                "type": "MemberExpression",
                "computed": false,
                "object": {
                  "type": "Identifier",
                  "name": "console",
                },
                "property": {
                  "type": "Identifier",
                  "name": "log",
                },
              },
              "arguments": [
                {
                  "type": "TemplateLiteral",
                  "quasis": [
                    {
                      "type": "TemplateElement",
                      "value": {
                        "raw": "Hello, ",
                        "cooked": "Hello, "
                      },
                      "tail": false,
                    },
                    {
                      "type": "TemplateElement",
                      "value": {
                        "raw": "!",
                        "cooked": "!"
                      },
                      "tail": true,
                    }
                  ],
                  "expressions": [
                    {
                      "type": "Identifier",
                      "name": "name",
                    }
                  ],
                }
              ],
            },
            "generator": false,
            "expression": true,
            "async": false,
          },
        }
      ],
      "kind": "const",
    }
  ],
  "sourceType": "module",
}

この中のどのASTノードに対して、JSDocは紐づくべきか。

たとえばここでいう関数、ArrowFunctionExpressionだとすると、

const greet =
  /** 
   * My greet function!
   * @param {string} name Your name.
   * @returns {void} Logged to console.
   */
  (name) => console.log(`Hello, ${name}!`);

というように記述されるべきでは?という話になったり。

ArrowFunctionExpressionからたどって、VariableDeclarationだとするなら、

/** For `a`? For `b`?? */
const a = () => {},
      b = () => {};

みたいなVariableDeclaratorが複数はさまるケースではどうなるんだ?

一方で、FunctionDeclarationだけの場合もある。

/** How about this? */
function greet(name) {}

ほかにも、

let my = /** @type {MyObj} */ ({ x: 1 });

こういうケースはどうなる・・?

さらには、

/** @internal */
class X {
  /** This may be also marked as internal!! */
  foo() {}
}

というように、離れたところで意味づけされてるケースもあったり・・・。

などなど、こういう話がとにかく大変だという話。

既存のツール

もちろんみんなそれぞれの方法でやってる。

eslint-plugin-jsdocは、@es-joy/jsdoccommentというモジュールに依存してて、そこではESLintのNode.js APIのSourceCode#getTokensBefore()というAPIを使ってる。

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

TypeScriptには専用のAPIがあるらしい。

TypeScriptのASTにおける、JSDocの扱いについて | Memory ice cubes https://leaysgur.github.io/posts/2024/02/28/162354

具体的にどういう実装になってるか読み解こうと何度もチャレンジしたけど、ぜんぶ挫折してる。

Expose getJSDocCommentsAndTags by Gerrit0 · Pull Request #53627 · microsoft/TypeScript https://github.com/microsoft/TypeScript/pull/53627

なんしか、対象のASTノードを受け取って、親を遡っていって探す方式が一般的なアプローチらしい。

OXCでの解釈

fn get_function_nearest_jsdoc_node(node: AstNode) -> AstNodeというヘルパを用意した。

https://github.com/oxc-project/oxc/blob/f9c08d1e28de7931fbce42d732743eb174c9f43a/crates/oxc_linter/src/utils/jsdoc.rs#L27

実装としては、

  • 与えられたAstNodeにJSDocが紐づいてるかチェックし、あったらそのAstNodeを返す
  • なければ、親を遡っていって同様のチェックをし、見つけられたAstNodeを返す
  • ただし特定のAstNodeに当たったら、Tie-breakerと見なして諦める

という考え方。

現状は30行くらいのシンプルな実装で事足りてるけど、もっとユースケースが増えてくると、もしかしたら対応できてないものが見つかるかもしれない。

OXCにおける実装の特殊な部分は、

という少しテクニカルな実装になってる。

正解はないし、これが妥当なのかもわからないけど、とりあえずなんとかなってる。

当初はなんたらDeclarationとかDefinitionとかだけに紐づければいいのでは?って思ってたけど、結局コメントはどこにでも書けるわけで。

汎用性を求めるなら、どういう意図でそのコメントが書かれたのか?がわからない以上、書かれた場所に紐づけるしかない。

そのうえで、用途に応じて捜索するのがまあ妥当かな〜って。

おわりに

これまで、ドキュメント用途として使おうと思ったことはないけど、JSDoc TSは割と推し・・・っていうスタンスだった。

が、この半年間ずっとJSDocのことをやってきて、今の気持ちを正直に書くと、どんな用途であれJSDocにはもうあんまり関わりたくないかもしれない・・・!

というか、

  • ただの(コメント)文字列に対して
  • 厳密なSpecもないまま
  • 方方があれやこれと拡張し続けた結果

そういうふわっとしたものに対して、改めて意味を見出したくないと言うほうが正しいかも。