Как я изобрёл велосипед: создание языка программирования с нуля ради одной игры и Telegram-бота

от автора

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

А теперь к сути 🙂

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

class Info(NamedTuple):    num: int    file: str    raw_line: strclass 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.parentSTANDARD_LIB_PATH = f"{STANDARD_LIB_PATH}{settings.standard_lib_path_postfix}"STD_NAME = settings.std_namedef _standard_lib_alias(path: str) -> str:    if _is_std(path):        return path.replace(STD_NAME, STANDARD_LIB_PATH)    return pathdef _is_std(path: str) -> bool:    return STD_NAME in pathdef 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.voidVOID: 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 pygamefrom src.core.types.atomic import CustomType, Number, Array, Booleanclass 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 Optionalfrom pathlib import Pathfrom src.core.extend.function_wrap import PyExtendWrapper, PyExtendBuilderfrom src.core.types.basetype import BaseAtomicTypebuilder = 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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1025306/