Pull to refresh

Sweet.js: Синтаксические расширения для JavaScript

Reading time 5 min
Views 12K
Давайте попробуем посмотреть на Sweet.js, компилятор, который реализует гигиенические макросы для JavaScript.

Работает он довольно просто — вы определяете набор шаблонов, по которым выполняется поиск по синтаксическому дереву. При совпадении макрос получает кусок дерева, который ему нужен и тело макроса определяет как этот кусок дерева должен трансформироваться. Далее результат встраивается обратно в дерево и процедура продолжается с того самого места.

Sweet.js оперирует своим собственным форматом синтаксического дерева, почти на уровне токенов, с минимальной структурой. С одной стороны это делает возможным определять довольно экзотические синтаксисы для своих макросов, с другой — делает написание макросов несколько сложнее, как если бы они были определены над стандартным AST JavaScript.



Начнем с простейшего примера, но сначала надо установить Sweet.js:

npm install --global sweet.js


После этого у нас должна быть доступна утилита sjs. Давайте напишем макрос, который будет менять местами значения двух переменных, поместим следующий код в файл swap.sjs:

macro swap {
  rule { $x , $y } => {
    var tmp = $x;
    $x = $y;
    $y = tmp
  }
}

var x = 11;
var y = 12;
swap x, y;
swap y, x;


Теперь чтобы получит ES5 совместимый JavaScript код мы должны просто «скормить» это компилятору sjs -r ./swap.sjs

var x = 11;
var y = 12;
var tmp = x;
x = y;
y = tmp;
var tmp$2 = y;
y = x;
x = tmp$2;


Момент, на который стоит обратить внимание, это то, что Sweet.js сгенерировал имена переменных при раскрытии макроса, таким образом исключив возможность конфликта имен. Это и значит, что Sweet.js реализует гигиенические макросы.

Теперь давайте напишем что-нибудь полезное. Как насчет набора макросов для написания тестов в стиле BDD. Начнем с простейших.

let describe = macro {
    rule { $name:lit { $body ... } } => {
        describe($name, function () {
            $body ...
        });
    }
}

let it = macro {
    rule { $name:lit { $body ... } } => {
        it($name, function () {
            $body ...
        });
    }
}

describe "My functionality" {
  it "works!" {
  
  }
}


В отличие от формы macro name мы использовали let name = macro — это сделано для того, чтобы исключить бесконечную рекурсию. Так как describe и it макросы возвращают набор токенов с именами, которые совпадают с именами самих макросов, то Sweet.js будет пытаться применить соответствующие макросы ещё и ещё, пока не кончится стэк. Форма let помогает избежать этого, так как она не создаёт биндинг для имени макроса внутри синтаксиса, который возвращается макросом.

Посмотрим что у нас получилось
describe('My functionality', function () {
    it('works!', function () {
    });
});


Это уже более полезно, чем swap-макрос, который мы написали в самом начале — позволяет сэкономить на написании кода и использовать синтаксические конструкции, более близкие к предметной области.

Посмотрим, что ещё полезного мы можем сделать с макросами для написания тестов. Как насчет набора макросов для написания утверждений (assertions)? Так как макросы имеют доступ к самой структуре кода, мы можем это использовать, для того, чтобы писать утверждения с информативными сообщениями о невыполнении утверждений. Заодно посмотрим как Sweet.js позволяет писать инфиксные макросы.

Как это все будет выглядеть? Я предлагаю следующий синтаксис:

2 + 2 should == 4
"aabbcc" should contain "bb"
[1, 2] should be truthy
x.y() should throw


При этом, при невыполненном утверждении я хочу видеть информативное сообщение об ошибке, которое не просто будет показывать значения текущих переменных в стиле undefined has no method x, но будет выводить какой именно код привел к этому. Например 2 + 2 should == 5 должен привести к сообщению об ошибке 2 + 2 should be equal to 5.

Начнем с того, что напишем макрос, который будет получать любое выражение JavaScript и генерировать строку кода, для этого выражения — «как парсинг, только наоборот». Это понадобится нам, чтобы генерировать информативные сообщения об ошибках.

macro fmt {
  case { _ ( $val:expr ) } => {

    function fmt(v) {
      return v.map(function(x){
        return x.token.inner ?
          x.token.value[0] + fmt(x.token.inner) + x.token.value[1] :
          x.token.value;
      }).join('');
    }

    return [makeValue('`' + fmt(#{$val}) + '`', #{here})];
  }
}


В отличие от предыдущих примеров, этот макрос представляет собой case-макрос. В отличие от rule-макросов, которые мы использовали ранее, case-макросы позволяют использовать всю мощь JavaScript чтобы определять синтаксическую трансформацию.

Я не буду расписывать детально, что делает этот макрос. Но схема такая — мы определяем функцию fmt которая обходит синтаксическое дерево и генерирует строку кода из него. Потом мы конструируем другое синтаксическое дерево, которое состоит из одного узла-строки и возвращаем его как результат макроса.

fmt(1 + 1) // "1+1"
fmt(x.y(1, 2)) // "x.y(1,2)"


Как видим, все работает на ура, за исключением того, что строка получается без пробелов. Написать лучшую версию макроса fmt остается в качестве упражнения читателю.

Теперь переходим к непосредственному определению синтаксиса для утверждений. Мы будем использовать модуль assert из стандартной библиотеки Node.js для самих утверждений и просто определим макросы, которые будут компилироваться в вызовы функций из этого модуля.

var assert = require('assert');

macro should {
  rule infix { $lhs:expr | == $rhs:expr } => {
    assert.deepEqual(
      $lhs, $rhs,
      fmt($lhs) + " should be equal to " + fmt($rhs));
  }

  rule infix { $lhs:expr | be truthy } => {
    assert.ok(
      $lhs,
      fmt($lhs) + " should be truthy");
  }

  rule infix { $lhs:expr | contain $rhs } => {
    assert.ok(
      $lhs.indexOf($rhs) > -1,
      fmt($lhs) + " should contain " + fmt($rhs));
  }

  rule infix { $lhs:expr | throw } => {
    assert.throws(
      function() { $lhs },
      Error,
      fmt($lhs) + " should throw");
  }
}


Мы использовали конструкцию rule infix для определения инфиксных правил, символ | в шаблоне показывает где должен находится символ имени макроса, в данном случае should.

Теперь набор утверждений

2 + 2 should == 4
"aabbcc" should contain "bb"
[1, 2] should be truthy
x.y() should throw


будет раскрываться в следующий ES5-валидный код

var assert = require('assert');
assert.deepEqual(2 + 2, 4, '`2+2`' + ' should be equal to ' + '`4`');
assert.ok('aabbcc'.indexOf('bb') > -1, '`aabbcc`' + ' should contain ' + '`bb`');
assert.ok([
    1,
    2
], '`[1,2]`' + ' should be truthy');
assert.throws(function () {
    x.y();
}, Error, '`x.y()`' + ' should throw');


Задача выполнена! Теперь вы можете начать писать свои макросы под свои задачи или определять свой синтаксис для каких-нибудь библиотек или фрэймворков.

Все макросы, которые я определял в этой статье (и даже чуть-чуть больше) доступен на npm и на github:



Для того, чтобы использовать их нужно сначала поставить необходимые пакеты из npm:

% npm install --global mocha sweet.js
% npm install sweet-bdd sweet-assertions


И потом компилировать и тестировать код

describe "additions" {
  it "works" {
    2 + 2 should == 4
  }
}


с помощью следующих команд

% sjs -m sweet-bdd -m sweet-assertions ./specs.sjs > specs.js
% mocha specs.js


На npm доступны также другие библиотеки с макросами. Предлагаю посмотреть например на sparkler, который реализует сравнение с шаблоном (pattern matching) в JavaScript:

function myPatterns {
  // Match literals
  case 42 => 'The meaning of life'

  // Tag checking for JS types using Object::toString
  case a @ String => 'Hello ' + a

  // Array destructuring
  case [...front, back] => back.concat(front)

  // Object destructuring
  case { foo: 'bar', x, 'y' } => x

  // Custom extractors
  case Email{ user, domain: 'foo.com' } => user

  // Rest arguments
  case (a, b, ...rest) => rest

  // Rest patterns (mapping a pattern over many values)
  case [...{ x, y }] => _.zip(x, y)

  // Guards
  case x @ Number if x > 10 => x
}


Думаю, было интересно. Обо всех замечаниях, пожеланиях пожалуйста в комментарии или, кто стесняется, мне на email.

UPDATE. Забыл сказать, что Sweet.js имеет возможность генерации карт кода (source maps), поэтому сложностей с отладкой (по крайней мере в браузере) быть не должно.
Tags:
Hubs:
+22
Comments 27
Comments Comments 27

Articles