30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Power Assert Deno を作った

Deno で Power Assert を利用するためのモジュールを作った。

github.com

Power Assert とは、テスト失敗時に詳細な情報を表示する仕組みのこと。

例えば以下のようなテストコードがあるとき、通常はx()y.zのそれぞれの結果と、それらが一致しないことのみが、表示される。

Deno.test("hello world", () => {
  const x = () => "Hello World 🦖"
  const y = {z: "Hello World 🦕"};
  assertEquals(x(), y.z);
});
-   "\"Hello World 🦖\""
+   "\"Hello World 🦕\""

だが Power Assert を利用すると、以下のようなより詳細な情報を得られるようになる。

assertEquals(x(), y.z);
             |    | |
             |    | "Hello World 🦕"
             |    Object{z:"Hello World 🦕"}
             "Hello World 🦖"

使用法

以下のコマンドで、インストールできる。

$ deno install -n assert -f --allow-read --allow-write --allow-run --unstable https://deno.land/x/power_assert_deno@0.1.0/cli.ts

-nフラグで指定した名前でインストールされるので、この場合、assertというコマンド名になる。

そして、標準のアサーション関数の代わりに、専用のアサーション関数を使うようにする。

- import { assert, assertEquals } from "https://deno.land/std/testing/asserts.ts";
+ import { assert, assertEquals } from "https://deno.land/x/power_assert_deno@0.1.0/mod.ts";

あとは、先程インストールしたassertコマンドを使えばよい。
src/ディレクトリにあるテストを実行するには、以下のようにすればよい。

$ assert src/

開発について

Power Assert という概念は特定の言語に特有のものではなく、様々な言語で実装されている。
私が Power Assert を知ったのは Node.js 版。JavaScript や TypeScript で何か作るときは基本的に入れるようにしているくらい好きなライブラリで、これを Deno でも使いたいというのが、そもそもの開発の動機。

また、Deno の習作の題材として丁度よさそう、という理由もあった。自分で一から考えるのに比べて、難易度が低い。
「何を作るべきか」というゴールが既に見えているし、実装を参考にすることもできる。
実際に、Node.js 版のソースコードに目を通し、ひたすらプリントデバッグして処理の流れを把握しながら、開発を進めていった。
一部の処理についてはコードをほぼ流用しているし、提供されている npm パッケージをそのままimportして使っている箇所もある(stringifier)。

課題点

いくつか、解決したくてもできなかった課題がある。これらを解決できれば、もっと実用性のあるモジュールになれる気がしている。
よい解決策を知っている方がいれば教えてください。

変換後のコードを一時ファイルとして書き出している

オリジナルのテストコードを Power Assert を使ったコードに変換して、その変換後のコードに対してテストを実行する。
そして Power Assert Deno では、変換後のコードを実際に書き出し、それに対してdeno testを実行する形になっている。
だが Node.js 版では恐らく、変換後のコードを書き出してはおらず、メモリ上で処理を終えているように見える。

espower-loaderのこの部分がその処理を行っていると思うのだが、require.extensionsという機能を使って、オリジナルのテストコードではなく変換後のコードを読み込ませている。
ドキュメントによればrequire.extensionsは既にdeprecatedのようではあるが。

nodejs.org

この処理を Deno で行うにはどうすればいいのか、そもそもそういった機能が用意されているのか、分からない。
だがいちいちファイルを書き出しそれを削除するのは無駄だし、インストール時に--allow-write権限を付けなければいけない原因にもなってしまっている。
できれば改善したい。

また、一時ファイルとして書き出す関係上、import文のパスを書き換えるという作業も発生している。import SomeModule from "./some.ts"というコードがある場合、./some.tsを解決できなくなるので、/User/numb/my-pj/some.tsのような絶対パスに書き換えて対応している。
そしてそれに関係して、Dynamic Import への対応が不完全なものになっている。import("./some.ts")ように文字列リテラルを渡されれば大丈夫なのだが、import(x)のように変数や式を渡されると、エラーになってしまう。
これ自体は頑張れば対応できる気もするが、一時ファイルとして書き出す仕組みそのものを改善できれば、そもそもパスの書き換え自体が不要になる。

行数を表示できない

Node.js 版では、ファイル名の後ろに、失敗したアサーション関数の行数が表示される
だが Power Assert Deno ではこれができていない。
というのも、まず TypeScript コードをトランスパイルして JavaScript コードに変換しているのだが、その際に改行などが行われ、オリジナルのテストコードの情報が失われてしまうのである。
変換後の JavaScript コードを AST に変換しているので、そこで得られる位置情報はオリジナルのものとは乖離してしまっている。
トランスパイル時にAAAA,OAAO,EACW,EAAE;AAClB,WAAW,EAAE,EAAE;...のような内容のソースマップが得られるのだが、これをどう活用すればいいのかも分からない。

アサーション関数のインポート元を書き換えないといけない

「使用法」のところで説明したように、"https://deno.land/x/power_assert_deno@0.1.0/mod.ts"からアサーション関数をインポートする形に書き換えないといけない。
依存モジュールの管理を一箇所で行っていればその部分を書き換えるだけで済むかもしれないが、テストコードには手を加えることなく利用できるのが理想的ではある。

AST で JavaScript のコードを変換する

ソースコードを AST(抽象構文木)と呼ばれるデータ構造に変換することで、ソースコードの検証や変換をプログラムによって行えるようになる。
例えば ESLint では、ソースコードを AST に変換して、それに対してチェックを行っている。
また、V8 などの JavaScript エンジンも、対象の JavaScript ソースコードを AST に変換してから、後続の処理を行う。

AST を使えるようになると、ソースコードの検証や変換を行うツールを自作できるようになる。
この記事では、JavaScript AST を扱うための方法を見ていく。

この記事で使用しているライブラリのバージョンは以下の通り。

  • esprima@4.0.1
  • estraverse@5.2.0
  • escodegen@2.0.0

動作環境は Node.js のv12.17.0で行っている。

また、この記事に出てくる木構造の画像は、以下のウェブアプリで作成した。

shape-painter.numb86.net

AST(抽象構文木)とは

ソースコードはただの文字列、テキストなので、そのままでは扱いづらい。
そこでまず、文字列を木構造に変換する。この変換のことを構文解析、またはパースと呼び、それによって得られた木構造を構文木と呼ぶ。
そして、コメントやスペース、改行など、プログラムの実行に不要な情報を取り去った構文木が、AST(抽象構文木)である。

ソースコードを AST に変換したら次は、その AST の中身を見て、コーディング規約に違反していないかどうかのチェックや、特定のルールに基づいた AST の書き換えなどを行う。
その作業を自力で行ってもよいが、AST を横断的に走査するためのツールが既にあるので、それを使うことが多い。
ソースコードの変換が目的の場合、最終的に AST をソースコードに戻す必要がある。

ソースコードを変換する場合、以下の流れになる。

  1. ソースコードを AST にパースする(Code -> AST)
  2. AST を走査し、任意のロジックに基づいて変換する(AST -> AST)
  3. AST をソースコードに変換する(AST -> Code)

まず、ソースコードから AST へのパースについて見ていく。

Esprima

JavaScript ソースコードを AST に変換するライブラリは複数あるが、今回はEsprimaを使う。
Esprimaによって得られる AST は、ESTreeという仕様に準拠している。

github.com

parseScript と parseModule

parseScriptもしくはparseModuleに対象のソースコードを渡すことで、AST を得られる。

const esprima = require('esprima');

const ast = esprima.parseScript('x = 1');

console.log(ast);
// Script {
//   type: 'Program',
//   body: [
//     ExpressionStatement {
//       type: 'ExpressionStatement',
//       expression: [AssignmentExpression]
//     }
//   ],
//   sourceType: 'script'
// }

取得できた AST の詳細は後述する。

JavaScript として無効なコードを渡すとエラーになる。

const esprima = require('esprima');

// Error: Line 1: Unexpected end of input
const ast = esprima.parseScript('x =');

parseScriptparseModuleの違いは、対象のソースコードをモジュールモードとして扱うか否か。
モジュールモードとして扱いたい場合は、parseModuleを使う必要がある。
そのため、以下のコードはエラーになる。import文はモジュールモードでしか使えず、無効なコードになっているため。

const esprima = require('esprima');

// Error: Line 1: Unexpected token
const ast = esprima.parseScript("import {parseScript} from 'esprima'");

parseModuleを使えば問題なく AST を取得できる。

const esprima = require('esprima');

const ast = esprima.parseModule("import {parseScript} from 'esprima'");

各種オプション

parseScriptparseModuleの第二引数にはオプションを渡せるので、いくつか紹介しておく。

jsx

jsxを有効にすると、JSX もパースできるようになる。

const esprima = require('esprima');

esprima.parseScript('<div>a</div>', {jsx: true}); // ok
esprima.parseScript('<div>a</div>'); // Error: Line 1: Unexpected token <

range

rangeオプションを有効にすると、[開始位置, 終了位置]という形で位置情報を取得できる。

以下のコードは、x = 10番目から始まり、5番目が始まる前で終了していることを示している。
そしてx = 1に含まれる1は、4番目から始まって5番目が始まる前で終了している。

const esprima = require('esprima');

const ast = esprima.parseScript('x = 1', {range: true});

console.log(ast.range); // [ 0, 5 ]

const {right} = ast.body[0].expression;
console.log(right.value); // 1
console.log(right.range); // [ 4, 5 ]

loc

行番号や列番号を取得するには、locオプションを使用する。

以下のコードは、ソースコード全体は1行目で始まり、3行目の5列目が始まる前で終了していることを示している。

const esprima = require('esprima');

const sourceCode = `x = 1
2
y = 3`;

const ast = esprima.parseScript(sourceCode, {loc: true});

console.log(ast.loc); // { start: { line: 1, column: 0 }, end: { line: 3, column: 5 } }

comment

AST に変換する際、コメントは無視される。だがcommentオプションを有効にすると、コメントに関する情報を得られる。

const esprima = require('esprima');

const sourceCode = `// start code
const a = 1;
/*
comment area
*/
`;

const ast = esprima.parseScript(sourceCode, {comment: true});

console.log(ast.comments);
// [
//   { type: 'Line', value: ' start code' },
//   { type: 'Block', value: '\ncomment area\n' }
// ]

typeLineBlockの 2 種類で、それぞれ一行コメントと複数行コメントを表している。

AST はどのような構造になっているのか

次は、Esprima で取得できた AST、つまり ESTree の中身を見ていく。

ESTree は、ノードと呼ばれる要素が入れ子になって、木構造を構成している。
そして各ノードはオブジェクトとして表現され、typeプロパティを持っている。

ルート要素は必ずProgramになる。

const esprima = require('esprima');

const ast = esprima.parseScript('x = 1');

console.log(ast.type); // Program

そしてProgrambodyに、子要素が入っている。

const esprima = require('esprima');

console.log(esprima.parseScript('x = 1').body.length); // 1
console.log(esprima.parseScript('x = 1; y = 2').body.length); // 2

x = 1だと式がひとつしかないので、bodyの要素はひとつになる。
x = 1; y = 2だと式が二つなので、bodyの要素も二つになる。

x = 1について確認してみると、ExpressionStatementというノードで、expressionプロパティを持っていることが分かる。

const esprima = require('esprima');

const ast = esprima.parseScript('x = 1');

const childElement = ast.body[0];

console.log(childElement);
// ExpressionStatement {
//   type: 'ExpressionStatement',
//   expression: AssignmentExpression {
//     type: 'AssignmentExpression',
//     operator: '=',
//     left: Identifier { type: 'Identifier', name: 'x' },
//     right: Literal { type: 'Literal', value: 1, raw: '1' }
//   }
// }

expressionにはAssignmentExpressionというノードが入っており、このノードはleftrightというプロパティを持っている。
そしてleftrightに、さらにノードが入っている。

図にすると、このような構成になっている。

f:id:numb_86:20200911101550p:plain

ただのテキストであるソースコードをこのようなデータ構造に変換することで、プログラムで扱いやすくなる。

Estraverse

次は、この木構造を辿って、検証や変換を行う。

Estraverse というライブラリを使うと、ESTree のノードを簡単に探索していける。

github.com

Esprima で作った ESTree を、traverseメソッドの第一引数に渡す。
そして第二引数にオブジェクトを渡し、そこでenterメソッドを定義する。
enterメソッドの引数には各ノードが渡されるので、そこで任意の処理を行うことができる。

const esprima = require('esprima');
const estraverse = require('estraverse');

const ast = esprima.parseScript('x = 1');

estraverse.traverse(ast, {
  enter(node) {
    console.log(node.type);
  },
});
// Program
// ExpressionStatement
// AssignmentExpression
// Identifier
// Literal

木構造を、上から順番に辿っているのが分かる。
先程の画像に、処理する順番を追加したのが以下。

f:id:numb_86:20200911101639p:plain

探索は、深さ優先探索で行う。
例えば、esprima.parseScript('foo(1) === bar(2)')は、以下の構造の AST を返す。

f:id:numb_86:20200911101653p:plain

そしてこれをtraverseメソッドに渡すと、以下の順番で探索していく。

f:id:numb_86:20200911101705p:plain

BinaryExpressionまで探索したあと、まずleftCallExpressionを探索する。
それが終わってから、rightCallExpressionを探索していく。

const esprima = require('esprima');
const estraverse = require('estraverse');

const ast = esprima.parseScript('foo(1) === bar(2)');

estraverse.traverse(ast, {
  enter(node) {
    if (node.type === 'Identifier') {
      console.log(node.type, node.name);
    } else if (node.type === 'Literal') {
      console.log(node.type, node.value);
    } else {
      console.log(node.type);
    }
  },
});
// Program
// ExpressionStatement
// BinaryExpression
// CallExpression
// Identifier foo
// Literal 1
// CallExpression
// Identifier bar
// Literal 2

enter と leave の違い

enterメソッドではなくleaveメソッドを定義することもできる。もちろん、両方のメソッドを定義してもよい。

estraverse.traverse(ast, {
  enter(node) {/* ... */},
  leave(node) {/* ... */},
});

両者とも出来ることは同じだが、呼ばれるタイミングが異なる。

深さ優先探索で探索していくことは既に述べたが、enterは、新しいノードに辿り着いたタイミングで実行される。
leaveはそれとは逆で、そのノードへ戻ってきたタイミングで、実行される。

const esprima = require('esprima');
const estraverse = require('estraverse');

const ast = esprima.parseScript('foo(1) === bar(2)');

estraverse.traverse(ast, {
  enter(node) {
    if (node.type === 'Identifier') {
      console.log('enter', node.type, node.name);
    } else if (node.type === 'Literal') {
      console.log('enter', node.type, node.value);
    } else {
      console.log('enter', node.type);
    }
  },
  leave(node) {
    if (node.type === 'Identifier') {
      console.log('leave', node.type, node.name);
    } else if (node.type === 'Literal') {
      console.log('leave', node.type, node.value);
    } else {
      console.log('leave', node.type);
    }
  },
});
// enter Program
// enter ExpressionStatement
// enter BinaryExpression
// enter CallExpression
// enter Identifier foo
// leave Identifier foo
// enter Literal 1
// leave Literal 1
// leave CallExpression
// enter CallExpression
// enter Identifier bar
// leave Identifier bar
// enter Literal 2
// leave Literal 2
// leave CallExpression
// leave BinaryExpression
// leave ExpressionStatement
// leave Program

見やすくするために CHALK を使って色を付けたのが以下。

f:id:numb_86:20200911101733p:plain

これでも分かりづらいと思うので、木構造に、実行される順番を書いた。
各ノードの左側に書かれた数字はenterによる実行、右側はleaveによる実行。
これで、処理の流れがイメージできると思う。

f:id:numb_86:20200911101744p:plain

skip と break

enterleaveメソッドのなかでは、this.skipthis.breakを呼び出すことができる。

まずはthis.skipについて。
this.skipを実行すると、その子要素への探索をスキップする。

const esprima = require('esprima');
const estraverse = require('estraverse');

const ast = esprima.parseScript('foo(1) === bar(2)');

estraverse.traverse(ast, {
  enter(node) {
    if (node.type === 'CallExpression' && node.callee.name === 'foo') {
      this.skip();
    }
    console.log('enter', node.type);
  },
  leave(node) {
    console.log('leave', node.type);
  },
});
// enter Program
// enter ExpressionStatement
// enter BinaryExpression
// enter CallExpression
// leave CallExpression
// enter CallExpression
// enter Identifier
// leave Identifier
// enter Literal
// leave Literal
// leave CallExpression
// leave BinaryExpression
// leave ExpressionStatement
// leave Program

この例の場合、左辺のCallExpressionの子要素への探索がスキップされる。

赤いノードのenterthis.skipが呼び出されたため、黒いノードの処理がスキップされる。

f:id:numb_86:20200911101756p:plain

this.breakを実行すると、現在実行中のenterleaveの処理が終わり次第、traverseメソッドによる探索を終了する。

const esprima = require('esprima');
const estraverse = require('estraverse');

const ast = esprima.parseScript('foo(1) === bar(2)');

estraverse.traverse(ast, {
  enter(node) {
    console.log('enter', node.type);
  },
  leave(node) {
    if (node.type === 'CallExpression') {
      this.break();
    }
    console.log('leave', node.type);
  },
});
// enter Program
// enter ExpressionStatement
// enter BinaryExpression
// enter CallExpression
// enter Identifier
// leave Identifier
// enter Literal
// leave Literal
// leave CallExpression

この例では、左辺のCallExpressionleaveを最後に、traverseメソッドが終了する。
実行中のenterメソッドやleaveメソッドは最後まで実行されるので、leave CallExpressionは表示される。

赤いノードのleavethis.breakが呼び出されたため、黒いノードへの探索は行われず、緑のノードのleaveも実行されない。

f:id:numb_86:20200911101808p:plain

replace で AST を書き換える

渡された AST を書き換えたい場合は、traverseメソッドではなくreplaceメソッドを使う。
基本的な使い方はtraverseと一緒で、enterleaveのなかで値を返すと、その値を元のノードと置き換える。

Literalの値を9に変えたい場合、以下のように書く。

const esprima = require('esprima');
const estraverse = require('estraverse');

const ast = esprima.parseScript('x = 1');

// Literal { type: 'Literal', value: 1, raw: '1' }
console.log(ast.body[0].expression.right);

const convertedAst = estraverse.replace(ast, {
  enter(node) {
    if (node.type === 'Literal') {
      return {
        ...node,
        value: 9,
        raw: '9',
      };
    }
    return node;
  },
});

// { type: 'Literal', value: 9, raw: '9' }
console.log(convertedAst.body[0].expression.right);

// { type: 'Literal', value: 9, raw: '9' }
console.log(ast.body[0].expression.right);

replaceは破壊的メソッドなので、convertedAstだけでなく、元の AST であるastも書き換えられているので注意する。
valuerawが変わっている他、Literalのインスタンスではなくただのオブジェクトになっている。

対象のノードを削除したい場合は、enterleaveのなかでthis.removeを呼び出す。

以下のコードでは、x = 1;を削除している。

const esprima = require('esprima');
const estraverse = require('estraverse');

const ast = esprima.parseScript('x = 1; y = 2;');

console.log(ast.body.length); // 2
console.log(ast.body[0].expression.left.name); // x
console.log(ast.body[1].expression.left.name); // y

estraverse.replace(ast, {
  enter(node) {
    if (node.type === 'ExpressionStatement') {
      if (node.expression.left.name === 'x') {
        this.remove();
      }
    }
  },
});

console.log(ast.body.length); // 1
console.log(ast.body[0].expression.left.name); // y
console.log(ast.body[1]); // undefined

元は文が二つあるのでast.body.length2だったが、x = 1;を削除してy = 2;のみが残ったため、ast.body.length1になった。

Escodegen

ソースコードの検証なら Esprima と Estraverse だけで行えるが、ソースコードの変換を行いたい場合、Estraverse で書き換えた AST をソースコードに戻す必要がある。

Escodegen を使うと、AST をソースコードに変換できる。

github.com

generateメソッドに AST を渡すと、ソースコードを返す。

先程のreplaceした結果を、ソースコードに復元してみる。

const esprima = require('esprima');
const estraverse = require('estraverse');
const escodegen = require('escodegen');

const ast = esprima.parseScript('x = 1');

const convertedAst = estraverse.replace(ast, {
  enter(node) {
    if (node.type === 'Literal') {
      return {
        ...node,
        value: 9,
        raw: '9',
      };
    }
    return node;
  },
});

console.log(escodegen.generate(convertedAst)); // x = 9;

AST に変換した時点で空行やコメントなどは取り除かれているので、それらを含まない状態のソースコードが生成される。

const esprima = require('esprima');
const escodegen = require('escodegen');

const str = `const x = 1;

const   y =   2 ;

// comment
const z=3;

`;

const ast = esprima.parseScript(str);

console.log(escodegen.generate(ast));
// const x = 1;
// const y = 2;
// const z = 3;