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

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

Escodegen でサロゲートペアを扱う

Escodegen は AST からソースコードを生成する npm パッケージ。
互換性のある AST を渡せば自動的にソースコードを生成してくれるのであまり悩むことはないのだが、絵文字などのサロゲートペアを扱うときには注意が必要になる。
Power Assert Deno の開発時に躓いたので、書き残しておく。

動作確認は Node.js のv14.7.0で行っており、利用しているライブラリのバージョンは以下の通り。

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

それぞれのライブラリの基本的な使い方は、以下を参照。

numb86-tech.hatenablog.com

どのような問題が起きるのか

以下のコードは、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にすればよいことが分かった。

github.com

早速試してみる。

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 が見つかった。

github.com

これによると、x-verbatim-propertyを使うと上手くいくらしい。

ドキュメントを改めて読んでみると、確かに記述があった。

github.com

この内容に沿って書いたのが、以下のコード。

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 = '🦕';を復元できた。