Sweet.js: Макросы в JavaScript

от автора

Давайте попробуем посмотреть на 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.

ссылка на оригинал статьи http://habrahabr.ru/post/210454/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *