Créer un mini compilateur avec PLY en Python
Un tutoriel complet pour comprendre comment construire un mini compilateur avec PLY, en séparant l'analyse lexicale, l'analyse syntaxique et l'exécution du code.
Créer un mini compilateur avec PLY en Python
Introduction
Créer un compilateur peut sembler réservé aux langages de bas niveau ou aux personnes très avancées. En réalité, on peut déjà comprendre les bases d'un compilateur en construisant un mini projet simple avec Python et PLY.
PLY signifie Python Lex-Yacc. C'est une bibliothèque qui permet de faire deux choses essentielles :
- découper une chaîne de caractères en tokens avec
lex; - analyser la structure grammaticale avec
yacc.
L'idée de ce tutoriel est de construire un petit compilateur pour un langage très simple, capable de comprendre :
- des nombres ;
- des opérations arithmétiques ;
- des variables ;
- des affectations ;
- des affichages avec
print.
Ce que vous allez apprendre
À la fin de ce tutoriel, vous saurez :
- comprendre le rôle d'un compilateur ;
- distinguer l'analyse lexicale et l'analyse syntaxique ;
- installer PLY ;
- écrire les tokens de base ;
- définir une grammaire simple ;
- construire un mini interpréteur ;
- tester votre mini langage avec plusieurs exemples.
1. Qu'est-ce qu'un compilateur ?
Un compilateur est un programme qui transforme un code source en une autre forme exploitable.
Selon le contexte, il peut :
- produire du code machine ;
- produire un langage intermédiaire ;
- exécuter directement une structure interne comme un arbre syntaxique.
Dans ce tutoriel, nous allons construire un mini compilateur de type interpréteur. Cela veut dire que notre programme va analyser une petite syntaxe, puis calculer directement le résultat.
2. Les grandes étapes du traitement
Un compilateur suit souvent plusieurs étapes :
- Analyse lexicale : on découpe le texte en tokens.
- Analyse syntaxique : on vérifie que les tokens forment une structure valide.
- Construction d'une représentation interne : souvent un arbre syntaxique.
- Exécution ou génération de code : on produit un résultat.
PLY aide précisément à gérer les deux premières étapes.
3. Préparer le projet
Avant d'écrire le code, il faut préparer le dossier du projet.
Structure conseillée
mini-compilateur/
├── main.py
└── requirements.txt
Installation de PLY
Dans votre terminal :
pip install ply
Ou, si vous préférez utiliser un fichier de dépendances :
ply
Puis :
pip install -r requirements.txt
Où mettre le code ?
- le code Python principal dans
main.py; - les dépendances dans
requirements.txt; - les exemples d'entrée directement dans le fichier Python pour commencer.
4. Définir le mini langage
Pour apprendre clairement, nous allons partir d'un langage très simple.
Il supportera :
- les nombres entiers ;
- les opérations
+,-,*,/; - les parenthèses ;
- les variables ;
- les affectations avec
=; - l'instruction
print.
Exemples valides
a = 10
b = 5
print(a + b * 2)
Le but est de comprendre comment transformer ce texte en calcul réel.
5. L'analyse lexicale avec PLY Lex
L'analyse lexicale consiste à découper le texte en morceaux compréhensibles appelés tokens.
Par exemple, cette ligne :
print(a + 3)
peut être découpée en :
PRINTLPARENIDPLUSNUMBERRPAREN
Exemple de tokens
Voici une première base pour notre analyse lexicale :
import ply.lex as lex
reserved = {
'print': 'PRINT',
}
tokens = [
'ID',
'NUMBER',
'PLUS',
'MINUS',
'TIMES',
'DIVIDE',
'LPAREN',
'RPAREN',
'EQUALS',
] + list(reserved.values())
# Tokens simples
t_PLUS = r'\+'
t_MINUS = r'-'
t_TIMES = r'\*'
t_DIVIDE = r'/'
t_LPAREN = r'\('
t_RPAREN = r'\)'
t_EQUALS = r'='
t_ignore = ' \t'
def t_NUMBER(t):
r'\d+'
t.value = int(t.value)
return t
def t_ID(t):
r'[a-zA-Z_][a-zA-Z0-9_]*'
t.type = reserved.get(t.value, 'ID')
return t
def t_newline(t):
r'\n+'
t.lexer.lineno += len(t.value)
def t_error(t):
print(f"Caractère illégal : {t.value[0]!r}")
t.lexer.skip(1)
lexer = lex.lex()
Explication
tokenscontient la liste des types reconnus ;t_NUMBERconvertit les nombres en entiers ;t_IDreconnaît les noms de variables ;reservedpermet de distinguer les mots-clés commeprint;t_errorgère les erreurs lexicales.
6. L'analyse syntaxique avec PLY Yacc
L'analyse syntaxique vérifie que les tokens sont assemblés dans le bon ordre.
Par exemple :
a = 3est valide ;= a 3ne l'est pas ;print(a + 2)suit une structure bien définie.
Construire la grammaire
Nous allons maintenant décrire les règles de notre mini langage.
import ply.yacc as yacc
# ... tokens et lexer déjà définis ...
precedence = (
('left', 'PLUS', 'MINUS'),
('left', 'TIMES', 'DIVIDE'),
)
names = {}
def p_statement_assign(p):
'statement : ID EQUALS expression'
names[p[1]] = p[3]
p[0] = p[3]
def p_statement_print(p):
'statement : PRINT LPAREN expression RPAREN'
print(p[3])
p[0] = p[3]
def p_expression_binop(p):
'''expression : expression PLUS expression
| expression MINUS expression
| expression TIMES expression
| expression DIVIDE expression'''
if p[2] == '+':
p[0] = p[1] + p[3]
elif p[2] == '-':
p[0] = p[1] - p[3]
elif p[2] == '*':
p[0] = p[1] * p[3]
elif p[2] == '/':
p[0] = p[1] / p[3]
def p_expression_group(p):
'expression : LPAREN expression RPAREN'
p[0] = p[2]
def p_expression_number(p):
'expression : NUMBER'
p[0] = p[1]
def p_expression_id(p):
'expression : ID'
p[0] = names.get(p[1], 0)
def p_error(p):
if p:
print(f"Erreur de syntaxe près de {p.value!r}")
else:
print("Erreur de syntaxe à la fin de l'entrée")
parser = yacc.yacc()
Ce que fait chaque règle
p_statement_assign
Cette règle traite une affectation comme a = 10.
p_statement_print
Cette règle traite l'affichage d'une expression.
p_expression_binop
Cette règle gère les opérations arithmétiques.
p_expression_group
Elle permet de gérer les parenthèses.
p_expression_id
Elle récupère la valeur d'une variable déjà définie.
7. Construire un fichier complet
Voici une version complète du mini compilateur dans un seul fichier main.py.
import ply.lex as lex
import ply.yacc as yacc
reserved = {
'print': 'PRINT',
}
tokens = [
'ID',
'NUMBER',
'PLUS',
'MINUS',
'TIMES',
'DIVIDE',
'LPAREN',
'RPAREN',
'EQUALS',
] + list(reserved.values())
t_PLUS = r'\+'
t_MINUS = r'-'
t_TIMES = r'\*'
t_DIVIDE = r'/'
t_LPAREN = r'\('
t_RPAREN = r'\)'
t_EQUALS = r'='
t_ignore = ' \t'
def t_NUMBER(t):
r'\d+'
t.value = int(t.value)
return t
def t_ID(t):
r'[a-zA-Z_][a-zA-Z0-9_]*'
t.type = reserved.get(t.value, 'ID')
return t
def t_newline(t):
r'\n+'
t.lexer.lineno += len(t.value)
def t_error(t):
print(f"Caractère illégal : {t.value[0]!r}")
t.lexer.skip(1)
lexer = lex.lex()
precedence = (
('left', 'PLUS', 'MINUS'),
('left', 'TIMES', 'DIVIDE'),
)
names = {}
def p_program_statement(p):
'program : statement'
p[0] = p[1]
def p_statement_assign(p):
'statement : ID EQUALS expression'
names[p[1]] = p[3]
p[0] = p[3]
def p_statement_print(p):
'statement : PRINT LPAREN expression RPAREN'
print(p[3])
p[0] = p[3]
def p_expression_binop(p):
'''expression : expression PLUS expression
| expression MINUS expression
| expression TIMES expression
| expression DIVIDE expression'''
if p[2] == '+':
p[0] = p[1] + p[3]
elif p[2] == '-':
p[0] = p[1] - p[3]
elif p[2] == '*':
p[0] = p[1] * p[3]
elif p[2] == '/':
p[0] = p[1] / p[3]
def p_expression_group(p):
'expression : LPAREN expression RPAREN'
p[0] = p[2]
def p_expression_number(p):
'expression : NUMBER'
p[0] = p[1]
def p_expression_id(p):
'expression : ID'
p[0] = names.get(p[1], 0)
def p_error(p):
if p:
print(f"Erreur de syntaxe près de {p.value!r}")
else:
print("Erreur de syntaxe à la fin de l'entrée")
parser = yacc.yacc()
if __name__ == '__main__':
while True:
try:
text = input('mini> ')
except EOFError:
break
if not text:
continue
parser.parse(text)
8. Exemple d'utilisation
Lancez le programme et testez :
a = 10
b = 5
print(a + b * 2)
Résultat attendu :
20
Autre exemple
x = 8
print((x + 2) * 3)
Résultat :
30
9. Comprendre l'ordre des opérations
Comme en mathématiques, certaines opérations sont prioritaires.
Par exemple :
10 + 5 * 2
Le résultat est 20, car la multiplication est effectuée avant l'addition.
La règle precedence permet de refléter cet ordre dans la grammaire.
10. Gérer les erreurs
Un compilateur doit savoir détecter les erreurs.
Erreur lexicale
Si un caractère n'est pas reconnu, t_error intervient.
Erreur syntaxique
Si la structure est incorrecte, p_error affiche un message.
Exemple :
a =
ou
print(1 + )
Ces cas doivent être signalés proprement.
11. Améliorations possibles
Une fois la base comprise, vous pouvez aller plus loin :
- ajouter plusieurs lignes de code ;
- gérer les chaînes de caractères ;
- créer un vrai arbre syntaxique abstrait ;
- ajouter les comparaisons ;
- ajouter les conditions
if; - ajouter les boucles ;
- générer du code Python à partir de l'arbre.
12. Résumé
Dans ce tutoriel, nous avons vu :
- ce qu'est un compilateur ;
- le rôle de l'analyse lexicale ;
- le rôle de l'analyse syntaxique ;
- comment utiliser PLY avec Python ;
- comment créer un mini langage arithmétique ;
- comment gérer des variables et
print; - comment détecter les erreurs.
Exercices
Exercice 1
Ajoutez le support des nombres à virgule.
Exercice 2
Ajoutez plusieurs lignes de code dans votre mini langage.
Exercice 3
Ajoutez une instruction print pour afficher des variables.
Exercice 4
Essayez de créer un message d'erreur plus lisible.
Corrigés
Correction exercice 1
Remplacez r'\d+' par une règle qui accepte aussi les nombres décimaux.
Correction exercice 2
Ajoutez une grammaire de type program : program statement.
Correction exercice 3
La règle p_expression_id permet déjà de lire la valeur d'une variable.
Prochaine étape
Quand vous serez à l'aise avec ce mini compilateur, la suite naturelle est de construire un vrai arbre syntaxique abstrait pour séparer l'analyse et l'exécution.