Ça Twig ou bien ?

Nicolas Perussel (aka @mamoot)
ekino-france
Published in
8 min readApr 12, 2023
Photo by Jonathan Borba on Unsplash

J’aimerais évoquer avec toi un sujet passionnant l’Abstract Syntax Tree (AST) ainsi que la notion de Visitor fréquemment associée.

Je vais te présenter un REX, sur une implémentation dédiée, dans le cadre du développement d’un connecteur à POEditor, qui fera la part belle à l’ami Benjamin Rambaud alias @beram qui a traité une partie du sujet lors de sa présentation aux Drupal Dev Days 2022. Pour ceux qui n’ont pas encore vu son talk “Une Introduction à l’Analyse Statique”, je vous invite à le découvrir ici ainsi que l’article d’ekino qui l’accompagne.

La pub est faite, let’s go !

Je vais te faire un petit débrief du travail que nous avons dû effectuer sur un projet, dont le but était d’extraire toutes les chaines de traduction utilisées à divers endroits de l’application Drupal 9 (PHP, Twig, Javascript, Yaml) afin de transmettre tout ça à un service tiers : POEditor. De la traduction de la configuration, au thème, en passant par les fichiers PHP, crois moi, ce travail d’extraction n’est pas simple !!!

Pour les Drupaliens pures souches, le module POTX ne correspond pas et aucune solution viable n’a été trouvée. Le développement “custom” s’est donc imposé, et IMHO, c’est ça qui est bon !

Je vais me focus sur la partie thème, donc plus précisément sur Twig, car je trouve la syntaxe plus simple mais c’est EXACTEMENT le même principe qu’avec le PHP-Parser de Nikita (❤️) qui nous avons utilisé pour extraire les chaines de traduction de la configuration.

Afin d’extraire les chaines traduction, il n’y a pas trente-six solutions :

  • La solution orientée “system” : tu utilises sed, c’est rapide et ça peut-être efficace, mais tu va manquer de souplesse pour l’entrée et la sortie des éléments trouvés. C’est une solution orientée “bidouille”.
  • La solution “obvious” : tu utilises nos bonnes vieilles regex, mais ce n’est pas hyper souple et c’est plutôt gourmand en terme de consommation CPU et mémoire (mais ça peut rester viable)
  • La solution des “damnés” (aka ‘à la mano’) : tu le fais manuellement, mais c’est pénible (très)
  • La “bonne” solution : tu utilises l’AST pour parcourir le code source => c’est THE WAY IMHO.

Par ailleurs, pour les Symfonistes, allez jeter un coup d’oeil dans la librairie TwigBridge, les notions de Node et de Visitor sont bien présentes 😺 .

Bref, pour comprendre comment est généré l’AST de Twig, il faut aborder certains mécanismes liés à la “Théorie de Compilation”. Dans le cas de Twig, tu vas avoir besoin d’un Lexer (qui va produire et organiser des Tokens), d’un Parser et même d’un Compiler.

Tu vas dans un premier temps charger le contenu d’un template Twig (via un Loader, notamment le ChainLoader); ensuite le Lexer va rentrer en jeu et analysera le contenu puis segmentera le code source en petits morceaux afin de simplifier le traitement. Enfin, l’analyseur syntaxique (Parser), va se charger d’interpréter les tokens produits par le Lexer, afin de créer un arbre syntaxique abstrait (AST: Abstract Syntax Tree).

Avec l’aide d’un schéma, comprendre ce workflow est sûrement plus simple :

https://imjching.com/writings/2017/02/16/lexical-analysis-with-antlr-v4/

Qu’est-ce qu’un “token” ?

Un “token” est une chaine de caractères catégorisée selon une table des symboles. En gros, ça permet au Lexer d’identifier la “grammaire” (ici le langage Twig) pour ensuite la convertir en données interprétables pour le Parser.

Les “tokens” sont généralement définis par des expressions régulières qui vont être “comprises” par le Lexer (on parle d’analyse lexicale). Celui-ci est donc en charge de lire un flux de caractères, d’identifier les “lexemes” (grammaire) et de les classer dans des types prédéfinis (les fameux token). Cette phase se nomme “Tokenizing” soit en bon Français “Tokenisation”.

Ci-dessous, quatre exemples de tokens fournis par Twig :

# Delimiters for blocks {% %}
\Twig\Token::BLOCK_START_TYPE, \Twig\Token::BLOCK_END_TYPE

#Delimiters for variables {{ }}
\Twig\Token::VAR_START_TYPE, \Twig\Token::VAR_END_TYPE

En tout, Twig reconnait 13 types de Token (sur sa version 3). La bonne nouvelle, c’est que nous pouvons en ajouter à volonté, et donc, étendre la grammaire du langage Twig. On parle alors d’ajout de “tags”.

Comment on fait ça l’ami ?

On se retrousse les manches et tu commences a étendre le “Lexer” par défaut, ou alors, tu peux décorer le bousin (via un Decorator) …

final class MyCustomTwigLexer extends \Twig\Lexer
{
// lot of work here to add custom LexFunction
}

# l'objet Environment permet de set un Lexer
# La porte est ouverte !
public function setLexer(Lexer $lexer)
{
$this->lexer = $lexer;
}

Mais avec cette méthode, tu vas un peu dans le mur… Heureusement, Twig est très bien conçu et te fournit une classe abstraite AbstractTokenParser pour faire ça avec plus d’aisance et un peu de DX.

Par exemple, dans Drupal, la fonction trans est enrichie avec un élément de grammaire plural . Tu trouveras un exemple d’implémentation en suivant ce lien : https://git.drupalcode.org/project/drupal/-/blob/10.1.x/core/lib/Drupal/Core/Template/TwigTransTokenParser.php

Ensuite il te faudra “register” ce nouveau “tag” :

$twig = new \Twig\Environment($loader);
$twig->addTokenParser(new MyNewToken());

La doc de Twig est très bien réalisée, je t’invite à la consulter : https://twig.symfony.com/doc/3.x/advanced.html#registering-a-new-tag

Mais là, je m’égare. Cela sera pour une prochaine fois ! Revenons à ce qui nous concerne directement : le Lexer et le Parser.

Le résultat du Lexer est relativement trivial. Le Lexer retourne un TokenStream avec tous les tokens “flatten”. En voici un aperçu :

Twig\TokenStream^ {#17270
-tokens: array:57 [
0 => Twig\Token^ {#17213
-value: ""
-type: 1
-lineno: 1
}
1 => Twig\Token^ {#17214
-value: "extends"
-type: 5
-lineno: 1
}
2 => Twig\Token^ {#17215
-value: "@organisms/accordion/accordion-card.twig"
-type: 7
-lineno: 1
}
3 => Twig\Token^ {#17216
-value: ""
-type: 3
-lineno: 1
}
4 => Twig\Token^ {#17217
-value: "\n"
-type: 0
-lineno: 2
}
5 => Twig\Token^ {#17218
-value: ""
-type: 1
-lineno: 3
}
6 => Twig\Token^ {#17219
-value: "block"
-type: 5
-lineno: 3
}
7 => Twig\Token^ {#17220
-value: "main_content"
-type: 5
-lineno: 3
}
8 => Twig\Token^ {#17221
-value: ""
-type: 3
-lineno: 3
}
9 => Twig\Token^ {#17222
-value: """
<div class="divide-y">\n

"""
-type: 0
-lineno: 4
}
...

Voici l’extrait Twig qui en découle :

{% embed '@organisms/accordion/accordion-card.twig' %}
{% block main_content %}
<div class="divide-y">

Attends, pourquoi embed est remplacé par un extends ? La réponse est ici : https://github.com/twigphp/Twig/blob/3.x/src/TokenParser/EmbedTokenParser.php#L43

Tu vois que c’est intéressant de comprendre les internals !

Voici la chaine de bout en bout pour retrouver les “Nodes” de l’AST, ça donne un truc du genre :

# Création de l'environnement
$loader = new \Twig\Loader\FilesystemLoader('/path/to/templates');
$twigEnvironment = new \Twig\Environment($loader);

# Ici on récupère le code source à "analyser"
$templateContent = \file_get_contents($templatePath);

# Ici, on demande à l'Environment de Twig de "tokenizer" le code source donné
# en argument. Pour ce faire, on doit créer une 'Source'.
# Le résultat est un "Token Stream" qui contient un array "flat" des tokens détectés
$tokenStream = $twigEnvironment->tokenize(new Source($templateContent, $templateName));

# Ensuite, nous pouvons utiliser le parser pour "traverser" la suite de Token
$nodes = $twigEnvironment->parse($tokenStream);

Maintenant, pour manipuler cet AST, tu vas devoir créer un “Visitor” (qui vient du design pattern Visitor). Benjamin Rambaud a tout dit, regarde sa conf !

Dans mon cas, je vais réaliser ces différentes tâches :

  1. Créer une classe que je suffixerai par “Visitor” car la DX c’est important
  2. Étendre la classe abstraite fournit par Twig AbstractNodeVisitor
  3. Demander à mon environnement Twig de prendre en compte mon visiteur

N’oublions pas que je suis dans un contexte Drupal et que ce visiteur n’existe pas. Si tu es sous Symfony, ce travail est déjà fait, tu es tranquille.

final class TwigTranslationNodeVisitor extends AbstractNodeVisitor
{

private array $messages = [];

public function getPriority(): int
{
return 1;
}

public function getMessagesToTranslate(): array
{
return $this->messages;
}

protected function doEnterNode(Node $node, Environment $env): Node
{

// Check if the node is a filter expression and the filter is "t"
if (
$node instanceof FilterExpression &&
't' === $node->getNode('filter')->getAttribute('value') &&
$node->getNode('node') instanceof ConstantExpression
) {

$this->messages[] = [
'message' => trim($node->getNode('node')->getAttribute('value')),
'context' => $this->extractContext($node->getNode('arguments')),
'metadata' => [
'usage' => 'twig:filter:t',
'line' => $node->getTemplateLine(),
'templateName' => $node->getTemplateName(),
],
];

}

return $node;
}

protected function doLeaveNode(Node $node, Environment $env): ?Node
{
return $node;
}

private function extractContext(Node $arguments): string
{
// @Todo
return "";
}

}
# Ajout du visiteur à l'environnement Twig
$twigTranslationNodeVisitor = new TwigTranslationNodeVisitor();
$twigEnvironment->addNodeVisitor($twigTranslationNodeVisitor);

La classe présentée ci-dessus est simplifiée et fait office d’exemple.
Dans notre cas, on cherche à alimenter le tableau “messages” uniquement avec les appels du filtre “t” (ex : {{ coucou|t }}).

Attention ! Dans cet exemple, il manque le “catch” des appels de trans en tant que “filter” et “function” ainsi que le “catch” des TwigNodeTrans (trans + plural).

Histoire de te faire une idée des chaines à capturer, la documentation Drupal fait la synthèse des différentes manières de traduire des chaines de caractères au sein des templates Twig avec Drupal 8+.

La logique d’invocation 🔥 est la suivante :

$themePath = '/app/app/themes/custom/mon_theme/';

$visitor = new TwigTemplateVisitor($twigEnvironment);

$directoryIterator = new \RecursiveDirectoryIterator($themePath);
$iterator = new \RecursiveIteratorIterator($directoryIterator);
$twigFiles = new \RegexIterator($iterator, '/\.twig$/');

/** @var \SplFileInfo $twigFile */
foreach ($twigFiles as $twigFile) {
$visitor->visitTwigTemplate($twigFile->getPathname(), $twigFile->getFilename());
}

dump($visitor->getMessagesToTranslate());

La méthode visitTwigTemplate effectue la “glu” décrite plus haut.

Voici un exemple de sortie depuis une commande “Drush” :

"templates/custom-error--403.html.twig" => array:3 [
0 => array:3 [
"message" => "@errorLabel@ (@errorCode@)"
"context" => "mysite: error"
"metadata" => array:3 [
"usage" => "twig:filter:trans"
"line" => 4
"templateName" => "custom-error--403.html.twig"
]
]
1 => array:3 [
"message" => "Sorry but you have to be logged in to access this page."
"context" => "mysite: error"
"metadata" => array:3 [
"usage" => "twig:filter:trans"
"line" => 12
"templateName" => "custom-error--403.html.twig"
]
]
2 => array:3 [
"message" => "Sorry but you don't have enough permissions to access this page."
"context" => "mysite: error"
"metadata" => array:3 [
"usage" => "twig:filter:trans"
"line" => 21
"templateName" => "custom-error--403.html.twig"
]
]
]

Avec cette méthode, on peut sortir plus de 8000 chaines de traduction en moins de 3s 🚀.

Puissant, non ? Cet article étant déjà bien huge, je vais m’arrêter là !

Si tu veux Deep Dive le sujet, la documentation de Twig sur les internals est un bon début. Après, il faudra expérimenter et s’amuser.

Je pense te parler dans un second article du mécanisme utilisé pour exporter les traductions de la configuration. Toute une histoire…

Un grand merci si tu m’as lu jusqu’au bout 😈.

--

--

Nicolas Perussel (aka @mamoot)
ekino-france

Web addict, passionnated PHP Developer (💜) focus on Software Craftmanship aspects and love to deep dive Framework implemenation. Mario fan and music lover.