# Tworzenie DSL w Pythonie: jak napisałem rosyjskojęzyczny interpreter na dyplom
Interpreter LawScript — to nie tylko akademicki eksperyment, ale praktyczny DSL z imperatywną podsystemą, stworzony w Pythonie do rozwiązywania zadań prawnych. Autor połączył deklaratywne opisy kontraktów z proceduralną logiką weryfikacji reguł, tworząc język hybrydowy, zaakceptowany przez katedrę jako projekt dyplomowy. W podstawie leży parser bez osobnego leksyka, preprocesor z metadanymi linii oraz kompilacja do AST, serializowanego jako „bajt-kod”.
Architektura bez leksyka: kiedy rower to zaleta
Zwykły łańcuch „leksyk → parser → kompilator → interpreter” został tu celowo przerwany: analiza leksykalna jest wbudowana w parser. Każda linia kodu źródłowego jest opakowana w klasę Line, która przechowuje numer linii, ścieżkę do pliku i oryginalny tekst. Dzięki temu zachowany jest kontekst błędów, a debugowanie staje się prostsze. Przykład struktury:
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)
Preprocesor obsługuje dyrektywy VKLYuChIT, rekurencyjnie ładując moduły z bieżącego katalogu lub standardowej biblioteki. Obsługiwane są trzy typy plików: .raw (źródła), .law (serializowane AST), .pyl (rozszerzenia w Pythonie). Importy są buforowane, by uniknąć ponownego przetwarzania.
Parsowanie przez kompozycję: gramatyka jako zestaw mikrousług
Parser jest zaimplementowany jako abstrakcyjna klasa bazowa, od której dziedziczą specjalistyczne parsery dla każdej konstrukcji gramatycznej. Pozwala to na zagnieżdżanie parserów: napotkawszy znaną konstrukcję, bieżący parser deleguje obsługę podrzędnemu. Wynikiem jest drzewo MetaObject, które następnie jest walidowane i przekształcane w wykonywalne obiekty.
Analiza leksykalna odbywa się metodą separate_line_to_token, która rozbija linię na tokeny, ignorując komentarze i sprawdzając balans nawiasów. W przypadku błędów wyświetlana jest wskazówka pozycyjna — kursor wskazuje problematyczny symbol. Na przykład, zbędna przecinka w wyrażeniu:
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"In vyrazhenii: '{res_expr}' worth lishnyaya zapyataya '{Tokens.comma}'\n\n"
f"{target_comma}\n"
)
Notacja polska odwrotna i złożone wyrażenia
Wyrażenia dowolnej złożoności, w tym argumenty domyślne, są kompilowane do stosu RPN. Umożliwia to poprawne obsługiwanie priorytetów operacji i zagnieżdżonych wywołań funkcji. Na przykład procedura sortowania tablicy:
OPREDELIT PROTsEDURU sortirovka_array(array_numbers) (
SET length = length_array(array_numbers);
SET minimum_index = 0;
LOOP index FROM 0 TO length-1 (
minimum_index = index;
LOOP internal_index FROM index+1 TO length-1 (
IF fetch_from_array(array_numbers, internal_index) MENShE fetch_from_array(array_numbers, minimum_index) THEN (
minimum_index = internal_index;
)
)
IF minimum_index NERAVNO index THEN (
SET temporary_variable = fetch_from_array(array_numbers, index);
change_in_array(array_numbers, index, fetch_from_array(array_numbers, minimum_index));
change_in_array(array_numbers, minimum_index, temporary_variable);
)
)
NAPEChATAT array_numbers;
)
Przekształca się w AST o następującej strukturze:
[
["AssignField", "TARGET", "length", "EXPR", [...]],
["AssignField", "TARGET", "minimum_index", "EXPR", [Number(0)]],
["Loop",
"FROM_EXPR", [Number(0)],
"TO_EXPR", [Service name: <length>, Number(1), Service name: <->],
[
["AssignOverrideVariable", "TARGET_EXPR", [...], "OVERRIDE_EXPR", [...]],
["Loop",
"FROM_EXPR", [...],
"TO_EXPR", [...],
[
["When", "EXPR", [...],
[["AssignOverrideVariable", ...]]
]
]
],
["When", "EXPR", [...]],
[["AssignField", ...], [...], [...]]
]
],
["Print", "EXPR", [Service name: <array_numbers>]]
]
Kompilator i wykonanie: od drzewa do akcji
Kompilator przechodzi po AST, przekształcając każdy węzeł w wykonywalny obiekt. Na przykład AssignField staje się przypisaniem do zmiennej, Loop — pętlą z granicami obliczonymi w czasie wykonywania. Interpreter kolejno wykonuje te obiekty, obsługując stos wywołań i zmienne lokalne. Dla przyspieszenia ponownych uruchomień AST jest serializowane do pliku .law — „bajt-kodu”, który ładuje się bez ponownego parsowania.
Co najważniejsze
- DSL o podwójnej naturze: deklaratywne kontrakty + imperatywna logika = unikalny hybryd dla zadań prawnych.
- Metadane w każdej linii: klasa
Linezachowuje kontekst dla precyzyjnej diagnostyki błędów. - Kompozycja parserów: każda konstrukcja gramatyczna to osobna klasa, co ułatwia rozszerzanie języka.
- RPN dla wyrażeń: obsługa złożonej arytmetyki i argumentów domyślnych bez zewnętrznych zależności.
- Serializacja AST: plik
.lawdziała jak bufor, przyspieszając uruchomienie po pierwszej kompilacji.
— Editorial Team
Brak komentarzy.