Création d'un DSL en Python : Comment j'ai construit un interpréteur en langage russe pour mon projet de diplôme
L'interpréteur LawScript n'est pas seulement une expérience académique — c'est un DSL pratique doté d'un sous-système impératif, construit en Python pour relever des défis juridiques. L'auteur a combiné des descriptions déclaratives de contrats avec une logique procédurale pour la validation des règles, créant un langage hybride approuvé par le département comme projet de thèse. Au cœur : un analyseur syntaxique sans lexer séparé, un préprocesseur avec métadonnées de ligne, et une compilation vers un AST sérialisé sous forme de « bytecode ».
Architecture sans lexer : Quand réinventer la roue est un atout
L'habituel pipeline « lexer → parser → compiler → interpreter » est délibérément rompu ici : l'analyse lexicale est intégrée directement dans l'analyseur syntaxique. Chaque ligne de code source est encapsulée dans une classe Line qui stocke le numéro de ligne, le chemin du fichier et le texte original. Cela préserve le contexte d'erreur et simplifie le débogage. Structure d'exemple :
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)
Le préprocesseur gère les directives VKLYuChIT, chargeant récursivement les modules du répertoire courant ou de la bibliothèque standard. Il supporte trois types de fichiers : .raw (fichiers source), .law (AST sérialisé), .pyl (extensions Python). Les imports sont mis en cache pour éviter le retraitement.
Analyse syntaxique par composition : La grammaire comme un ensemble de microservices
L'analyseur syntaxique est implémenté comme une classe de base abstraite, avec des analyseurs spécialisés qui en héritent pour chaque construction grammaticale. Cela permet d'imbriquer les analyseurs : en rencontrant une construction familière, l'analyseur courant délègue à son enfant. Le résultat est un arbre MetaObject, qui est ensuite validé et converti en objets exécutables.
L'analyse lexicale est gérée par la méthode separate_line_to_token, qui divise la ligne en tokens tout en ignorant les commentaires et en vérifiant l'équilibre des crochets. En cas d'erreur, elle fournit un indice positionnel — un curseur pointe vers le symbole problématique. Par exemple, une virgule superflue dans une expression :
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"
)
Notation polonaise inversée et expressions complexes
Les expressions de toute complexité, y compris les arguments par défaut, sont compilées en une pile RPN. Cela gère correctement la priorité des opérateurs et les appels de fonctions imbriqués. Par exemple, une procédure de tri de tableau :
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;
)
Cela se transforme en un AST comme ceci :
[
["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>]]
]
Compilateur et exécution : De l'arbre aux actions
Le compilateur parcourt l'AST, transformant chaque nœud en un objet exécutable. Par exemple, AssignField devient une assignation de variable, Loop une boucle avec des bornes calculées à l'exécution. L'interpréteur exécute ces objets séquentiellement, gérant une pile d'appels et des variables locales. Pour des relances plus rapides, l'AST est sérialisé dans un fichier .law — « bytecode » — chargé sans réanalyse.
Points importants
- DSL à double nature : contrats déclaratifs + logique impérative = hybride unique pour les tâches juridiques.
- Métadonnées par ligne : classe
Linepréserve le contexte pour des diagnostics d'erreur précis. - Composition de l'analyseur : chaque construction grammaticale est sa propre classe, facilitant les extensions du langage.
- RPN pour les expressions : gère l'arithmétique complexe et les arguments par défaut sans dépendances externes.
- Sérialisation de l'AST : fichier
.lawagit comme un cache, accélérant les lancements après la compilation initiale.
— Editorial Team
Aucun commentaire pour le moment.