今年はずっとOXCにJSDocのサポートを入れることをOSS活動としてやっていて、その振り返りも兼ねて。
feat(ast,parser): parse jsdoc · Issue #168 · oxc-project/oxc https://github.com/oxc-project/oxc/issues/168
JSDocのパーサーを書くにあたって、いちばんむずかしかったのは、いわゆる仕様がないこと。(あらやだ実務みたい)
JSDoc
皆さんご存知のとおりJSDocとは、JavaScriptの複数行コメントを使って、何らかの意図を記述できるというもの。
一般的にJSDocと呼ばれるものは、そのコメント内テキストの先頭を*
からはじめる、つまり/**
で複数行コメントを書くことが多い。
/**
* My greet function!
* @param {string} name Your name.
* @returns {void} Logged to console.
*/
const greet = (name) => console.log(`Hello, ${name}!`);
ランタイムには影響しない、ドキュメント生成ツールとしてのJSDocが始祖かな・・・?
Use JSDoc: Index https://jsdoc.app/
JavaScript AST
JavaScriptの仕様という観点からすると、コメントそれ自体のトークンを検出することはやってる。
ただその内側にはなんでも書いてよくて、どういう内容かは興味がない。
JavaScript ASTのデファクトフォーマットであるESTreeにも、コメントに関する取り決めはない。
JavaScriptのASTにおける、コメントの扱いについて | Memory ice cubes https://leaysgur.github.io/posts/2024/01/30/132331
ESTreeもデファクトであって、唯一無二のものではない。 JSXみたいな拡張もあるけど、そういうのは全部コミュニティドリブン。
そういうわけで?TypeScriptの本家のASTもESTreeとは全然違うし、JSDoc TSのサポートのためか、ASTにJSDocに関する表現がある。
TypeScript AST Viewer https://ts-ast-viewer.com
(オプションをいじらないと最初は見えないかも)
既存のJSDocパーサー
そういうわけなので、
- なんらかの目的のためにJSDocを書くと
- ドキュメント目的、JSDoc TS、etc…
- それぞれの目的に応じた実装が使われる
というのが実態。
ドキュメントならJSDoc.appで、JSDoc TSやLSPでの表示ならTypeScriptになるし、ESLintならeslint-plugin-jsdoc(とその依存ライブラリ)という具合に、みんなそれぞれの実装を持ってる。
どれが正解とかはないのである。
JSDocの仕様
みんな用途が違うので、パーサーとしての具合も違って当たり前。
/**
ではじまるコメントで@xxx
みたいなタグが書ける
くらいのコンセンサスはあるかもしれないが、それ以上はないと思ってよさそう。
@xxx
のタグの種類も、ボディのフォーマットも決まってない- JSDoc.appにあるリストが全てではない
- なんならユーザーが自由に作ることもできる
- 改行の扱いは?
- 各行頭の
*
は必須なのか? - 記述できる文字種のルールは?
/***
みたくアスタリスク2個ではなく3個ではじめたら?- そもそも
@xxx
のことはタグって呼ぶ?コメント部は?
などなど、不定である。
なので、JSDocのパーサーを書くぞ!となった場合、まずどういう目的でどうパースしたいのかを、自分で考えて決める必要がある。 既存のツールをリライトしたいというなら、元のコードを素直に模倣すればいいけど。
OXCでの仕様解釈
この記事の本旨はここから。
本当は何らかのパーサーSpecを記述するためのフォーマットもあるんやろうけど、とりあえずフリーフォーマットで。
JavaScriptのパーサーがASTを生成する過程で、見つかった複数行コメントを受け取ったら、JSDocをパースできる。
コードは以下にあります。
https://github.com/oxc-project/oxc/tree/main/crates/oxc_semantic/src/jsdoc
Step 1: コメントとタグ部の分割
/**
* コメント
* ========== ここ =========
* @タグ1
* @タグ2
*/
まず単体のJSDocは、コメント部と複数のタグ部に分けられる。
コメントテキストの文字を先頭から見ていって、最初の@
を見つけたら、そこまでがコメント部。
ここで注意するべきは、愚直に@
に出会ったら終了というロジックを書いてはいけないところ。
{@see https://...}
のように、インラインタグと呼ばれるものがある- コメント部は往々にしてMarkdownライクに記述されるので
`@`
で囲まれているかもしれない- なんならMarkdownのコードブロックが記述される可能性もある
これらのチェックが必要になる。
Step 2: JSDocTagの分割
/**
* @タグ1
* ========== ここ =========
* @タグ2
* ========== ここ =========
* @タグ3
*/
タグ部をそれぞれのJSDocTagに分割する。
これもコメント部とタグ部の分割と同じルールでやればよい。後述するけど、JSDocのコメントと、JSDocTagのコメントは同じ扱いにできる。
JSDoc.app的には、JSDocTagは改行されると終わるって書いてあるけど、世間のユースケースはそうではないものが多い。
/** @private @deprecated @@x */
これもValidな4つのJSDocTagとみなせる。
JSDocTagのパース
@param {string} name Your name.
@returns {string} Formatted message.
@type {{ x: number }}
@deprecated Since version 8.
などなど、分割したそれぞれのJSDocTagの中をパースしていく。
端的に、
- 一般的に使われていそうなタグの種別を見て
- もし
param
なら
- もし
- 一般的に使われていそうなそのボディをそれぞれパース
- 型、変数名、コメントの3つ
ということをやりたくなると思うけど、そんな甘い話はない。
まず、タグの種別を決め打ちできるか?という判断がある。
これは、その用途として、既定の種別だけを対象にできるのか?知らない種別を無視できるのか?ということ。
そして、ボディの用途も同様に、既定のフォーマットのみを対象にできるのか?という判断。
たとえば一般的な@param
は、以下のバリエーションが想定される。
/** @param {type} name This is comment. */
/** @param {type} "This is comment." */
/** @param {type} */
/** @param name This is comment. */
/** @param name */
/** @param "This is comment." */
/** @param */
こういうバリエーションをどこまでサポートしたいかを決める必要がある。
型 変数名 コメント
という3つが最大公約数というわけでもない。(@returns
には変数名はいらない)
極論、すべてのタグに対して{string}
のような型を記述することもできる。
そういうわけなので、OXCでは種別を見てそのstruct
の構造を決めるのではなく、それぞれの用途に応じたメソッドを定義しておいて、ユースケース側で選ぶようにした。
// イメージ
struct JSDocTag {
pub kind: Kind;
}
impl JSDocTag {
pub fn comment() -> CommentPart {}
pub fn r#type() -> Option<TypePart> {}
pub fn type_comment() -> (Option<TypePart>, CommentPart) {}
pub fn type_name_comment() -> (Option<TypePart>, Option<TypeNamePart>, CommentPart) {}
}
OXCでの当初のサポート目標であるeslint-plugin-jsdoc
は、なんとタグ種別に対して任意のエイリアエスを貼れる機能があり・・・。
@param
の代わりに@p
を使うといったことができてしまうので、愚直に書かれてるままパースすることはできなかった。
まあどのみちユーザーが新たに生み出す記法にサポートしたいかは判断する必要があり、真に汎用的な実装にはできない。
JSDocTagの型
{}
で囲まれた型の部分。
これはTypeScriptの型を書くところという判断もできるし、ただの文字列として扱う判断もできる。
型としてはプリミティブだけではなく、配列やオブジェクトも定義できるし、もしかしたらスコープ内の変数かもしれないし、import()
先かもしれない。
なので、ただの文字列として扱うのではない場合は、この型文字列のパースから始める必要があって、それなりに大変。
とりあえずただの文字列とする場合も、{ x: { y: ["foo bar"] } }
のように、空白を書いたり{}
をネストしたりできるので、そのチェックは必要。
JSDocTagの変数名
変数名も同じく、ただの文字列ではない。
@param {boolean} [optionA]
みたく、オプショナルな型かどうかを明示されることが多い。
@param {boolean} [optionA = true]
みたく、オプショナルな上でデフォルトが明示されることもある。
もちろんただのコメントなので、それを実装として型として解釈したいかどうかによって、ここもどの程度までパースするかが変わる。
デフォルト値が明示される場合、[]
や空白が登場することになるので、そのチェックが必要。
JSDocのコメント、JSDocTagのコメント
たかがコメント、されどコメント。
ここで最も面倒なのは、各行の先頭にあるかもしれない*
と、改行の扱い。
/**
* コメント
* ========== ここ =========
* @タグ1
*/
この場合、分割直後のコメント部は、\n * コメント\n * ========== ここ ========\n *
という状態。
* コメント
* ========== ここ =========
*
- もちろん各行の
*
は必須ではない - 普通に登場する文字種でもあるので、チェックするべき?
- Markdownでいうリストのバレットにも使える
- というかMarkdownが書かれたらどうする
- トリムした結果、空行になったらどうする?
- 単数行コメントの場合は?
みたいなことを考慮しないといけない。正直とっても面倒くさい。
Span
OXCでは、Span
と呼ばれる元ソースコードにおける座標を保持する慣例があり。
それを保持しておくことで、Lintエラー時にこの部分がおかしい!というフィードバックができたりする。
それを計算するために、ソースコードグローバルなコメント開始位置をそれぞれ抱えたまま、JSDoc内部のオフセットをパースしながら合算して・・みたいな処理が必要。
このSpan
の座標はu32
型だが、パース処理で登場するオフセットや文字の長さはusize
なので、型キャストがとにかく面倒だった。
おわりに
長くなってきたのでこの記事は終わりにするけど、まだJSDocのすべてをカバーできてはいない。
なんらかの目的のためにパースしたJSDocは、その目的に応じて、ESTreeやらのASTとの関連を知りたいはず。
Linterの場合、@param
とASTのFormalParameter
部を見比べて・・・のようになるので。
というわけで、Parse編はこれで終わり、そのあたりのAttach & Find編はまたの機会に。