🧊

JSDocをサポートするということ Parse編

今年はずっと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編はまたの機会に。