Le fonctionnement de Valhalla, partie 3 : afficher les suggestions

Et voici donc le second article de cette série où je vous explique le fonctionnement du package Atom que je développe : Valhalla. Aujourd’hui, on va voir comment Valhalla fait pour afficher ses suggestions.

Tout ce passe dans dans le fichier provider.js (ou presque). On y trouve une classe (ValaProvider) divisée en trois grandes parties :

  • L’initialisation, où on va notamment lancer la découpe de notre code, comme on l’a vu dans la partie précédente. On va aussi préciser à Atom que le moteur de suggestions n’est valable que dans du Vala ;
  • Le moteur de suggestions, qui va dire quelle suggestions afficher en fonction du contexte, en se basant sur le code qu’on a découpé auparavant ;
  • Des fonctions pour nous aider un peu, par exemple pour afficher facilement une méthode dans les suggestions ou pour déterminer le type d’une expression.

On va surtout s’intéresser à la deuxième partie, c’est là que la plupart des choses se font.

Le moteur de suggestions

Pour obtenir ses suggestions, Atom va exécuter la fonction getSuggestions de notre classe (on verra plus tard comment il sait quelle fonction prendre). Cette fonction retourne une Promise qui est résolue avec un tableau contenant les suggestions sous forme d’objets, ressemblant généralement à ça.

{
    text: 'Suggestion',
    type: 'function',
    leftLabel: 'Super',
    rightLabel: 'Génial',
    description: 'Une super suggestion, vraiment géniale !',
    descriptionMoreURL: 'https://bat41.gitlab.io'
}

Ce code donnera quelque chose comme ça :

Notre suggestion

On a aussi d’autres options plus avancés, si ça vous intéresse, vous pouvez aller jeter un coup d’œuil au wiki du package qui gère l’autocomplétion.

On crée donc un tableau vide qui contiendra toutes nos suggestions par la suite, puis on utilise la méthode qu’on a déjà vu pour explorer notre arbre de blocs de code, et on filtre en fonction du contexte.

// Version (très simplifiée) pour que vous compreniez le principe.
getSuggestions ({editor, bufferPosition, scopeDescriptor, prefix, activatedManually}) {
    // On obtient la ligne actuelle.
    var line = editor.getTextInRange([[bufferPosition.row, 0], bufferPosition]);

    return new Promise ((resolve) => {
        let suggestions = [];

        const trimLine = line.trim ();

        // La méthode récursive utilisée pour explorer les blocs de code.
        let explore = (scope) => {

            // Ici par exemple, si la ligne commence par `using `,
            // on affiche les différents espaces de noms disponibles
            if (trimLine.startsWith('using ')) {
                // Le bloc actuel est un espace de nom
                if (scope.data && scope.data.type == 'namespace') {
                    // Il commence comme ce que l’utilisateur a tapé
                    if (name.startsWith(prefix) || prefix == ' ') {
                        // show suggestion
                        let suggestion = {
                            text: name + ';',
                            type: 'import',
                            displayText: name,
                            description: `The ${name} namespace.`
                        };
                        suggestions.push (suggestion);
                    }
                }
            }

            // On explore les enfants
            for (child of scope.children) {
                explore (child);
            }
        }

        // On explore tout les scopes à la racine
        for (scope of this.manager.scopes) {
            explore (scope);
        }

        // ON envoie nos suggestions
        if (trimLine != '') {
            resolve(suggestions);
        }
    });
}

En réalité, c’est bien plus complexe que ça, mais le principe reste toujours le même.

Lier tout ça au reste d’Atom

On a maintenant un joli moteur de suggestions, sauf qu’Atom ne sait pas qu’il faut l’utiliser ! Résultat : il ne se passe rien…

Il faut savoir qu’à peu près tout dans Atom est un package (et c’est peut être la plus grande force de cet éditeur) : la fonction rechercher, l’affichage des onglets, des suggestions… Et les différents packages ont un moyen très simple de communiquer entre eux, appelé services. Un package propose un service, les autres peuvent y souscrire et ils échangent des information ainsi. C’est par exemple ce que fait autocomplete-plus, le package qui affiche les suggestions : on peut ainsi lui donner nos propres moteurs de suggestions. Et Valhalla utilise ce service, grâce à ce morceau de son manifeste.

"providedServices": {
  "autocomplete.provider": {
    "versions": {
      "2.0.0": "getProvider"
    }
  }
}

Ici, on indique à autocomplete-plus qu’il peut obtenir notre moteur grâce à la méthode getProvider, qui se trouve dans l’objet exporté par Valhalla (export default ou module.export) , situé dans valhalla.js. Cette méthode retourne simplement une instance de la classe ValaProvider ! Et voilà, Atom sait où aller chercher les suggestions pour le code Vala et peut les afficher.


Cet article est terminé, mais un autre devrait bientôt arriver. En tout cas j’espère qu’il vous aura intéressé.

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

Cet article est donc le premier de la série où je vais vous expliquer le fonctionnement 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' 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 Vous l’avez ?.'
    },
    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 littérales ("chaîne de caractères", par exemple) et tout ce qui ne nous intéresse pas;
  • Les caractères sont ajoutés à une variable token (qui en fait n’est pas un seul token, mais plusieurs) ;
  • Si le charactère actuel est { ou ;, ça veut dire qu’on arrive à une déclaration :
    • On va alors utiliser tout plein d’expression rationnelles 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 !

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 !

Le fonctionnnement de Valhalla, partie 1 : Introduction

Pour ceux qui ne le savent pas, je développe un package pour Atom nommé Valhalla. Il est écrit en JavaScript (ES6) et vous pouvez voir le code (libre bien-sûr) sur GitHub. Ce package vous fournit tout un tas d'outils pour vous rendre l'écriture de code dans le langage Vala plus simple.

J'ai décidé de créer une petite série d'article pour vous expliquer comment fonctionne ce package, car la création de package Atom est assez mal documentée (et en français n'en parlons pas), et que même si je prévois quelque chose de plus complet (mais pas pour tout de suite), ça permet d'avoir un aperçu de comment Atom gère ses packages.

Principaux modules

Valhalla se décompose globalement ainsi :

  • Un module qui va chercher les fichiers .vapi (où sont décrits les différentes classes, méthodes, interfaces et autres disponibles avec les bibliothèques installées sur le sytème) et qui les décompose en objets JavaScript utilisables plus tard pour plusieurs choses ;
  • Un autre module qui affiche les suggestions, à partir de ce qu'on a récupérer dans les fichier .vapi ;
  • Un autre qui est chargé d'afficher une petite documentation, toujours grâce à ce qu'on a récupéré avant ;
  • Des petits outils comme la création instantanée de classe ou d'interfaces, l'intégration avec le package atom-build, ou encore une fonctionnalité « Entourer avec ... ».

Chacun de ses points aura son article détaillé dans la petite série d'article que je prévoie écrire. Au niveau du rythme, rien n'est vraiment prévu, même si je pense faire quelque chose comme un article par semaine. En tout cas le premier est déjà disponible, juste ici.

Le fonctionnement de Valhalla, partie 1 : Introduction

Pour ceux qui ne le savent pas, je développe un package pour Atom nommé Valhalla. Il est écrit en JavaScript (ES6) et vous pouvez voir le code (libre bien-sûr) sur GitHub. Ce package vous fournit tout un tas d’outils pour vous rendre l’écriture de code dans le langage Vala plus simple.

J’ai décidé de créer une petite série d’article pour vous expliquer comment fonctionne ce package, car la création de package Atom est assez mal documentée (et en français n’en parlons pas), et que même si je prévois quelque chose de plus complet (mais pas pour tout de suite), ça permet d’avoir un aperçu de comment Atom gère ses packages.

Principaux modules

Valhalla se décompose globalement ainsi :

  • Un module qui va chercher les fichiers .vapi (où sont décrits les différentes classes, méthodes, interfaces et autres disponibles avec les bibliothèques installées sur le sytème) et qui les décompose en objets JavaScript utilisables plus tard pour plusieurs choses ;
  • Un autre module qui affiche les suggestions, à partir de ce qu’on a récupéré dans les fichier .vapi ;
  • Un autre qui est chargé d’afficher une petite documentation, toujours grâce à ce qu’on a récupéré avant ;
  • Des petits outils comme la création instantanée de classe ou d’interfaces, l’intégration avec le package atom-build, ou encore une fonctionnalité « Entourer avec… ».

Chacun de ses points aura son article détaillé dans la petite série d’article que je prévoie écrire. Au niveau du rythme, rien n’est vraiment prévu, même si je pense faire quelque chose comme un article par semaine. En tout cas le premier est déjà disponible, juste ici.