ANTLR/JavaScript

ANTLR

ANTLR est une bibliothèque qui permet de générer du code d'analyse de documents texte à partir d'une grammaire. Jusqu'à la version 3, le code était généré uniquement pour le langage Java. Depuis la version 4, d'autres cibles sont disponibles : JavaScript, C#, Python et bientôt C++.

Dans cet article, je vais montrer comment mettre en oeuvre un parser avec JavaScript.

Le code source est disponible sur GitHub

Création du projet

ANTLR est une API Java. J'utilise donc un IDE Java pour créer mon projet. Je choisi par ailleurs d'utiliser Gradle pour la construction du projet.

Voici la structure du projet Gradle généré par mon IDE :

Antlr1.png

Je modifie ensuite le fichier de construction 'build.gradle'. L'élément important dans ce script est l'argument '-Dlanguage=JavaScript'.

plugins {
   id 'antlr'
}

repositories {
   mavenCentral()
}

group 'icodem'
version '1.0-SNAPSHOT'
description = 'CSV ANTLR4-based parser'

dependencies {
   antlr "org.antlr:antlr4:4.5.1"
}

generateGrammarSource {
   group "${project.name}"
   description 'Generates the parser code based on the grammar'

   arguments += ["-visitor", "-Dlanguage=JavaScript"]

   copy {
       from 'build/generated-src/antlr/main'
       into 'src/main/resources'
       include '**/CSV*.js'
       include '**/CSV*.tokens'
   }
}

clean {
   delete fileTree('src/main/resources') {
       include '**/CSV*.js'
       include '**/CSV*.tokens'
   }
}

wrapper {
   gradleVersion = '2.5'
}


La grammaire

Je prends une grammaire dans le repo GitHub de ANTLR et je place le fichier correspondant dans le répertoire 'src/main/antlr' :


grammar CSV;

document        : hdr row+ EOF;

hdr : row ;

row : field (',' field)* '\r'? '\n' ;

field : TEXT | STRING | ;

TEXT : ~[,\n\r"]+ ;
STRING : '"' ('""'|~'"')* '"' ;

Antlr2.png

On peut donc maintenant générer les fichiers JavaScript pour notre analyseur via la commande Gradle 'generateGrammarSource'. Les fichiers générés sont copiés dans le répertoire 'src/main/resources'.

Antlr3.png

Installer le runtime ANTLR

Le runtime ANTLR est l'ensemble des fichiers JavaScript qui permettent l'exécution de notre analyseur JavaScript constitué des fichiers générés précedemment. Il est téléchargeable depuis le site de ANTLR. Il faut prendre la version de runtime qui correspondant à la version utilisée pour la génération, i.e. dans notre exemple antlr-javascript-runtime-4.5.1.zip.

On place ensuite le runtime dans notre projet dans le répertoire 'src/main/resources'.

Antlr4.png

Il faut aussi ajouter le fichier 'require.js' que je place pour ma part dans le sous-répertoire 'lib'. A noter que ce script ne fait pas partie du runtime ANTLR. Il faut donc le télécharger depuis le site GitHub de ANTLR.

Antlr5.png

Exécution dans un navigateur

L'exécution est effectué dans une page HTML. Les points importants :

Antlr6.png

<!DOCTYPE html>
<html>
<head>
   <meta charset="UTF-8">
   <title>Parsing CSV avec ANTLR 4</title>
   <script src="lib/require.js"></script>
</head>

<body>
    <p id="display"/>
</body>

<script>
   // chargement du runtime ANTLR
   var antlr4 = require('antlr4/index');

   // chargement de l'analyseur CSV
   var CSVLexer = require('./CSVLexer');
   var CSVParser = require('./CSVParser');

   // chaîne à analyser
   var input = "Nom,Prénom,Age\nLampion,Émile,29\nLarosière,Jean,48\n";

   // préparation des objets pour l'analyse
   var chars = new antlr4.InputStream(input);
   var lexer = new CSVLexer.CSVLexer(chars);
   var tokens  = new antlr4.CommonTokenStream(lexer);
   var parser = new CSVParser.CSVParser(tokens);
   parser.buildParseTrees = true;

   // invocation de l'analyse
   var tree = parser.document();

   // affichage du résultat
   var display = document.getElementById("display");
   display.innerHTML = tree.toStringTree(null, parser);

   </script>

</html>


Exécution dans une JVM

On peut exécuter notre analyseur JavaScript dans une JVM à l'aide du moteur de script JavaScript de la JVM. Avec Java 8, il s'agit de Nashorn.

Pour la mise en oeuvre, nous utilisons une classe Java, un script JS et une version adapté de 'require' pour Nashorn :

Antlr7.png


Le script JS fournit une fonction de parsing. Autrement dit, cette fonction invoque l'analyseur JS comme nous le faisions dans le cas d'une exécution dans une page web.

Fichier 'script.js' :

function parseCSV(input) {
    // chargement du script 'require' spécifique nashorn
    load('src/main/resources/lib/require-nashorn.js');
    require.basePath = 'src/main/resources/';

    // chargement du runtime ANTLR 
    var antlr4 = require('antlr4/index');

    // chargement de l'analyseur CSV
    var CSVLexer = require('./CSVLexer');
    var CSVParser = require('./CSVParser');

    // chaîne à analyser
    var input = "Nom,Prénom,Age\nLampion,Émile,29\nLarosière,Jean,48\n"

    // préparation des objets pour l'analyse
    var chars = new antlr4.InputStream(input);
    var lexer = new CSVLexer.CSVLexer(chars);
    var tokens  = new antlr4.CommonTokenStream(lexer);
    var parser = new CSVParser.CSVParser(tokens);
    parser.buildParseTrees = true;

    // invocation de l'analyse
    var tree = parser.document();

    var result = tree.toStringTree(null, parser);
    return result
}

La classe Java instancie le moteur JS Nashorn puis sollicite le précédent script.

Fichier 'Main.java' :

public class Main {

   public static void main(String[] args) throws Exception {
       ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn");
       Bindings globalScope = engine.getBindings(ScriptContext.GLOBAL_SCOPE);
       globalScope.put("global", globalScope);
       globalScope.put("window", "");// just to avoid fs.js module loading in FileStream.js

       engine.eval("load('src/main/resources/script.js')");

       Invocable invocable = (Invocable) engine;

       String input = "Nom,Prénom,Age\nLampion,Émile,29\nLarosière,Jean,48\n";
       Object result = invocable.invokeFunction("parseCSV", input);

       System.out.println(result);

   }
}

Le fichier 'require.js' disponible avec ANTLR n'est pas compatible pour une exécution avec Nashorn (du moins, je ne suis pas arrivé à le faire fonctionner). J'ai donc adapté le script de ANTLR à Nashorn.

Fichier 'require-nashorn.js' :

// NOTE The loadModule parameter points to the function, which prepares the
//      environment for each module and runs its code. Scroll down to the end of
//      the file to see the function definition.
(function(loadModule) { 'use strict';
 
// INFO Java objects
   var Paths = Java.type("java.nio.file.Paths");
   var Files = Java.type("java.nio.file.Files");
   var StandardCharsets = Java.type("java.nio.charset.StandardCharsets");
   var Collectors = Java.type("java.util.stream.Collectors");

// INFO Current module descriptors
//      pwd[0] contains the descriptor of the currently loaded module,
//      pwd[1] contains the descriptor its parent module and so on.

   var pwd = Array();

// INFO Module cache
//      Contains getter functions for the exports objects of all the loaded
//      modules. The getter for the module 'mymod' is name '$name' to prevent
//      collisions with predefined object properties (see note below).
//      As long as a module has not been loaded the getter is either undefined
//      or contains the module code as a function (in case the module has been
//      pre-loaded in a bundle).
 
   var cache = new Object();

// INFO Module getter
//      Takes a module identifier, resolves it and gets the module code via
//      Java NIO API. If this was successful the code and
//      some environment variables are passed to the load function. The return
//      value is the module's `exports` object. If the cache already
//      contains an object for the module id, this object is returned directly.
 
   function require(identifier) {

       var descriptor = resolve(identifier);
       var cacheid = '$'+descriptor.id;
       if (cache[cacheid]) {
           return cache[cacheid];
       }

       var content = Files.lines(descriptor.path, StandardCharsets.UTF_8)
           .collect(Collectors.joining("\n"));
       loadModule(descriptor, cache, pwd, content);
       return cache[cacheid];

   }

// INFO Module resolver
//      Takes a module identifier and resolves it to a module id and path. Both
//      values are returned as a module descriptor, which can be passed to
//      `fetch` to load a module.

   function resolve(identifier) {

       var basePath = Paths.get(require.basePath);
       var parentPath = basePath;
       if (pwd.length > 0) {
           parentPath = pwd[0].path.getParent();
       }
       var scriptPath = parentPath.resolve(identifier + ".js").normalize();

       // build id corresponding to script
       var subpath = scriptPath.subpath(basePath.getNameCount(), scriptPath.getNameCount());
       var id = subpath.toString();

       return {'id': id, 'path': scriptPath};
   }


// INFO Exporting require to global scope
   global.require = require;

})(

// INFO Module loader
//      Takes the module descriptor, the global variables and the module code,
//      sets up the module environment, defines the module getter in the cache
//      and evaluates the module code.

   function (module) {
       var exports = new Object();
       Object.defineProperty(module, 'exports', {'get':function(){return exports;},'set':function(e){exports=e;}});
       arguments[2].unshift(module);
       Object.defineProperty(arguments[1], '$'+module.id, {'get':function(){return exports;}});
       try {
           eval(arguments[3]);
       } catch (e) {
           print("*** eval ERROR " + module.path + " : " + e.message);
       }

       arguments[2].shift();
   }

);