Le fonctionnnement de Valhalla, partie 2 : Découper le code

Cet article est donc le premier de la série où je vais vous expliquer le fonctionnnement de Valhalla. Nous allons commencer par voir comment Valhalla découpe le code des différentes bibliothèques installées.

Tout d'abord, au lancement d'Atom, le pckage va être chargé, et la fonction activate que l'on trouve dans le fichier valhalla.js (on lui indique dans le manifeste). Dans cette fonction, on va faire tout un tas de chose, mais on va surtout initialiser une nouvelle instance de la classe ValaProvider qui fournit les suggestions lorsqu'on commence à taper quelque chose. Mais cette partie ne nous intéresse pas encore. C'est juste de ce bout de code, dans le constructeur de ValaProvider dont nous allons parler :

const vapiDir = atom.config.get('valhalla.vapiDir');
this.manager = new ScopeManager();

atom.workspace.observeTextEditors((editor) => {
    // dès qu'un éditeur est créé
    if (editor.getPath() && editor.getPath().endsWith('.vala')) {
        // si c'est un fichier Vala qui y est ouvert
        editor.onDidStopChanging((event) => {
            // quand il a été modifié, on le « découpe » (voir explication après)
            this.manager.parse(editor.getText(), editor.getPath());
        });
        // Et puis on le découpe aussi maintenant
        this.manager.parse(editor.getText(), editor.getPath());
    }
});

// On lit les fichiers .vapi
fs.readdir (vapiDir, (err, files) => {
    if (err) {
       console.error (err);
       return;
    }

    for (file of files) {
       if (file.endsWith('.vapi')) {
           let content = fs.readFileSync(path.join(vapiDir, file), 'utf-8');
           // On les « découpe »
           this.manager.parse(content, file);
       }
    }
});

Ici, on crée une nouvelle instance de la classe ScopeManager, qui a pour rôle de « découper » le code des bibliothèques installées (contenu dans des fichiers .vapi) et le code écrit par l'utilisateur d'Atom et nous en faire des objets, beaucoup plus simples à utiliser. Par exemple, après avoir été « découpée », une classe ressemblera à quelque chose comme ça :

{
    top: scopeParent, // Le scope parent (un espace de nom par exemple). Je ne l'ai pas réellement mis, car il contient une référence vers ces enfants, donc cet objet et on n'en finirait pas.
    at: [[0, 0] [10, 1]], // L'endroit où est définie cette classe dans le fichier.
    name: 'public class Hey : Object ', // La définition de la classe.
    children: [enfant1, enfant2, enfant3], // Les enfants de cette classe (méthodes, propriétés ...), pareil que pour `top`, je ne les ait pas mis.
    file: 'bibli.vapi', // Le fichier d'où vient la classe.
    documentation: { // La documentation de cette classe. En général elle n'est pas dans les fichiers .vapi, mais dans les fichiers .vala (qui continnent le code écrit par l'utilisateur)
        short: 'Une super classe.',
        long: 'En vrai elle a trop la classe ! \n\n\n\n\n\n\n\n ... \n\n\n LOL.'
    },
    vapi: true, // Indique si cette classe vient d'un fichier .vapi.
    data: { // Des données à propos de la classe, qu'on parse une fois et qu'on réutilise plein de fois.
        attributes: [ // Les attributs de cette classe.
            {
                name: 'CCode',
                parameters: 'cname = ' // Oui, il n'y a pas de valeur après le =, parce que c'est une expression entre " et qu'elles sont ignorées.
            }
        ],
        type: 'class', // Indique qu'on a affaire à une classe.
        name: 'Hey', // Le nom de la classe.
        access: 'public', // Sa visibilité.
        inherits: 'Object' // Les classes dont elle hérite
    }
}

Et voilà c'est tout beau, près à être réutilisé pour afficher des suggestions !

L'algorithme qui fait ça

Je vais essayer de décomposer les grandes lignes de la méthode parse qui est chargée de faire toute cette découpe :

  • On a un tableau (this.scopes), qui contient les objets représentant la racine de chaque fichier ;
  • On trouve celui du fichier courant, ou on le crée : c'est la variable currentScope ;
  • On passe chaque caractère du code un à un, en ignorant les commentaires, les expressions litérales ("chaîne de caractères", par exemple) et tout ce qui ne nous intérèsse pas;
  • Les caractères sont ajoutés à une varibale token (qui en fait n'est pas un seul token, mais plusieurs) ;
  • Si le caractères actuel est { ou ;, ça veut dire qu'on arrive à une déclaration :
    • On va alors utiliset tout plein d'expression rationelles pour faire la découpe de notre token ;
    • On stocke ce qu'on a découpé dans la liste des enfants de currentScope, et l'objet qu'on a créé devient le currentScope ;
    • On réinitialise token;

Au final on se retrouve avec ce qu'on pourrait appeler un « arbre bi-directionnel » représentant les différentes déclarations de notre code. On peut alors l'explorer comme ceci :

const explore = (scope) => {
    // traitement des données du bloc de code ...
    // par exemple pour faire des suggestions

    for (const child of scope.children) {
        explore (child);
    }
};

for (const scope of this.scopes) {
    explore (scope);
}

Optimisations

Au début, je n'avait de propriété data contenant les différentes informations sur le bloc de code correspondant (son nom, son type, ses arguments, etc). Je devait donc réutiliser des Regex à chaque fois pour découper mes objets. Ce qui en plus de prendre de la place, ralentissait beaucoup Atom (se taper 10000 fois une dizaine de Regex différentes, ça fait pas du bien). J'ai donc pris un peu de temps pour réécrire ça comme il faut.

Une erreur un peu plus subtile, vient du fait que je stockait mes RegEx en « dur » dans mon code, comme ça.

if (klassMatch = token.match(/^(public |private |internal )?(abstract )?class ([\w\.]+)( :( [\w\.,]+)+)?( ?{)?$/)) {
    // récupérer les informations
}

Sauf que JavaScript (et à peu près tous les langages en fait) compile les RegEx lors de leur création, ce qui prend pas mal de temps aussi. J'ai donc changé ça, en stockant toutes les RegEx dont j'ai besoin dans this.re. C'est marrant, mais ça fait gagner un temps fou !

Une autre erreur que j'ai assez souvent fait avant de m'en rendre compte, est d'utiliser beaucoup de console.log (genre dans une boucle sur 10000 éléments). Atom est vraiment beaucoup ralenti, et on peut même le faire planter comme ça ! Maintenant, je préfère mettre des points d'arrêts dans le déboggueur.


Voilà ce premier article est fini, j'espère que ça vous aura intéréssé ou aidé. Le prochain devrait sortir bientôt !