ソースコードを 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 をソースコードに戻す必要がある。
ソースコードを変換する場合、以下の流れになる。
- ソースコードを AST にパースする(Code -> AST)
- AST を走査し、任意のロジックに基づいて変換する(AST -> AST)
- 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);
取得できた AST の詳細は後述する。
JavaScript として無効なコードを渡すとエラーになる。
const esprima = require('esprima');
const ast = esprima.parseScript('x =');
parseScript
とparseModule
の違いは、対象のソースコードをモジュールモードとして扱うか否か。
モジュールモードとして扱いたい場合は、parseModule
を使う必要がある。
そのため、以下のコードはエラーになる。import
文はモジュールモードでしか使えず、無効なコードになっているため。
const esprima = require('esprima');
const ast = esprima.parseScript("import {parseScript} from 'esprima'");
parseModule
を使えば問題なく AST を取得できる。
const esprima = require('esprima');
const ast = esprima.parseModule("import {parseScript} from 'esprima'");
各種オプション
parseScript
やparseModule
の第二引数にはオプションを渡せるので、いくつか紹介しておく。
jsx
jsx
を有効にすると、JSX もパースできるようになる。
const esprima = require('esprima');
esprima.parseScript('<div>a</div>', {jsx: true});
esprima.parseScript('<div>a</div>');
range
range
オプションを有効にすると、[開始位置, 終了位置]
という形で位置情報を取得できる。
以下のコードは、x = 1
は0
番目から始まり、5
番目が始まる前で終了していることを示している。
そしてx = 1
に含まれる1
は、4
番目から始まって5
番目が始まる前で終了している。
const esprima = require('esprima');
const ast = esprima.parseScript('x = 1', {range: true});
console.log(ast.range);
const {right} = ast.body[0].expression;
console.log(right.value);
console.log(right.range);
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);
comment
AST に変換する際、コメントは無視される。だがcomment
オプションを有効にすると、コメントに関する情報を得られる。
const esprima = require('esprima');
const sourceCode = `
const a = 1;
`;
const ast = esprima.parseScript(sourceCode, {comment: true});
console.log(ast.comments);
type
はLine
とBlock
の 2 種類で、それぞれ一行コメントと複数行コメントを表している。
AST はどのような構造になっているのか
次は、Esprima で取得できた AST、つまり ESTree の中身を見ていく。
ESTree は、ノードと呼ばれる要素が入れ子になって、木構造を構成している。
そして各ノードはオブジェクトとして表現され、type
プロパティを持っている。
ルート要素は必ずProgram
になる。
const esprima = require('esprima');
const ast = esprima.parseScript('x = 1');
console.log(ast.type);
そしてProgram
のbody
に、子要素が入っている。
const esprima = require('esprima');
console.log(esprima.parseScript('x = 1').body.length);
console.log(esprima.parseScript('x = 1; y = 2').body.length);
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);
expression
にはAssignmentExpression
というノードが入っており、このノードはleft
やright
というプロパティを持っている。
そしてleft
とright
に、さらにノードが入っている。
図にすると、このような構成になっている。
ただのテキストであるソースコードをこのようなデータ構造に変換することで、プログラムで扱いやすくなる。
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);
},
});
木構造を、上から順番に辿っているのが分かる。
先程の画像に、処理する順番を追加したのが以下。
探索は、深さ優先探索で行う。
例えば、esprima.parseScript('foo(1) === bar(2)')
は、以下の構造の AST を返す。
そしてこれをtraverse
メソッドに渡すと、以下の順番で探索していく。
BinaryExpression
まで探索したあと、まずleft
のCallExpression
を探索する。
それが終わってから、right
のCallExpression
を探索していく。
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);
}
},
});
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);
}
},
});
見やすくするために CHALK を使って色を付けたのが以下。
これでも分かりづらいと思うので、木構造に、実行される順番を書いた。
各ノードの左側に書かれた数字はenter
による実行、右側はleave
による実行。
これで、処理の流れがイメージできると思う。
skip と break
enter
やleave
メソッドのなかでは、this.skip
やthis.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);
},
});
この例の場合、左辺のCallExpression
の子要素への探索がスキップされる。
赤いノードのenter
でthis.skip
が呼び出されたため、黒いノードの処理がスキップされる。
this.break
を実行すると、現在実行中のenter
やleave
の処理が終わり次第、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);
},
});
この例では、左辺のCallExpression
のleave
を最後に、traverse
メソッドが終了する。
実行中のenter
メソッドやleave
メソッドは最後まで実行されるので、leave CallExpression
は表示される。
赤いノードのleave
でthis.break
が呼び出されたため、黒いノードへの探索は行われず、緑のノードのleave
も実行されない。
replace で AST を書き換える
渡された AST を書き換えたい場合は、traverse
メソッドではなくreplace
メソッドを使う。
基本的な使い方はtraverse
と一緒で、enter
やleave
のなかで値を返すと、その値を元のノードと置き換える。
Literal
の値を9
に変えたい場合、以下のように書く。
const esprima = require('esprima');
const estraverse = require('estraverse');
const ast = esprima.parseScript('x = 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;
},
});
console.log(convertedAst.body[0].expression.right);
console.log(ast.body[0].expression.right);
replace
は破壊的メソッドなので、convertedAst
だけでなく、元の AST であるast
も書き換えられているので注意する。
value
やraw
が変わっている他、Literal
のインスタンスではなくただのオブジェクトになっている。
対象のノードを削除したい場合は、enter
やleave
のなかで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);
console.log(ast.body[0].expression.left.name);
console.log(ast.body[1].expression.left.name);
estraverse.replace(ast, {
enter(node) {
if (node.type === 'ExpressionStatement') {
if (node.expression.left.name === 'x') {
this.remove();
}
}
},
});
console.log(ast.body.length);
console.log(ast.body[0].expression.left.name);
console.log(ast.body[1]);
元は文が二つあるのでast.body.length
は2
だったが、x = 1;
を削除してy = 2;
のみが残ったため、ast.body.length
は1
になった。
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));
AST に変換した時点で空行やコメントなどは取り除かれているので、それらを含まない状態のソースコードが生成される。
const esprima = require('esprima');
const escodegen = require('escodegen');
const str = `const x = 1;
const y = 2 ;
const z=3;
`;
const ast = esprima.parseScript(str);
console.log(escodegen.generate(ast));