Les compilateurs permettent d'analyser et manipuler des données textuelles. Leurs applications sont très nombreuses. Hoa\Compiler propose de manipuler plusieurs compilateurs selon les besoins.

Table des matières

Introduction

Ce qui se conçoit bien s'énonce clairement, et les mots pour le dire viennent aisément.

Un langage est une façon d'exprimer ou de formuler une solution à un problème. Et des problèmes, il en existe beaucoup. Nous lisons et écrivons dans plusieurs langages au quotidien, et certains de ces langages sont compris par des machines. Cette opération est possible grâce aux compilateurs.

La théorie des langages étudie entre autres l'analyse automatique de ces langages à travers des outils comme des automates ou des grammaires. Il est nécessaire d'avoir un cours détaillé pour bien comprendre tous ces concepts. Toutefois, nous allons essayer de vulgariser un minimum pour permettre une utilisation correcte de Hoa\Compiler.

Langage et grammaire

Un langage est un ensemble de mots. Chaque mot est une séquence de symboles appartenant à un alphabet. Un symbole représente la plus petite unité lexicale d'un langage, il est atomique et nous l'appellons lexème (ou token en anglais). Les séquences de lexèmes représentant les mots sont construites avec des règles. À partir d'un mot et d'une règle racine, nous allons essayer de dériver ses sous-règles. Si une dérivation existe, alors le mot est considéré comme valide, sinon il est considéré comme invalide. Nous parlons aussi de reconnaissance de mots. Par exemple, si nous considérons les règles suivantes :

    exp ::= exp + exp
          | nombre
 nombre ::= chiffre nombre
          | chiffre
chiffre ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

Le mot que nous voulons reconnaître est 7 + 35. La règle racine est exp. Si nous la dérivons (de gauche à droite et de haut en bas, ou left-to-right et top-to-bottom en anglais), nous pouvons avoir exp + exp ou nombre (la disjonction, i.e. le « ou », est représentée par le symbole « | ») :

exp + exp | nombre
→ exp + exp
→ ( exp + exp | nombre ) + exp
→ nombre + exp
→ ( chiffre nombre | chiffre ) + exp

Nous continuons à dériver jusqu'à éliminer toutes les règles et n'avoir que des lexèmes :

…
→ ( chiffre nombre | chiffre ) + exp
→ chiffre + exp
→ ( 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ) + exp
→ 7 + exp
→ 7 + ( exp + exp | nombre )
→ 7 + nombre
→ 7 + ( chiffre nombre | chiffre )
→ 7 + chiffre nombre
→ 7 + ( 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 ) nombre
→ 7 + 3 nombre
→ 7 + 3 ( chiffre nombre | chiffre )
→ 7 + 3 chiffre
→ 7 + 3 ( 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 )
→ 7 + 3 5

Une dériviation existe bel et bien pour reconnaître le mot 7 + 35, c'est donc un mot valide pour ces règles.

Un ensemble de règles est appelé une grammaire. Et donc, une grammaire représente un langage !

Toutefois, il existe plusieurs catégories de grammaires. C'est en 1956 qu'a été formulée la hiérarchie de Chomsky, classant les grammaires en quatre niveaux :

  1. grammaires générales, ou unrestricted grammars, reconnaissant les langages dits de Turing, aucune restriction n'est imposée aux règles ;
  2. grammaires contextuelles, ou context-sensitive grammars, reconnaissant les langages contextuels ;
  3. grammaires algébriques, ou context-free grammars, reconnaissant les langages algébriques, basés sur les automates à pile ;
  4. grammaires régulières, ou regular grammars, reconnaissant les langages réguliers.

Chaque niveau reconnait le niveau suivant. Hoa\Compiler ne traite que les langages définis par les grammaires de niveau 3 et 4. Pour donner rapidement une idée, les grammaires régulières peuvent s'apparenter aux expressions régulières (comme les PCRE), bien connues des développeurs. Mais les grammaires régulières ne permettent pas par exemple de reconnaître des couples de symboles (comme des parenthèses, des accolades ou des guillemets), alors que les grammaires algébriques le permettent (grâce à la notion de piles de lexèmes).

Reconnaissance de mots

En général, le processus de compilation débute par deux analyses : lexicale et syntaxique. Une analyse lexicale consiste à découper un mot en une séquence de lexèmes. Cette séquence sera ensuite utilisée par l'analyseur syntaxique afin de vérifier que le mot appartient au langage.

Selon la grammaire, la reconnaissance ne se fera pas de la même manière, mais le principe reste identique : prendre les lexèmes les uns après les autres dans la séquence et vérifier qu'ils permettent d'avancer dans la dérivation des règles de notre grammaire.

Les analyses syntaxiques sont aussi classées en catégories : LL, LR, LALR etc. Hoa\Compiler ne propose que des analyseurs syntaxiques LL, pour Left-to-right Leftmost derivation, i.e. de la plus haute règle vers la plus profonde, et les règles sont dérivées de la gauche vers la droite. Là encore, il existe des sous-catégories, dont deux que traite Hoa\Compiler : LL(1) et LL(*). D'une manière générale, nous parlons d'analyseurs syntaxiques LL(k) : si un lexème ne permet pas de dériver une règle comme il faut, alors l'analyseur peut revenir jusqu'à k étapes en arrière ; nous parlons aussi de backtrack. Autrement dit, les règles peuvent être ambiguës : à chaque fois que nous dérivons une règle de la grammaire, nous avons plusieurs choix possibles et l'analyseur peut se tromper, c'est pourquoi il doit parfois revenir en arrière. La variable k permet de définir le niveau d'ambiguïté. Si une grammaire peut être analysée par un analyseur syntaxique LL(1), elle est dite non-ambiguë : à chaque lexème utilisé pour dériver nos règles, il n'y a qu'un seul choix possible. Et si nous avons un analyseur syntaxique LL(*), cela signifie que la variable k est indéfinie. L'exemple suivant illustre une grammaire non-ambiguë :

rule ::= a b c | d e f

Et cet exemple illustre une grammaire ambiguë :

rule1 ::= a rule2
rule2 ::= b rule3 | b rule4
rule3 ::= c d
rule4 ::= e f

Voyons quand nous essayons de trouver une dérivation pour le mot abef à partir de la règle racine rule1 :

rule1
→ a rule2                      a  bef ✔
  → a (b rule3 | b rule4)      a  bef
    → a b rule3               ab  ef  ✔
      → a b c d              abe  f   ✖
    ← a b rule3               ab  ef  ✖
  ← a (b rule3 | b rule4)      a  bef
    → a b rule4               ab  ef  ✔
      → a b e f             abef      ✔

La règle rule2 est ambiguë, ce qui peut entraîner une mauvaise dérivation et donc un retour en arrière, un backtracking.

Compilateur de compilateurs LL(k)

Écrire des compilateurs est une tâche laborieuse. Ce n'est pas forcément toujours difficile mais souvent répétitif et long. C'est pourquoi il existe des compilateurs de compilateurs, ou autrement dit, des générateurs de compilateurs. La plupart du temps, ces compilateurs de compilateurs utilisent un langage intermédiaire pour écrire une grammaire. La bibliothèque Hoa\Compiler propose la classe Hoa\Compiler\Llk\Llk qui permet l'écriture de compilateurs de compilateurs à travers un langage dédié.

Langage PP

Le langage PP, pour PHP Parser, permet d'exprimer des grammaires algébriques. Il s'écrit dans des fichiers portant l'extension .pp (voir le fichier hoa://Library/Compiler/.Mime).

Une grammaire est constituée de lexèmes et de règles. La déclaration d'un lexème se fait de la manière suivante : %token namespace_in:name value -> namespace_out, où name représente le nom du lexème, value représente sa valeur, au format PCRE (attention à ne pas reconnaître de valeur vide, auquel cas une exception sera levée), et namespace_in et namespace_out représentent les noms des espaces de noms et sont optionels (vaut default par défaut). Par exemple number qui représente un nombre composé de chiffres de 0 à 9 :

%token number \d+

Les espaces de noms représentent des sous-ensembles disjoints de lexèmes, utilisés pour faciliter les analyses. Une déclaration %skip est similaire à %token excepté qu'elle représente un lexème à sauter, c'est à dire à ne pas considérer. Un exemple courant de lexèmes %skip est les espaces :

%skip space \s

Pour expliquer les règles, nous allons utiliser comme exemple la grammaire Json.pp, grammaire légèrement simplifiée du langage JSON (voir la RFC4627). La grammaire complète se situe dans le fichier hoa://Library/Json/Grammar.pp. Ainsi :

%skip   space          \s
// Scalars.
%token  true           true
%token  false          false
%token  null           null
// Strings.
%token  quote_         "        -> string
%token  string:string  [^"]+
%token  string:_quote  "        -> default
// Objects.
%token  brace_         {
%token _brace          }
// Arrays.
%token  bracket_       \[
%token _bracket        \]
// Rest.
%token  colon          :
%token  comma          ,
%token  number         \d+

value:
    <true> | <false> | <null> | string() | object() | array() | number()

string:
    ::quote_:: <string> ::_quote::

number:
    <number>

#object:
    ::brace_:: pair() ( ::comma:: pair() )* ::_brace::

#pair:
    string() ::colon:: value()

#array:
    ::bracket_:: value() ( ::comma:: value() )* ::_bracket::

Nous remarquons que nous avons deux espaces de noms pour les lexèmes : default et string (cela permet de ne pas confondre les lexèmes quote_ et string:_quote qui ont la même représentation). Nous remarquons ensuite la règle value qui est une disjonction de plusieurs lexèmes et règles. Les constructions du langage PP sont les suivantes :

  • rule() pour appeler une règle ;
  • <token> et ::token:: pour déclarer un lexème (les espaces de noms n'apparaissent pas ici) ;
  • | pour une disjonction (un choix) ;
  • () pour grouper ;
  • e? pour dire que e est optionel ;
  • e+ pour dire que e peut apparaître 1 ou plusieurs fois ;
  • e* pour dire que e peut apparaître 0 ou plusieurs fois ;
  • e{x,y} pour dire que e peut apparaître entre x et y fois ;
  • #node pour créer un nœud node dans l'arbre ;
  • token[i] pour unifier des lexèmes entre eux.

Peu de constructions mais amplement suffisantes.

Enfin, la grammaire du langage PP est écrite… dans le langage PP ! Vous pourrez la trouver dans le fichier hoa://Library/Compiler/Llk/Llk.pp.

Processus de compilation

Le processus de compilation qu'utilise Hoa\Compiler\Llk\Llk est classique. Il commence par analyser lexicalement la donnée textuelle, le mot, i.e. à transformer notre donnée en une séquence de lexèmes. L'ordre de déclaration des lexèmes est primordial car l'analyseur lexical va les prendre les uns après les autres. Ensuite, c'est l'analyseur syntaxique qui entre en jeu afin de reconnaître notre donnée.

Si l'analyse syntaxique est un succès, nous obtenons une trace. Cette trace peut être transformée en AST, pour Abstract Syntax Tree. Cet arbre représente notre donnée textuelle après analyse. Il a l'avantage de pouvoir être visité (nous détaillerons plus loin), ce qui permet par exemple d'ajouter de nouvelles contraintes qui ne peuvent pas être exprimées dans la grammaire, comme une vérification de type.

Manipulons un peu Hoa\Compiler\Llk\Llk. Cette classe est un assistant pour lire une grammaire au format PP facilement. Elle prend en seul argument un flux en lecture vers la grammaire et retourne un compilateur Hoa\Compiler\Llk\Parser prêt à l'emploi. Sur ce compilateur, nous allons appeler la méthode Hoa\Compiler\Llk\Parser::parse pour analyser une donnée JSON. Si la donnée est correcte, nous aurons en retour un AST, sinon une exception sera levée. Enfin, nous allons utiliser le visiteur Hoa\Compiler\Visitor\Dump pour afficher notre AST :

// 1. Load grammar.
$compiler = Hoa\Compiler\Llk\Llk::load(new Hoa\File\Read('Json.pp'));

// 2. Parse a data.
$ast      = $compiler->parse('{"foo": true, "bar": [null, 42]}');

// 3. Dump the AST.
$dump     = new Hoa\Compiler\Visitor\Dump();
echo $dump->visit($ast);

/**
 * Will output:
 *     >  #object
 *     >  >  #pair
 *     >  >  >  token(string:string, foo)
 *     >  >  >  token(true, true)
 *     >  >  #pair
 *     >  >  >  token(string:string, bar)
 *     >  >  >  #array
 *     >  >  >  >  token(null, null)
 *     >  >  >  >  token(number, 42)
 */

Quand nous écrivons et testons une grammaire, nous allons répéter ces trois tâches très régulièrement. C'est pourquoi, le script hoa propose la commande compiler:pp. Cette commande propose d'analyser une donnée par rapport à une grammaire et d'appliquer un visiteur si besoin sur l'AST résultant. Notons que la lecture de la donnée peut se faire à travers un pipe :

$ echo '[1, [1, [2, 3], 5], 8]' | hoa compiler:pp Json.pp 0 --visitor dump
>  #array
>  >  token(number, 1)
>  >  #array
>  >  >  token(number, 1)
>  >  >  #array
>  >  >  >  token(number, 2)
>  >  >  >  token(number, 3)
>  >  >  token(number, 5)
>  >  token(number, 8)

C'est un moyen pratique pour tester rapidement des données par rapport à notre grammaire. Il ne faut pas hésiter à regarder l'aide de la commande compiler:pp pour plus de détails !

Les analyses s'effectuent sur la règle racine mais nous pouvons préciser une autre règle à l'aide du deuxième argument de la méthode Hoa\Compiler\Llk\Parser::parse :

$compiler->parse('{"foo": true, "bar": [null, 42]}', 'object');

Pour utiliser la règle racine, il suffit de passer null.

Et enfin, pour ne pas générer l'AST mais uniquement savoir si la donnée est valide ou pas, nous pouvons utiliser le dernier argument de notre méthode en lui passant false :

$valid = $compiler->parse('{"foo": true, "bar": [null, 42]}', null, false);
var_dump($valid);

/**
 * Will output:
 *     bool(true)
 */

Erreurs

Les erreurs de compilation sont remontées à travers des exceptions, ainsi :

$ echo '{"foo" true}' | hoa compiler:pp Json.pp 0 --visitor dump
Uncaught exception (Hoa\Compiler\Exception\UnexpectedToken):
Hoa\Compiler\Llk\Parser::parse(): (0) Unexpected token "true" (true) at line 1
and column 8:
{"foo" true}
       ↑
in hoa://Library/Compiler/Llk/Parser.php at line 1

Plusieurs exceptions peuvent remonter selon le contexte :

  • durant l'analyse lexicale, Hoa\Compiler\Exception\UnrecognizedToken quand un lexème n'est pas reconnu, i.e. quand la donnée textuelle ne peut plus être découpée en une séquence de lexèmes, et Hoa\Compiler\Exception\Lexer quand d'autres erreurs plus générales arrivent, par exemple si un lexème reconnaît une valeur vide ;
  • durant l'analyse syntaxique, Hoa\Compiler\Exception\UnexpectedToken quand un lexème n'est pas attendu, i.e. qu'il ne permet plus de dériver les règles de la grammaire.

L'exception parente est Hoa\Compiler\Exception\Exception.

Nœuds

Le processus de compilation aboutit très souvent à la production d'un AST. Il est important de contrôler sa forme, sa taille, les données qu'il contient etc. C'est pourquoi il est nécessaire de comprendre la notation #node car elle permet de créer des nœuds dans l'AST. Une précision tout d'abord, les lexèmes déclarés avec la syntaxe <token> apparaîtront dans l'arbre, alors que les autres lexèmes, déclarés avec la syntaxe ::token::, n'y apparaîtront pas. En effet, dans notre dernier exemple, les lexèmes quote_, brace_, colon, comma etc. n'apparaissent pas. Ensuite, il est important de noter que les déclarations de nœuds se surchargent les unes par rapport aux autres au sein d'une même règle. Enfin, un nom de règle peut être précédé par #, comme pour la règle #array, qui permet de définir un nœud par défaut, mais il peut être surchargé. Par exemple, si nous remplaçons la règle array par :

#array:
    ::bracket_:: value() ( ::comma:: value() #bigarray )* ::_bracket::

Si le tableau ne contient qu'une seule valeur, le nœud s'appelera #array, sinon il s'appelera #bigarray ; voyons plutôt :

$ echo '[42]' | hoa compiler:pp Json.pp 0 --visitor dump
>  #array
>  >  token(number, 42)
$ echo '[4, 2]' | hoa compiler:pp Json.pp 0 --visitor dump
>  #bigarray
>  >  token(number, 4)
>  >  token(number, 2)

Bien sûr, il peut arriver qu'un nœud soit créé ou pas selon le dérivation empruntée dans la règle. Le mécanisme est normalement assez intuitif.

Espace de noms

Détaillons un peu le fonctionnement de l'analyseur lexical vis à vis des espaces de noms.

Les lexèmes sont placés dans des espaces de noms, c'est à dire que seuls les lexèmes de l'espace de noms courant seront utilisés par l'analyseur lexical. L'espace de noms par défaut est default. Pour déclarer l'espace de noms d'un lexème, il faut utiliser l'opérateur :. Quand un lexème est consommé, il peut changer l'espace courant pour la suite de l'analyse lexicale, grâce à l'opérateur ->. Ainsi, nous allons déclarer cinq lexèmes : foo et bar dans l'espace default, baz dans ns1 et qux et foo dans ns2. Le fait de déclarer deux fois foo n'est pas gênant car ils sont dans des espaces de noms différent :

%token      foo   fo+     -> ns1
%token      bar   ba?r+   -> ns2
%token  ns1:baz   ba?z+   -> default
%token  ns2:qux   qux+
%token  ns2:foo   FOO     -> ns1

Écrire default:bar est strictement équivalent à bar. Le lexème foo emmène dans ns1, bar dans ns2, ns1:baz dans default, ns2:qux reste dans ns2 et ns2:foo emmène dans ns1. Observons la séquence de lexèmes produite par l'analyseur lexical avec la donnée fooooobzzbarrrquxFOObaz :

$pp = '%token      foo   fo+     -> ns1'     . "\n" .
      '%token      bar   ba?r+   -> ns2'     . "\n" .
      '%token  ns1:baz   ba?z+   -> default' . "\n" .
      '%token  ns2:qux   qux+'               . "\n" .
      '%token  ns2:foo   FOO     -> ns1';

// 1. Parse PP.
Hoa\Compiler\Llk\Llk::parsePP($pp, $tokens, $rawRules);

// 2. Run the lexical analyzer.
$lexer    = new Hoa\Compiler\Llk\Lexer();
$sequence = $lexer->lexMe('fooooobzzbarrrquxFOObaz', $tokens);

// 3. Pretty-print the result.
$format   = '%' . (strlen((string) count($sequence)) + 1) . 's  ' .
            '%-13s %-20s  %-20s  %6s' . "\n";
$header   = sprintf($format, '#', 'namespace', 'token name', 'token value', 'offset');

echo $header, str_repeat('-', strlen($header)), "\n";

foreach ($sequence as $i => $token) {
    printf(
        $format,
        $i,
        $token['namespace'],
        $token['token'],
        $token['value'],
        $token['offset']
    );
}

/**
 * Will output:
 *      #  namespace     token name            token value           offset
 *     ---------------------------------------------------------------------
 *      0  default       foo                   fooooo                     0
 *      1  ns1           baz                   bzz                        6
 *      2  default       bar                   barrr                      9
 *      3  ns2           qux                   qux                       14
 *      4  ns2           foo                   FOO                       17
 *      5  ns1           baz                   baz                       20
 *      6  default       EOF                   EOF                       23
 */

Nous lisons les lexèmes, leur espace et leur valeur dans le tableau. La donnée a pu être découpée, et nous sommes passés d'un espace à un autre. Si cette fois nous essayons avec la donnée foqux, nous aurons une erreur : fo correspond au lexème foo dans l'espace default, nous changeons alors d'espace pour ns1, et là, aucun lexème dans cet espace ne peut reconnaître au moins q. Une erreur sera levée.

Jusqu'à maintenant, nous avons vu comment passer d'un espace à l'autre avec l'opérateur ->. Aucun historique sur les espaces traversés n'est conservé. Toutefois, dans certains cas rares, il peut arriver qu'un espace de lexèmes soit accessible via plusieurs autres et que nous aimerions qu'un lexème déclenche le retour vers l'espace de noms précédent. Autrement dit, nous aimerions un historique des espaces traversés et pouvoir y naviguer (en arrière uniquement). C'est le rôle du mot-clé __shift__ * n, à utiliser après l'opérateur -> et à la place du nom d'un espace. __shift__ est équivalent à dire : revient à l'espace précédent. __shift__ est équivalent à __shift__ * 1, et __shift__ * n à : revient n fois à l'espace précédent.

Lorsque le mot-clé __shift__ apparaît dans la grammaire, les espaces sont gérés comme une pile, d'où le vocabulaire shift. Il faut faire attention à bien dépiler les espaces pour ne pas avoir une pile trop conséquente.

Prenons en exemple la grammaire suivante :

%token       foo1  a    -> ns1
%token       foo2  x    -> ns2
%token       end   e

%token   ns1:bar   b
%token   ns1:baz   c    -> ns3
%token   ns1:tada  t    -> __shift__

%token   ns2:bar   y
%token   ns2:baz   z    -> ns3
%token   ns2:tada  T    -> __shift__

%token   ns3:qux   =   -> __shift__

#test:
    ( <foo1> | <foo2> ) <bar> <baz> <qux> <tada> <end>

Cette grammaire reconnaît deux données : abc=te et xyz=Te. Si le premier lexème foo1 est rencontré, l'analyseur syntaxique changera d'espace pour ns1. Quand il rencontrera le lexème ns1:baz, il passera dans ns3. Ensuite, quand il rencontrera ns3:qux, il reviendra à l'espace précédent, soit ns1. Et ainsi de suite. Maintenant, s'il ne rencontre pas foo1 mais foo2, il ira dans l'espace ns2, puis dans ns3 puis à nouveau ns2. Les mots-clés __shift__ pour ns1:tada et ns2:tada n'ont pas d'autres buts que de dépiler les espaces, mais aucune ambiguïté n'existe : ces espaces ne sont accessibles que par un seul espace, à savoir default.

Maintenant, enregistrons cette grammaire dans un fichier NamespaceStack.pp et utilisons l'option -s/--token-sequence de la commande hoa compiler:pp :

$ echo -n 'abc=te' | hoa compiler:pp NamespaceStack.pp 0 --token-sequence
 #  namespace     token name            token value                     offset
-------------------------------------------------------------------------------
 0  default       foo1                  a                                    0
 1  ns1           bar                   b                                    1
 2  ns1           baz                   c                                    2
 3  ns3           qux                   =                                    3
 4  ns1           tada                  t                                    4
 5  default       end                   e                                    5
 6  default       EOF                   EOF                                  6

$ echo -n 'xyz=Te' | hoa compiler:pp NamespaceStack.pp 0 --token-sequence
 #  namespace     token name            token value                     offset
-------------------------------------------------------------------------------
 0  default       foo2                  x                                    0
 1  ns2           bar                   y                                    1
 2  ns2           baz                   z                                    2
 3  ns3           qux                   =                                    3
 4  ns2           tada                  T                                    4
 5  default       end                   e                                    5
 6  default       EOF                   EOF                                  6

Nous voyons que l'analyse lexicale a réussi à jongler avec les espaces de noms, comme attendu. Nous avions deux façons d'accéder à l'espace ns3 : soit depuis ns1, soit depuis ns2. L'analyseur a réussi à créer un historique des espaces et à y naviguer.

Unification

Une caractéristique qu'apporte le langage PP par rapport à d'autres langages de grammaires connus est la capacité d'exprimer une unification de lexèmes. Imaginons la grammaire suivante :

%token  quote   '|"
%token  string  \w+

rule:
    ::quote:: <string> ::quote::

Les guillemets qui entourent la chaîne de caractère peuvent être de deux sortes : simple, avec le symbole « ' », ou double, avec le symbole « " ». Ainsi, les données "foo" et 'foo' sont valides, mais également "foo' et 'foo" !

L'unification des lexèmes permet d'ajouter une contrainte supplémentaire sur la valeur des lexèmes à l'exécution. La syntaxe est la suivante : token[i]. La valeur de i indique quels lexèmes vont devoir porter la même valeur. Et enfin, l'unification est locale à une instance d'une règle, c'est à dire qu'il n'y a pas d'unification entre les lexèmes de plusieurs règles et que cela s'applique sur la règle appelée uniquement. Ainsi, l'exemple devient :

rule:
    ::quote[0]:: <string> ::quote[0]::

Ce qui invalide les données "foo' et 'foo".

L'unification trouve de nombreuses applications, comme par exemple unifier les noms des balises ouvrantes et fermantes du langage XML.

Abstract Syntax Tree

Un arbre syntaxique abstrait représente une donnée textuelle sous forme structurelle. Chaque nœud de cet arbre est représenté par la classe Hoa\Compiler\Llk\TreeNode. Parmis les méthodes utiles, nous trouvons :

  • getId pour obtenir l'identifiant du nœud ;
  • getValueToken pour obtenir le nom du lexème ;
  • getValueValue pour obtenir la valeur du lexème ;
  • isToken si le nœud représente un lexème ;
  • getChild($i) pour obtenir le $i-ème enfant d'un nœud ;
  • getChildren pour obtenir tous les nœuds ;
  • getChildrenNumber pour connaître le nombre d'enfants ;
  • getParent pour obtenir le nœud parent ;
  • getData pour obtenir une référence vers un tableau de données ;
  • accept pour appliquer un visiteur.

Les visiteurs sont le moyen le plus pratique pour parcourir un AST. En guise d'exemple, nous allons écrire le visiteur PrettyPrinter qui va réécrire une donnée JSON avec notre propre convention (espacements, retours à la ligne etc.). Un visiteur doit implémenter l'interface Hoa\Visitor\Visit et n'aura qu'une seule méthode à écrire : visit qui prend trois arguments : l'élément et deux arguments accessoires (en référence et en copie). Voyons plutôt :

class PrettyPrinter implements Hoa\Visitor\Visit
{
    public function visit (
        Hoa\Visitor\Element $element,
        &$handle = null,
        $eldnah = null
    ) {
        static $i      = 0;
        static $indent = '    ';

        // One behaviour per node in the AST.
        switch ($element->getId()) {
            // Object: { … }.
            case '#object':
                echo '{', "\n";
                ++$i;

                foreach ($element->getChildren() as $e => $child) {
                    if (0 < $e) {
                        echo ',', "\n";
                    }

                    echo str_repeat($indent, $i);
                    $child->accept($this, $handle, $eldnah);
                }

                echo "\n", str_repeat($indent, --$i), '}';

                break;

            // Array: [ … ].
            case '#array':
                echo '[', "\n";
                ++$i;

                foreach ($element->getChildren() as $e => $child) {
                    if (0 < $e) {
                        echo ',', "\n";
                    }

                    echo str_repeat($indent, $i);
                    $child->accept($this, $handle, $eldnah);
                }

                echo "\n", str_repeat($indent, --$i), ']';

                break;

            // Pair: "…": ….
            case '#pair':
                echo
                    $element->getChild(0)->accept($this, $handle, $eldnah),
                    ': ',
                    $element->getChild(1)->accept($this, $handle, $eldnah);

                break;

            // Many tokens.
            case 'token':
                switch ($element->getValueToken()) {
                    // String: "…".
                    case 'string':
                        echo '"', $element->getValueValue(), '"';

                        break;

                    // Booleans.
                    case 'true':
                    case 'false':

                    // Null.
                    case 'null':

                    // Number.
                    case 'number':
                        echo $element->getValueValue();

                        break;
                }

                break;
        }
    }
}

Nous allons voir tout de suite un exemple d'utilisation :

$compiler    = Hoa\Compiler\Llk\Llk::load(new Hoa\File\Read('Json.pp'));
$ast         = $compiler->parse('{"foo": true, "bar": [null, 42]}');
$prettyPrint = new PrettyPrinter();
echo $prettyPrint->visit($ast);

/**
 * Will output:
 *     {
 *         "foo": true,
 *         "bar": [
 *             null,
 *             42
 *         ]
 *     }
 */

La méthode getData est très pratique pour stocker des données susceptibles d'être réutilisées, par exemple d'un visiteur à l'autre. Cette méthode retourne une référence sur un tableau ; ainsi :

$data = $element->getData();

if (!isset($data['previousComputing'])) {
    throw new Exception('Need a previous computing.', 0);
}

$previous = $data['previousComputing'];

Il est courant d'utiliser un visiteur par contrainte : vérifiation des symboles, vérification de types etc. Certains peuvent laisser des données nécessaires pour le suivant. La méthode getData n'impose aucune structuration des données, elle propose uniquement un accès à un tableau. Ce sera à vous de faire le reste.

Utiliser la classe Hoa\Compiler\Llk\TreeNode est vraiment trivial et nous l'utiliserons la plupart du temps avec un visiteur.

Traces

Le compilateur LL(k) que propose Hoa est clairement découpé en plusieurs couches. Chaque couche est exploitable pour permettre une modularité maximum. Quand la grammaire est traduite en « règles machines » et que les analyseurs lexical et syntaxique ont validé une donnée, il en résulte une trace. Cette trace est un tableau composé de trois classes seulement :

  • Hoa\Compiler\Llk\Rule\Entry quand l'analyseur syntaxique est rentré dans une règle ;
  • Hoa\Compiler\Llk\Rule\Ekzit quand l'analyseur syntaxique est sorti d'une règle ;
  • Hoa\Compiler\Llk\Rule\Token quand l'analyseur syntaxique a rencontré un lexème.

Nous pouvons l'obtenir grâce à la méthode Hoa\Compiler\Llk\Parser::getTrace. Pour bien comprendre cette trace, nous allons commencer par un exemple :

$compiler = Hoa\Compiler\Llk\Llk::load(new Hoa\File\Read('Json.pp'));
$ast      = $compiler->parse('{"foo": true, "bar": [null, 42]}');
$i        = 0;

foreach ($compiler->getTrace() as $element) {
    if ($element instanceof Hoa\Compiler\Llk\Rule\Entry) {
        echo str_repeat('>   ', ++$i), 'enter ', $element->getRule(), "\n";
    } elseif ($element instanceof Hoa\Compiler\Llk\Rule\Token) {
        echo
            str_repeat('    ', $i + 1), 'token ', $element->getTokenName(),
            ', consumed ', $element->getValue(), "\n";
    } else {
        echo str_repeat('<   ', $i--), 'ekzit ', $element->getRule(), "\n";
    }
}

/**
 * Will output:
 *     >   enter value
 *     >   >   enter object
 *                 token brace_, consumed {
 *     >   >   >   enter pair
 *     >   >   >   >   enter string
 *                         token quote_, consumed "
 *                         token string, consumed foo
 *                         token _quote, consumed "
 *     <   <   <   <   ekzit string
 *                     token colon, consumed :
 *     >   >   >   >   enter value
 *                         token true, consumed true
 *     <   <   <   <   ekzit value
 *     <   <   <   ekzit pair
 *     >   >   >   enter 13
 *     >   >   >   >   enter 12
 *                         token comma, consumed ,
 *     >   >   >   >   >   enter pair
 *     >   >   >   >   >   >   enter string
 *                                 token quote_, consumed "
 *                                 token string, consumed bar
 *                                 token _quote, consumed "
 *     <   <   <   <   <   <   ekzit string
 *                             token colon, consumed :
 *     >   >   >   >   >   >   enter value
 *     >   >   >   >   >   >   >   enter array
 *                                     token bracket_, consumed [
 *     >   >   >   >   >   >   >   >   enter value
 *                                         token null, consumed null
 *     <   <   <   <   <   <   <   <   ekzit value
 *     >   >   >   >   >   >   >   >   enter 21
 *     >   >   >   >   >   >   >   >   >   enter 20
 *                                             token comma, consumed ,
 *     >   >   >   >   >   >   >   >   >   >   enter value
 *                                                 token number, consumed 42
 *     <   <   <   <   <   <   <   <   <   <   ekzit value
 *     <   <   <   <   <   <   <   <   <   ekzit 20
 *     <   <   <   <   <   <   <   <   ekzit 21
 *                                     token _bracket, consumed ]
 *     <   <   <   <   <   <   <   ekzit array
 *     <   <   <   <   <   <   ekzit value
 *     <   <   <   <   <   ekzit pair
 *     <   <   <   <   ekzit 12
 *     <   <   <   ekzit 13
 *                 token _brace, consumed }
 *     <   <   ekzit object
 *     <   ekzit value
 */

Cet exemple nous révèle plusieurs choses. Tout d'abord, les informations que nous donne la trace peuvent être utiles : si nous sommes sur une règle, nous avons son nom (avec la méthode getRule), et si nous sommes sur un lexème, nous avons son nom (avec la méthode getTokenName), sa représentation (sous la forme d'une PCRE, avec la méthode getRepresentation), sa valeur (avec la méthode getValue), si c'est un nœud à conserver dans l'AST (avec la méthode isKept) et son index d'unification s'il existe (avec la méthode getUnificationIndex). Bien sûr, tout ceci est modifiable, ce qui peut influencer la construction de l'AST qui est le processus (optionnel) suivant.

Ensuite, nous remarquons que parfois nous entrons dans une règle qui existe dans la grammaire, comme object, pair, value etc., et parfois nous entrons dans une règle numérique, comme 13, 12, 21, 20 etc. Quand la grammaire est interprétée pour être transformée en « règles machines », chacune de ses règles est linéarisée selon les opérateurs du langage PP :

  • Hoa\Compiler\Llk\Rule\Choice pour une disjonction ;
  • Hoa\Compiler\Llk\Rule\Concatenation pour une concaténation ;
  • Hoa\Compiler\Llk\Rule\Repetition pour une répétition ;
  • Hoa\Compiler\Llk\Rule\Token pour un lexème (déjà vu précédemment).

Toutes les règles sous ce format sont accessibles à travers la méthode Hoa\Compiler\Llk\Parser::getRules sous la forme d'un tableau. Nous allons afficher toutes les règles accessibles depuis la règle racine pour mieux comprendre :

$compiler = Hoa\Compiler\Llk\Llk::load(new Hoa\File\Read('Json.pp'));

// 1. Get all rules.
$rules    = $compiler->getRules();

// 2. Start with the root rule.
$stack    = [$compiler->getRootRule() => true];

while (false !== current($stack)) {
    $rule = key($stack);
    next($stack);
    echo
        "\n", '"', $rule, '" is a ',
        strtolower(substr(get_class($rules[$rule]), 22));

    $subrules = $rules[$rule]->getContent();

    // 3a. Token.
    if (null === $subrules) {
        continue;
    }

    echo ' of rules: ';

    // 3b. Other rules.
    foreach ((array) $rules[$rule]->getContent() as $subrule) {
        if (!array_key_exists($subrule, $stack)) {
            // 4. Unshift new rules to print.
            $stack[$subrule] = true;
        }

        echo $subrule, ' ';
    }
}

/**
 * Will output:
 *     "value" is a choice of rules: 1 2 3 string object array number
 *     "1" is a token
 *     "2" is a token
 *     "3" is a token
 *     "string" is a concatenation of rules: 5 6 7
 *     "object" is a concatenation of rules: 10 pair 13 14
 *     "array" is a concatenation of rules: 18 value 21 22
 *     "number" is a token
 *     "5" is a token
 *     "6" is a token
 *     "7" is a token
 *     "10" is a token
 *     "pair" is a concatenation of rules: string 16 value
 *     "13" is a repetition of rules: 12
 *     "14" is a token
 *     "18" is a token
 *     "21" is a repetition of rules: 20
 *     "22" is a token
 *     "16" is a token
 *     "12" is a concatenation of rules: 11 pair
 *     "20" is a concatenation of rules: 19 value
 *     "11" is a token
 *     "19" is a token
 */

Si nous lisons la règle object, nous savons que c'est la concaténation des règles 10, pair, 13 et 14. 10 est un lexème, pair est la concaténation des règles string, 16 et value, et ainsi de suite. La grammaire initiale est transformée pour être sous sa forme la plus réduite possible. Ceci permet de raisonner beaucoup plus facilement et rapidement sur les règles. En effet, les traitements sur la grammaire ne sont pas réservés aux AST. À l'étape précédente, avec la trace, nous pouvons déjà effectuer des traitements.

Génération

Une grammaire peut être utile pour deux objectifs : valider une donnée (si nous la voyons comme un automate) ou générer des données. Jusqu'à présent, nous avons vu comment valider une donnée à travers plusieurs processus : préparation de notre compilateur, exécution des analyseurs lexical et syntaxique résultant sur une trace, transformation de la trace vers un AST pour enfin visiter cet arbre. Mais nous pouvons nous arrêter à la première étape, la préparation de notre compilateur, pour exploiter les règles afin de générer une donnée qui sera valide par rapport à notre grammaire.

Hoa\Compiler\Llk\Sampler propose trois algorithmes de générations de données :

  • génération aléatoire et uniforme ;
  • génération exhaustive bornée ;
  • génération basée sur la couverture.

Pourquoi proposer trois algorithmes ? Parce qu'il est illusoire de penser qu'un seul algorithme peut suffir aux nombreux contextes d'utilisations. Chacun répond à des besoins différents, nous l'expliquerons plus loin.

Générer une donnée à partir d'une grammaire se fait en deux étapes :

  1. générer des valeurs pour les lexèmes afin d'avoir des données brutes ;
  2. générer un chemin dans les règles de la grammaire.

Un chemin est équivalent à une dérivation, le vocabulaire est différent selon notre objectif : validation ou génération.

Génération isotropique de lexèmes

Pour générer les valeurs des lexèmes, peu importe l'algorithme utilisé, il doit être rapide. Nous allons utiliser un parcours dit isotrope. Nous partons d'une règle et nous avançons uniquement à partir de choix aléatoires et uniformes localement (uniquement pour ce choix). Par exemple si nous avons une disjonction entre trois sous-règles, nous allons tirer aléatoirement et uniformément entre 1 et 3. Si nous avons une concaténation, nous allons juste concaténer chaque partie. Et enfin, une répétition n'est rien d'autre qu'une disjonction de concaténation : en effet, e{1,3} est strictement équivalent à e | ee | eee. Illustrons plutôt :

([ae]+|[x-z]!){1,3}              repeat [ae]+|[x-z]! 2 times
→ ([ae]+|[x-z]!)([ae]+|[x-z]!)  choose between [ae]+ and [x-z]!
→ ([ae]+)([ae]+|[x-z]!)         repeat ae 2 times
→ [ae][ae]([ae]+|[x-z]!)        choose between a and e
→ e[ae]([ae]+|[x-z]!)           again
→ ea([ae]+|[x-z]!)              choose between [ae]+ and [x-z]!
→ ea([x-z]!)                    choose between x, y and z
→ eay!

Cette génération est naïve mais ce n'est pas important. Ce qui est vraiment important est la génération des chemins dans les règles, ou autrement dit, la génération des séquences de lexèmes.

Génération aléatoire et uniforme

Le premier algorithme est celui de la génération aléatoire et uniforme. Cet algorithme est utile si nous voulons générer des séquences de lexèmes de taille n fixe et avec une uniformité non pas locale (comme avec un parcours isotrope) mais sur l'ensemble de toutes les séquences possibles. Succinctement, l'algorithme travaille en deux étapes : pré-calcul (une seule fois par taille) puis génération. Le pré-calcul est une étape automatique et calcule le nombre de séquences et sous-séquences possibles de taille n. Cette étape aide au calcul de fonctions de répartions pour guider la génération des séquences de lexèmes finales.

Nous allons générer 10 données aléatoires de taille 7, c'est à dire composées de 7 lexèmes. Pour cela, nous allons utiliser la classe Hoa\Compiler\Llk\Sampler\Uniform qui prend en premier argument notre grammaire, en deuxième le générateur de valeurs de lexèmes (ici Hoa\Regex\Visitor\Isototropic) et enfin la taille :

$sampler = new Hoa\Compiler\Llk\Sampler\Uniform(
    // Grammar.
    Hoa\Compiler\Llk\Llk::load(new Hoa\File\Read('Json.pp')),
    // Token sampler.
    new Hoa\Regex\Visitor\Isotropic(new Hoa\Math\Sampler\Random()),
    // Length.
    7
);

for ($i = 0; $i < 10; ++$i) {
    echo $i, ' => ', $sampler->uniform(), "\n";
}

/**
 * Will output:
 *     0 => [ false , null , null ]
 *     1 => [ " l " , null ]
 *     2 => [ [ true ] , true ]
 *     3 => [ [ [ 4435 ] ] ]
 *     4 => [ [ [ 9366 ] ] ]
 *     5 => [ true , false , null ]
 *     6 => { " |h<# " : false }
 *     7 => [ [ [ false ] ] ]
 *     8 => [ false , true , 7 ]
 *     9 => [ false , 5 , 79 ]
 */

Nous pouvons redéfinir la taille avec la méthode Hoa\Compiler\Llk\Sampler\Uniform::setLength. Nous aurons peut-être remarqué que certains opérateurs de répétition n'ont pas de bornes supérieures, comme + ou * ; dans ce cas, nous la définissons à n.

Génération exhaustive bornée

Le deuxième algorithme est celui de la génération exhaustive bornée. Cet algorithme génère toutes les séquences (c'est le côté exhaustif) de lexèmes de taille 1 jusqu'à n (c'est le caractère borné). Un aspect très intéressant de cet algorithme est que l'exhaustivité est une forme de preuve ! L'algorithme fonctionne comme un itérateur et est très simple à utiliser :

$sampler = new Hoa\Compiler\Llk\Sampler\BoundedExhaustive(
    // Grammar.
    Hoa\Compiler\Llk\Llk::load(new Hoa\File\Read('Json.pp')),
    // Token sampler.
    new Hoa\Regex\Visitor\Isotropic(new Hoa\Math\Sampler\Random()),
    // Length.
    7
);

foreach ($sampler as $i => $data) {
    echo $i, ' => ', $data, "\n";
}

/**
 * Will output:
 *     0 => true
 *     1 => false
 *     2 => null
 *     3 => " 8u2 "
 *     4 => { " ,M@aj{ " : true }
 *     5 => { " x`|V " : false }
 *     6 => { " NttB " : null }
 *     7 => { " eJWwA " : 0 }
 *     8 => [ true ]
 *     9 => [ true , true ]
 *     10 => [ true , true , true ]
 *     11 => [ true , true , false ]
 *     12 => [ true , true , null ]
 *     13 => [ true , true , 32 ]
 *     14 => [ true , false ]
 *     15 => [ true , false , true ]
 *     16 => [ true , false , false ]
 *     17 => [ true , false , null ]
 *     18 => [ true , false , 729 ]
 *     19 => [ true , null ]
 *     20 => [ true , null , true ]
 *     21 => [ true , null , false ]
 *     …
 *     157 => [ 780 , 01559 , 32 ]
 *     158 => 344
 */

A l'instar de l'algorithme précédent, nous pouvons redéfinir la borne maximum avec la méthode Hoa\Compiler\Llk\Sampler\BoundedExhaustive::setLength. Et les opérateurs de répétition sans borne supérieure utilisent n.

Génération basée sur la couverture

Le dernier algorithme est celui de la génération basée sur la couverture. Cet algorithme réduit l'explosion combinatoire rencontrée avec l'algorithme précédent mais l'objectif est tout autre : il va générer des séquences de lexèmes qui vont « activer » toutes les branches des règles de la grammaire. Une règle est dite couverte si et seulement si ses sous-règles sont toutes couvertes, et un lexème est dit couvert s'il a été utilisé dans une séquence. Pour assurer une diversité dans les séquences produites, les points de choix entre des sous-règles non couvertes sont résolus par tirages aléatoires. L'algorithme fonctionne également comme un itérateur :

$sampler = new Hoa\Compiler\Llk\Sampler\Coverage(
    // Grammar.
    Hoa\Compiler\Llk\Llk::load(new Hoa\File\Read('Json.pp')),
    // Token sampler.
    new Hoa\Regex\Visitor\Isotropic(new Hoa\Math\Sampler\Random())
);

foreach ($sampler as $i => $data) {
    echo $i, ' => ', $data, "\n";
}

/**
 * Will output:
 *     0 => true
 *     1 => { " )o?bz " : null , " %3W) " : [ false , 130 , " 6 " ] }
 *     2 => [ { " ny  " : true } ]
 *     3 => { " Ne;[3 " : [ true , true ] , " th: " : true , " C[8} " : true }
 */

Pour éviter l'explosion combinatoire et assurer la termination de l'algorithme, nous utilisons l'heuristique suivante sur les opérateurs de répétition : * répétera 0, 1 et 2 fois, + répétera 1 et 2 fois, et enfin {x,y} répétera x, x + 1, y - 1 et y fois. Cette heuristique trouve ses origines dans le test aux limites.

Nous remarquons dans notre exemple que 4 données sont générées et suffisent à couvrir l'ensemble de notre grammaire !

Comparaison entre les algorithmes

Voici quelques indices pour savoir quand utiliser tel ou tel autre algorithme.

Génération aléatoire et uniforme :
  • rapide pour des petites données, grande diversité dans les données et taille fixe ;
  • la phase de pré-calcul est exponentielle et donc longue bien que, ensuite, la génération soit très rapide.
Génération exhaustive bornée :
  • rapide pour des petites données et l'exhaustivité est efficace ;
  • nombre exponentiel de données.
Génération basée sur la couverture :
  • rapide pour des données de taille moyenne ou grande, et diversité des données ;
  • ne considère pas de taille.

Compilateur de compilateurs LL(1)

La documentation pour le compilateur LL(1), à travers la classe Hoa\Compiler\Ll1, n'est pas encore écrite. L'objectif est différent de Hoa\Compiler\Llk : il n'existe pas de langage intermédiaire, les automates sont codés en dur avec des tableaux et ce type de compilateurs est orienté haute performance. C'est pourquoi son comportement est monolithique.

Conclusion

Hoa\Compiler propose deux compilateurs de compilateurs : Hoa\Compiler\Llk et Hoa\Compiler\Ll1, afin de valider des données. Le langage PP permet d'écrire des grammaires algébriques de manière simple et naturelle. Le compilateur de compilateurs LL(k) est découpé en processus distincts ce qui le rend très hackable. Enfin, différents algorithmes permettent de générer des données à partir d'une grammaire selon le contexte d'utilisation.