CongoCC – der Congo Parser Generator

“The function of a good software is to make the complex appear to be simple”

Grady Booch

Zum Ende des Jahres lässt man all die vielen unerledigten Dinge Revue passieren und versucht noch das eine oder andere abzuarbeiten. Als letzter Beitrag des Jahres 2023 wird deshalb noch CongoCC vorgestellt. Der Congo Parser Generator ist der Nachfolger von JavaCC 21 und dem noch älteren JavaCC.

Einige der hier vorgestellten Features existierten bereits in JavaCC 21, aber unter der Haube von CongoCC wurde in der Zwischenzeit eine Menge getan. Mittlerweile glänzt CongoCC mit Contextual Predicates, Context-sensitive Tokenization. Full 32-bit Unicode Support, Code Injection, Include Directive, Up-to-here Marker, Grammars for Java 19, Lua, Python and C#.

Eine erfreuliche Neuerung im CongoCC Projekt ist es außerdem, dass die aktuelle Version von CongoCC über Maven Central zur Verfügung gestellt wird.

<dependency>
  <groupId>org.congocc</groupId>
  <artifactId>org.congocc.parser.generator</artifactId>
  <version>2.0.0-RC5</version>
</dependency>

Zusätzlich existiert auch ein Maven Plugin für CongoCC. Das automatische Erzeugen der Klassen im FreshMarker Projekt aus der FreeMarker Grammatik mit JavaCC 21 war mit einigen Stolpersteinen versehen. Ein Umstieg auf CongoCC wird an dieser Stelle sicherlich einiges einfacher gestalten.

<plugin>
  <groupId>org.congocc</groupId>
  <artifactId>org.congocc.maven.plugin</artifactId>
  <version>2.0.0-RC5</version>
  <executions>
    <execution>
      <goals>
        <goal>ccc-generate</goal>
      </goals>
      <configuration>
        <grammarFile>src/main/ccc/SOJA.ccc</grammarFile>
        <outputDir>target/generated-sources/ccc</outputDir>
        <jdk>17</jdk>
      </configuration>
    </execution>
  </executions>
 </plugin>

Diese Konfiguration des Maven Plugin liest eine Grammatik Datei aus dem src/main/ccc Ordner und schreibt die generierten Dateien nach target/generated-sources/ccc. Im Gegensatz zum altgewordenen JavaCC beherrscht CongoCC auch aktuelle JDK Versionen. Dieses Beispiel wird mit Java 17 erzeugt.

Die im Beispiel verwendete Datei SOJA.ccc enthält keine Grammatik, sondern nur einige Code Injections für eine andere Grammatik. Die INCLUDE und INJECT Direktiven sind zwei der interessantes Verbesserungen für Nutzer von bestehenden Grammatiken.

PARSER_PACKAGE="de.schegge.soja.json";
NODE_PACKAGE="de.schegge.soja.json.ast";

INCLUDE "JSON.ccc"

INJECT Node :
  import de.schegge.soja.JsonHandlerAdapter;
  extends JsonHandlerAdapter;
{
}

INJECT Value :
  import de.schegge.soja.JsonHandlerAdapter;
  import de.schegge.soja.JsonHandler;
  implements JsonHandlerAdapter;
{
  public void adapt(JsonHandler handler) {
    for (Node node : children()) {
        node.adapt(handler);
    }
  }
}

INJECT KeyValuePair :
  import de.schegge.soja.JsonHandlerAdapter;
  import de.schegge.soja.JsonHandler;
  implements JsonHandlerAdapter;
{
  public void adapt(JsonHandler handler) {
    handler.startAttribute(get(0).getSource());
    get(2).adapt(handler);
    handler.endAttribute(get(0).getSource());
  }
}

INJECT JSONObject :
  import de.schegge.soja.JsonHandlerAdapter;
  import de.schegge.soja.JsonHandler;
  implements JsonHandlerAdapter;
{
  public void adapt(JsonHandler handler) {
    handler.startObject("");
    for (Node node : children()) {
        node.adapt(handler);
    }
    handler.endObject("");
  }
}

INJECT Array :
  import de.schegge.soja.JsonHandlerAdapter;
  import de.schegge.soja.JsonHandler;
  implements JsonHandlerAdapter;
{
  public void adapt(JsonHandler handler) {
    handler.startArray("");
    for (Node node : children()) {
      node.adapt(handler);
    }
    handler.endArray("");
  }
}

INJECT Literal :
  import de.schegge.soja.JsonHandlerAdapter;
  import de.schegge.soja.JsonHandler;
  implements JsonHandlerAdapter;
{
  public void adapt(JsonHandler handler) {
    switch (getType()) {
      case NUMBER: handler.value(Long.parseLong(getSource())); break;
      case STRING_LITERAL: handler.value(getSource()); break;
      case TRUE: handler.value(true); break;
      case FALSE: handler.value(false); break;
      case NULL: handler.nullValue();
    }
  }
}

Mit der INCLUDE Direktive wird die JSON.ccc Beispiel-Grammatik aus dem CongoCC Projekt inkludiert und die folgenden INJECT Direktiven fügen Implementierungen für die JsonHandlerAdapter#adapt(JsonHandler handler) Methode in diverse generierte Klassen ein.

public interface JsonHandlerAdapter {
    default void adapt(JsonHandler handler) {
    }
}

Das JsonHandlerAdapter Interface enthält eine leere Default-Methode, die durch das Einfügen in das Interface Node in allen generierten Knoten der Grammatik zur Verfügung stehen.

Die restlichen Code Einfügungen rufen zusätzlich die Methoden des JsonHandler Interfaces auf. Damit kann einer JsonHandler Implementierung Beginn und Ende eines Attributes, Arrays oder eines JSON Objects mitgeteilt werden.

Verwendet wird dies innerhalb des folgenden JsonReader Klasse. Sie liest eine JSON Beschreibung aus einem String mit einer ebenfalls generierten SOJAParser Instanz.

public class JsonReader {
    private final SOJAParser parser;

    public JsonReader(String source) {
        parser = new SOJAParser(source);
    }

    public void read(JsonHandler handler) {
        handler.startDocument();
        parser.Root();
        Node rootNode = parser.rootNode();
        rootNode.get(0).adapt(handler);
        handler.endDocument();
    }
}

Das Parsen der Eingabe wird mit der Methode Root ausgeführt. Danach findet sich das Ergebnis in der Variablen rootNode, bzw. im einzigen Element des Knoten.

Der folgende Unit Tests zeigt die erfolgreiche Verarbeitung eines kleinen JSON Arrays, dass Informationen zu meinem Ahnen Wilhelm Wortmann enthält.

@Spy
private JsonHandler handler = new PrintingJsonHandler();

@Test
void withWilhelmWortmann() {
    new JsonReader("""
             [{
               "test": true,
               "name": "Wilhelm Wortmann",
               "birth": {
                 "place": "Diepholz",
                 "date": "1821-07-29"
               }
             }]
            """).read(handler);
        verify(handler).startDocument();
        verify(handler).endDocument();
}

Der Unit Test prüft, ob bei der Verarbeitung die Methoden startDocument und endDocument aufgerufen wurden. Zusätzlich werden von dem PrintingJsonHandler alle Methoden Aufrufe auf den STDOUT protokolliert.

startDocument
startArray
startObject
startAttribute: "test"
boolean: true
endAttribute: "test"
startAttribute: "name"
string: "Wilhelm Wortmann"
endAttribute: "name"
startAttribute: "birth"
startObject
startAttribute: "place"
string: "Diepholz"
endAttribute: "place"
startAttribute: "date"
string: 1821-07-29"
endAttribute: "date"
endObject
endAttribute: "birth"
endObject
endArray
endDocument

Dieses Beispiel zeigt, wie einfach eigene Anwendungen durch CongoCC mit moderne Parsern ausgestattet werden können.

Leave a Comment