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
というヘルパを用意した。
実装としては、
- 与えられた
AstNode
にJSDocが紐づいてるかチェックし、あったらそのAstNode
を返す - なければ、親を遡っていって同様のチェックをし、見つけられた
AstNode
を返す - ただし特定の
AstNode
に当たったら、Tie-breakerと見なして諦める
という考え方。
現状は30行くらいのシンプルな実装で事足りてるけど、もっとユースケースが増えてくると、もしかしたら対応できてないものが見つかるかもしれない。
OXCにおける実装の特殊な部分は、
- パフォーマンスのために、Linterやらユースケース層での仕事は減らしたい
- 動的に0からJSDocを捜索することはしない
- ASTをビルドする際、JSDocをどこに紐づけるか先に決めておく
- このフラグ情報を使って、1手で取得するか、効率的に捜索するか選ぶ
という少しテクニカルな実装になってる。
正解はないし、これが妥当なのかもわからないけど、とりあえずなんとかなってる。
当初はなんたらDeclarationとかDefinitionとかだけに紐づければいいのでは?って思ってたけど、結局コメントはどこにでも書けるわけで。
汎用性を求めるなら、どういう意図でそのコメントが書かれたのか?がわからない以上、書かれた場所に紐づけるしかない。
そのうえで、用途に応じて捜索するのがまあ妥当かな〜って。
おわりに
これまで、ドキュメント用途として使おうと思ったことはないけど、JSDoc TSは割と推し・・・っていうスタンスだった。
が、この半年間ずっとJSDocのことをやってきて、今の気持ちを正直に書くと、どんな用途であれJSDocにはもうあんまり関わりたくないかもしれない・・・!
というか、
- ただの(コメント)文字列に対して
- 厳密なSpecもないまま
- 方方があれやこれと拡張し続けた結果
そういうふわっとしたものに対して、改めて意味を見出したくないと言うほうが正しいかも。