18
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

エムスリーAdvent Calendar 2015

Day 9

JavaScript の文法を自分好みに変えてみる(null と undefined は友達の巻)

Last updated at Posted at 2015-12-08

@reki2000Javaの文法を自分好みに変えてみる(nullは友達の巻)が面白い!と思ったので、JavaScript で同じことをやってみます。

自分で javac をコンパイルして、好きな文法を追加した Java言語のソースを書いちゃうという話です。
今回は Groovy などで見かける ?. という Null Safe Reference を導入してみました。似たものに Elvis Operator とよばれる ?: もあって、そのうちこれにも挑戦してみたいです。

この記事の成果は shuhei/babel-plugin-null-safe-accessor という Babel プラグインとして利用できます。

方針

JavaScript には null だけでなく undefined もあるので、以下のような変換にしたいと思います。関数呼び出しの場合も便利に使えるように末尾の () も考慮します。

// 新文法
var b = a?.b;
a?.b(c, d);

// 既存文法で表現した場合
var b = a == null ? a : a.b;
a == null ? a : a.b(c, d);

V8 などをいじってコンパイルするのはハードルが高いので、実行前の変換を考えます。JavaScript 文法の拡張には Sweet.js というものがありますが、今回はすでに多くの方が使っている Babel でやってみようと思います。

Babel の JavaScript 変換の仕組みは大まかに言うと以下のようになります。

元のコード -> Parser -(AST)-> Transformers -(AST)-> Generator -> 出力されるコード

今回はまず Parser が新しい文法をパースできるようにする必要があります。Parser は Tokenizer を継承しており、Tokenizer がパースした token から AST を組み立てていきます。?. 用に questionDot という token と、それに対応する AST type を用意します。

?. の元になる .(プロパティへのアクセス)の AST 表現は以下の様なものです。

{
  "type": "MemberExpression",
  "computed": false,
  "object": { /* オブジェクト */ },
  "property": { /* プロパティ */ }
}

?. 用に MemberExpression と同様の構造の SafeMemberExpression という新しい AST type を用意し、Parser が解釈・出力できるようにします。その後 Transformer で SafeMemberExpression を上述の三項演算子の AST に変換します。

Parser

現状の Babel 6 ではプラグインで新文法を追加することはできません。babel-plugin-syntax-* というような公式のプラグインがありますが、フラグを on にしているだけなのです。しょうがないので node_modules 以下のソースを編集していきます(後でモンキーパッチ化してモジュールに切り出します)。

まずは触る場所のあたりをつけるために、どこでエラーになるか見てみます。

npm init
npm install -D babel-core
index.js
var babel = require('babel-core');

var code = 'var c = a?.b?.c;';
var result = babel.transform(code);
$ node index.js
/Users/shuhei/work/js/null-friendly-js/node_modules/babel-core/lib/transformation/file/index.js:547
      throw err;
      ^

SyntaxError: unknown: Unexpected token (1:10)
> 1 | var c = a?.b?.c;
    |           ^
    at Parser.pp.raise (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/location.js:22:13)
    at Parser.pp.unexpected (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/util.js:91:8)
    at Parser.pp.parseExprAtom (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:510:12)
    at Parser.pp.parseExprSubscripts (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:265:19)
    at Parser.pp.parseMaybeUnary (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:245:19)
    at Parser.pp.parseExprOps (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:176:19)
    at Parser.pp.parseMaybeConditional (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:158:19)
    at Parser.pp.parseMaybeAssign (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:121:19)
    at Parser.pp.parseMaybeConditional (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:163:28)
    at Parser.pp.parseMaybeAssign (/Users/shuhei/work/js/null-friendly-js/node_modules/babylon/lib/parser/expression.js:121:19)

node_modules/babylon/lib/parser/expression.js でエラーが出ていることがわかります。エラーの内容は、そんな token はありませんというもの。Token を増やす必要がありそうです。node_modules/bablylon/lib/tokenizer/types.js を見ると dot, doubleColon など token の一覧があります。ここに ?. に対応する questionDot という token を追加します。

node_modules/babylon/lib/parser/expression.js
types.questionDot = new TokenType("?.", { beforeExpr: true });

そして ?. というコードに対して questionDot token を返すようにします。現在の babel ではパフォーマンスのため char code を使って文字を判断しているようですね。

bablylon/lib/tokenizer/index.js
  Tokenizer.prototype.getTokenFromCode = function getTokenFromCode(code) {
    switch (code) {
      /* snip */
      case 63:
        var next = this.input.charCodeAt(this.state.pos + 1);
        if (next === 46) {
          // ?.
          this.state.pos += 2;
          return this.finishToken(_types.types.questionDot);
        } else {
          ++this.state.pos;return this.finishToken(_types.types.question);
        }
      /* snip */
    }
  };

ここまでで Tokenizer ができたので、今度は token から AST を作る方も修正します。dot から MemberExpression を作るところの横で、同じように questionDot から SafeMemberExpression を作ります。

node_modules/babylon/lib/parser/expression.js
pp.parseSubscripts = function (base, startPos, startLoc, noCalls) {
  for (;;) {
    if (/* snip */) {
      /* snip */
    } else if (this.eat(_tokenizerTypes.types.questionDot)) {
      var node = this.startNodeAt(startPos, startLoc);
      node.object = base;
      node.property = this.parseIdentifier(true);
      node.computed = false;
      base = this.finishNode(node, "SafeMemberExpression");
    } /* snip */
  }
}

ここで試しに実行してみます。

$ node index.js
/Users/shuhei/work/js/null-friendly-js/node_modules/babel-core/lib/transformation/file/index.js:547
      throw err;
      ^

ReferenceError: unknown: unknown node of type "SafeMemberExpression" with constructor "Node"
    at CodeGenerator.print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:69:13)
    at CodeGenerator.VariableDeclarator (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/generators/statements.js:292:10)
    at CodeGenerator._print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:143:19)
    at CodeGenerator.print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:88:10)
    at CodeGenerator.printJoin (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:178:12)
    at CodeGenerator.printList (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:243:17)
    at CodeGenerator.VariableDeclaration (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/generators/statements.js:275:8)
    at CodeGenerator._print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:143:19)
    at CodeGenerator.print (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:88:10)
    at CodeGenerator.printJoin (/Users/shuhei/work/js/null-friendly-js/node_modules/babel-generator/lib/printer.js:178:12)

SafeMemberExpression の AST が Generator まで来ているので、パースがうまくいっていることがわかりますね!

Transformer

次は Transformer を書きます。今回の変換の対象は以下です。

  • メソッド呼び出し(callee が SafeMemberExpression)な CallExpression
  • メソッド呼び出しでない SafeMemberExpression

書き方は Babel plugin handbook を参考に。babel-template を使えば AST を作るのも簡単ですね。

index.js
var babel = require('babel-core');
var template = require('babel-template');

var buildReference = template('OBJECT == null ? OBJECT : OBJECT.PROPERTY');
var buildMethodCall = template('OBJECT == null ? OBJECT : OBJECT.METHOD(ARGUMENTS)');

function transformSafeMemberExpression(t) {
  var types = t.types;
  return {
    visitor: {
      SafeMemberExpression: function (path) {
        var safeReference = buildReference({
          OBJECT: path.node.object,
          PROPERTY: path.node.property
        });
        path.replaceWith(safeReference);
      },
      CallExpression: function (path) {
        var callee = path.node.callee;
        if (callee.type === 'SafeMemberExpression') {
          var safeMethodCall = buildMethodCall({
            OBJECT: callee.object,
            METHOD: callee.property,
            ARGUMENTS: path.node.arguments
          });
          path.replaceWith(safeMethodCall);
        }
      }
    }
  };
};

var code = [
  'var c = a?.b?.c;',
  'a?.b?.c(d, e);',
].join('\n');
var result = babel.transform(code, {
  plugins: [transformSafeMemberExpression]
});
console.log(result.code);

SafeMemberExpression なんてないよ、と言われてしまうので、ちょっと雑ですが以下のように AST type 定義に追加しておきます。

index.js
var babelTypes = require('babel-types');

// For visitor validation.
babelTypes.TYPES.push('SafeMemberExpression');

// For node validation.
babelTypes.FLIPPED_ALIAS_KEYS['Expression'].push('SafeMemberExpression');

そして実行すると・・・

$ node index.js
var c = (a == null ? a : a.b) == null ? a == null ? a : a.b : (a == null ? a : a.b).c;
(a == null ? a : a.b) == null ? a == null ? a : a.b : (a == null ? a : a.b).c(d, e);

できました!同じチェックを何回もしているのが微妙ですが、ちゃんと変換できているようです。

プラグイン化

上記のコード変更をうまいことモンキーパッチに変えて、プラグインモジュールを作りました。

shuhei/babel-plugin-null-safe-accessor

普通の Babel プラグイン同様、以下のような感じで利用できます。

npm install -D babel-plugin-null-safe-accessor
.babelrc
{
  "plugins": [
    "null-safe-accessor"
  ]
}

こちらでは、ついでに a.b?() のように関数/メソッドを null 安全に呼び出す SafeCallExpression も追加してあります。そのうち ?[] にも対応してみたいですね。

まとめ

  • Java でできて JavaScript でできないことはない!(嘘)
  • Babel に文法追加用の API はないけど、モンキーパッチすれば好きな文法を追加できる。
18
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?