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

Power Assert Deno を作った

Deno で Power Assert を利用するためのモジュールを作った。

github.com

Power Assert とは、テスト失敗時に詳細な情報を表示する仕組みのこと。

例えば以下のようなテストコードがあるとき、通常はx()y.zのそれぞれの結果と、それらが一致しないことのみが、表示される。

Deno.test("hello world", () => {
  const x = () => "Hello World 🦖"
  const y = {z: "Hello World 🦕"};
  assertEquals(x(), y.z);
});
-   "\"Hello World 🦖\""
+   "\"Hello World 🦕\""

だが Power Assert を利用すると、以下のようなより詳細な情報を得られるようになる。

assertEquals(x(), y.z);
             |    | |
             |    | "Hello World 🦕"
             |    Object{z:"Hello World 🦕"}
             "Hello World 🦖"

使用法

以下のコマンドで、インストールできる。

$ deno install -n assert -f --allow-read --allow-write --allow-run --unstable https://deno.land/x/power_assert_deno@0.1.0/cli.ts

-nフラグで指定した名前でインストールされるので、この場合、assertというコマンド名になる。

そして、標準のアサーション関数の代わりに、専用のアサーション関数を使うようにする。

- import { assert, assertEquals } from "https://deno.land/std/testing/asserts.ts";
+ import { assert, assertEquals } from "https://deno.land/x/power_assert_deno@0.1.0/mod.ts";

あとは、先程インストールしたassertコマンドを使えばよい。
src/ディレクトリにあるテストを実行するには、以下のようにすればよい。

$ assert src/

開発について

Power Assert という概念は特定の言語に特有のものではなく、様々な言語で実装されている。
私が Power Assert を知ったのは Node.js 版。JavaScript や TypeScript で何か作るときは基本的に入れるようにしているくらい好きなライブラリで、これを Deno でも使いたいというのが、そもそもの開発の動機。

また、Deno の習作の題材として丁度よさそう、という理由もあった。自分で一から考えるのに比べて、難易度が低い。
「何を作るべきか」というゴールが既に見えているし、実装を参考にすることもできる。
実際に、Node.js 版のソースコードに目を通し、ひたすらプリントデバッグして処理の流れを把握しながら、開発を進めていった。
一部の処理についてはコードをほぼ流用しているし、提供されている npm パッケージをそのままimportして使っている箇所もある(stringifier)。

課題点

いくつか、解決したくてもできなかった課題がある。これらを解決できれば、もっと実用性のあるモジュールになれる気がしている。
よい解決策を知っている方がいれば教えてください。

変換後のコードを一時ファイルとして書き出している

オリジナルのテストコードを Power Assert を使ったコードに変換して、その変換後のコードに対してテストを実行する。
そして Power Assert Deno では、変換後のコードを実際に書き出し、それに対してdeno testを実行する形になっている。
だが Node.js 版では恐らく、変換後のコードを書き出してはおらず、メモリ上で処理を終えているように見える。

espower-loaderのこの部分がその処理を行っていると思うのだが、require.extensionsという機能を使って、オリジナルのテストコードではなく変換後のコードを読み込ませている。
ドキュメントによればrequire.extensionsは既にdeprecatedのようではあるが。

nodejs.org

この処理を Deno で行うにはどうすればいいのか、そもそもそういった機能が用意されているのか、分からない。
だがいちいちファイルを書き出しそれを削除するのは無駄だし、インストール時に--allow-write権限を付けなければいけない原因にもなってしまっている。
できれば改善したい。

また、一時ファイルとして書き出す関係上、import文のパスを書き換えるという作業も発生している。import SomeModule from "./some.ts"というコードがある場合、./some.tsを解決できなくなるので、/User/numb/my-pj/some.tsのような絶対パスに書き換えて対応している。
そしてそれに関係して、Dynamic Import への対応が不完全なものになっている。import("./some.ts")ように文字列リテラルを渡されれば大丈夫なのだが、import(x)のように変数や式を渡されると、エラーになってしまう。
これ自体は頑張れば対応できる気もするが、一時ファイルとして書き出す仕組みそのものを改善できれば、そもそもパスの書き換え自体が不要になる。

行数を表示できない

Node.js 版では、ファイル名の後ろに、失敗したアサーション関数の行数が表示される
だが Power Assert Deno ではこれができていない。
というのも、まず TypeScript コードをトランスパイルして JavaScript コードに変換しているのだが、その際に改行などが行われ、オリジナルのテストコードの情報が失われてしまうのである。
変換後の JavaScript コードを AST に変換しているので、そこで得られる位置情報はオリジナルのものとは乖離してしまっている。
トランスパイル時にAAAA,OAAO,EACW,EAAE;AAClB,WAAW,EAAE,EAAE;...のような内容のソースマップが得られるのだが、これをどう活用すればいいのかも分からない。

アサーション関数のインポート元を書き換えないといけない

「使用法」のところで説明したように、"https://deno.land/x/power_assert_deno@0.1.0/mod.ts"からアサーション関数をインポートする形に書き換えないといけない。
依存モジュールの管理を一箇所で行っていればその部分を書き換えるだけで済むかもしれないが、テストコードには手を加えることなく利用できるのが理想的ではある。