Как стать автором
Обновить
2107.27

Что не так с сорсмапами и как с ними не связываться?

Время на прочтение23 мин
Количество просмотров5.5K

Здравствуйте, меня зовут Дмитрий Карловский и у меня… посттравматическое стрессовое расстройство после генерации сорсмапов. И сегодня, с вашей помощью, мы будем это лечить путём максимально глубокого погружения в травмирующие события.



Это — текстовая расшифровка выступления на HolyJS'21. Вы можете посмотреть видео запись, прочитать как статью, либо открыть в интерфейсе проведения презентаций.


Как я до этого докатился?


Сперва история болезни:



Однажды я разработал простой формат Tree для представления абстрактных синтаксических деревьев в наиболее наглядной форме. На базе этого формата я реализовал уже несколько языков. Один из них — язык view.tree — предназначен для декларативного описания компонент и композиции их друг с другом. И именно на этом языке описаны все стандартные визуальные компоненты фреймворка $mol. Это позволяет писать короткий и наглядный код, делающий при этом много чего полезного.


Зачем DSL? Бойлерплейт!


Сейчас вы видите законченное приложение на $mol:


$my_app $my_page
    title @ \Are you ready for SPAM?
    body /
        <= Agree $my_checkbox
            checked?val <=> agree?val true

Оно состоит из панельки, внутри которой расположен чекбокс. И вместе они связаны двусторонней связью по заданным свойствам. В этих 5 строчках кода есть даже поддержка локализации. Эквивалентный код на JavaScript занимает в 5 раз больше места:


class $my_app extends $my_page {
    title() {
        return this.$.$my_text( '$my_app_title' )
    }
    body() {
        return [ this.Agree() ]
    }
    Agree() {
        const obj = new this.$.$my_checkbox()
        obj.checked = val => this.agree( val )
        return obj
    }
    agree( val = true ) {
        return val
    }
}
$my_mem( $my_app.prototype, "agree" )
$my_mem( $my_app.prototype, "Agree" )

Этот код, хоть и на более привычном многим языке, куда более сложный в понимании. Кроме того, он совсем потерял иерархию, чтобы добиться того же уровня гибкости. Плоский класс хорош тем, что от него можно отнаследоваться и переопределить любой аспект поведения компонента.


Таким образом, одна из основных причин использования DSL — это возможность писать простой и лаконичный код, который легко изучить, в котором сложно накосячить, и который легко поддерживать.


Зачем DSL? Пользовательские скрипты!


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


@assignee = $me
@component = \Frontend
@estimate ?= 1D
@deadline = $prev.@deadline + @estimate

Тут он говорит: назначь меня ответственным на все задачи; укажи, что они все относятся к фронтенду; если эстимейт не задан, то пропиши 1 день; и выстрой их дедлайны по очереди с учётом получившихся эстимейтов.


JS в песочнице? Это законно?!7


И тут вы, возможно, спросите: почему бы просто не дать пользователю в руки JS? И тогда я с вами, внезапно, соглашусь. У меня даже есть песочница для безопасного исполнения пользовательского JS. И онлайн-песочница для песочницы:


sandbox.js.hyoo.ru


Можете попробовать выбраться из неё. Мой любимый пример: Function is not a function — в очень духе JS.


JS в песочнице? Не, это не для средних умов..


Однако, для обычного пользователя JS — слишком сложен.



Ему было бы куда проще освоить какой-то простой язык, ориентированный на его бизнес-область, а не язык общего назначения типа JS.


Зачем DSL? Разные таргеты!


Ещё одна причина создавать свой DSL — это возможность написать код один раз, а исполнять его в самых разных рантаймах:


  • JS
  • WASM
  • GPU
  • JVM
  • CIL

А разные таргеты зачем? Одна модель, чтобы править всеми!


В качестве иллюстрации, приведу пример из одного стартапа, который я разрабатывал. За пол года разработки мы сделали довольно много. И всё благодаря тому, что у нас был универсальный изоморфный API, который конфигурировался простеньким DSL, где описывалось, какие у нас есть сущности, какие у них есть атрибуты, какие у них типы, как связаны с другими сущностями, какие на них есть индексы и всё такое. Всего несколько десятков сущностей и под сотню связей. Простой пример — модель задачи..


Task
    title String
    estimate Duration

Из этого декларативного описания, которое занимает несколько килобайт, генерируется уже код, работающий как на сервере, так и на клиенте, и, конечно, схема базы данных тоже автоматически обновляется.


class Task extends Model {
    title() {
        return this.json().title
    }
    estimate() {
        return new Duration( this.json().estimate )
    }
}
$my_mem( Task.prototype, "estimate" )

CREATE CLASS Task extends Model;
CREATE PROPERTY title string;
CREATE PROPERTY estimate string;

Таким образом, разработка (и особенно рефакторинг) существенно ускоряется. Достаточно поменять строчку в конфиге, и через несколько секунд мы уже можем дёргать на клиенте за новую сущность.


Зачем DSL? Фатальный недостаток же!


Ну и, конечно, какой же программист не любит быстрой езды на велосипеде?



Зачем всё это? Транспиляция и проверки!


Так у нас появилось множество разных полезных инструментов:


  • Babel и прочие транспайлеры.
  • Uglify и прочие минификаторы.
  • TypeScript, AssemblyScript и прочие языки программирования.
  • TypeScript, FlowJS, Hegel и прочие тайпчекеры.
  • SCSS, Less, Stylus PostCSS и прочие CSS генераторы.
  • SVGO, CSSO и прочие оптимизаторы.
  • JSX, Pug, Handlebars и прочие шаблонизаторы.
  • MD, TeX и прочие языки разметки.
  • ESLint и прочие линтеры.
  • Pretier и прочие форматтеры.

Разрабатывать их — задача не простая. Да даже чтобы плагин к любому из них написать — придётся попариться. Так что давайте порассуждаем, как всё это можно было бы упростить. Но сперва разберём проблемы, которые нас подстерегают на пути..


Так какие проблемы? Это не то, что я написал!


Допустим, пользователь написал такой вот простенький маркдаун-шаблон..


Hello, **World**!

А мы сгенерировали развесистый код, который собирает DOM через JS..


function make_dom( parent ) {
    {
        const child = document.createTextNode( "Hello, " )
        parent.appendChild( child )
    }
    {
        const child = document.createElement( "strong" )
        void ( parent => {
            const child = document.createTextNode( "World" )
            parent.appendChild( child )
        } )( child )
        parent.appendChild( child )
    }
    { 
        const child = document.createTextNode( "!" )
        parent.appendChild( child )
    }
}

Если пользователь столкнётся с ним, например, при дебаге, то ему потребуется много времени, чтобы понять, что это за лапшекод, и что он вообще делает.


Так какие проблемы? Да тут чёрт ногу сломит!


Совсем печально, когда код не просто раздутый, но и минифицированный с однобуквенными именами переменных и функций..


Hello, **World**!

function make_dom(e){{const t=document.createTextNode("Hello, ");
e.appendChild(t)}{const t=document.createElement("strong");
(e=>{const t=document.createTextNode("World");e.appendChild(t)})(t),
e.appendChild(t)}{const t=document.createTextNode("!");e.appendChild(t)}}

Чем помогут сорсмапы? Исходники и отладка!


Но тут нам на помощь приходят сорсмапы. Они позволяют вместо сгенерированного кода показывать программисту тот код, что он написал.



Более того, с сорсмапами будут работать инструменты отладки: можно будет исполнять его по шагам, ставить брейкпоинты внутри строки и всё такое. Почти нативненько.


Чем помогут сорсмапы? Стек-трейсы!


Кроме того, сорсмапы используются для отображения стек-трейсов.



Браузер сперва показывает ссылки на сгенерированный код, в фоне загружая сорсмапы, после чего на лету подменяет ссылки на исходный код.


Чем помогут сорсмапы? Значения переменных!


Третья ипостась сорсмапов — отображение значения переменных.



В примере исходника используется имя next, но в рантайме такой переменной нет, ибо в сгенерированном коде переменная называется pipe. Однако, при наведении на next, браузер делает обратный мапинг и выводит уже значение именно переменной pipe.


Спецификация? Не, не слышал..


Интуитивно ожидается, что у сорсмапов должна быть обстоятельная спецификация, которую можно заимплементить и всё, мы в шоколаде. Этой штуке ведь уже 10 лет. Однако, всё не так радужно..



Спека насчитывает 3 версии. Первую я не нашёл, а остальные — это просто заметки в Google Docs.


Вся история сорсмапов — это история о том, как программист, делающий инструменты разработчика, героически боролся за уменьшение их размеров. Суммарно они уменьшились в итоге примерно на 60%. Это мало того, что довольно смешная цифра сама по себе, так ещё борьба за размер сорсмапов — довольно бессмысленное занятие, ведь скачиваются они лишь на машине разработчика и то лишь тогда, когда он занимается отладкой.


То есть мы получаем классическую беду многих программистов: оптимизация не того, что важно, а того, что интересно или проще оптимизировать. Никогда так не делайте!


Как всё же разобраться в сорсмапах?


Если вы решитесь связаться с сорсмапами, то вам могут пригодиться следующие статьи:



Далее же я расскажу вам про подводные грабли, которые обильно рассыпаны тут и там во имя уменьшения размера..


Как сорсмапы подключаются?


Подключать сорсмапы можно двумя способами. Можно через HTTP заголовок..


SourceMap: <url>

Но это довольно бестолковый вариант, так как требует специальной настройки веб-сервера. Далеко не каждый статический хостинг вообще такое позволяет.


Предпочтительнее использовать другой способ — размещение ссылки в конце сгенерированного кода..


//# sourceMappingURL=<url.js.map>

/*# sourceMappingURL=<url.css.map> */

Как видите, у нас тут отдельный синтаксис для JS и отдельный для CSS. При этом второй вариант синтаксически корректен и для JS, но нет, так не заработает. Из-за этого мы не можем обойтись одной универсальной функцией для генерации кода с сорсмапами. Нам обязательно нужно отдельная функция для генерации JS кода и отдельная для CSS. Вот такое вот усложнение на ровном месте.


Как сорсмапы устроены?


Давайте посмотрим, что там у них внутри..


{
    "version": 3,
    "sources": [ "url1", "url2", ... ],
    "sourcesContent": [ "src1", "src2", ... ],
    "names": [ "va1", "var2", ... ],
    "mappings": "AAAA,ACCO;AAAA,ADJH,WFCIG;ADJI;..."
}

Поле sources содержит ссылки на исходники. Там могут быть любые строки, но обычно это относительные ссылки, по которым браузер будет выкачивать исходники. Но я рекомендую всегда класть эти исходники в sourcesContent — это убережёт вас от проблем с тем, что мапинги в какой-то момент у вас будут одной версии, а исходники другой, или вообще не скачаются. И тогда — счастливой отладки. Да, сорсмапы раздуваются в размере, зато это гораздо более надёжное решение, что важно во время отладки и без того глючащего кода. Получаем, что вся та борьба за размер сорсмапов была бессмысленна, так как добрая половина сорсмапа — это исходные коды.


В поле names хранятся имена переменных в рантайме. Этот костыль уже не нужен, так как сейчас браузеры умеют как прямой, так и обратный мапинг. То есть они сами вытягивают имена переменных из сгенерированного кода.


Ну а в поле mappings уже находятся, собственно, мапинги для сгенерированного кода.


Как расшифровать мапинги?


Представим мапинги для наглядности в несколько строк, чтобы понять их структуру..


AAAA,ACCO;
AAAA,ADJH,WFCIG;
ADJI;
...

Для каждой строки сгенерированного файла задаётся несколько спанов, разделённых запятыми. А в конце — точка с запятой для разделения строк. Тут у нас 3 точки с запятой, поэтому в сгенерированном файле минимум 3 строки.


Важно подчеркнуть, что хотя точка с запятой и может быть висящей, а вот запятые висящими быть не могут. Ну, точнее, ФФ их скушает и не подавится, а вот Хром просто проигнорирует такие сорсмапы без какого-либо сообщения об ошибке.


Это что за спаны такие?


Спан — это некоторый набор чисел в количестве 1, 4 или 5 штук. Спан указывает на конкретное место в конкретном исходнике.



Пятое число — это номер имени переменной в списке names, который (как мы уже выяснили), не нужен, поэтому это число просто не указываем.


Так что в этих числах-то?


Остальные 4 числа — это номер колонки в соответствующей строке генерированного файле, номер исходника, номер строки исходника и номер колонки в этой строке.



Имейте в виду, что номера начинаются с 0. Три последних числа можно опустить, тогда у нас будет лишь указатель на колонку в сгенерированном файле, который никуда в исходниках не мапится. Чуть позже я расскажу, зачем это надо. А пока разберём, как числа кодируются..


И это всё в 5 байтах? Дифференциальное кодирование!


Наивно было бы сериализовать спаны как-нибудь так (каждая строка — один спан)..


TC SI SR SC
0 1 40 30
3 3 900 10
6 3 910 20

Но в сорсмапах применяется дифференциальное кодирование. То есть значения полей представлены как есть лишь для первого спана. Для остальных же сохраняется уже не абсолютное значение, а относительное — разница между текущим и предыдущим спаном..


TC SI SR SC
0 1 40 30
+3 +2 +860 -20
+3 0 +10 +10

Обратите внимание, что если к 40 от первого спана прибавить 860, то получится 900 для второго спана, а если добавить ещё 10, то 910 для третьего спана.


Информации в таком представлении сохраняется столько же, но размерность чисел несколько уменьшается — они становятся ближе к 0.


И это всё в 5 байтах? VLQ кодирование!


Далее применяется VLQ кодирование, или кодирование с переменной длиной. Чем ближе число к 0, тем меньше оно требует байт для представления..


Values Bits Count Bytes Count
-15… +15 5 1
-511… +511 10 2
-16383… +16383 15 3

Как видите, каждые 5 значимых бит информации требуют по 1 дополнительному байту. Это не самый эффективный способ кодирования. Например, в WebAssembly применяется LEB128, где по байту тратится уже на каждые 7 значимых бит. Но это уже бинарный формат. А у нас тут мапинги зачем-то сделаны в формате JSON, который текстовый.


В общем, формат переусложнили, а в размере не особо-то и выиграли. Ну да ладно, это ещё цветочки..


Как же сорсмапы хороши! Был бы исходник..


Сорсмапы мапят не диапазон байт в одном файле на диапазон в другом, как мог бы подумать наивный программист. Они мапят лишь точки. И всё, что попадёт между замапленной точкой и следующей в одном файле — оно как бы оказывается замапленным на всё, что после соответствующей точки до следующей в другом файле.


И это, конечно, приводит к разным проблемам. Например, если мы добавим какой-то контент, которого нет в исходниках, и, соответственно, никуда не замапим, то он просто приклеится к предыдущему указателю..



В примере мы добавили Bar. И если не пропишем для него никакой мапинг (а мапить-то его некуда), то он приклеится к Foo. Получится, что Foo мапится на FooBar, и, например, отображение значений переменных при наведении перестаёт работать.


Чтобы этого не происходило, нужно Bar замапить в никуда. Для этого как раз и нужен вариант спана с одним числом. В данном случае это будет число 3, так как Bar начинается с третьей колонки. Таким образом, мы говорим, что после данного указателя до следующего (или конца строки) контент никуда не замаплен, а Foo мапится только лишь на Foo.


Как же сорсмапы хороши! Был бы результат..


Есть и обратная ситуация, когда в исходнике контент есть, а в результат он не идёт. И тут тоже может быть проблема с прилипанием..



Получается, надо вырезанный контент куда-нибудь всё же замапить. Но куда? Единственное место — это куда-нибудь в конец результирующего файла. Это вполне себе рабочее решение. И всё бы хорошо, но если пайплайн наш на этом не заканчивается, и продолжается обработка, то могут быть проблемы.


Например, если мы далее склеиваем несколько сгенерированных файлов вместе, то нам нужно объединить их мапинги. Устроены они таким образом, что их можно просто сконкатенировать. Однако при этом конец одного файла станет началом следующего. И всё благополучно сломается.


А если надо склеить сорсмапы?


Можно было бы делать хитрый ремапинг при конкатенации, но тут нам на помощь приходит ещё один формат сорсмапов. Вот это твист! Их на самом деле два. Композитные сорсмапы выглядят уже следующим образом..


{
    version: 3,
    sections: [
        {
            offset: {
                line: 0,
                column: 0
            },
            url: "url_for_part1.map"
        },
        {
            offset: {
                line: 100,
                column: 10
            },
            map: { ... }
        }
    ],
}

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


Причём обратите внимание на то, что задаётся начало секции в формате "строка-колонка", что крайне не удобно. Ведь для того, чтобы отмерить секцию, необходимо пересчитать все переводы строк в предыдущих секциях. Особенно весело такие приколы смотрелись бы при генерации бинарных файлов. Благо сорсмапы by design их не поддерживают.


А макросы? Мапим на их внутрянку..


Ещё один крайний случай — макросы в том или ином виде. То есть кодогенерация на прикладном уровне. Возьмём для примера макрос log, который принимает некоторое выражение и оборачивает его в условное логирование...


template log( value ) {
    if( logLevel > Info ) { // A
        console.log( value ) // B
    }
}

log!stat1()
log!stat2()
log!stat3()

Таким образом мы не вычисляем потенциально тяжёлое выражение, если логирование выключено, но при этом не пишем кучу однотипного кода.


Внимание, вопрос: куда мапить сгенерированный макросом код?


if( logLevel > Info ) { // A
    console.log( stat1() ) // B
}

if( logLevel > Info ) { // A
    console.log( stat2() ) // B
}

if( logLevel > Info ) { // A
    console.log( stat3() ) // B
}

Если мы замапим его на содержимое макроса, то получится, что, при исполнении кода по шагам, мы будем ходить внутри макроса: A-B-A-B-A-B. А в точке его применения так и не остановимся. То есть разработчик не сможет увидеть, откуда он попал в макрос, и что ему передавалось.


А макросы? Мапим на их применение..


Тогда может быть лучше мапить весь сгенерированный код на место применения макроса?


template log( value ) {
    if( logLevel > Info ) {
        console.log( value )
    }
}

log!stat1() // 1
log!stat2() // 2
log!stat3() // 3

if( logLevel > Info ) { // 1
    console.log( stat1() ) // 1
}

if( logLevel > Info ) { // 2
    console.log( stat2() ) // 2
}

if( logLevel > Info ) { // 3
    console.log( stat3() ) // 3
}

Но тут получается иная беда: мы остановились на строке 1, потом снова на строке 1, потом опять… Так продолжаться может утомительно долго, смотря сколько инструкций окажется внутри макроса. Короче, теперь отладчик уже будет по несколько раз останавливаться на одном и том же месте, так и не зайдя в код макроса. Это и так-то неудобно, плюс отлаживать сами макросы таким образом просто не реально.


А макросы? Мапим и на применение, и на внутрянку!


С макросами лучше совмещать оба подхода. Сначала добавить инструкцию, которая ничего полезного не делает, но мапится на место применения макроса, а сгенерированный макросом код уже мапить на код макроса..


template log( value ) {
    if( logLevel > Info ) { // A
        console.log( value ) // B
    }
}

log!stat1() // 1
log!stat2() // 2
log!stat3() // 3

void 0 // 1
if( logLevel > Info ) { // A
    console.log( stat1() ) // B
}

void 0 // 2
if( logLevel > Info ) { // A
    console.log( stat2() ) // B
}

void 0 // 3
if( logLevel > Info ) { // A
    console.log( stat3() ) // B
}

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


Хорошо бы в 4 версии сорсмапов добавили поддержку макросов. Эх, мечты, мечты..


Как же сорсмапы хороши! Если бы не имена переменных..


Ну и касательно переменных, тут всё тоже довольно дубово. Если вы думаете, что можно выделить произвольное выражение в исходнике, и ожидать, что браузер глянет, во что оно мапится, и попробует это исполнить, то как бы не так!


  • Только имена переменных, никаких выражений.
  • Только полное совпадение.

Как же сорсмапы хороши! Если бы не evil..


И ещё один дьявол в деталях реализации. Если вы генерируете код не на сервере, а на клиенте, то, чтобы его исполнить, вам потребуется та или иная форма вызова интерпретатора. Если вы воспользуетесь для этого eval, то с мапингами будет хорошо, но работать будет медленно. Гораздо быстрее сделать функцию и её уже исполнять много раз..


new Function( '', 'debugger' )

Но браузер под капотом делает что-то типа:


eval(`
    (function anonymous(
    ) {
        debugger
    })
`)

То есть добавляет в ваш код сверху две строки, отчего все мапинги сворачивают не туда. Чтобы это побороть, надо сорсмапы сдвинуть вниз, например, добавив пару точек с запятой в начало мапинга. Тогда new Function будет хорошо мапиться. Но съедет теперь уже в eval.


То есть, когда вы генерируете мапинги, вы должны чётко понимать, как вы этот код будете запускать, иначе мапинги будут показывать не туда.


Как же сорсмапы хороши! Но что-то пошло не так..


Ну и самая главная беда сорсмапов: если вы где-то накосячите, то в большинстве случаев браузер вам ничего не скажет, а просто проигнорирует. И тогда придётся лишь гадать..


  • Карты Таро
  • Натальные карты
  • Гугл Карты

И даже Гугл тут мало чем помогает, ибо там в основном ответы на вопросы в духе "как настроить WebPack?". А там есть всего один разумный вариант настройки. Зачем пользователям раздали столько гранат — не понятно.


Пофантазируем? Сорсмапы здорового человека!


Ладно, с сорсмапами сейчас всё довольно печально. Давайте попробуем спроектировать их сейчас с нуля. Я бы сделал для этого бинарный формат, где мапились бы не указатели, а конкретные диапазоны байт. На спан выделим константные 8 байт, то есть машинное слово. Работать с ним просто, быстро и, главное, его достаточно для наших нужд. Состоять спан будет из 3 чисел: смещение диапазона в кумулятивном исходнике (конкатенация всех исходников), длина этого диапазона, и длина диапазона в результате.


Field Bytes Count
source_offset 3
source_length 3
target_length 2

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


Но, к сожалению, приходится работать с тем, что есть сейчас..


Стоит ли связываться с сорсмапами?


Надеюсь, мне удалось показать, что сорсмапы — это то ещё болото, в которое лучше не лезть. В процессе трансформаций за ними нужно аккуратно следить, чтобы они не потерялись и не съехали. В сообщения об ошибках надо указывать на исходник, а в случае макросов нужно выводить трейс по исходникам. Итого:


  • Сложно само по себе.
  • Проносить через трансформации.
  • Проносить в сообщения об ошибках.
  • Плюс трейс по шаблонам.

Я бы не хотел с ними связываться, но мне пришлось. Но давайте подумаем, как их всё же избежать..


Сложно? Возьмём Бабель!


Возьмём популярный инструмент, например Babel. Наверняка же там все проблемы уже решены и можно садиться да ехать!


Возьмём первый попавшийся плагин..


import { declare } from "@babel/helper-plugin-utils";
import type NodePath from "@babel/traverse";

export default declare((api, options) => {
    const { spec } = options;
    return {
        name: "transform-arrow-functions",
        visitor: {
            ArrowFunctionExpression(
                path: NodePath<BabelNodeArrowFunctionExpression>,
            ) {
                if (!path.isArrowFunctionExpression()) return
                path.arrowFunctionToExpression({ // Babel Helper
                    allowInsertArrow: false,
                    specCompliant: !!spec,
                })
            },
        },
    }
})

Он трансформирует стрелочную функцию в обычную. Задача вроде бы простая, да и кода не так уж много! Однако, если присмотреться, то всё, что эта портянка делает, — это вызывает стандартный бабелевский хелпер и всё. Как-то многовато кода для такой простой задачи!


Бабель, зачем столько бойлерплейта?


Ладно, давайте заглянем в этот хелпер..


import "@babel/types";
import nameFunction from "@babel/helper-function-name";

// ...

this.replaceWith(
    callExpression( // mapped to this
        memberExpression( // mapped to this
            nameFunction(this, true) || this.node, // mapped to this
            identifier("bind"), // mapped to this
        ),
        [checkBinding ? identifier(checkBinding.name) : thisExpression()],
    ),
);

Ага, тут генерируются новые узлы AST, используя глобальные функции-фабрики. Но беда в том, что вы не контролируете, куда они будут замаплены. А чуть ранее я показывал, как важно точно контролировать, что куда мапится. Тут же этой информации нет, поэтому Бабелю ничего не остаётся, как мапить новые узлы на тот единственный узел, на который сматчился плагин (this), что далеко не всегда даёт адекватный результат.


Займёмся отладкой? AST курильщика..


Следующая проблема — отладка трансформаций. Тут нам важно иметь возможность смотреть, какое AST было до трансформации, а какое стало после. Возьмём простенький JS код:


const foo = { "bar": 123 };


Вы только гляньте, как выглядит типичное абстрактное синтаксического дерево (AST) для него..


{
    "type": "Program",
    "sourceType": "script",
    "body": [
        {
            "type": "VariableDeclaration",
            "kind": "const",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "foo"
                    },
                    "init": {
                        "type": "ObjectExpression",
                        "properties": [
                            ...

И это лишь его половина. И это даже не бабелевское AST, а какой-то ноунейм — я просто взял самое компактное из тех, что есть на ASTExplorer. Собственно, поэтому этот инструмент вообще и появился, ибо без него смотреть на эти JSON-чики — боль и страдания.


Займёмся отладкой? AST здорового человека!


И тут нам на помощь приходит формат Tree, который я когда-то разработал специально для целей наглядного представления AST..


const foo = { "bar": 123 };


{;}
    const
        foo
        {,}
            :
                \bar
                123

Как видите, js.tree представление уже гораздо нагляднее. И не требует никаких ASTExplorer. Хотя я и сделал для него патч для поддержки tree, который уже второй год игнорируется мейнтейнером. Это — open-source, детка!


И как с этим работать? Всё, что нужно и ничего ненужного!


В моей реализации API для Tree ($mol_tree2) у каждого узла есть лишь 4 свойства: это имя типа, сырое значение, список дочерних узлов и спан (указатель на диапазон в исходнике)..


interface $mol_tree2 {
    readonly type: string
    readonly value: string
    readonly kids: $mol_tree2[]
    readonly span: $mol_span
}

Каждый спан содержит ссылку на исходник, само содержимое исходника, номера строки и столбца начала диапазона и длину этого диапазона..


interface $mol_span {
    readonly uri: string
    readonly source: string
    readonly row: number
    readonly col: number
    readonly length: number
}

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


И как с этим работать? Локальные фабрики!


Генерация новых узлов происходит не глобальными функциями-фабриками, а наоборот, локальными методами-фабриками..


interface $mol_tree2 {
    struct( type , kids ): $mol_tree2
    data( value , kids ): $mol_tree2
    list( kids ): $mol_tree2
    clone( kids ): $mol_tree2
}

Каждая такая фабрика создаёт новый узел, но наследует спан от существующего узла.


Почему это работает?


Таким образом, мы можем точно контролировать, на какую часть исходника будет мапиться каждый узел, даже после применения множества трансформаций AST..



На диаграмме вы видите, как мы из 2 файлов сгенерировали 1 путём 3 трансформаций, которые что-то вырезали, что-то добавили, а что-то перемешали. Но привязка к исходным кодам никуда не потерялась.


И как с этим работать? Обобщённые трансформации!


Для написания трансформаций есть 4 обобщённых метода..


interface $mol_tree2 {
    select( ... path ): $mol_tree2
    filter( ... path, value ): $mol_tree2
    insert( ... path, value ): $mol_tree2
    hack( belt, context ): $mol_tree2[]
}

Каждый из них создаёт новое AST, никак не меняя существующее, что очень удобно при отладке. Они позволяют делать глубокие выборки, фильтрацию по глубокой выборке, глубокие вставки и применять хаки.


Это что за хаки такие? Шаблонный пример..


Хаки — самая мощная штука, позволяющая пройтись по дереву, заменяя узлы разных типов на результат исполнения разных обработчиков. Проще всего их работу продемонстрировать на примере реализации тривиального шаблонизатора для AST. Допустим, у нас есть шаблон конфига для нашего сервера..


rest-api
    login @username
    password @password
db-root
    user @username
    secret @password

Распарсив его в AST, мы можем похачить наш конфиг всего в несколько строчек кода..


consfig.list(
    config.hack({
        '@username': n => [ n.data( 'jin' ) ],
        '@password': p => [ p.data( 'пороль' ) ],
    })
)

В результате получится, что все плейсхолдеры заменены на нужные нам значения..


rest-api
    login \jin
    password \пороль
db-root
    user \jin
    secret \пороль

А если что-то более сложное? Скрипт автоматизации..


Рассмотрим пример посложнее — скрипт автоматизации..


click \$my_app.Root(0).Task(0)
click \$my_app.Root(0).Details().TrackTime()

Тут у нас есть команда click. Ей передаётся идентификатор элемента, по которому должно быть произведено нажатие.


Что ж, похачим этот скрипт так, чтобы на выходе получился AST яваскрипта..


script.hack({
    click: ( click, belt )=> {
        const id = click.kids[0]
        return [
            click.struct( '()', [
                id.struct( 'document' ),
                id.struct( '[]', [
                    id.data( 'getElementById' ),
                ] ),
                id.struct( '(,)', [ id ] ),
                click.struct( '[]', [
                    click.data( 'click' ),
                ] ),
                click.struct( '(,)' ),
            ] ),
        ]
    },
})

Обратите внимание, что часть узлов создаются из имени команды (click), а часть из идентификатора элемента (id). То есть отладчик будет останавливаться и там, и там. А стек-трейсы ошибок будут указывать на правильные места исходника.


А можно ещё проще? jack.tree — макро-язык для трансформаций!


Но можно погрузиться ещё глубже и сделать DSL для обработки DSL. Например, трансформацию скрипта автоматизации можно описать следующим образом на языке jack.tree..


hack script {;} from

hack click ()
    document
    [] \getElementById
    (,) data from
    [] \click
    (,)

script jack
    click \$my_app.Root(0).Task(0)
    click \$my_app.Root(0).Details().TrackTime()

Каждый hack — это макрос, который матчится на заданный тип узла, и заменяющий его на что-то иное. Это пока ещё прототип, но он уже много чего умеет.


А если разные таргеты? Трансформируем в JS, вырезая локализацию..


Хаки позволяют не просто дословно транслировать один язык в другой. С их помощью, можно экстрактировать из кода интересующую нас информацию. Например, у нас есть скрипт на некотором простом DSL, который что-то там выводит на английском языке..


+js
    print @ begin \Hello, World!
    when onunload print @ end \Bye, World!

И мы можем преобразовать его в JS так, чтобы вместо английских текстов дёргалась функция localize с нужным ключом, просто завернув его в макрос +js..


{
    console.log( localize("begin") )

    function onunload() {
        console.log( localize("end") )
    }
}

А если разные таргеты? Вычленяем переводы, игнорируя логику..


Но мы можем применить к нему другой макрос — +loc..


+loc
    print @ begin \Hello, World!
    when onunload print @ end \Bye, World!

И тогда, наоборот, вся логика будет проигнорирована, а мы получим JSON со всеми ключами и соответствующими им текстами..


{
    "begin": "Hello, World!",
    "end": "Bye, World!"
}

А если разные таргеты? Меняем трансформации как перчатки..


На jack.tree эти макросы описываются сравнительно не сложным кодом..


hack +js
    hack print ()
        console
        [] \log
        (,) from
    hack @ ()
        localize
        (,) type from
    hack when function
        struct type from
        (,)
        {;} kids from
    {;} from

hack +loc
    hack print from
    hack when kids from
    hack @ :
        type from
        kids from
    {,} from

Как видите, внутри макроса можно объявлять другие макросы. То есть язык легко можно расширять средствами самого этого языка. Таким образом, можно генерировать разный код. Можно учитывать контекст, в котором находятся узлы, и матчиться только в этом контексте. Короче, техника очень простая, но мощная и при этом шустрая, так как нам не приходится ходить по дереву туда-сюда — мы только спускаемся по нему.


Что-то пошло не так? Трейс трансформаций!


Большая сила требует и большой ответственности. Если что-то пойдёт не так и возникнет исключение, а у нас макрос на макросе и макросом погоняет, то крайне важно выводить трейс, который поможет разобраться, кто где на что матчился по пути к месту ошибки..



Тут мы видим, что исключение возникло в точке (1), но ошибка человеком была допущена в точке (2), в которую мы пришли из точки (3).


Ну зачем ещё один велосипед?


И тут вы, скорее всего, задаётесь вопросом: "Дима, зачем ещё один велосипед? Горшочек, не вари! Хватит уже велосипедов!". Я бы и рад, но давайте вкратце сравним его с альтернативами..


Babel TypeScript tree
Сложность API ~300 ~10
Абстракция от языка
Иммутабельность API
Удобная сериализация
Самодостаточность

У Бабеля порядка 300 функций, методов и свойств. У TS там вообще какое-то запредельное complexity, ещё и почти без документации.


Все они гвоздями прибиты к JS, что осложняет их использование для кастомных языков. У них мутабельное API без лаконичной сериализации AST, что существенно осложняет отладку.


Наконец, AST Бабеля не является самодостаточным, то есть мы не можем из него напрямую сгенерировать и результирующий скрипт, и сорсмапы к нему — для этого надо окольными путями протягивать исходники. С сообщениями об ошибках — та же беда. У TS с этим получше, но тут уже другая крайность — он вместе с бананом даёт вам и обезьяну, и джунгли, и даже свою солнечную систему в нескольких ипостасях.


Типичный пайплайн… что-то тут не так..


Давайте глянем как выглядит типичный фронтенд-пайплайн..


  • TS: распарсил, транспилировал, сериализовал.
  • Webpack: распарсил, пошатал деревья, собрал, сериализовал.
  • Terser: распарсил, минифицировал, сериализовал.
  • ESLint: распарсил, всё проверил, сериализовал.

Что-то тут не так… Всем этим инструментам не хватает единого языка для общения, то есть некоторого представления AST, которое было бы, с одной стороны максимально простым и абстрактным, а с другой — позволяло бы выразить всё необходимое каждому инструменту, но не завязывалось бы на него.


И, на мой взгляд, формат Tree подходит для этого как нельзя лучше. Поэтому в перспективе было бы классно протолкнуть им идею перейти на этот формат. Но я для этого, к сожалению, недостаточно инфлюенсер. Так что не будем сильно раскатывать губу, но немного помечтаем..


А как бы выглядел пайплайн здорового человека?


  • Распарсили в AST.
  • Всё протрансформировали и прочекали.
  • Сериализовали в скрипты/стили и сорсмапы.

Таким образом, основная работа проходит на уровне AST без промежуточных сериализаций. А даже если нам и потребуется временно сериализовать AST чтобы, например, передать его в другой процесс, то компактный Tree может быть сериализован и распаршен куда быстрее развесистого JSON.


Как избежать разъезда результата и сорсмапа? text.tree!


Ок, мы потрансформировали AST, осталось его сериализовать. Если это делать для каждого языка отдельно, то это будет слишком сложно, ведь иной язык может насчитывать десятки, а то и сотни типов узлов. И каждый нужно не только правильно сериализовать, но и правильно сформировать для него спан в мапинге.


Чтобы упростить эту задачу, мы со Стефаном разработали язык text.tree, где есть всего 3 типа узлов: строки, отступы и сырой текст. Простой пример..


line \{ 
indent
    line
        \foo
        \: 
        \123
line \ }

{
    foo: 123
}

//# sourceMappingURL=data:application/json,
%7B%22version%22%3A3%2C%22sources%22%3A%5B%22
unknown%22%5D%2C%22sourcesContent%22%3A%5B%22
line%20%5C%5C%7B%5Cnindent%5Cn%5Ctline%5Cn%5C
t%5Ct%5C%5Cfoo%5Cn%5Ct%5Ct%5C%5C%3A%20%5Cn%5C
t%5Ct%5C%5C123%5Cnline%20%5C%5C%7D%5Cn%22%5D
%2C%22mappings%22%3A%22%3B%3BAAAA%2CAAAK%3BAACL
%2CAACC%2CCACC%2CGACA%2CEACA%3BAACF%2CAAAK%3B%22%7D

Любой другой язык может быть сравнительно несложно трансформирован в text.tree без плясок вокруг спанов. А дальнейшая сериализация с формированием сорсмапов — это просто применение стандартных, уже написанных функций.


А если нужен WebAssembly? wasm.tree -> bin.tree


Ну а в дополнение к текстовой сериализации, у нас есть и сериализация бинарная. Тут всё то же самое: трансформируем любой язык в bin.tree, после чего стандартной функцией получаем из него бинарник. Например, возьмём не хитрый код на языке wasm.tree..


custom xxx

type xxx
    => i32
    => i64
    => f32
    <= f64

import foo.bar func xxx

А теперь прогоним его через wasm.tree компилятор и получим bin.tree, который тут же преобразуем в бинарник и провалидируем WASM рантаймом..


\00
\61
\73
\6D
\01
\00
\00
\00

Можно писать код как сразу на wasm.tree, так и на каком-либо своём DSL, который уже трансформировать в wasm.tree. Таким образом, вы легко можете писать под WebAssembly, не погружаясь в дебри его байт-кода. Ну… когда я этот компилятор таки допилю, конечно. Если кто-то готов помочь — присоединяйтесь.


Даже WASM с сорсмапингом?!


И, конечно, из bin.tree мы автоматически получаем и сорсмапы. Только вот работать они не будут. Для WASM нужно генерировать уже более взрослый формат мапинга, который используется для компилируемых языков программирования..


DWARF


Но в эти дебри мне пока страшно лезть..


Ничего не забыли?


Пока что мы говорили лишь про генерацию кода из нашего DSL. Но для комфортной работы с ним требуется ещё много вещей..


  • Подсветка синтаксиса
  • Подсказки
  • Проверки
  • Рефакторинги

Единое расширение, чтобы править всеми… Да ладно?!


У меня есть дикая идея — для каждой IDE сделать по одному универсальному плагину, который сможет читать декларативное описание синтаксиса языка и по нему обеспечивать базовую интеграцию с IDE: подсветку, подсказки, валидацию. Я пока что так реализовал подсветку.


На $mol-канале есть трёхминутное видео процесса описания нового языка для своего проекта..



Вам не нужно ничего перезапускать, ставить девелоперские сборки среды разработки или специальные расширения к ним. Вы просто пишете код, и он в реальном времени перекрашивает ваш DSL.


Справа вы видите код на языке view.tree, а слева описание этого языка. Плагин при этом ничего не знает про этот язык, но благодаря описанию знает, как его раскрашивать.


Что нужно для автоматической подсветки?


Работает это просто: встретив незнакомый язык (определяемый по расширению файла), плагин сканирует воркспейс на предмет существования схемы для этого языка. Если найдёт несколько схем, то соединяет их.


Есть требование и к самому языку — семантика его узлов должна задаваться синтаксически. Например, "начинается с доллара" или "имеет имя null". То есть не должно быть синтаксически неразличимых узлов, имеющих разную семантику. Это, впрочем, полезно не только для подсветки, но и для упрощения понимания языка самим пользователем.


Итого, что нужно:


  • Декларативное описание языка.
  • Синтаксическая привязка к семантике.
  • Без установки для каждого языка.
  • Дефолтная эвристика.

Да, описание языка вовсе не обязательно, ведь порой хватает и дефолтной эвристики для раскраски любых tree-based языков.


Куда-куда пойти?


На этом моё повествование подходит к концу. Надеюсь, мне удалось вас заинтересовать своими изысканиями. И если так, вам могут пригодиться следующие ссылки..



Спасибо, что выслушали. Мне полегчало.


Свидетельские показания


  • ❌ Вначале было немного сложно сфокусироваться на проблематике.
  • ❌ Сложновато и не видно, где это применять.
  • ❌ Я так и не понял, зачем этот доклад нужен на этой конференции тему вроде раскрыл, но дезайн DSL несколько странный, практическая применимость = 0.
  • ❌ Название не соответствует заявленному (даже минимально), информация о sourcemap идет с 5 минуты по 35-ю, в остальное время автор вещает про свой фреймворк, который не имеет никакого отношения к теме. Зря потратил время, лучше бы посмотрел другого автора.
  • ✅ Прикольная тема и Дима даже почти отвязался от профдеформации с $mol.
  • ✅ Интересный доклад. Дмитрий очень хорошо рассказал предметную область, осветил возможные проблемы и подумал об удобстве использования для пользователя. Очень круто!

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Есть ли у вас на проекте кастомный DSL?
9.09% Да, на нём пишут наши пользователи3
33.33% Да, на нём пишут разработчики11
9.09% Да, на нём пишут аналитики или другие непрограммисты3
36.36% Нет, используем лишь распространённые языки12
45.45% Нет, только языки общего назначения15
Проголосовали 33 пользователя. Воздержались 11 пользователей.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 23: ↑18 и ↓5+13
Комментарии28

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud