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

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

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;

Deno で CLI ツールを作る

Deno.argsDeno.exitなどの機能を使い、Deno で CLI ツールを作っていく。

Deno のバージョンは1.3.1で動作確認している。

コマンドライン引数の受け取り方

渡されたコマンドライン引数は、Deno.argsで取得することができる。

例えば$ deno run cli.ts a b --foo 1 -b 2を実行すると、以下の結果になる。

// cli.ts
console.log(Deno.args); // [ "a", "b", "--foo", "1", "-b", "2" ]

このままでも使えないことはないが、標準モジュールが提供しているparseメソッドを使うと、より使いやすくなる。

import { parse } from "https://deno.land/std@0.66.0/flags/mod.ts";

console.log(Deno.args); // [ "a", "b", "--foo", "1", "-b", "2" ]

const parsedArgs = parse(Deno.args);
console.log(parsedArgs); // { _: [ "a", "b" ], foo: 1, b: 2 }
console.log(parsedArgs.foo); // 1
console.log(parsedArgs.b); // 2

parse(Deno.args)はオブジェクトを返し、_プロパティには、フラグと関連付けられなかった全てのコマンドライン引数が入る。今回の例だとabが該当する。

終了コード

Deno.exitメソッドで、プロセスを終了させることができる。このメソッドに渡した引数が、終了コードになる。

以下のコードは、Deno.exit(3);の行でプロセスが終了してしまうので、bは表示されない。

console.log('a');
Deno.exit(3);
console.log('b');

直前に実行したコマンドの終了コードは$?に入っているので、確認してみる。

$ deno run cli.ts
a
$ echo $?
3

Deno.exitに引数を渡さなかったり、Deno.exitを実行することなくプロセスが終了したりした場合は、0が終了コードになる。

テキストファイルを読み込む CLI ツール

ちょっとしたサンプルとして、指定された名前のファイルがカレントディレクトリにあった場合、それをテキストファイルとして読み込んで表示するプログラムを書いた。

const fileName = Deno.args[0];
const filePath = `${Deno.cwd()}/${fileName}`; // Deno.cwd() で、カレントディレクトリのパスを取得できる

const fileData = await Deno.readFile(filePath);
const decoder = new TextDecoder("utf-8");
const fileText = decoder.decode(fileData);

console.log(fileText);

Hello!と書かれたhello.txtを用意した上で、実行してみる。

$ deno run cli.ts hello.txt
error: Uncaught PermissionDenied: read access to <CWD>, run again with the --allow-read flag

権限がないため、エラーになった。--allow-readをつければ、実行できる。

$ deno run --allow-read cli.ts hello.txt
Hello!

CLI ツールの配布方法

Deno には、deno installという、CLI ツールの配布やインストールを簡単に行えるサブコマンドが用意されている。

例えば、console.log("Hi!");とだけ書かれたsay_hi.tsという名前のファイルを、Gist に置く。

そしてそれをインストールする。

$ deno install https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
Download https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
Check https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
✅ Successfully installed say_hi

そうすると、say_hiコマンドが使えるようになっている。

$ say_hi
Hi!

このように、非常に簡単にインストールできる。
配布する側も、ただウェブ上にファイルを置くだけでよい。

インストールされる場所

デフォルトだと、$HOME/.denoというディレクトリに、bin/インストールしたファイル名(拡張子は省略)という形でインストールされる。

対象のディレクトリは--rootで指定することも可能。

$ deno install --root ./ https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
$ ls ./bin/
say_hi

./を指定したので、./bin/say_hiとしてインストールされた。

インストールされるファイルの名前

インストールしようとした場所に既に同名のファイルがある場合は、エラーになる。

$ deno install https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
Download https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
Check https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
error: Existing installation found. Aborting (Use -f to overwrite).

エラーメッセージにあるように-fオプションをつけることで、上書きできる。

また、-nオプションを使うと、インストールされる名前を指定することができる。

$ deno install -n greet https://gist.githubusercontent.com/numb86/0f3352f26cadbead72c98557ba4dae03/raw/76ba53c6e7c3864f30f9f543d051a1e3d7f4dea9/say_hi.ts
$ greet
Hi!

指定しなかった場合は拡張子を省略したファイル名になるのだが、ファイル名がmod.tscli.tsの場合は、親のディレクトリ名になる。

例えば、以下の内容のcli.tsというファイルを Gist に置き、それをインストールする。

console.log("Hello!");

そうすると、cli.tsの親ディレクトリであるaf5628023388ce6e280fbcb328e1081fa1cb4179という名前でインストールされる。

$ deno install https://gist.githubusercontent.com/numb86/26045a06e6d9ff5ca294d7b291120c95/raw/af5628023388ce6e280fbcb328e1081fa1cb4179/cli.ts
Download https://gist.githubusercontent.com/numb86/26045a06e6d9ff5ca294d7b291120c95/raw/af5628023388ce6e280fbcb328e1081fa1cb4179/cli.ts
Check https://gist.githubusercontent.com/numb86/26045a06e6d9ff5ca294d7b291120c95/raw/af5628023388ce6e280fbcb328e1081fa1cb4179/cli.ts
✅ Successfully installed af5628023388ce6e280fbcb328e1081fa1cb4179
$ af5628023388ce6e280fbcb328e1081fa1cb4179
Hello!

権限の指定

--allow-readのような権限の指定は、インストール時に行う。

例として、先程作ったテキストファイルを読み込むプログラムを、read_file.tsとしてGist に置く。

何の権限も指定せずにこれをインストールしてみると、成功する。

$ deno install https://gist.githubusercontent.com/numb86/1bd65079ceafc1c23fce14915af37178/raw/e9873beb7d9d76913192affc43674b1edef85bbb/read_file.ts
Download https://gist.githubusercontent.com/numb86/1bd65079ceafc1c23fce14915af37178/raw/e9873beb7d9d76913192affc43674b1edef85bbb/read_file.ts
Check https://gist.githubusercontent.com/numb86/1bd65079ceafc1c23fce14915af37178/raw/e9873beb7d9d76913192affc43674b1edef85bbb/read_file.ts
✅ Successfully installed read_file

だがread_fileを実行すると、失敗する。実行時に--allow-readを付けることもできない。

$ read_file hello.txt
error: Uncaught PermissionDenied: read access to <CWD>, run again with the --allow-read flag
$ read_file --allow-read hello.txt
error: Uncaught PermissionDenied: read access to <CWD>, run again with the --allow-read flag

--allow-readをつけて、インストールし直す。

$ deno install -f --allow-read https://gist.githubusercontent.com/numb86/1bd65079ceafc1c23fce14915af37178/raw/e9873beb7d9d76913192affc43674b1edef85bbb/read_file.ts
Download https://gist.githubusercontent.com/numb86/1bd65079ceafc1c23fce14915af37178/raw/e9873beb7d9d76913192affc43674b1edef85bbb/read_file.ts
Check https://gist.githubusercontent.com/numb86/1bd65079ceafc1c23fce14915af37178/raw/e9873beb7d9d76913192affc43674b1edef85bbb/read_file.ts
✅ Successfully installed read_file

これで、実行できるようになった。

$ read_file hello.txt
Hello!

ローカルファイルもインストール可能

ウェブ上のあるファイルだけでなく、ローカルにあるファイルもインストールできる。

// say_bye.ts
console.log("Bye");
$ deno install say_bye.ts
Check file:///Users/numb/hello-deno/say_bye.ts
✅ Successfully installed say_bye
$ say_bye
Bye

参考資料