"Many thanks to Lea Verou, et al., for Prism.js!" - Brendan Eich, créateur of JavaScript
De nombreuses librairies JavaScript proposent depuis bien longtemps la coloration syntaxique de code. Mais c’est une librairie récente, puisque datant de 2013, qui tire le mieux son épingle du jeu en ayant déjà été adoptée par des sites comme Mozilla. Cette librairie s’appelle Prism et est l’oeuvre de Lea Verou, à qui on doit également la librairie -prefix-free et le Code Playground Dabblet. Avec Prism, notre code n’aura jamais été aussi joli. Peu de thèmes mais une qualité et un rendu remarquable. Le plus surprenant survient quand on inspecte le source de la librairie : seulement 400 lignes de JavaScript (là où Google Prettify et SyntaxHighlighter avoisinent les 2000 lignes).
Comment Prism réalise-t-il ce tour de force ? C’est ce que nous allons découvrir en recodant Prism de zéro.
Prism est publié sous licence MIT. Le code présenté dans cet article a été simplifié pour des raisons évidentes et n’a pas pour vocation à être utilisé en dehors de ce contexte d’apprentissage. Cet article est basé sur la dernière de Prism au moment de cet article.
C’est parti
La documentation pour étendre Prism nous donne un premier regard sur le fonctionnement interne de la librairie. Très peu de fonctions sont nécessaires. Commençons donc par dresser la structure générale :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
var Prism = (function() {
var self = {
languages : {}, // Chaque langage supporté vient ajouter sa définition ici
highlightAll : function() {
var elements = document.querySelectorAll('code[class*="language-"]');
for (var i = 0, element; element = elements[i++];) {
self.highlightElement(element);
}
},
highlightElement : function(element) {
var language = element.className.match(/\blanguage-(?!\*)(\w+)\b/i)[1];
var grammar = self.languages[language];
// Set language on the parent, for styling
var parent = element.parentNode;
if (/pre/i.test(parent.nodeName)) {
parent.className = parent.className + ' language-' + language;
}
var code = element.textContent;
element.innerHTML = self.highlight(code, grammar, language);
},
highlight : function(text, grammar, language) {
var tokens = self.tokenize(text, grammar);
return self.Token.stringify(tokens, language);
},
tokenize : function(text, grammar) {
// La fonction de plus bas niveau représentant l’analyseur lexical
}
};
return self;
})();
document.addEventListener('DOMContentLoaded', Prism.highlightAll);
- 7
- Point d'entrée. Recherche tous les codes source dans la page.
- 27
- On remplace le contenu précédent par le contenu stylisé.
- 31
- On découpe le code par tokens qui vont être colorisés à l'aide de classes CSS.
En lisant attentivement le code, on constate qu'il y a peu de difficultés.
On recherche les balises <code>
avec une classe commençant par language-
.
On extrait leur contenu qui va être découpé en lexème comme le ferait un compilateur.
La différence est qu’un compilateur va bien plus loin en recréant
un arbre syntaxique abstrait
dans le but degénérer le langage cible. Ici, rien de tout çà, on ne cherche même pas à valider
que la syntaxe du langage est respectée, on cherche juste des lexèmes (token) pour leur appliquer des classes CSS.
L’analyseur lexical
La fonction tokenize
accepte deux paramètres :
text
: le contenu de la balise<code>
grammar
: la définition du langage, souvent définie dans des fichiers séparés
Prenons un exemple pour mieux illustrer le comportement de cette fonction :
La variable text
contient notre programme HelloWorld et grammar
contient l’objet Prism.languages.javalite
.
La définition du langage reste très basique. Prism supporte bien plus d’options pour gérer de nombreux cas particuliers que nous discuterons par la suite. Notre définition identifie 3 tokens avec à chaque fois une expression régulière décrivant le format de chacun d’entre eux.
Token ou lexème ?
Le Dragon Book nous apporte la réponse
A lexeme is a sequence of characters in the source program that matches the pattern for a token and is identified by the lexical analyzer as an instance of that token.
Pour revenir à notre exemple, notre définition du langage Java est constitué de 3 tokens
(ex : keyword
). Les chaînes de caractères "public"
ou "static"
sont deux
lexèmes du même token keyword
.
Cette définition n'est pas reprise au sein du source de Prism où lexèmes et tokens sont confondus sous le même terme de token.
Voici ce que nous retourne la fonction tokenize
:
On retrouve notre code source découpé en lexèmes. Pour chaque lexème ayant un token associé (mot-clé, ponctuation
ou chaîne de caractères), un objet Token
est créé contenant la valeur du lexème et le nom du token :
Toujours obscur ? Pas d'inquiétude, nous reviendrons sur l'analyseur lexical dans la dernière partie de cet article.
La coloration syntaxique
Une fois la liste de lexèmes en notre possession, il ne nous reste plus grand chose pour réaliser la coloration
syntaxique. C’est la méthode Token.stringify
qui s’en charge :
Cette méthode récursive est d’abord appelée avec le tableau complet. Pour chaque lexème sans token, la valeur est
conservée. Pour les autres, on décore chaque valeur par une balise <span/>
avec les classes
token
et le nom du token (keyword
, punctuation
, string
, ...).
Quelques déclarations CSS est le tour est joué. La balise <pre>
se chargeant quant à elle de
conserver l’espacement et les retours à la ligne.
Voici à quoi ressemble notre HelloWorld avec ces styles :
La dernière pièce manquante à notre puzzle reste toujours l’analyseur lexical.
L'analyseur lexical (le retour)
Commençons par une première version supportant la grammaire précédente.
Au premier abord, la fonction peut paraître complexe mais son fonctionnement est assez simple. Pour chaque token du langage, on itère sur la liste, qui initialement contient le code source complet, mais qui va, itération après itération devenir notre liste de lexèmes.
Déroulons l’algorithme toujours sur notre exemple en ne considérant que le token keyword
dont
l’expression régulière est pour rappel : /\b(public|static|class|void)\b/g
'public class HelloWorld { … }'
satisfait-il notre expression ? Oui
On remplace alors l’élément du tableau par 3 éléments :
- la chaine avant la correspondance : chaine vide ici donc aucun élément à ajouter
- le lexème identifié :
public
- la chaine après la correspondance :
' class HelloWorld { … }'
' class HelloWorld { … }'
satisfait-il notre expression ? Oui
Idem, on remplace l’élément par 3 éléments :
- la chaine avant la correspondance : le caractère espacement ' '.
- le lexème identifié :
class
- la chaine après la correspondance :
' HelloWorld { … }'
Il s’agit d’un lexème déjà identifié, on continue.
' HelloWorld { … }'
satisfait-il notre expression ? Non
Après plusieurs itérations, on atteint enfin la fin du tableau avant de recommencer avec le prochain token, et ainsi de suite jusqu’à parcourir la grammaire complète.
Terminé !
Cela termine notre tour de Prism. Moins de 120 lignes auront été nécessaires.
Vous pouvez consulter le source complet de l’exemple ici.
Bonus : La réalité des langages
Exprimer les tokens à l’aide d’expressions régulières est incontournable. Le programme LEX, créé en 1975 par Mike Lesk et Eric Schmidt, ancien PDF de Google, fonctionnait déjà de la sorte. Malheureusement, les expressions régulières ont des limitations, surtout que leur support dans certains langages comme JavaScript est limité.
Un exemple : nom de classe en Java
Une première proposition serait : [a-z0-9_]+
Problème : Cette expression régulière retourne également les noms variables et de constantes.
Solution :
On peut s’en sortir soit en supportant uniquement les conventions de nommages Java (Camel Case avec première
lettre en majuscule pour une classe) mais cela est trop restrictif pour une librairie comme Prism.
La solution retenue est différente. Un nom de classe en Java ne peut figurer qu’à certains endroits bien définis
(ex : après le mot clé class
). L’idée est donc de rechercher les identifiants immédiatement précédé
par un des mots-clés. Peut-on faire çà avec une expression régulière ? La réponse est oui. (mais...)
lookbehind + lookahead = lookaround
Le Lookahead et le Lookbehind permettent d’exprimer des assertions sur ce qui doit précéder ou suivre la correspondance trouvée par une expression régulière. Un exemple s’impose :
-
java(?!script)
recherche les occurrences dejava
qui ne sont pas suivies descript
(java, javafx mais pas javascript).
On parle de Negative Lookahead
. -
java(?=script)
recherche les occurrences dejava
suivies descript
(javascript mais pas java ou javafx).
On parle de Positive Lookahead
. -
(?<!java)script
recherche les occurrences descript
qui ne sont pas précédées dejava
(script, postscript mais pas javascript).
On parle de Negative Lookbehind
. -
(?<=java)script
recherche les occurences descript
précédées dejava
(javascript mais pas postscript).
On parle de Positive Lookbehind
.
Attention : L’expression régulière (?<=java)script
est différente de javascript
.
Les caractères satisfaisant les lookarounds ne sont pas retournés dans la correspondance
(le résultat est script
pour la première et javascript
pour la seconde).
L’idée des lookarounds est assez facile à saisir. La difficulté est dans leur utilisation car les différences entre les langages est assez marquée. Par exemple, de nombreux langages y compris Perl limitent les caractères autorisés dans un lookbehind (pas de métacaractères ou autres, Perl doit être capable de déterminer de combien de caractères il doit revenir en arrière). Vous trouverez plus d’informations ici.
Qu’en est-il en JavaScript ? La réponse ne va pas nous arranger : JavaScript ne supporte que les lookaheads. Prism tente d’atténuer ce manque en proposant un support limité des Positive Lookbehinds :
Avec cette nouvelle définition, on recherche les identifiants précédés d'un des mot-clés définis. Côté implémentation, si le lookbehind est activé, Prism retire la valeur du premier groupe capturé pour définir la valeur du lexème.
Voici à nouveau la méthode tokenize
avec les lignes modifiées mises en évidence :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
tokenize : function(text, grammar) {
var strarr = [ text ];
tokenloop: for ( var token in grammar) {
if (!grammar.hasOwnProperty(token) || !grammar[token]) {
continue;
}
var pattern = grammar[token],
lookbehind = !!pattern.lookbehind,
lookbehindLength = 0;
pattern = pattern.pattern || pattern;
for (var i = 0; i < strarr.length; i++) { // Don’t cache length as it changes during the loop
var str = strarr[i];
if (str instanceof self.Token) {
continue;
}
var match = pattern.exec(str);
if (match) {
if (lookbehind) {
lookbehindLength = match[1].length;
}
var from = match.index - 1 + lookbehindLength,
match = match[0].slice(lookbehindLength),
len = match.length,
to = from + len,
before = str.slice(0, from + 1),
after = str.slice(to + 1);
var args = [ i, 1 ];
if (before) {
args.push(before);
}
var wrapped = new self.Token(token, match);
args.push(wrapped);
if (after) {
args.push(after);
}
Array.prototype.splice.apply(strarr, args);
}
}
}
return strarr;
}
Note : L’expression régulière actuelle dans Prism ignore notamment les déclarations de variables ou les casts.
Avec cette nouvelle fonctionnalité, on peut désormais envisager des exemples plus poussés comme celui-ci :
Quiz : Trouvez à quel token correspond l’expression régulière suivante ?
Indice : Cette expression a quelque chose de méta en elle.
Remarquez aussi l’utilisation de la solution lookbehind de Prism et le lookahead supporté par tous les navigateurs.
Voici l’implémentation complète :
A vous de forker
- Prism propose un système de "hooks" pour l’écriture de plugins. Pour comprendre son implémentation et surtout comment les plugins l’exploitent, vous pouvez vous tourner vers prism-core.js et vers le dossier plugins.
-
Prism supporte l’intégration d’un langage au sein d’un autre (ex : du HTML contenant du JavaScript ou du
CSS). L’implémentation se révèle élégante, ne nécessitant que quelques dizaines de lignes. Pour en savoir plus :
prism-core.js.
Indice : regarder du côté des propriétés
inside
etrest
.
A Retenir
- Connaître les expressions régulières est un sérieux atout pour un développeur.
- JavaScript ne supporte pas les lookbehinds.
- Token != Lexème