"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 :

// exemple.html
<pre><code class="language-javalite">
public class HelloWorld {

    public static void main(String[] args) {
        String test = "Hello World!";
        System.out.println(test);
    }
}
</code></pre>
// prism-javalite.js
Prism.languages.javalite = {
 'keyword': /\b(public|static|class|void)\b/g,
 'string': /("|')(\\?.)*?\1/g,
 'punctuation': /[{}[\];(),.:]/g
};

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 :

[
  "\n",
  { type: "keyword", content: "public"},
  " ",
  { type: "keyword", content: "class"},
  " HelloWorld ",
  { type: "punctuation", content: "{"},
  "\n\n",
  { type: "keyword", content: "public"},
  " ",
  { type: "keyword", content: "static"},
  " ",
  { type: "keyword", content: "void"},
  " main",
  { type: "punctuation", content: "("},
  "String",
  { type: "punctuation", content: "["},
  { type: "punctuation", content: "]"},
  " args",
  { type: "punctuation", content: ")"},
  " ",
  { type: "punctuation", content: "{"},
  "\n\t\tString test = ",
  { type: "string", content: "\"Hello World!\""},
  { type: "punctuation", content: ";"},
  "\n\t\tSystem",
  { type: "punctuation", content: "."},
  "out",
  { type: "punctuation", content: "."},
  "println",
  { type: "punctuation", content: "("},
  "test",
  { type: "punctuation", content: ")"},
  { type: "punctuation", content: ";"},
  "\n",
  { type: "punctuation", content: "}"},
  "\n",
  { type: "punctuation", content: "}"},
  "\n"
]

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 :

Token: function(type, content) {
 this.type = type;
 this.content = content;
}

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 :

Token.stringify = function(o, language, parent) {
 if (typeof o == 'string') { // Lexème sans token associé ?
  return o;
 }

 if (Array.isArray(o)) { // Liste de lexème
                         // => on construit le résultat de manière récurvise
  return o.map(function(element) {
   return Token.stringify(element, language, o);
  }).join('');
 }

 var content = Token.stringify(o.content, language, parent);
 var classes = [ 'token', o.type ];

 return '<span class="' + classes.join(' ') + '">' + content + '</span>';
};

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.

pre {
    font-family: Consolas, Monaco, 'Andale Mono', monospace;*
    line-height: 1.5;
    color: black;
}
.token.punctuation {
    color: #999;
}
.token.string {
    color: #690;
}
.token.keyword {
    color: #07a;
}

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.

tokenize : function(text, grammar) {
    var strarr = [ text ];

    tokenloop: for ( var token in grammar) {
        if (!grammar.hasOwnProperty(token) || !grammar[token]) {
            continue;
        }

        var pattern = grammar[token];

        for (var i = 0; i < strarr.length; i++) {

            var str = strarr[i];

            if (str instanceof self.Token) {
                continue;
            }

            var match = pattern.exec(str);

            if (match) {
                var from = match.index - 1,
                    match = match[0],
                    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;
}

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

strarray = ['public class HelloWorld { … }'];
i = 0       +-----------------------------+

'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 { … }'
strarray = [Token, ' class HelloWorld { … }'];
i = 1              +-----------------------+

' 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 { … }'
strarray = [Token, ' ', Token, ' HelloWorld { … }'];
i = 2                   +---+

Il s’agit d’un lexème déjà identifié, on continue.

strarray = [Token, ' ', Token, ' HelloWorld { … }'];
i = 3                          +-----------------+

' 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 de java qui ne sont pas suivies de script (java, javafx mais pas javascript).
    On parle de Negative Lookahead
    .
  • java(?=script) recherche les occurrences de java suivies de script (javascript mais pas java ou javafx).
    On parle de Positive Lookahead
    .
  • (?<!java)script recherche les occurrences de script qui ne sont pas précédées de java (script, postscript mais pas javascript).
    On parle de Negative Lookbehind
    .
  • (?<=java)script recherche les occurences de script précédées de java (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 :

// prism-javalite.js
Prism.languages.javalite = {
  'class-name': {
    pattern: /(?:(class|interface|extends|implements|instanceof|new)\s+)[a-z0-9_]+/ig,
    lookbehind: true
  }
};

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 ?

/(^|[^/])\/(?!\/)(\[.+?]|\\.|[^/\r\n])+\/[gim]{0,3}(?=\s*($|[\r\n,.;})]))/g
  

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 :

var Prism = (function() {

 var self = {

  languages : {},

  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) {
   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;
  },

  Token: function(type, content) {
   this.type = type;
   this.content = content;

   self.Token.stringify = function(o, language, parent) {
    if (typeof o == 'string') {
     return o;
    }

    if (Array.isArray(o)) {
     return o.map(function(element) {
      return self.Token.stringify(element, language, o);
     }).join('');
    }

    var content = self.Token.stringify(o.content, language, parent);
    var classes = [ 'token', o.type ];

    return '<span class="' + classes.join(' ') + '">' + content + '</span>';
   };
  }
 };

 return self;

})();

document.addEventListener('DOMContentLoaded', Prism.highlightAll);


Prism.languages.java = {
 // C-like
 'comment': {
   pattern: /(^|[^\\])\/\*[\w\W]*?\*\//g,
   lookbehind: true
 },
 'string': /("|')(\\?.)*?\1/g,
 'class-name': {
  pattern: /((?:(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/ig,
  lookbehind: true
 },
 'keyword': /\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/g,
 'boolean': /\b(true|false)\b/g,
 'function': {
  pattern: /[a-z0-9_]+\(/ig,
 },
 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?)\b/g,
 'operator': /[-+]{1,2}|!|<=?|>=?|={1,3}|&{1,2}|\|?\||\?|\*|\/|\~|\^|\%/g,
 'ignore': /&(lt|gt|amp);/gi,
 'punctuation': /[{}[\];(),.:]/g,

 // Java Specific
 'keyword': /\b(abstract|continue|for|new|switch|assert|default|goto|package|synchronized|boolean|do|if|private|this|break|double|implements|protected|throw|byte|else|import|public|throws|case|enum|instanceof|return|transient|catch|extends|int|short|try|char|final|interface|static|void|class|finally|long|strictfp|volatile|const|float|native|super|while)\b/g,
 'number': /\b0b[01]+\b|\b0x[\da-f]*\.?[\da-fp\-]+\b|\b\d*\.?\d+[e]?[\d]*[df]\b|\W\d*\.?\d+\b/gi,
 'operator': {
  pattern: /(^|[^\.])(?:\+=|\+\+?|-=|--?|!=?|<{1,2}=?|>{1,3}=?|==?|&=|&&?|\|=|\|\|?|\?|\*=?|\/=?|%=?|\^=?|:|~)/gm,
  lookbehind: true
 }
};

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 et rest.

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