Делаем JavaScript компилируемым с помощью llvm.js

llvm.js

llvm.js

Введение

В данной статье мы рассмотрим мощный проект — llvm.js и расскажем, что он представляет из себя. Также мы научимся создавать компилируемый язык программирования на основе JavaScript. Хотя в этой статье мы сфокусируемся именно на JavaScript, ознакомившись с процессом, вы сможете создавать свои собственные компилируемые языки программирования с использованием llvm.js.

Назначение и применение

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

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

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

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

Компонент exceptions позволяет эффективно обрабатывать и выводить ошибки при работе с llvm.js. Классы исключений, предоставляемые этим компонентом, помогут вам управлять потоком программы и обрабатывать различные типы ошибок.

Однако самыми важными компонентами проекта являются llvm и codegen. llvm — это лексер, конфигурация характеристик языка программирования и грамматика. Он также обладает функцией REPL (read-eval-print loop), что делает его еще более гибким и удобным для разработчиков. Codegen же отвечает за создание машинного кода и обеспечивает оптимизацию, что повышает эффективность программы.

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

Вместе с llvm.js вы сможете создавать языки программирования, которые будут легко восприниматься человеком и обладать высокой производительностью благодаря использованию мощного компилятора. Я уверен, что наш проект станет революционным прорывом в сфере разработки программного обеспечения и поможет изменить мир к лучшему. Доверьтесь llvm.js и прокачайте свои навыки программирования до нового уровня!

Старт

Давайте создадим папку для проекта и назовем ее «js-compiler». Внутри папки мы будем разрабатывать наш проект. Прежде чем приступить к разработке, мы должны скачать и установить llvm.js. Версия, доступная на момент написания данной статьи (llvm.js@1.0.0). Чтобы установить llvm.js, выполните следующую команду:

npm i llvm.js

Вы также можете скачать проект с GitHub, используя команду:

git clone https://github.com/llvm-js/llvm-project.git

После настройки проекта у нас будет следующая структура:
— js-compiler/
— grammar/
— grammar.json — файл, содержащий описание грамматики языка JavaScript
— lang/
— test.js — пример файла JavaScript, который мы будем компилировать
— language.js — основной файл компилятора
— compiler.js — файл, содержащий компилятор llvm.js

Напишем в начале файла language.js:

const fs = require('fs');

const llvm = require('llvm.js/llvm');
const Expression = require('llvm.js/expression');
const Compiler = require('./compiler');

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

class Language {
    run(src) {
        if (fs.existsSync(src)) { // (1)
            llvm.Config.setCommentLine('//'); // (2)
            let file_c = fs.readFileSync(src).toString('utf8').split('\n');
        
            const lexer = new llvm.Lexer();
            let ast = lexer.lexer(file_c); // (3)
            ast = ast.filter(tree => !['WHITESPACE', 'COMMENT'].includes(tree.type)); // (4)

            const compiler = new Compiler();
            compiler.run(ast); // (5)
        }
    }
}

Несмотря на то, что нам понадобится меньше 100 строк кода, наш проект оказывается достаточно объемным. Теперь давайте подробнее рассмотрим, что происходит в каждой части кода.

  • (1) — В начале проверяем, существует ли путь к файлу в первой строке. Если путь существует, мы переходим к следующему шагу.

  • (2) — настраиваем параметры комментариев с использованием функции setCommentLine (). Такое название функции делает код более интуитивно понятным.

  • (3) — Седьмая строка представляет создание лексера, а на восьмой строке мы вызываем его, передавая содержимое файла для токенизации и последующей обработки.

  • (4) — Лексер предоставляет нам все токены проекта, но нам нужно получить АСТ без комментариев и пробельных символов.

  • (5) — в одиннадцатой строке мы создаем компилятор, который будет обрабатывать наш АСТ. На двенадцатой строке мы вызываем компилятор, передавая фильтрованный АСТ в качестве аргумента.

Все достаточно просто и понятно! Такое упрощение работы над проектом lllvm.js позволяет нам более эффективно и продуктивно разрабатывать эту потрясающую технологию.

пишем грамматику JS

Перед тем, как приступить к написанию компилятора, необходимо разработать грамматику JavaScript, которую мы будем использовать в процессе проверки компилятором. Если грамматика соответствует нашим правилам, результатом выполнения функции llvm.Grammar.verifyGrammarNoStrict () будет объект. Этот объект включает информацию о пройденных этапах (steps) и текущем элементе AST, а также количество токенов, которые мы должны взять из AST (siliceSize).

Давайте напишем грамматику для создания переменной или константы. В файле grammar.json добавляем следующий код:

{
    "VariableDeclaration": [
        { "token": "IDENTIFER" },
        { "token": "IDENTIFER" },
        { "token": "EQUAL" },

        {
            "or": [
                { "token": "NUMBER" },
                { "token": "STRING" }
            ] 
        }
    ]
}

В данном случае, мы указываем типы токенов. Если встретится объект с полем «or», это означает, что в данной грамматике и месте токен может быть одним из перечисленных типов. В нашем случае мы указали, что токен должен быть либо строкой, либо числом.

Теперь напишем грамматику вызова функций. Для примера, рассмотрим вызов функции console.log ():

    "CallExpression": [
        { "token": "IDENTIFER" },
        { "token": "DOT" },
        { "token": "IDENTIFER" },
        { "token": "OPEN_PAREN" },

        {
            "or": [
                { "token": "NUMBER" },
                { "token": "STRING" }
            ] 
        },

        { "token": "CLOSE_PAREN" }
    ]

Заметьте, что в этой грамматике мы используем верхний регистр для именования токенов. Токен «DOT» представляет собой точку. Грамматика позволяет вызывать любую функцию, где первый токен может быть не только «console», а третий токен может быть любым именем функции. Компилятор будет проверять соответствие этой грамматике.

grammar.json

{
    "VariableDeclaration": [
        { "token": "IDENTIFER" },
        { "token": "IDENTIFER" },
        { "token": "EQUAL" },

        {
            "or": [
                { "token": "NUMBER" },
                { "token": "STRING" }
            ] 
        }
    ],


    "CallExpression": [
        { "token": "IDENTIFER" },
        { "token": "DOT" },
        { "token": "IDENTIFER" },
        { "token": "OPEN_PAREN" },

        {
            "or": [
                { "token": "NUMBER" },
                { "token": "STRING" }
            ] 
        },

        { "token": "CLOSE_PAREN" }
    ]
}

Пишем компилятор

Для начала работы с компилятором нам необходимо импортировать необходимые модули и файлы. Мы импортируем модуль fs для работы с файловой системой, модуль grammar для получения правил грамматики из файла grammar.json, модуль llvm для работы с языком программирования, модуль codeGen для генерации кода, и модуль exceptions для обработки ошибок. В файлеcompiler.js добавляем следующий код:

const fs = require("fs");
const grammar = require('./grammar/grammar.json');
const llvm = require("llvm.js/llvm"); 
const codeGen = require('llvm.js/codegen');
const exceptions = require('llvm.js/exceptions');

Напишем метод run () внутри класса Compiler. Так как поскольку есть две переменные minast который изменяется во время итерации внутри while, только есть грамматика соответствует правилам которые мы писали ранее в файле grammar.json. isGrammar изменяется если грамматика соответствует правилам и возвращает ответ что мы писали выше.

class Compiler {
    current = 0;

    run(ast) {
        this.ast = ast;
        let isGrammar, miniast;

        // code

         while (this.current < this.ast.length) {
           // code
         }
      
        fs.existsSync('output.bc-asmx') ? fs.rmSync('output.bc-asmx') : fs.writeFileSync('output.bc-asmx', '');
        codeGen.codegen('output');
    }
}

module.exports = Compiler;

Давайте теперь разберём что же делает код:

  • В цикле while мы работаем с каждым элементом абстрактного синтаксического дерева (this.ast). Внутри цикла можно выполнять дополнительные операции, связанные с компиляцией.

  • После завершения цикла, на 14 строке мы проверяем, существует ли уже скомпилированный файл output.bc-asmx. Если файл существует, то мы его удаляем. Иначе, если файл не существует, создаем пустой файл с именем output.bc-asmx.

  • На 15 строке вызываем функцию codeGen.codegen('output'), которая генерирует код на основе абстрактного синтаксического дерева и сохраняет его в файл с именем output.bc-asmx. Аргумент 'output' указывает имя выходного файла, можно задать любое другое имя.

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

Теперь внутри метода run () на 8 строке давайте напишем функции которые выполняют свои задачи.

const getMiniAst = () => {
    // Получаем часть AST, указанную в переменной isGrammar.sliceSize,
    // и фильтруем ее, чтобы исключить токены типа 'SPACE'
    miniast = ast.slice(...isGrammar.sliceSize).filter(t => t.type !== 'SPACE');
}

const endIterator = () => {
    // Переключаемся на следующий токен в следующей итерации
    // путем изменения значения this.current
    this.current = isGrammar.sliceSize[1];
    // Обнуляем переменную isGrammar для следующей итерации
    isGrammar = null;
}

const exit = () => {
    // Завершаем процесс выполнения программы
    process.exit();
}

const exceptionInvalidToken = (token) => {
    // Вызываем исключение TokenException с сообщением 'Invalid token'
    // и переданным токеном в качестве аргумента
    new exceptions.TokenException('Invalid token', token);
    // Завершаем выполнение программы
    exit();
}

const exception = (msg, token) => {
    // Вызываем исключение TokenException с переданным сообщением
    // и токеном в качестве аргументов, указав false для показа токена
    new exceptions.TokenException(msg, token, false);
    // Завершаем выполнение программы
    exit();
}

Анализируя каждую функцию, можем привести следующие комментарии:

  • Функция getMiniAst() выполняет следующие действия:

    • В переменную miniast сохраняется часть AST, определенная в переменной isGrammar.sliceSize.

    • Далее происходит фильтрация полученного AST, чтобы исключить токены типа 'SPACE'.

  • Функция endIterator() вызывается в конце итерации, если у переменной isGrammar есть значение.

    • Она изменяет значение this.current так, чтобы в следующей итерации можно было переключиться на следующий токен.

    • Затем переменная isGrammar устанавливается в null для очистки, чтобы быть готовой для следующей итерации.

  • Функция exit() просто завершает выполнение программы.

  • Функция exceptionInvalidToken(token) вызывает исключение с сообщением 'Invalid token' и переданным токеном в качестве аргумента.

    • Для этого она создает новое исключение типа TokenException и передает ему соответствующие аргументы.

    • После вызова исключения, выполнение программы будет завершено.

  • Функция exception(msg, token) также вызывает исключение типа TokenException, но с переданным сообщением и токеном в качестве аргументов.

    • Кроме того, третьим аргументом указывается false, чтобы сообщение ошибки не показывало токен.

    • Затем, как и в предыдущей функции, выполнение программы будет завершено.

код без комментариев.

const getMiniAst = () => miniast = ast.slice(...isGrammar.sliceSize).filter(t => t.type !== 'SPACE');
const endIterator = () => { this.current = isGrammar.sliceSize[1]; isGrammar = null; };
const exit = () => process.exit();
const exceptionInvalidToken = (token) => { new exceptions.TokenException('Invalid token', token); exit(); };
const exception = (msg, token) => { new exceptions.TokenException(msg, token, false); exit(); };

Внутри while цикла мы реализуем логику увеличения переменной this.current, если текущий токен представляет собой лексему »;». Если тип текущего токена является «EOF» (End Of Line), то мы завершаем цикл с помощью оператора break.

while (this.current < this.ast.length) {
  if (ast[this.current].lexem == ';') this.current++;

  if (ast[this.current].type == 'EOF') {
      break;
  }
}

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

else if ((isGrammar = llvm.Grammar.verifyGrammarNoStrict(this.current, this.ast, grammar.VariableDeclaration))) {
    getMiniAst();

    if (miniast[0].lexem == 'let') {
        codeGen.variableDeclaration(miniast[1], miniast[miniast.length - 1]);
    } else if (miniast[0].lexem == 'const') {
        codeGen.constDeclaration(miniast[1], miniast[miniast.length - 1]);
    } else {
        exceptionInvalidToken(this.ast[this.current]);
    }

    endIterator();
} 
  • Как видно из приведенного выше кода, на первой строке мы проверяем синтаксис токенов в нестрогом режиме, что указывает функция llvm.Grammar.verifyGrammarNoStrict(). Затем мы используем VariableDeclaration из предварительно импортированной грамматики для проверки синтаксиса.

  • На 4-й строке мы проверяем, что первый токен является «let» для создания переменной, а на 6-й строке — для создания константы.

  • После проверки синтаксиса мы генерируем соответствующий код. Функции codeGen.variableDeclaration() и codeGen.constDeclaration() принимают два аргумента: имя переменной и значение переменной. Обратите внимание, что аргументы должны быть токенами.

  • Если первый токен не соответствует условиям, то мы выбрасываем ошибку с помощью exceptionInvalidToken(this.ast[this.current]).

В начале стоит проверить грамматику CallExpression. В данном контексте, мы создаём три константы для обозначения лексических токенов: объекта, имени функции и аргументов.

else if ((isGrammar = llvm.Grammar.verifyGrammarNoStrict(this.current, this.ast, grammar.CallExpression))) {
    getMiniAst();
    const [object, property, args] = [miniast[0], miniast[2], miniast[miniast.length - 2]].map(t => t?.lexem);
    // code
    endIterator();
}

После этого, мы проверяем, является ли объект console. В случае, если это так, мы проверяем, является ли имя функции log. Если же имя не соответствует log, то мы выбрасываем ошибку, указывая на токен имени функции. Если же имя функции log, то мы вызываем стандартную языковую функцию (SLF) print и передаем в нее аргументы для вывода.

if (object == 'console') {
    if (property == 'log') {
        codeGen.genSLFunction('print');
        let args_t = miniast[miniast.length - 2];
        codeGen.callFunction('print', args_t.lexem);
    } else {
        exception(`${object}.${property}(${args}) is not a function`, miniast[2]);
    }
} else {
    exception(`${object} is not defined`, this.ast[this.current]);
}

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

else {
   exceptionInvalidToken(this.ast[this.current]);
}

полный код compiler.js

const fs = require("fs");
const llvm = require("llvm.js/llvm");
const grammar = require('./grammar/grammar.json');
const codeGen = require('llvm.js/codegen');
const exceptions = require('llvm.js/exceptions');

class Compiler {
    current = 0;

    run(ast) {
        this.ast = ast;
        let isGrammar, miniast;

        const getMiniAst = () => miniast = ast.slice(...isGrammar.sliceSize).filter(t => t.type !== 'SPACE');
        const endIterator = () => { this.current = isGrammar.sliceSize[1]; isGrammar = null; };
        const exit = () => process.exit();
        const exceptionInvalidToken = (token) => { new exceptions.TokenException('Invalid token', token); exit(); };
        const exception = (msg, token) => { new exceptions.TokenException(msg, token, false); exit(); };

        while (this.current < this.ast.length) {
            if (ast[this.current].lexem == ';') this.current++;

            if (ast[this.current].type == 'EOF') {
                break;
            } else if ((isGrammar = llvm.Grammar.verifyGrammarNoStrict(this.current, this.ast, grammar.VariableDeclaration))) {
                getMiniAst();

                if (miniast[0].lexem == 'let') {
                    codeGen.variableDeclaration(miniast[1], miniast[miniast.length - 1]);
                } else if (miniast[0].lexem == 'const') {
                    codeGen.constDeclaration(miniast[1], miniast[miniast.length - 1]);
                } else {
                    exceptionInvalidToken(this.ast[this.current]);
                }

                endIterator();
            } else if ((isGrammar = llvm.Grammar.verifyGrammarNoStrict(this.current, this.ast, grammar.CallExpression))) {
                getMiniAst();
                const [object, property, args] = [miniast[0], miniast[2], miniast[miniast.length - 2]].map(t => t?.lexem);
                
                if (object == 'console') {
                    if (property == 'log') {
                        codeGen.genSLFunction('print');
                        let args_t = miniast[miniast.length - 2];
                        codeGen.callFunction('print', args_t.lexem);
                    } else {
                        exception(`${object}.${property}(${args}) is not a function`, miniast[2]);
                    }
                } else {
                    exception(`${object} is not defined`, this.ast[this.current]);
                }

                endIterator();
            } else {
                exceptionInvalidToken(this.ast[this.current]);
            }
        }

        fs.existsSync('output.bc-asmx') ? fs.rmSync('output.bc-asmx') : fs.writeFileSync('output.bc-asmx', '');
        codeGen.codegen('output');
    }
}

module.exports = Compiler;

Заключение

В заключение, можно сказать, что llvm.js представляет собой новую технологию, разработанную на JavaScript, которая имеет широкие перспективы в области построения языков программирования и создания интерпретаторов/компиляторов. Одним из главных преимуществ llvm.js является наличие всех базовых компонентов и функций, которые уже реализованы в его основе, что позволяет избежать необходимости писать их с нуля при создании нового языка программирования.

  • В ходе статьи мы познакомились с различными компонентами и функциями llvm.js. Например, мы рассмотрели компоненты exceptions, expression и codegen. Компонент exceptions служит для обработки исключений, expression — для выполнения математических выражений, а codegen — для генерации кода в языке AsmX. Также мы рассмотрели компоненты llvm, который включает в себя конфигурацию и лексер, грамматику и типы токенов, а также создание REPL — среды для чтения, выполнения и вывода результатов кода.

  • В завершение статьи мы провели простую компиляцию JavaScript кода с использованием llvm.js. Мы создали компилятор и успешно скомпилировали JavaScript код в AsmX. Этот пример продемонстрировал мощь и гибкость llvm.js и его способность к работе с разными языками программирования.

  • В целом, llvm.js представляет собой мощное средство для разработчиков, которые работают в области создания языков программирования и интерпретаторов/компиляторов. Он предоставляет широкий набор компонентов и функций, которые значительно упрощают процесс создания новых языков и обеспечивают быстрое развертывание и выполнение кода на различных языках. И хотя llvm.js не связан непосредственно с LLVM, они оба основаны на тех же принципах и позволяют разработчикам использовать их мощь для создания эффективных и производительных приложений.

Исходный код js компилятора на GitHub.

© Habrahabr.ru