Всем привет! Сразу хочу сказать. Я просто пришел поделиться, как мне кажется, достаточно интересным проектом. Не претендую на то, что данный язык надо тянуть в продакшен и т.д. Более того, я прекрасно понимаю, что данный ЯП не годится для этого.
А теперь к сути :-)
Я достаточно давно мечтал сделать свой язык программирования, но времени на такое обычно мало. Однако, когда я учился в институте, ко мне пришла прекрасная идея: можно сделать язык программирования своим дипломным проектом. Когда все-таки я пришел с этой идеей к научному руководителю - он меня развернул со словами: "зачем ты собрался писать еще один велосипед? Это не интересно."
Но я не сдавался, поэтому, чтобы "продать" эту идею институту, я решил сделать синтаксис этого ЯП полностью на русском языке и, внимание, вообще сделать 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}'nn"
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:
Блок ВЫПОЛНИТЬ - это точка входа в контракт 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" ТО (
ответ = форматировать_строку("Привет! Я бот, который умеет говорить на следующие темы: {}", темы_разговора);
)
ИНАЧЕ ЕСЛИ входит_в_строку(нижний_регистр(текст), "прив") ИЛИ входит_в_строку(нижний_регистр(текст), "здрав") ТО (
ответ = _случайный_ответ(ответы:приветствия, генератор_рандома);
)
ИНАЧЕ ЕСЛИ входит_в_строку(нижний_регистр(текст), "как дел") ТО (
ответ = _случайный_ответ(ответы:как_дела, генератор_рандома);
)
ИНАЧЕ (
ответ = форматировать_строку("Я Вас не понял, поэтому отвечу как эхо-бот :) Эхо: '{}'nnНапомню, что я могу говорить на следующие темы: {}", текст, темы_разговора);
)
В ФОНЕ отправить_сообщение(адрес, идентификатор_чата, ответ);
)
)
А вот класс Актуализатор, который отвечает за 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. А потом сделал на нём:
-
Игру «Поймай еду» — с графикой на Pygame, обработкой клавиатуры и игровым циклом.
-
Telegram-бота — с long polling, очередями сообщений, парсингом конфигов и тестами.
Всё это — работающий код, который можно потрогать и запустить.
Зачем я это сделал? Потому что это прикольно :3
Автор: BERKYT115
