Escodegen は AST からソースコードを生成する npm パッケージ。
互換性のある AST を渡せば自動的にソースコードを生成してくれるのであまり悩むことはないのだが、絵文字などのサロゲートペアを扱うときには注意が必要になる。
Power Assert Deno の開発時に躓いたので、書き残しておく。
動作確認は Node.js のv14.7.0
で行っており、利用しているライブラリのバージョンは以下の通り。
- esprima@4.0.1
- estraverse@5.2.0
- escodegen@2.0.0
それぞれのライブラリの基本的な使い方は、以下を参照。
どのような問題が起きるのか
以下のコードは、source
を Esprima で AST に変換し、Escodegen でその AST をソースコードに変換している。
const esprima = require('esprima'); const escodegen = require('escodegen'); const source = `const x = 'abc';`; const ast = esprima.parseScript(source); console.log(escodegen.generate(ast)); // const x = 'abc';
AST に対して何の操作も行っていないので、元の文字列と同じconst x = 'abc';
が生成される。
だが、source
にサロゲートペアが含まれていると、上手くいかなくなる。
const esprima = require('esprima'); const escodegen = require('escodegen'); const source = `const x = '🦕';`; const ast = esprima.parseScript(source); console.log(escodegen.generate(ast)); // const x = '\uD83E\uDD95';
🦕
が\uD83E\uDD95
になってしまっている。
AST の時点では🦕
のままなので、Escodegen がコードを生成する際に\uD83E\uDD95
に置き換わってしまっていることが分かる。
const esprima = require('esprima'); const escodegen = require('escodegen'); const source = `const x = '🦕';`; const ast = esprima.parseScript(source); console.log(ast.body[0].declarations[0].init); // Literal { type: 'Literal', value: '🦕', raw: "'🦕'" } console.log(escodegen.generate(ast)); // const x = '\uD83E\uDD95';
🦕
の Code Point は1f995
であり、それを UTF-16 で表現したものが\uD83E\uDD95
なので、意味するものが変わってしまっているわけではない。
console.log('🦕'.codePointAt(0).toString(16)); // 1f995 console.log('\uD83E\uDD95'); // 🦕
とはいえ、文字列としては明らかに別物なので、これでは困る。
ちなみに、テンプレートリテラルの場合は🦕
がそのまま維持される。シングルクォーテーションかダブルクォーテーションで括ったときに問題となる。
const esprima = require('esprima'); const escodegen = require('escodegen'); console.log(escodegen.generate(esprima.parseScript('const x = "🦕";'))); // const x = '\uD83E\uDD95'; console.log(escodegen.generate(esprima.parseScript("const x = '🦕';"))); // const x = '\uD83E\uDD95'; console.log(escodegen.generate(esprima.parseScript('const x = `🦕`;'))); // const x = `🦕`;
verbatim オプション
調べてみると、verbatim
というオプションの値をraw
にすればよいことが分かった。
早速試してみる。
const esprima = require('esprima'); const escodegen = require('escodegen'); const source = `const x = '🦕';`; const ast = esprima.parseScript(source); console.log(escodegen.generate(ast)); // const x = '\uD83E\uDD95'; console.log(escodegen.generate(ast, {verbatim: 'raw'})); // const x = ('🦕');
🦕
のまま、コードを生成することができた。
だが今度は、不要な()
が付与されてしまっている。JavaScript のコードとしては意味は変わらないのだが、文字列としてはやはり別物である。
それに、オブジェクトのキーとして文字列を使っていた場合に、JavaScript として有効ではないコードが生成されてしまう。
const esprima = require('esprima'); const escodegen = require('escodegen'); const source = `const x = {"some-key": 1}`; const ast = esprima.parseScript(source); console.log(escodegen.generate(ast, {verbatim: 'raw'})); // const x = { ("some-key"): (1) };
const x = { ("some-key"): (1) };
を実行するとシンタックスエラーになる。
x-verbatim-property
この()
を何とかできないのか、そもそもverbatim
にはraw
しか渡せないのだろうかと調べてみると、以下の Issue が見つかった。
これによると、x-verbatim-property
を使うと上手くいくらしい。
ドキュメントを改めて読んでみると、確かに記述があった。
この内容に沿って書いたのが、以下のコード。
const esprima = require('esprima'); const escodegen = require('escodegen'); const estraverse = require('estraverse'); const source = `const x = '🦕';`; const ast = esprima.parseScript(source); estraverse.replace(ast, { enter(node) { if (node.type === 'Literal' && typeof node.value === 'string') { return { ...node, 'x-verbatim-property': { content: node.value, precedence: escodegen.Precedence.Primary, }, }; } return undefined; }, }); console.log(escodegen.generate(ast, {verbatim: 'x-verbatim-property'})); // const x = 🦕;
こうすると🦕
は維持されるのだが、今度は'
が失われてしまっている。文字列がクォーテーションで囲まれていないので、const x = 🦕;
もやはり、シンタックスエラーになる。
この問題に対応したのが、以下のコード。
const esprima = require('esprima'); const escodegen = require('escodegen'); const estraverse = require('estraverse'); const source = `const x = '🦕';`; const ast = esprima.parseScript(source); estraverse.replace(ast, { enter(node) { if (node.type === 'Literal' && typeof node.value === 'string') { const wrapByQuotation = (value) => { if (value.includes("'")) { return `"${value}"`; } return `'${value}'`; }; return { ...node, 'x-verbatim-property': { content: wrapByQuotation(node.value), precedence: escodegen.Precedence.Primary, }, }; } return undefined; }, }); console.log(escodegen.generate(ast, {verbatim: 'x-verbatim-property'})); // const x = '🦕';
無事にconst x = '🦕';
を復元できた。