ソースコードを 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
で行っている。
また、この記事に出てくる木構造の画像は、以下のウェブアプリで作成した。
AST(抽象構文木)とは
ソースコードはただの文字列、テキストなので、そのままでは扱いづらい。
そこでまず、文字列を木構造に変換する。この変換のことを構文解析、またはパースと呼び、それによって得られた木構造を構文木と呼ぶ。
そして、コメントやスペース、改行など、プログラムの実行に不要な情報を取り去った構文木が、AST(抽象構文木)である。
ソースコードを AST に変換したら次は、その AST の中身を見て、コーディング規約に違反していないかどうかのチェックや、特定のルールに基づいた AST の書き換えなどを行う。
その作業を自力で行ってもよいが、AST を横断的に走査するためのツールが既にあるので、それを使うことが多い。
ソースコードの変換が目的の場合、最終的に AST をソースコードに戻す必要がある。
ソースコードを変換する場合、以下の流れになる。
- ソースコードを AST にパースする(Code -> AST)
- AST を走査し、任意のロジックに基づいて変換する(AST -> AST)
- AST をソースコードに変換する(AST -> Code)
まず、ソースコードから AST へのパースについて見ていく。
Esprima
JavaScript ソースコードを AST に変換するライブラリは複数あるが、今回はEsprima
を使う。
Esprima
によって得られる AST は、ESTree
という仕様に準拠している。
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 =');
parseScript
とparseModule
の違いは、対象のソースコードをモジュールモードとして扱うか否か。
モジュールモードとして扱いたい場合は、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'");
各種オプション
parseScript
やparseModule
の第二引数にはオプションを渡せるので、いくつか紹介しておく。
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 = 1
は0
番目から始まり、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' } // ]
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
そしてProgram
のbody
に、子要素が入っている。
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
というノードが入っており、このノードはleft
やright
というプロパティを持っている。
そしてleft
とright
に、さらにノードが入っている。
図にすると、このような構成になっている。
ただのテキストであるソースコードをこのようなデータ構造に変換することで、プログラムで扱いやすくなる。
Estraverse
次は、この木構造を辿って、検証や変換を行う。
Estraverse というライブラリを使うと、ESTree のノードを簡単に探索していける。
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
木構造を、上から順番に辿っているのが分かる。
先程の画像に、処理する順番を追加したのが以下。
探索は、深さ優先探索で行う。
例えば、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); } }, }); // 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 を使って色を付けたのが以下。
これでも分かりづらいと思うので、木構造に、実行される順番を書いた。
各ノードの左側に書かれた数字は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); }, }); // 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
の子要素への探索がスキップされる。
赤いノードの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); }, }); // enter Program // enter ExpressionStatement // enter BinaryExpression // enter CallExpression // enter Identifier // leave Identifier // enter Literal // leave Literal // leave CallExpression
この例では、左辺の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'); // 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
も書き換えられているので注意する。
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); // 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.length
は2
だったが、x = 1;
を削除してy = 2;
のみが残ったため、ast.body.length
は1
になった。
Escodegen
ソースコードの検証なら Esprima と Estraverse だけで行えるが、ソースコードの変換を行いたい場合、Estraverse で書き換えた AST をソースコードに戻す必要がある。
Escodegen を使うと、AST をソースコードに変換できる。
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;