azu/textlint 1.4.3

先週リリースした textlint ですが、色々修正や課題だった部分を実装して現在1.4.3が最新になってます。

textlintはMarkdown等をJavaScriptでルールを書いてLint出来るツールで、詳しくは以下を参照して下さい。

1.0.0から1.4.3での変更点をまとめると以下のような感じです。

ルールで <Type>:exit が利用できるように

textlint/create-rules.md at master · azu/textlint

ドキュメントの方も更新してありますが、ESLintと同じように以下のように書けるようになりました。

module.exports = function (context) {
    var exports = {};
    exports[context.Syntax.Str] = function (node) {
        // Enter
    };
    exports[context.Syntax.Str + ":exit"] = function (node) {
        // Leave
    };
    return exports;
};

MarkdownやテキストはTxtNodeからなる木構造のJSONオブジェクトにパースされます。 (DOMと同じようなものです、MarkdownとかはHTMLを意識した方が感覚的に分かりやすいです)

textlint内部ではこのASTを上から走査していくのですが、ルールにはどのTxtNodeがきたら呼ばれる関数を登録するだけというスタイルになっています。

例えば、以下の場合はTxtNodeのtypeの値がcontext.Syntax.Str("Str")となるNodeを見つけたら、enterStrNodeという関数が呼び出される形になります。

exports[context.Syntax.Str] = function enterStrNode(node){}

1.4以降ではこれに加えて、<type名>:exitというキーの指定が追加されました。

exports[context.Syntax.Str + ":exit"] = function leaveStrNode(node){}

これはどういう時に呼ばれるかというと、ASTを走査するときに上から順に探索していき枝Nodeまで行ったら辿り付いたら、そこより下にNodeはないため、もう一度上の親Nodeに戻ります。

この親Nodeに戻ったタイミングで呼ばれるのが、<type名>:exitというキーの指定された関数です。

具体的にそれを可視化するツールも作ったので、以下に適当なMarkdownを書いて実行してみるとつかめるかもしれません。

visualize txt-ast-traverse

  • azu/visualize-txt-traverse
  • Estraverseと同じ方式でASTをたどっていくようになった
  • つまり木構造のASTのそれぞれのNodeをEnterとLeave二回のタイミングで呼ばれる様になってる

以下のJavaScript ASTの話と大体同じなので参考になるかもしれません

Nodeの.parentが辿れるように

それぞれルールに登録した関数はnodeの値がやってきますが、node.parentとすることでそのnodeの親を辿れるようになりました。

サンプルのno-todoのルールでこれを利用しているのでそちらも参照して下さい。

これはどういう時に使えるかというと、ある文字列がきたらエラーとして報告したいけど、その文字列がリンクのテキストだったり、画像のalt属性だったら無視するという時等に使います。

no-todoの例だとisNodeWrapped(node, [Syntax.Link, Syntax.Image, Syntax.BlockQuote])という所で、LinkまたはImage、BlockQuoteのnodeが親にいるなら無視するというルールを入れています。

/**
 * Get parents of node.
 * The parent nodes are returned in order from the closest parent to the outer ones.
 * @param node
 * @returns {Array}
 */
function getParents(node) {
    var result = [];
    // child node has `parent` property.
    var parent = node.parent;
    while (parent != null) {
        result.push(parent);
        parent = parent.parent;
    }
    return result;
}
/**
 * Return true if `node` is wrapped any one of `types`.
 * @param {TxtNode} node is target node
 * @param {string[]} types are wrapped target node
 * @returns {boolean|*}
 */
function isNodeWrapped(node, types) {
    var parents = getParents(node);
    var parentsTypes = parents.map(function (parent) {
        return parent.type;
    });
    return types.some(function (type) {
        return parentsTypes.some(function (parentType) {
            return parentType === type;
        });
    });
}
/**
 * @param {RuleContext} context
 */
module.exports = function (context) {
    var exports = {};
    // When `Node`'s type is `Str` come, call this callback.
    /*
    e.g.)
        - [ ] TODO

    Exception) [todo:text](http://example.com)
    */
    exports[context.Syntax.Str] = function (node) {
        var Syntax = context.Syntax;
        // not apply this rule to the node that is child of `Link`, `Image` or `BlockQuote` Node.
        if (isNodeWrapped(node, [Syntax.Link, Syntax.Image, Syntax.BlockQuote])) {
            return;
        }
        var text = context.getSource(node);
        if (/todo:/i.test(text)) {
            context.report(node, new context.RuleError("found TODO: '" + text + "'"));
        }
    };
    exports[context.Syntax.ListItem] = function (node) {
        var text = context.getSource(node);
        if (/\[\s+\]\s/i.test(text)) {
            context.report(node, new context.RuleError("found TODO: '" + text + "'"));
        }
    };
    return exports;
};

ESLintのようなcontext.getAncestors()でまとめて親を取る便利メソッドを追加するかはちょっと迷ってるので、 意見がある人は何でもいいのでIssues · azu/textlintにIssueを立てて下さい。

とりあえず今はazu/textlint-rule-helperというヘルパーライブラリに切り出しています。説明のために上記のルールは長々書いてますが、ヘルパーライブラリを使えば半分以下になると思います。

パーサの分離

Markdownとテキストのパーサをtextlint本体から分離してモジュールにしました。

  • Markdown ASTパーサをazu/markdown-to-astに分離
    • 現在はCommonMarkのパーサをラップした形のライブラリに切り出した
    • ASTのそれぞれのNodeの位置情報なども大分正確に吐き出せるように直した + テスト
    • mdastの方でrange/locationの実装始まったようなのでこちらにも注目(将来的に切り替えるかもしれない)
    • TxtNode interfaceという形式に沿って使ってる限り大丈夫なようにするつもりなので、ルール書く人は気にしなくてもいいかも
  • プレーンテキストのASTパーサを書きなおしてazu/txt-to-astに分離

実際にあるMarkdownの文章からどういうNodeができるかは以下のパーサにデモエディタを作ったので、色々入力して試してみるといいかもしれません。(プログラムと違って文章はそこまで深い木になったりしないので、Nodeのtypeだけ分かれば大体問題ない気がします)

screenshot

細かいバグの修正

  • context.getSource()で取れる位置をできるだけ正確に取れるように
  • 複数のファイルを指定した時にエラーメッセージが重複していた問題の修正

簡単なルールの作成補助ライブラリ

あるnodeの親node一覧を取得したり、先ほどのno-todoのようにあるnodeの親に特定のtypeがいないかどうかなどの典型的な処理をまとめたライブラリを以下に作ってあります。

このヘルパーを使った例として、@__gfx__さんの Swift訳語集をチェックするルールを作ってみました。

{ 訳語 : 原語 }という感じのJSONを用意してチェックするの簡単なルールです。ある英単語が文中にでてきたら知らせる簡単なしくみで、リンクのテキストや引用文、画像のAlt属性の時は除外するというのを加えるのにazu/textlint-rule-helperを使っています。

// { 訳語 : 原語 }
var dict = require("./swift-words.json");
var Helper = require("textlint-rule-helper").RuleHelper;
/**
 *
 * @param {RuleContext} context
 */
module.exports = function (context) {
    var helper = new Helper(context);
    var rule = {};
    var Syntax = context.Syntax;
    var translatedWords = Object.keys(dict);
    var matchWords = translatedWords.map(function (key) {
        return dict[key];
    });
    var matchRegexp = matchWords.map(function (word) {
        return new RegExp((word), "i");
    });
    rule[context.Syntax.Str] = function (node) {
        if (helper.isChildNode(node, [Syntax.Link, Syntax.Image, Syntax.BlockQuote])) {
            return;
        }
        var text = context.getSource(node);
        matchRegexp.forEach(function (WordRegexp, index) {
            var match = text.match(WordRegexp);
            if (match) {
                var matchedString = match[0];
                var expectedWord = translatedWords[index];
                context.report(node, new context.RuleError(matchedString + " => " + expectedWord));
            }
        });
    };
    return rule;
};

おわりに

1.4で文章のLintに必要最低限の機能は揃った感じがします。 結構普通にルールを書けるようになったのでそれなりに便利になって気がするので、何か面白い使い道が思いついたら触ってみるといいかもしれません。

ドキュメントの英語のヘボさだったり、デフォルトのルール案だったり、ルール作ってみたとか、こういう機能が欲しいとかあったらIssue等を立てて下さい。