akito0107 Tech-Blog

articles

TSDoc と documentation tests

はじめに

この記事はTypeScript アドベントカレンダー7 日目の記事です。 昨日はmasanori_msl さんの記事でした。

TypeScript の Doc Comment の仕様として、TSDocがあります。 TSDoc 自体の仕様はinitial の draftも出ていない状況で、エコシステムが整いきっているとはまだ言い難い状況ですが、 すでに VSCode ではその Syntax Highlight に対応しているなど、今後は TSDoc が TypeScript の Doc Comment の標準的な仕様になるかと思われます。

上記の repository にリファレンス実装として Parser が提供されていたので、今回はその Parser を使って簡単なアプリケーションを書いてみました。

Rust のdocumentation testsや、Go のExamplesのように、 Doc Comment に埋め込んだ example コードを実行して、その正当性をチェックするツールです。

この記事では TSDoc について簡単に整理した上で、実際に作ったものの紹介をします。

作ったものはこちら

TSDoc

コードにコメントを書き、そこから API 等のドキュメントを生成する仕組みは多くの言語で幅広く採用されています。この記事ではその仕組みを Doc Comment と呼びます。 TypeScript にはいくつかの Doc Comment の仕様がありますが、現在ですと、 Microsoft も含むチームで仕様の策定を進めているTSDocが今後標準的な位置付けになっていくかと思います。 TSDoc 自体は JSDoc に loosely based とのことで、JSDoc でコメントを書いていたユーザーからすると、馴染みやすいものになっていると思います。

TSDocの repository では RFC に関する議論や、 その標準実装の Parser が提供されているだけで、この repository には documentation 生成ツールが含まれているわけではありません。 つまり実際に Doc Comment からなんらかの html や markdown の documentation を生成するためには、別のツールを使う必要があります。

api-extractorを使えば TSDoc に乗っ取った Documentation を生成することができますが、 api-extractor は documentation 以外の範囲もカバーするツールなので、シンプルに documentation だけを生成したいといったユースケースに対応するようなツールは見つけることができませんでした (もし知っている方がいらっしゃいましたら教えてください...)

documentation の生成を気楽に試すようなツールはまだありませんが、 前述の repository でTSDoc Playgroundという Web 上で TSDoc を試せるツールがあるので、そちらでおおまかな雰囲気をつかめるかと思います。

TSDoc の status としては、2019 年 12 月 7 日の時点で initial draft がまだ出ていない段階ですので、今後、細かいところは変わってくる可能性があります。 詳しくはREADME の Roadmapを参照してみてください。 TSDoc の詳細な Syntax については、現在はプロジェクトの Status として前述のような状態ですので、完全なドキュメントは見つけられませんでした。 ですが、api-extractorに参考になりそうなドキュメントを見つけましたので、 実際に Doc Comment を書く際には参照してると良いかもしれません。

tsdoc-testify

実際に作ったツールの紹介をします。

今回作ったツールは、TSDoc の@example block に書かれたコードを整形し、jest 等で実行可能な test code として出力するものです。 Rust の documentation testsのようなことを実現するツールで、 example で書かれたコードが実際に valid なものかどうかをチェックすることができるようになります。 例えば、API の変更があった際の documentation のチェックや、簡単なものだと、example のコードに typo が紛れていないかのチェックをすることができます。

僕自身、documentation を 書かない 雑に書くことが多く、書いたとしてもそのメンテナンスが億劫になってしまうことがよくあり、そのチェックの手間を減らしたいと思ってこのツールを作成しました。

Getting Started

cli ツールとして publish しているので、npm or yarn で install してください。

$ npm install -g tsdoc-testify

TSDoc の Style でコメントを書いた .ts のファイルを用意してください。今回は以下のようなファイルを用意します。

/**
 * sum function
 *
 * @remarks
 * demo
 *
 * @example
 *
 * ```
 * import * as assert from "assert";
 * import { sum } from "./sample";
 *
 * assert.equal(sum(2, 1), 3);
 * ```
 *
 * @param a
 * @param b
 */
export function sum(a: number, b: number) {
  return a + b;
}

このファイルを sum.ts という名前で保存し、以下のコマンドを実行してください。

$ tsdoc-testify --filepath path/to/sum.ts

filepath の正規表現を使いたいときは --fileMatch のオプションを使ってください。

$ tsdoc-testify --fileMatch 'path/to/**/*.ts'

すると、sum.doctest.ts というファイル名で同じディレクトリに以下のファイルが生成されていると思います。

// Code generated by "tsdoc-testify"; DO NOT EDIT.

import * as assert from "assert";
import { sum } from "./sum";
test("/Users/akito/workspace/tsdoc-testify/examples/sum.ts_0", () => {
  assert.equal(sum(2, 1), 3);
});

あとはのコードを jest で実行すると、 @example のコードをチェックすることができます。

% yarn jest sum.doctest.ts
yarn run v1.19.2
$ /Users/akito/workspace/tsdoc-testify/node_modules/.bin/jest sum.doctest.ts
 PASS  examples/sum.doctest.ts
  ✓ /Users/akito/workspace/tsdoc-testify/examples/sum.ts_0 (1ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.999s
Ran all test suites matching /sum.doctest.ts/i.
✨  Done in 3.53s.

以上が基本的な使い方です。基本的に一つのソースファイルに対応して一つのテストが生成されます。

現在ですと、 import のパスを修正する機能がないので、example 対象のコードを import { sum } from "./sum" として import 読み込む必要があります。 (ここは修正する予定で、auto で import させるか path の修正の機能をつけるかする予定です。)

以下のように、複数の @example で同じ module を import していても、生成される test code では import はまとめて出力されるので、 syntax としては valid なコードが吐き出されるかと思います(そうでなかったらバグなので、報告していただけると助かります)

/**
 * sum function
 *
 * @remarks
 * demo
 *
 * @example
 *
 * ```
 * import * as assert from "assert";
 * import { sum } from "./math";
 *
 * assert.equal(sum(2, 1), 3);
 * ```
 *
 * @param a
 * @param b
 */
export function sum(a: number, b: number) {
  return a + b;
}

/**
 * sub function
 *
 * @example
 *
 * ```
 * import * as assert from "assert";
 * import { sub } from "./math";
 *
 * assert.equal(sub(4, 5), -1);
 * ```
 * @param a
 * @param b
 */
export function sub(a: number, b: number) {
  return a - b;
}

generate される file

// Code generated by "tsdoc-testify"; DO NOT EDIT.

import * as assert from "assert";
import { sum, sub } from "./math";
test("/Users/akito/workspace/tsdoc-testify/examples/math.ts_0", () => {
  assert.equal(sum(2, 1), 3);
});
test("/Users/akito/workspace/tsdoc-testify/examples/math.ts_1", () => {
  assert.equal(sub(4, 5), -1);
});

Custom Tag

@exampleCaseName

デフォルトで、test の名前 (test の第一引数の文字列)は 生成元ファイル + 順番の番号 となっています。 これを、 @exampleCaseName という inline tag を用いて修正することができます。

/**
 * sum function
 *
 * @remarks
 * demo
 *
 * @example
 * {@exampleCaseName custom case name}
 *
 * ```
 * import * as assert from "assert";
 * import { sum } from "./math";
 *
 * assert.equal(sum(2, 1), 3);
 * ```
 *
 * @param a
 * @param b
 */
export function sum(a: number, b: number) {
  return a + b;
}
// Code generated by "tsdoc-testify"; DO NOT EDIT.

import * as assert from "assert";
import { sum } from "./math";
test("custom case name", () => {
  assert.equal(sum(2, 1), 3);
});

@ignoreExample

@example に擬似コードを使いたい場合や、そのままでは動かせないようなコードを書きたい場合、そのままテストコードとして生成されたら困るケースがあるかと思います。 その際は、 @ignoreExample という inline tag を用いて生成を skip させることができます。

/**
 * sum function
 *
 * @remarks
 * demo
 *
 * @example
 * {@ignoreExample}
 *
 * ```
 * import * as assert from "assert";
 * import { sum } from "./math";
 *
 * 擬似コードなどを書きたい。
 * assert.equal(sum(2, 1), 3);
 * ```
 *
 * @param a
 * @param b
 */
export function sum(a: number, b: number) {
  return a + b;
}

これで、この @example ブロックの生成は抑制されます。

実装

最後に簡単に実装について触れます。前述した通り、 tsdoc-testify では tsdoc の parser を使っています。 それ以外にも、 TypeScript の compiler API を用いて、ソースコード生成と、 import 等の整形を行っています。

TypeScript の Compiler API については他にもドキュメントが多くあるかと思うので、ここでは tsdoc の Parser の使い方を紹介します。

comment を parse する

すぐ上で TypeScript の Compier API には触れないと言いましたが、実は tsdoc の parser を使って実際のファイルのコメントを取得するためには、まずは TypeScript の Compiler API を使って TypeScript の AST を取得する必要があります。(正確言うと、must ではないのですが、Compiler API を使った方が簡単に実現できます。)

先ほどの sum.ts を parse して、AST を取得します。

import * as ts from "typescript";
import * as path from "path";
import * as fs from "fs";

const source = ts.createSourceFile(
  filepath,
  fs.readFileSync(filepath).toString(),
  ts.ScriptTarget.ES2015
);

forEachChild メソッドを call して、ts.SourceFile の子の node を辿ります。 TS AST Viewer で見ると確認できるかと思いますが、今回のケースでは FunctionDeclaration の node が該当します。 forEachChild の中で、 ts.isFunctionDeclaration でチェックして、目的の node だけを対象にします。

const commentRanges: ts.CommentRange[] = [];

const fullText = source.getFullText();

source.forEachChild(node => {
  if (!ts.isFunctionDeclaration(node)) {
    return false;
  }
  commentRanges.push(
    ...(ts.getTrailingCommentRanges(fullText, node.pos) || []) // <= これがTrivia API
  );
});

console.log(commentRanges);

TypeScript の Compiler API には、Comment を扱うための API があります。TypeScript Deep Dive の Trivia APIを参照してみてください。 この API では、Comment の中身はパースできませんが、コメントがどの position から始まって、終わっているのかの range を取得することができます。

この range と、 source code のファイル全体の文字列を tsdoc の parse に渡すことにより、comment の Node が取得できます。

const textRange = commentRanges[0];
const textRange = tsdoc.TextRange.fromStringRange(
  fullText,
  range.pos,
  range.end
);
const config = new tsdoc.TSDocConfiguration();
const tsdocParser: tsdoc.TSDocParser = new tsdoc.TSDocParser(config);

const parserContext = tsdocParser.parseRange(textRange);

console.log(parserContext.docComment); // <= ここにparseされたコメントのNodeが入っている

tsdoc.TextRange.fromStringRange に comment の range と source code の fullText を渡し、tsdoc のTextRangeを生成した上で、 TSDocParserparseRange を呼び出します。

TSDocParser には parseString のメソッドもありますが、より実践的には TS Compiler API と組み合わせて、parseRange を使った方が利便性が高いと思います。

より詳しい Example はtsdoc repository の api-demoを参照してください。

custom tag の handling

今回のケースの @ignoreExample や、 @exampleCaseName のように、custom な tag の handling も比較的簡単にできます。

TSDocConfigurationnew した上で、TSDocTagDefinition を add すれば Node として handling してくれます。

const config = new tsdoc.TSDocConfiguration();
const exampleCaseName = new tsdoc.TSDocTagDefinition({
  tagName: "@exampleCaseName",
  syntaxKind: tsdoc.TSDocTagSyntaxKind.InlineTag
});
const ignoreCase = new tsdoc.TSDocTagDefinition({
  tagName: "@ignoreExample",
  syntaxKind: tsdoc.TSDocTagSyntaxKind.InlineTag
});

config.addTagDefinitions([exampleCaseName, ignoreCase]);

SyntaxKind は、用途に合わせて、 BlockTag, InlineTag , ModifierTag を選択します。 この辺りも、前述のapi-demoのコードが参考になるかと思います。

まとめ

TSDoc についての紹介と、TSDoc の parser を使ったアプリケーションの紹介を行いました。 TSDoc 自体はまだまだ出てきたばかりの仕様ですが、将来的にはこれが TS のスタンダードになっていくのかなと思っています。

メンテナンスしやすいドキュメンテーションに少しでも近づけばと思い、このツールを作ってみました。

明日はpco2699 さんの記事です!

About

技術ネタ中心にその他雑多なことを。

Tags
  • Go

    5
  • Poem

    1
  • TypeScript

    5
  • AST

    6
  • Frontend

    4
  • misc

    3
  • 静的解析

    2
  • wasm

    1
  • Validator

    1
  • Node.js

    1
  • Assert

    1