Всем привет! Сразу хочу сказать. Я просто пришел поделиться, как мне кажется, достаточно интересным проектом. Не претендую на то, что данный язык надо тянуть в продакшен и т.д. Более того, я прекрасно понимаю, что данный ЯП не годится для этого.

А теперь к сути :-)

Я достаточно давно мечтал сделать свой язык программирования, но времени на такое обычно мало. Однако, когда я учился в институте, ко мне пришла прекрасная идея: можно сделать язык программирования своим дипломным проектом. Когда все-таки я пришел с этой идеей к научному руководителю - он меня развернул со словами: "зачем ты собрался писать еще один велосипед? Это не интересно."

Но я не сдавался, поэтому, чтобы "продать" эту идею институту, я решил сделать синтаксис этого ЯП полностью на русском языке и, внимание, вообще сделать DSL язык для юристов. Но, с важным нюансом, там будут процедуры для императивной проверки всяких юридических правил. Так родился уникальный гибрид: юридический DSL, под капотом которого живет полноценный императивный язык. Кафедра получила то, что хотела, а я — легальную возможность писать интерпретатор в рабочее время. Win-win!

Важно! Непосредственно код моего языка программирования я частично привожу скриншотами, чтобы была подсветка синтаксиса! Там где это возможно я пользуюсь подсветкой 1С ;-)

Язык делится на две философии:

Декларативная. С простым описанием контрактов:

Декларативный код
Декларативный код

Императивная. Где уже понятный нам, программистам, код:

Императивный код
Императивный код

В конечном итоге, мне дали зеленый свет на разработку моего собственного языка, который называется LawScript. Таким образом, я нашел-таки время на реализацию своей давней мечты, поскольку, мой диплом и есть то, что я хочу сделать. Короче говоря, убил двух зайцев :)

В данной статье не буду затрагивать декларативную часть и то, как они между собой дружат (А они дружат!), просто буду писать так, как будто её нет и данный язык представляет из себя очередной императивный велосипед(Впрочем, так оно и есть).

В начале было слово...

Архитектура языка.
Архитектура языка.

В начале было слово и слово было у Программиста, и слово было Токен! :) Понятно, что мой язык программирования построен как и все другие: лексер - парсер - компилятор в байт-код и интерпретатор. Но погодите, на схеме нет лексера! К сожалению, это не опечатка. Действительно, лексер перемешан с парсером. Ну а что? Мой велосипед! Как хочу так и пишу :)

Препроцессор

Прежде, чем код дойдет до лексера, а тем более до парсера, он проходит этап препроцессинга. На данном этапе, обрабатываются включения файлов кода друг в друга (импорты) и сохранение мета-информации о строках. Для этого, каждая строка оборачивается в такой класс:


class Info(NamedTuple):
    num: int
    file: str
    raw_line: str


class Line(str):
    def __new__(cls, value: str, num: int = 0, file: str = ""):
        obj = str.__new__(cls, value)
        obj.raw_data = value
        obj.num = num
        obj.file = file

        return obj

    def get_file_info(self) -> Info:
        return Info(
            num=self.num,
            file=self.file,
            raw_line=self.raw_data
        )

Да-да, я написал ЯП на python:) Да, мой язык очень медленный.

Сам же препроцессор представляет из себя обычную python-функцию, которая построчно читает файл, пока не встретит команду ВКЛЮЧИТЬ после чего ищет файл по соответствующему пути в той же директории, что и сам скрипт, если не находит его, то выбрасывает исключение.

Файлы бывают 3-х типов:

  • .raw - это исходый код LawScript

  • .law - это байт-код всего проекта, собранный в один файл (на самом деле просто сериализованное AST, которое интерпретатор выполняет без повторного парсинга. Назвал байт-кодом для солидности ?)

  • .pyl - это Python-расширения для языка

Упрощенно, препроцессор выглядит так:

STANDARD_LIB_PATH = Path(__file__).resolve().parent.parent.parent
STANDARD_LIB_PATH = f"{STANDARD_LIB_PATH}{settings.standard_lib_path_postfix}"
STD_NAME = settings.std_name


def _standard_lib_alias(path: str) -> str:
    if _is_std(path):
        return path.replace(STD_NAME, STANDARD_LIB_PATH)

    return path


def _is_std(path: str) -> bool:
    return STD_NAME in path


def import_preprocess(path, byte_mode: Optional[bool] = True) -> Union[Compiled, str]:
  """Обработка импорта файла"""

def preprocess(raw_code, path: str) -> list:
    folder = os.path.dirname(path)

    prepared_code = [line.strip() for line in raw_code.split("\n")]
    imports = set() # В реальном коде здесь кэш импортированных модулей

    code = []

    for offset, line in enumerate(prepared_code):
        code.append(Line(line.strip(), num=offset+1, file=path))

    preprocessed = []

    for offset, line in enumerate(code):
        match line.split(" "):
            case [Tokens.include, package] if package.endswith(Tokens.star):
              # Обработка импорта формата ВКЛЮЧИТЬ директория.*
            case [Tokens.include, module] if re.search(r'\.\S+$', module):
              # Обработка импорта формата ВКЛЮЧИТЬ директория.молуль
            case [Tokens.include, module]:
              # Обработка импорта формата ВКЛЮЧИТЬ молуль
            case _:
              # Просто строка исходного кода. Сохраняем
              preprocessed.append(line)

              imports.add(path)

    return [line for line in preprocessed if line]

Лексер и Парсер

Предлагаю посмотреть, что там в лексере-парсере, коль уж о них заговорили.


class Parser(ABC):
    def __init__(self):
        self.jump: int = -1

    @abstractmethod
    def parse(self, body: list[str], jump: int) -> int: ...

    @abstractmethod
    def create_metadata(self, stop_num: int) -> MetaObject: ...

    @staticmethod
    def parse_sequence_words_to_str(words: Sequence[str]):
        return " ".join(words)

    def execute_parse(self, parser: Type["Parser"], code: list[Line], num: int) -> Union[MetaObject, BaseType]:
        parser = parser()
        meta = parse_execute(parser, code, num)
        self.jump = self.next_num_line(meta.stop_num)

        return meta

    @staticmethod
    def next_num_line(num_line: int) -> int:
        return num_line + 1

    @staticmethod
    def previous_num_line(num_line: int) -> int:
        return num_line - 1

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

Парсер строит AST, узлами которого являются объекты MetaObject. Которые потом в компиляторе уже валидируются и компилируются в нормальные объекты языка LawScript.

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


    def separate_line_to_token(self, line: Line) -> list[str]:
        self._check_quotes(line)
        raw_line = line.raw_data

        is_string = False # флаг для отслеживания строковых литералов (в примере опущено)

        for offset, symbol in enumerate(raw_line):
          # Убираем комментарии из сырой строки

        end_symbols = (Tokens.left_bracket, Tokens.right_bracket, Tokens.comma, Tokens.end_expr)

        for end_symbol in end_symbols:
            if raw_line.endswith(end_symbol):
                break
        else:
            raise InvalidSyntaxError(
                f"Некорректная строка: '{line.raw_data}', возможно Вы забыли один из этих знаков в конце: <удалено в примере>"
            )

        separated_line = self.__split(raw_line)

        tokens = []

        for token in separated_line:
            if token in Tokens:
                tokens.append(token)
                continue

            # Другая специфическая обработка
            
        return tokens

Обратная польская нотация

А как дела обстоят с выражениями? В данном языке можно писать что-то сложное? Да! Однозначно! Можно даже писать выражения для аргументов по умолчанию, как в python!

Парочка примеров:

Сложные выражения
Сложные выражения
Аргументы по умолчанию
Аргументы по умолчанию

Каждое выражение в процессе парсинга собирается в RPN-стек. Код там не самый красивый и не самый маленький у меня получился, поэтому я его опущу. Отмечу лишь один момент. На этапе парсинга отлавливается много ошибок, и часть из них на этапе построения RPN-стека. Я даже подсказываю позицию, где возникла та или иная ошибка:

                    if token_ == Tokens.right_bracket:
                        sub_expr = expr[offset:]
                        previous_tok = sub_expr[offset_ - 1]

                        if previous_tok == Tokens.comma:
                            err_expr = ''.join([str(i) for i in sub_expr][:offset_+1])
                            sub_expr = [str(i) for i in sub_expr]
                            res_expr = ''.join(str(i) for i in expr)

                            target_comma = (
                                f"{err_expr}\n"
                                f"{" " * (sum(len(t) for o, t in enumerate(sub_expr) if o < offset_ - 1))}^"
                            )

                            raise InvalidExpression(
                                f"В выражении: '{res_expr}' стоит лишняя запятая '{Tokens.comma}'\n\n"
                                f"{target_comma}\n"
                            )

Вот как это выглядит для пользователя языка:

В конечном итоге, из такого кода:


ОПРЕДЕЛИТЬ ПРОЦЕДУРУ сортировка_массива(массив_чисел) (
    ЗАДАТЬ длина = длина_массива(массив_чисел);
    ЗАДАТЬ минимальный_индекс = 0;

    ЦИКЛ индекс ОТ 0 ДО длина-1 (
        ! Находим минимальный элемент в оставшейся части массива
        минимальный_индекс = индекс;

        ЦИКЛ внутренний_индекс ОТ индекс+1 ДО длина-1 (
            ЕСЛИ достать_из_массива(массив_чисел, внутренний_индекс) МЕНЬШЕ достать_из_массива(массив_чисел, минимальный_индекс) ТО (
                минимальный_индекс = внутренний_индекс;
            )
        )

        ! Меняем местами найденный минимальный элемент с текущим
        ЕСЛИ минимальный_индекс НЕРАВНО индекс ТО (
            ЗАДАТЬ временная_переменная = достать_из_массива(массив_чисел, индекс);
            изменить_в_массиве(массив_чисел, индекс, достать_из_массива(массив_чисел, минимальный_индекс));
            изменить_в_массиве(массив_чисел, минимальный_индекс, временная_переменная);
        )
    )

    НАПЕЧАТАТЬ массив_чисел;
)

Получается такое AST:

[
  ["AssignField", "TARGET", "длина", "EXPR", [...]],
  ["AssignField", "TARGET", "минимальный_индекс", "EXPR", [Число(0)]],
  ["Loop",
    "FROM_EXPR", [Число(0)],
    "TO_EXPR",   [Служебное имя: <длина>, Число(1), Служебное имя: <->],
    [
      ["AssignOverrideVariable", "TARGET_EXPR", [...], "OVERRIDE_EXPR", [...]],
      ["Loop",
        "FROM_EXPR", [...],
        "TO_EXPR",   [...],
        [
          ["When", "EXPR", [...],
            [["AssignOverrideVariable", ...]]
          ]
        ]
      ],
      ["When", "EXPR", [...]],
      [["AssignField", ...], [...], [...]]
    ]
  ],
  ["Print", "EXPR", [Служебное имя: <массив_чисел>]]
]

Компилятор

После того как парсер построил AST, в дело вступает компилятор. Его задача — пройтись по всем узлам MetaObject, провалидировать типы (насколько это возможно в динамическом языке) и превратить их в исполняемые объекты.

В компиляторе всё довольно стандартно: рекурсивный обход дерева, таблица символов для областей видимости и проверка того, что оператор ПРОПУСТИТЬ или ПРЕРВАТЬ находится в теле цикла, а не где-то в другом месте. Или, например, что конструктор класса не пытается вернуть значение через ВЕРНУТЬ — такие штуки отлавливаются ещё на этапе компиляции.

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

Ниже - главный метод компилятора. Сам компилятор занимает 600+ строк кода, поэтому опустим большую часть реализации. Покажу только основную логику:

def compile(self) -> Compiled:
    compiled_modules = {}

    # Регистрируем встроенные исключения языка
    for name, ex in EXCEPTIONS.items():
        ex_def = create_define_class_wrap(ex)
        self.compiled[ex_def.name] = ex_def

    for idx, meta in enumerate(self.ast):
        compiled = self.execute_compile(meta)

        # Если скомпилировался целый модуль — сливаем его содержимое
        if isinstance(compiled, Compiled):
            compiled_modules = {**compiled_modules, **compiled.compiled_code}
            continue

        printer.logging(f"Команда компиляции №{idx + 1}", level="INFO")

        if compiled.name in self.compiled:
            printer.logging(f"Ошибка: {compiled.name} уже существует", level="ERROR")
            raise NameAlreadyExist(compiled.name, info=compiled.meta_info)

        self.compiled[compiled.name] = compiled
        printer.logging(f"Скомпилировано: {compiled.name}", level="INFO")

    # Собираем итоговый словарь: сначала импорты, потом наш код
    compiled_without_build_modules = self.compiled
    self.compiled = {**compiled_modules, **self.compiled}

    # Компилируем тела процедур и методов
    for name, compiled in compiled_without_build_modules.items():
        if isinstance(compiled, Procedure):
            self.body_compile(compiled.body)
            if compiled.default_arguments is not None:
                self.compile_default_args(compiled.default_arguments)

        elif isinstance(compiled, ClassDefinition):
            self.body_compile(compiled.constructor.body)
            compiled.constructor.name = compiled.name

            for method in compiled.methods.values():
                if method.default_arguments is not None:
                    self.compile_default_args(method.default_arguments)
                self.body_compile(method.body)

            # Конструктор не должен возвращать значение
            for cmd in compiled.constructor.body.commands:
                if isinstance(cmd, Return):
                    raise InvalidSyntaxError(
                        f"Конструктор класса '{compiled.name}' не может содержать '{Tokens.return_}'",
                        info=cmd.expression.meta_info
                    )

    return Compiled(self.compiled)

Обратите внимание на ветку if isinstance(compiled, Compiled). Это случай, когда мы компилируем не отдельную сущность, а целый импортированный модуль — его содержимое просто сливается с общим словарём скомпилированных объектов без проверок имен. Это нужно для того, чтобы в перспективе можно было переопределять процедуры импортируемых библиотек. Своего рода полиморфизим.

Интерпретатор

Интерпретатор делится на несколько исполнителей (Executor). У каждой грамматической конструкции есть своя реализация.

Класс Executor представляет собой по сути интерфейс:


class Executor(ABC):
    @abstractmethod
    def execute(self) -> BaseAtomicType: ...

Он не обладает никаким состоянием и поведением. Но выступает в качестве контракта.

Исполнителей в LawScript существует достаточно много, но два основных, которые являются точками входа в императивную и декларативную часть языка, я опишу:

  • ExecuteBlockExecutor - для входа в императивную часть

  • CheckerSituationExecutor - для входа в декларативную часть

Их представления в коде LawScript:

ExecuteBlockExecutor
ExecuteBlockExecutor
CheckerSituationExecutor
CheckerSituationExecutor

Блок ВЫПОЛНИТЬ - это точка входа в контракт LawScript. Внутри данного блока разрешены только выражения: вызовы функций, арифметика и пр. Какие-то сложные грамматические конструкции по типу циклов, операторов ветвления или запуска фоновых задач (и такое есть, да), запрещены. ExecuteBlockExecutor просто итерируется по выражениям внутри себя и вызывает на каждое из них исполнитель выражений: ExpressionExecutor

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

Код ExecuteBlockExecutor достаточно прост, поэтому покажу его полностью:


class ExecuteBlockExecutor(Executor):
    def __init__(self, execute_block: 'ExecuteBlock', compiled: 'Compiled'):
        self.execute_block = execute_block
        self.compiled = compiled

    def execute(self) -> BaseAtomicType:
        for expression in self.execute_block.expressions:
            scope_stack = ScopeStack()

            for object_name, value in self.compiled.compiled_code.items():
                scope_stack.set(Variable(object_name, value))

            expression_executor = ExpressionExecutor(expression, scope_stack, self.compiled)
            expression_executor.execute()

        return VOID

В языке есть специальное значение VOID — аналог None в Python или void в других языках. Оно возвращается, когда выражение или блок не производит полезного результата.

Как устроен VOID под капотом


class Void(BaseAtomicType):
    def __init__(self):
        super().__init__(None)

    @classmethod
    def type_name(cls):
        return "Пустота"

    def __str__(self) -> str:
        return Tokens.void



VOID: Final[Void] = Void()

Простая обертка :)

Что ещё умеет LawScript?

Думаю, логика работы языка понятна. Чтобы не перегружать и так уже объемную статью отмечу тезисно пару фич языка, которыми я особенно горжусь:

1. Честное ООП с одиночным наследованием

Классы, методы, конструкторы — всё как у взрослых. Можно наследоваться от другого класса и переопределять методы. Множественное наследование не завёз — так как приводит к спагетти-коду.

2. Асинхронность

Да, в моём велосипеде есть своя асинхронность! Под капотом — планировщик с пулом потоков и циклом событий в каждом. Задачи переключаются через механизм, похожий на yield from. Можно запускать фоновые задачи прямо из кода LawScript.


ОПРЕДЕЛИТЬ ПРОЦЕДУРУ фоновая_задача(номер) (
    НАПЕЧАТАТЬ форматировать_строку("Задача номер {}, делает запрос в интернет", номер);
    ЗАДАТЬ результат = запрос_в_интернет("GET", "https://ya.ru", таблица());
    НАПЕЧАТАТЬ форматировать_строку("Задача номер {}, выполнена! Статус: {}", номер, извлечь_из_таблицы(результат, "статус_код"));
)

ОПРЕДЕЛИТЬ ПРОЦЕДУРУ главная() (
    ЗАДАТЬ задачи = Список();

    ЦИКЛ номер ОТ 1 ДО 25 (
        задачи:добавить(В ФОНЕ фоновая_задача(номер));
    )

    ждать_всех(задачи:в_массив());
)

ВЫПОЛНИТЬ (
    главная();
)

3. FFI — интеграция с Python в обе стороны

Самая полезная фича для реального применения. Через файлы .pyl можно писать расширения на чистом Python и вызывать их из LawScript как обычные функции. Надо просто реализовать класс, который наследуется от PyExtendWrapper и реализовать в нем метод call!

Но самое интересное — интеграция работает в обе стороны. При желании можно передать в PyExtendWrapper ссылку на LawScript-процедуру и вызвать её прямо из Python-кода через метод run_procedure. Это открывает широчайшие возможности: например, библиотека на Python может дёргать callback, написанный на LawScript, когда происходит какое-то событие.

Именно так я прикрутил Telegram Bot API и графику для игры — всё, что нельзя или неудобно писать на самом LawScript, выносится в Python-расширения.

4. Подробная обработка ошибок

Я постарался сделать сообщения об ошибках максимально дружелюбными. Интерпретатор показывает не только строку, но и конкретную позицию, где возникла проблема — со стрелочкой, как в Rust или современном Python. Чтобы было понятно не только что сломалось, но и где именно. В некоторых случаях интерпретатор даже понимает, что вы хотели написать, и подсказывает правильный вариант.

ОПРЕДЕЛИТЬ ПРОЦЕДУРУ главная() (
    ЗАДАТЬ тест;

    тест1;
)

ВЫПОЛНИТЬ (
    главная();
)
Пример вывода ошибки
Пример вывода ошибки

Гвоздь программы...

Наконец-то переходим к тому, ради чего мы собственно собрались! Я действительно настолько увлекся данным пет-проектом, что написал на нем во-первых, игру, а во-вторых, телеграм-бота :)))

Игра

Для работы с графикой я обернул библиотеку Pygame. Дело это было не простое — пришлось прокидывать классы Pygame в рантайм LawScript. Но, слава Богу, всё решилось простыми обёртками.


Суть механизма проста: каждый тип из Pygame наследуется от CustomType — базового класса для всех пользовательских типов в LawScript. В конструкторе мы сохраняем оригинальный объект Pygame, а в словаре fields описываем его свойства, доступные из кода на LawScript. Обратите внимание на русскоязычные названия полей — ширина, высота, клавиша, это_выход. Именно так к ним обращается программист на LawScript.

Обёртки типов Pygame (GameScreen, GameEvent, GameImage и другие)
import pygame

from src.core.types.atomic import CustomType, Number, Array, Boolean


class GameScreen(CustomType):
    def __init__(self, screen):
        super().__init__()
        self.screen = screen

    def eq(self, other: 'GameScreen'):
        if isinstance(other, GameScreen):
            return self.screen == other.screen
        return False

    def __str__(self) -> str:
        return "ИгровоеОкно"

    @classmethod
    def type_name(cls):
        return "ИгровоеОкно"


class GameEventType(CustomType):
    def __init__(self, type_):
        super().__init__(type_)
        self.type = type_

    def eq(self, other: 'GameEventType'):
        if isinstance(other, GameEventType):
            return self.type == other.type
        return False

    def __str__(self) -> str:
        return str(self.value)

    @classmethod
    def type_name(cls):
        return "ТипСобытия"


class GameEvent(CustomType):
    def __init__(self, event):
        super().__init__()
        self.event = event
        self.fields = {
            "тип": GameEventType(event.type),
            "это_выход": Boolean(self.event.type == pygame.QUIT)
        }

        if event.type == pygame.KEYDOWN or event.type == pygame.KEYUP:
            self.fields["клавиша"] = Number(event.key)
        elif event.type == pygame.MOUSEBUTTONDOWN or event.type == pygame.MOUSEBUTTONUP:
            self.fields["кнопка"] = Number(event.button)
            self.fields["позиция"] = Array([Number(event.pos[0]), Number(event.pos[1])])
        elif event.type == pygame.MOUSEMOTION:
            self.fields["позиция"] = Array([Number(event.pos[0]), Number(event.pos[1])])
            self.fields["относительно"] = Array([Number(event.rel[0]), Number(event.rel[1])])

    def eq(self, other: 'GameEvent'):
        if isinstance(other, GameEvent):
            return self.event == other.event
        return False

    def __str__(self) -> str:
        return "Событие"

    @classmethod
    def type_name(cls):
        return "Событие"


class GameImage(CustomType):
    def __init__(self, image):
        super().__init__()
        self.image = image
        self.fields = {
            "ширина": Number(image.get_width()),
            "высота": Number(image.get_height())
        }

    def eq(self, other: 'GameImage'):
        if isinstance(other, GameImage):
            return self.image == other.image
        return False

    def __str__(self) -> str:
        return "Картинка"

    @classmethod
    def type_name(cls):
        return "Картинка"


class GameRect(CustomType):
    def __init__(self, rect):
        super().__init__()
        self.rect = rect
        self.fields = {
            "x": Number(rect.x),
            "y": Number(rect.y),
            "ширина": Number(rect.width),
            "высота": Number(rect.height),
            "центр_x": Number(rect.centerx),
            "центр_y": Number(rect.centery),
            "верх": Number(rect.top),
            "низ": Number(rect.bottom),
            "лево": Number(rect.left),
            "право": Number(rect.right)
        }

    def eq(self, other: 'GameRect'):
        if isinstance(other, GameRect):
            return self.rect == other.rect
        return False

    def __str__(self) -> str:
        return "Прямоугольник"

    @classmethod
    def type_name(cls):
        return "Прямоугольник"


class GameText(CustomType):
    def __init__(self, text_surface):
        super().__init__()
        self.text_surface = text_surface
        self.fields = {
            "ширина": Number(text_surface.get_width()),
            "высота": Number(text_surface.get_height())
        }

    def eq(self, other: 'GameText'):
        if isinstance(other, GameText):
            return self.text_surface == other.text_surface
        return False

    def __str__(self) -> str:
        return "Текст"

    @classmethod
    def type_name(cls):
        return "Текст"

Точно также я обернул функции Pygame:

Обёртки функций Pygame
from typing import Optional

from pathlib import Path

from src.core.extend.function_wrap import PyExtendWrapper, PyExtendBuilder
from src.core.types.basetype import BaseAtomicType

builder = PyExtendBuilder()
standard_lib_path = f"{Path(__file__).resolve().parent.parent}/modules/_/"
MOD_NAME = "game"


@builder.collect(func_name='_инициализация_игрового_движка')
class Init(PyExtendWrapper):
    def __init__(self, func_name: str):
        super().__init__(func_name)
        self.empty_args = True
        self.count_args = 0

    def call(self, args: Optional[list[BaseAtomicType]] = None):
        import pygame

        from src.core.types.atomic import VOID

        pygame.init()

        return VOID


@builder.collect(func_name='_создать_окно')
class CreateScreen(PyExtendWrapper):
    def __init__(self, func_name: str):
        super().__init__(func_name)
        self.count_args = 2

    def call(self, args: Optional[list[BaseAtomicType]] = None):
        import pygame

        from src.core.extend.standard_lib.lib_game.util import GameScreen
        from src.core.types.atomic import Number
        from src.core.exceptions import ErrorType

        wight = args[0]
        height = args[1]

        if not isinstance(wight, Number):
            raise ErrorType(f"Первый аргумент должен иметь тип '{Number.type_name()}'!")

        if not isinstance(height, Number):
            raise ErrorType(f"Второй аргумент должен иметь тип '{Number.type_name()}'!")

        screen = pygame.display.set_mode((wight.value, height.value))

        real_screen = GameScreen(screen)

        return real_screen
      

def build_module():
    builder.build_python_extend(f"{standard_lib_path}{MOD_NAME}")


if __name__ == '__main__':
    build_module()

Тут только часть

И уже после этого, я сделал отдельный модуль, в котором написал простой класс Игра на LawScript с игровым циклом. Код данного класса я опущу. Зато покажу код простой игрушки) Которую я, к слову, навайбкодил :3

Игра "Поймай еду"
ВКЛЮЧИТЬ стандартная_библиотека.игры
ВКЛЮЧИТЬ стандартная_библиотека.рандом


ОПРЕДЕЛИТЬ КЛАСС ЛовляЕды (
    ОПРЕДЕЛИТЬ КОНСТРУКТОР (ссылка) () (
        ссылка:игра = Игра("Поймай еду");
        ссылка:окно = ссылка:игра:создать_экран(800, 600);
        ссылка:генератор = ГенераторСлучайныхЧисел();
        ссылка:счет = 0;
        ссылка:игра_окончена = ЛОЖЬ;
        ссылка:время_жизни = 100;
        ссылка:еда_x = ссылка:генератор:получить_целое_в_диапазоне(50, 750);
        ссылка:еда_y = ссылка:генератор:получить_целое_в_диапазоне(50, 550);
        ссылка:размер_еды = 30;

        ЗАДАТЬ обновление = ссылка:обновление;
        ЗАДАТЬ отрисовка = ссылка:отрисовка;
        ссылка:игра:игровой_цикл(обновление, отрисовка, 60);
    )

    ОПРЕДЕЛИТЬ МЕТОД (ссылка) обновление(дельта, события) (
        ЕСЛИ ссылка:игра_окончена ТО (
            ЕСЛИ ссылка:игра:нажата_клавиша_по_имени("ПРОБЕЛ") ТО (
                ссылка:перезапустить();
            )
            ВЕРНУТЬ;
        )

        ссылка:время_жизни = ссылка:время_жизни - 0.7;

        ЕСЛИ ссылка:время_жизни МЕНЬШЕ 0 ТО (
            ссылка:игра_окончена = ИСТИНА;
            ВЕРНУТЬ;
        )

        ЗАДАТЬ мышь = ссылка:игра:получить_позицию_мыши();
        ЗАДАТЬ mx = достать_из_массива(мышь, 0);
        ЗАДАТЬ my = достать_из_массива(мышь, 1);

        ЕСЛИ ссылка:игра:нажата_кнопка_мыши(1) ТО (
            ЗАДАТЬ dx = mx - ссылка:еда_x;
            ЗАДАТЬ dy = my - ссылка:еда_y;
            ЗАДАТЬ dist = корень(dx * dx + dy * dy);

            ЕСЛИ dist МЕНЬШЕ ссылка:размер_еды ТО (
                ссылка:счет = ссылка:счет + 1;
                ссылка:время_жизни = ссылка:время_жизни + 30;
                ЕСЛИ ссылка:время_жизни БОЛЬШЕ 100 ТО (
                    ссылка:время_жизни = 100;
                )
                ссылка:еда_x = ссылка:генератор:получить_целое_в_диапазоне(50, 750);
                ссылка:еда_y = ссылка:генератор:получить_целое_в_диапазоне(50, 550);
            )
        )
    )

    ОПРЕДЕЛИТЬ МЕТОД (ссылка) отрисовка() (
        ссылка:игра:залить_экран(массив(30, 30, 50));

        ЕСЛИ ссылка:игра_окончена ТО (
            ЗАДАТЬ текст = ссылка:игра:создать_текст(форматировать_строку("Игра окончена! Счет: {}", ссылка:счет), 48, массив(255, 255, 255));
            ссылка:игра:отобразить_текст(текст, 200, 250);
            ЗАДАТЬ рестарт = ссылка:игра:создать_текст("ПРОБЕЛ - заново", 24, массив(200, 200, 200));
            ссылка:игра:отобразить_текст(рестарт, 280, 320);
            ВЕРНУТЬ;
        )

        ! Шкала времени
        ЗАДАТЬ ширина_шкалы = 400 * ссылка:время_жизни / 100;
        ЗАДАТЬ шкала = ссылка:игра:создать_прямоугольник(200, 20, ширина_шкалы, 20);
        ссылка:игра:нарисовать_прямоугольник(шкала, массив(255, 100, 100), 0);

        ! Еда
        ЗАДАТЬ размер = ссылка:размер_еды;
        ссылка:игра:нарисовать_круг(массив(255, 255, 0), ссылка:еда_x, ссылка:еда_y, размер);
        ссылка:игра:нарисовать_круг(массив(255, 100, 0), ссылка:еда_x - 5, ссылка:еда_y - 5, размер / 3);

        ! Счет
        ЗАДАТЬ текст = ссылка:игра:создать_текст(форматировать_строку("Счет: {}", ссылка:счет), 32, массив(255, 255, 255));
        ссылка:игра:отобразить_текст(текст, 10, 10);

        ! Прицел
        ЗАДАТЬ мышь = ссылка:игра:получить_позицию_мыши();
        ЗАДАТЬ mx = достать_из_массива(мышь, 0);
        ЗАДАТЬ my = достать_из_массива(мышь, 1);
        ссылка:игра:нарисовать_линию(массив(255, 255, 255), mx - 10, my, mx + 10, my);
        ссылка:игра:нарисовать_линию(массив(255, 255, 255), mx, my - 10, mx, my + 10);
    )

    ОПРЕДЕЛИТЬ МЕТОД (ссылка) перезапустить() (
        ссылка:счет = 0;
        ссылка:игра_окончена = ЛОЖЬ;
        ссылка:время_жизни = 100;
        ссылка:еда_x = ссылка:генератор:получить_целое_в_диапазоне(50, 750);
        ссылка:еда_y = ссылка:генератор:получить_целое_в_диапазоне(50, 550);
    )
)

ОПРЕДЕЛИТЬ ПРОЦЕДУРУ корень(x) (
    ВЕРНУТЬ x ^ 0.5;
)

ВЫПОЛНИТЬ (
    ЛовляЕды();
)
Работающая игрушка
Работающая игрушка

Телеграм бот

А чтобы доказать, что на LawScript можно писать не только игрушки, но и что-то приближенное к реальности, я запилил Telegram-бота.

Бот умеет:

  • Отвечать на команду /start

  • Поддерживать "светскую" беседу на темы «приветствия» и «как дела»

  • Работать в режиме long polling (постоянный опрос сервера Telegram)

  • Обрабатывать ошибки сети и переподключаться

Под капотом — всё те же .pyl расширения. HTTP-запросы к Telegram API я обернул в функцию запрос_в_интернет, а всю бизнес-логику написал уже на чистом LawScript.

Вот как выглядит основной модуль бота:

Основной модуль Telegram-бота на LawScript
ВКЛЮЧИТЬ стандартная_библиотека.интернет
ВКЛЮЧИТЬ стандартная_библиотека.время
ВКЛЮЧИТЬ стандартная_библиотека.рандом
ВКЛЮЧИТЬ стандартная_библиотека.строки
ВКЛЮЧИТЬ стандартная_библиотека.конкурентность
ВКЛЮЧИТЬ стандартная_библиотека.ввод_вывод
ВКЛЮЧИТЬ стандартная_библиотека.структуры.*

ВКЛЮЧИТЬ config.*
ВКЛЮЧИТЬ bot.updater
ВКЛЮЧИТЬ bot.sender
ВКЛЮЧИТЬ handlers


ОПРЕДЕЛИТЬ ПРОЦЕДУРУ главная() (
    ЗАДАТЬ фоновые_задачи = Список();
    ЗАДАТЬ события = Очередь(100);
    ЗАДАТЬ настройки = Настройки();
    ЗАДАТЬ ТОКЕН = настройки:токен;
    ЗАДАТЬ БАЗОВЫЙ_АДРЕС = форматировать_строку("{}{}", настройки:адрес, ТОКЕН);
    ЗАДАТЬ актуализатор = Актуализатор(БАЗОВЫЙ_АДРЕС, ТОКЕН);

    фоновые_задачи:добавить(В ФОНЕ актуализатор:ожидание_новых_событий(события));
    фоновые_задачи:добавить(В ФОНЕ обработчик_текста(БАЗОВЫЙ_АДРЕС, события));

    НАПЕЧАТАТЬ "Бот запущен! И ждет сообщений...";

    ждать_всех(фоновые_задачи:в_массив());
)

ВЫПОЛНИТЬ (
    главная();
)

Обработчик сообщений реализует простую, но расширяемую логику:

Обработчик текстовых сообщений
ОПРЕДЕЛИТЬ ПРОЦЕДУРУ _случайный_ответ(ответы, генератор_рандома) (
    ВЕРНУТЬ достать_из_массива(ответы, генератор_рандома:получить_целое_в_диапазоне(0, длина_массива(ответы) - 1));
)

ОПРЕДЕЛИТЬ КЛАСС ТекстовыеОтветы (
    ОПРЕДЕЛИТЬ КОНСТРУКТОР (ссылка) () (
        ссылка:приветствия = массив("И тебе привет!", "Привет!", "Приветик", "Приветос");
        ссылка:как_дела = массив("Хорошо", "Отлично", "Прекрасно, а у Вас?", "Оки-доки");
    )
)

ОПРЕДЕЛИТЬ ПРОЦЕДУРУ обработчик_текста(адрес, очередь_событий) (
    ЗАДАТЬ сообщение;
    ЗАДАТЬ идентификатор_чата;
    ЗАДАТЬ текст;
    ЗАДАТЬ ответ;
    ЗАДАТЬ ответы = ТекстовыеОтветы();
    ЗАДАТЬ генератор_рандома = ГенераторСлучайныхЧисел();
    ЗАДАТЬ темы_разговора = массив("приветствия", "как дела");

    ПОКА ИСТИНА (
        КОНТЕКСТ (
            сообщение = очередь_событий:взять();
        )
        ОБРАБОТЧИК ОчередьПуста КАК _ (
            ЖДАТЬ В ФОНЕ асинхронный_сон(0.1);
            ПРОПУСТИТЬ;
        )

        идентификатор_чата = извлечь_из_таблицы(извлечь_из_таблицы(сообщение, "chat"), "id");

        ЕСЛИ НЕ есть_ключ_в_таблице(сообщение, "text") ТО (
            В ФОНЕ отправить_сообщение(адрес, идентификатор_чата, "Я понимаю только текст :(");
            ПРОПУСТИТЬ;
        )

        текст = извлечь_из_таблицы(сообщение, "text");

        ЕСЛИ текст РАВНО "/start" ТО (
            ответ = форматировать_строку("Привет! Я бот, который умеет говорить на следующие темы: {}", темы_разговора);
        )
        ИНАЧЕ ЕСЛИ входит_в_строку(нижний_регистр(текст), "прив") ИЛИ входит_в_строку(нижний_регистр(текст), "здрав") ТО (
            ответ = _случайный_ответ(ответы:приветствия, генератор_рандома);
        )
        ИНАЧЕ ЕСЛИ входит_в_строку(нижний_регистр(текст), "как дел") ТО (
            ответ = _случайный_ответ(ответы:как_дела, генератор_рандома);
        )
        ИНАЧЕ (
            ответ = форматировать_строку("Я Вас не понял, поэтому отвечу как эхо-бот :) Эхо: '{}'\n\nНапомню, что я могу говорить на следующие темы: {}", текст, темы_разговора);
        )

        В ФОНЕ отправить_сообщение(адрес, идентификатор_чата, ответ);
    )
)

А вот класс Актуализатор, который отвечает за long polling — постоянный опрос сервера Telegram на предмет новых сообщений:

Long polling на LawScript
ОПРЕДЕЛИТЬ КЛАСС Актуализатор (
    ОПРЕДЕЛИТЬ КОНСТРУКТОР (ссылка) (базовый_адрес, токен="ВАШ_ТОКЕН", интервал_опроса=0.5) (
        ссылка:_токен = токен;
        ссылка:_базовый_адрес = базовый_адрес;
        ссылка:_интервал_опроса = интервал_опроса;
    )

    ОПРЕДЕЛИТЬ МЕТОД (ссылка) ожидание_новых_событий(очередь_событий) (
        ЗАДАТЬ сдвиг = 0;
        ЗАДАТЬ обновления;
        ЗАДАТЬ номер_обновления;
        ЗАДАТЬ события;
        ЗАДАТЬ событие;

        ПОКА ИСТИНА (
            обновления = ссылка:получить_событие(сдвиг);

            ЕСЛИ НЕ извлечь_из_таблицы(обновления, "ok") ТО (
                НАПЕЧАТАТЬ форматировать_строку("Произошла ошибка: {}", извлечь_из_таблицы(обновления, "description"));
                ПРОПУСТИТЬ;
            )

            события = извлечь_из_таблицы(обновления, "result");

            НАПЕЧАТАТЬ "получены события";

            ЦИКЛ счет ОТ 0 ДО длина_массива(события) - 1 (
                событие = достать_из_массива(события, счет);
                сдвиг = извлечь_из_таблицы(событие, "update_id") + 1;

                НАПЕЧАТАТЬ форматировать_строку("Обработка события со сдвигом: {}", сдвиг);

                ЕСЛИ есть_ключ_в_таблице(событие, "message") ТО (
                    ЗАДАТЬ сообщение = извлечь_из_таблицы(событие, "message");

                    ПОКА ИСТИНА (
                        КОНТЕКСТ (
                            очередь_событий:положить(сообщение);
                            ПРЕРВАТЬ;
                        )
                        ОБРАБОТЧИК ОчередьПолна КАК _ (
                            НАПЕЧАТАТЬ форматировать_строку("Очередь на обработку полна. Повторяю попытку запланировать событие: {}", сдвиг);
                            ЖДАТЬ В ФОНЕ асинхронный_сон(0.5);
                        )
                    )
                )
            )

            ЖДАТЬ В ФОНЕ асинхронный_сон(ссылка:_интервал_опроса);
        )
    )

    ОПРЕДЕЛИТЬ МЕТОД (ссылка) получить_событие(сдвиг=ПУСТОТА) (
        ЗАДАТЬ адрес = форматировать_строку("{}/getUpdates?timeout=30", ссылка:_базовый_адрес);

        ЕСЛИ сдвиг НЕРАВНО ПУСТОТА ТО (
            адрес = форматировать_строку("{}&offset={}", адрес, сдвиг);
        )

        ЗАДАТЬ ответ;

        ПОКА ИСТИНА (
            КОНТЕКСТ (
                ответ = запрос_в_интернет("GET", адрес, таблица());
                ПРЕРВАТЬ;
            )
            ОБРАБОТЧИК ОшибкаПротоколаПередачиГиперТекста КАК _ (
                НАПЕЧАТАТЬ "Произошла ошибка, при попытке обновить данные. Инициализация следующей попытки...";
                ЖДАТЬ В ФОНЕ асинхронный_сон(1);
            )
        )

        ВЕРНУТЬ извлечь_из_таблицы(ответ, "json");
    )
)

И, конечно, куда без тестов! Я написал небольшой тестовый модуль с моками, чтобы проверять логику бота, не дёргая реальный Telegram:

Тесты для бота
ВКЛЮЧИТЬ стандартная_библиотека.тесты
ВКЛЮЧИТЬ стандартная_библиотека.время
ВКЛЮЧИТЬ стандартная_библиотека.рандом
ВКЛЮЧИТЬ стандартная_библиотека.структуры.*

ВКЛЮЧИТЬ bot.sender
ВКЛЮЧИТЬ config.*
ВКЛЮЧИТЬ handlers


! Мок
ОПРЕДЕЛИТЬ ПРОЦЕДУРУ запрос_в_интернет(метод, адрес, данные) (
    ВЕРНУТЬ таблица();
)

! Мок
ОПРЕДЕЛИТЬ ПРОЦЕДУРУ прочитать_файл(путь_до_файла) (
    ВЕРНУТЬ массив("ТОКЕН=123", "АДРЕС=321");
)

ОПРЕДЕЛИТЬ ПРОЦЕДУРУ запуск() (
    ЗАДАТЬ тестовый_сценарий = ТестовыйСценарий();
    тестовый_сценарий:простой_тест(отправить_сообщение("127.0.0.1", 1337, "тест") РАВНО таблица());

    ЗАДАТЬ ключи_файла = массив("ТОКЕН", "АДРЕС");
    ЗАДАТЬ значения_файла = массив("123", "321");
    тестовый_сценарий:простой_тест(парсер() РАВНО таблица(ключи_файла, значения_файла));

    ЗАДАТЬ настройки = Настройки();
    тестовый_сценарий:простой_тест((настройки:токен РАВНО "123") И (настройки:адрес РАВНО "321"));
)

ВЫПОЛНИТЬ (
    запуск();
)
Пример работы бота
Пример работы бота

Что в итоге?

Я написал язык программирования на Python, с русскоязычным синтаксисом, классами, асинхронностью и FFI. А потом сделал на нём:

  1. Игру «Поймай еду» — с графикой на Pygame, обработкой клавиатуры и игровым циклом.

  2. Telegram-бота — с long polling, очередями сообщений, парсингом конфигов и тестами.

Всё это — работающий код, который можно потрогать и запустить.

Зачем я это сделал? Потому что это прикольно :3

Ссылка на исходники

Комментарии (0)