diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..88907420eabc71f41a10b13cd76de0738cf2d5df
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,84 @@
+variables:
+  GIT_SUBMODULE_STRATEGY: recursive
+
+stages:
+  - build
+  - test
+  - ragdoc_build
+  - ragdoc_view
+  - publish
+
+before_script:
+  - export GRADLE_USER_HOME=`pwd`/.gradle
+
+cache:
+  paths:
+    - .gradle/wrapper
+    - .gradle/caches
+
+build:
+  image: openjdk:8
+  stage: build
+  script:
+    - ./gradlew --console=plain --no-daemon assemble
+  artifacts:
+    paths:
+      - "src/gen"
+    expire_in: 1 week
+
+test:
+  image: openjdk:8
+  stage: test
+  script:
+    - ./gradlew --console=plain --no-daemon test
+  artifacts:
+    reports:
+      junit: build/test-results/test/**/TEST-*.xml
+
+ragdoc_build:
+  image:
+    name: "git-st.inf.tu-dresden.de:4567/jastadd/ragdoc-builder"
+    entrypoint: [""]
+  stage: ragdoc_build
+  needs:
+    - build
+  script:
+    - JAVA_FILES=$(find src/ -name '*.java')
+    - /ragdoc-builder/start-builder.sh -excludeGenerated -d data/ $JAVA_FILES
+  artifacts:
+    paths:
+      - "data/"
+
+ragdoc_view:
+  image:
+    name: "git-st.inf.tu-dresden.de:4567/jastadd/ragdoc-view:relations"
+    entrypoint: [""]
+  stage: ragdoc_view
+  needs:
+    - ragdoc_build
+  script:
+    - DATA_DIR=$(pwd -P)/data
+    - mkdir -p pages/docs/ragdoc
+    - OUTPUT_DIR=$(pwd -P)/pages/docs/ragdoc
+    - cd /ragdoc-view/src/ && rm -rf data && ln -s $DATA_DIR
+    - /ragdoc-view/build-view.sh --output-path=$OUTPUT_DIR
+  artifacts:
+    paths:
+      - "pages/docs/ragdoc"
+
+pages:
+  image: python:3.8-buster
+  stage: publish
+  needs:
+    - ragdoc_view
+    - test
+  before_script:
+    - pip install -U mkdocs mkdocs-macros-plugin mkdocs-git-revision-date-localized-plugin
+  script:
+    - cd pages && mkdocs build
+#  only:
+#    - develop
+#    - master
+  artifacts:
+    paths:
+      - public
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000000000000000000000000000000000000..7f348667c77aac1df614eddfa1721935c68dddcf
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "src/main/jastadd/mustache"]
+	path = src/main/jastadd/mustache
+	url = ../mustache.git
diff --git a/build-template.gradle b/build-template.gradle
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/build.gradle b/build.gradle
index aa3f23414c768afc27fada826a68fb8d66c2afaf..cc16a72d97cc4efff8a2a44fcba801746c912d07 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,101 +1,137 @@
+plugins {
+    id 'java-library'
+    id 'application'
+    id 'org.jastadd'
+    id 'java'
+    id 'idea'
+    id 'java-test-fixtures'
+}
+
+ext {
+    mainClassName = 'org.jastadd.relast.compiler.RelastSourceToSourceCompiler'
+}
 
-apply plugin: 'java'
-apply plugin: 'jastadd'
-apply plugin: 'application'
-apply plugin: "idea"
+// set the main class name for `gradle run`
+application.mainClassName = "${mainClassName}"
 
 sourceCompatibility = 1.8
-
-mainClassName = 'org.jastadd.relast.compiler.RelastSourceToSourceCompiler'
+targetCompatibility = 1.8
 
 repositories {
-    jcenter()
+    mavenCentral()
 }
 
-buildscript {
-    repositories.jcenter()
-    dependencies {
-        classpath 'org.jastadd:jastaddgradle:1.13.3'
+sourceSets {
+    model {
+        java {
+            srcDir "src/gen/java"
+        }
     }
 }
 
-dependencies {
-    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.4.0'
-    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.4.0'
-    testCompile 'org.assertj:assertj-core:3.12.1'
-    compile 'org.jastadd:jastadd:2.3.4'
-    runtime 'org.jastadd:jastadd:2.3.4'
-    compile group: 'net.sf.beaver', name: 'beaver-rt', version: '0.9.11'
+task modelJar(type: Jar) {
+    group = "build"
+    archiveBaseName = 'model'
+    archiveVersion = ''
+    from sourceSets.model.output
 }
 
-sourceSets {
-    main {
-        java.srcDir "src/gen/java"
-        java.srcDir "buildSrc/gen/java"
-    }
+artifacts {
+    archives modelJar
 }
 
-test {
-    useJUnitPlatform()
+dependencies {
 
-    maxHeapSize = '1G'
+    modelImplementation group: 'org.jastadd', name: 'jastadd', version: '2.3.4'
+    modelImplementation group: 'net.sf.beaver', name: 'beaver-rt', version: '0.9.11'
+
+    implementation files(modelJar.archiveFile.get())
+    api group: 'org.jastadd', name: 'jastadd', version: '2.3.4'
+    api group: 'net.sf.beaver', name: 'beaver-rt', version: '0.9.11'
+    implementation group: 'com.github.jknack', name: 'handlebars', version: '4.2.0'
+    implementation group: 'org.yaml', name: 'snakeyaml', version: '1.27'
+
+    // test
+    testRuntimeClasspath files(modelJar.archiveFile.get())
+
+    // test fixtures
+    testFixturesApi group: 'org.slf4j', name: 'slf4j-jdk14', version: '1.7.30'
+    testFixturesApi group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '5.7.0'
+    testFixturesApi group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '5.7.0'
+    testFixturesApi group: 'org.assertj', name: 'assertj-core', version: '3.18.0'
+    testFixturesApi group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.12.0-rc1'
+    testFixturesApi group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: '2.12.0-rc1'
+    testFixturesApi group: 'commons-io', name: 'commons-io', version: '2.8.0'
+}
+
+def versionFile = 'src/main/resources/preprocessor.properties'
+def versionProps = new Properties()
+
+try {
+    file(versionFile).withInputStream { stream -> versionProps.load(stream) }
+    version = versionProps['version']
+} catch (e) {
+    // this happens, if either the properties file is not present, or cannot be read from
+    throw new GradleException("File ${versionFile} not found or unreadable. Aborting.", e)
 }
 
 jar {
     manifest {
-        attributes "Main-Class": 'org.jastadd.relast.compiler.RelastSourceToSourceCompiler'
+        attributes "Main-Class": "${mainClassName}"
     }
 
     from {
-        configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
+        configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
     }
 }
 
+test {
+    useJUnitPlatform()
+
+    maxHeapSize = '1G'
+}
+
+// Input and output files for relast
+def relastInputFiles = [
+        "src/main/jastadd/RelAst.relast",
+        "src/main/jastadd/mustache/Mustache.relast"
+]
+def relastOutputFiles = [
+        "src/gen/jastadd/RelAst.ast",
+        "src/gen/jastadd/RelAst.jadd",
+        "src/gen/jastadd/RelAstRefResolver.jadd",
+        "src/gen/jastadd/RelAstResolverStubs.jrag"
+]
+
 task relast(type: JavaExec) {
+    classpath = files("libs/relast.jar")
     group = 'Build'
-    main = "-jar"
 
     doFirst {
-        delete "src/gen/jastadd/*.ast"
-        delete "src/gen/jastadd/RelAst.jadd"
-        delete "src/gen/jastadd/RelAstRefResolver.jadd"
-        delete "src/gen/jastadd/RelAstResolverStubs.jrag"
-        mkdir  "src/gen/jastadd/"
+        delete relastOutputFiles
+        mkdir "src/gen/jastadd/"
     }
 
     args = [
-            "libs/relast.jar",
-            "./src/main/jastadd/RelAst.relast",
             "--listClass=java.util.ArrayList",
             "--jastAddList=JastAddList",
             "--useJastAddNames",
             "--file",
             "--resolverHelper",
             "--grammarName=./src/gen/jastadd/RelAst"
-    ]
-
-    inputs.files file("../libs/relast.jar"),
-            file("src/main/jastadd/RelAst.relast")
-    outputs.files file("./src/gen/jastadd/RelAst.ast"),
-            file("src/gen/jastadd/RelAst.jadd"),
-            file("src/gen/jastadd/RelAstRefResolver.jadd"),
-            file('src/gen/jastadd/RelAstResolverStubs.jrag')
+    ] + relastInputFiles
+
+    inputs.files relastInputFiles
+    outputs.files relastOutputFiles
 }
 
 jastadd {
     configureModuleBuild()
     modules {
         //noinspection GroovyAssignabilityCheck
-        module("RelAst") {
-
-            java {
-                basedir "."
-                include "src/main/**/*.java"
-                include "src/gen/**/*.java"
-            }
+        module("Preprocessor") {
 
             jastadd {
-                basedir "."
                 include "src/main/jastadd/**/*.ast"
                 include "src/main/jastadd/**/*.jadd"
                 include "src/main/jastadd/**/*.jrag"
@@ -105,10 +141,10 @@ jastadd {
             }
 
             scanner {
-                include "src/main/jastadd/scanner/Header.flex",         [-4]
-                include "src/main/jastadd/scanner/Preamble.flex",       [-3]
-                include "src/main/jastadd/scanner/Macros.flex",         [-2]
-                include "src/main/jastadd/scanner/RulesPreamble.flex",  [-1]
+                include "src/main/jastadd/scanner/Header.flex",        [-4]
+                include "src/main/jastadd/scanner/Preamble.flex",      [-3]
+                include "src/main/jastadd/scanner/Macros.flex",        [-2]
+                include "src/main/jastadd/scanner/RulesPreamble.flex", [-1]
                 include "src/main/jastadd/scanner/Keywords.flex",       [0]
                 include "src/main/jastadd/scanner/Symbols.flex",        [1]
                 include "src/main/jastadd/scanner/RulesPostamble.flex", [2]
@@ -122,8 +158,8 @@ jastadd {
     }
 
     cleanGen.doFirst {
-        delete "src/gen/java/org"
-        delete "src/gen-res/BuildInfo.properties"
+        delete "src/gen"
+        delete "src/gen-res"
     }
 
     preprocessParser.doFirst {
@@ -132,7 +168,7 @@ jastadd {
 
     }
 
-    module = "RelAst"
+    module = "Preprocessor"
 
     astPackage = 'org.jastadd.relast.ast'
 
@@ -149,3 +185,11 @@ jastadd {
 }
 
 generateAst.dependsOn relast
+
+clean.dependsOn(cleanGen)
+
+modelJar.dependsOn(generateAst, modelClasses)
+modelClasses.dependsOn(generateAst)
+compileJava.dependsOn(modelJar)
+
+jar.dependsOn(modelJar)
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1b16c34a71cf212ed0cfb883d14d1b8511903eb2..be52383ef49cdf484098989f96738b3d82d7810d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/libs/relast.jar b/libs/relast.jar
index df0f6ce751cc1351525ff1d46cf09e83185adfe5..9f1d60c7c99a1e35d9cf5558d5f329c5aa7ba66e 100644
Binary files a/libs/relast.jar and b/libs/relast.jar differ
diff --git a/pages/.gitignore b/pages/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a95071c4b0fd05bccf528339a15b2e93458bc694
--- /dev/null
+++ b/pages/.gitignore
@@ -0,0 +1 @@
+/docs/ragdoc/
diff --git a/pages/custom_theme/footer.html b/pages/custom_theme/footer.html
new file mode 100644
index 0000000000000000000000000000000000000000..104412a1203422ecedc0acda6149a88fb2d72eaa
--- /dev/null
+++ b/pages/custom_theme/footer.html
@@ -0,0 +1,12 @@
+{% block footer %}
+<p>{% if config.copyright %}
+<small>{{ config.copyright }}<br></small>
+{% endif %}
+<hr>
+Built with <a href="https://www.mkdocs.org/">MkDocs</a> using a <a href="https://github.com/snide/sphinx_rtd_theme">theme</a> provided by <a href="https://readthedocs.org">Read the Docs</a>.
+{% if page and page.meta and page.meta.git_revision_date_localized %}
+<small><br><i>Last updated {{ page.meta.git_revision_date_localized }}</i></small>
+{% endif %}
+</p>
+{% endblock %}
+
diff --git a/pages/docs/index.md b/pages/docs/index.md
new file mode 100644
index 0000000000000000000000000000000000000000..ad4a5ac5810d184f65cb4e9a29e134235c89f9ca
--- /dev/null
+++ b/pages/docs/index.md
@@ -0,0 +1,4 @@
+# Relational RAGs Preprocessor Documentation
+
+The [Relational RAGs Preprocessor](https://git-st.inf.tu-dresden.de/jastadd/relast-preprocessor) is a framework to create custom preprocessors for [JastAdd](http://jastadd.org) and JastAdd-based [Relational RAGs](http://www.relational-rags.eu).
+
diff --git a/pages/docs/using.md b/pages/docs/using.md
new file mode 100644
index 0000000000000000000000000000000000000000..a42891831f635201bd179762e0fa9fc01ed91774
--- /dev/null
+++ b/pages/docs/using.md
@@ -0,0 +1,200 @@
+# Creating a Preprocessor
+
+## Project Structure
+
+The *gradle* build system is required to use the preprocessor.
+It is recommended to use the same version of gradle as the preprocessor does internally, which currently is 6.7.
+
+To create a new preprocessor, follow the following steps:
+
+1. Create a new gradle project
+2. Include this repository as a submodule in the directory `relast.preprocessor`
+   - copy the contents of this repository into this directory; this can be done using a git submodule
+   - in your `settings.gradle` add a dependency to it:
+     ```groovy
+     include 'relast.preprocessor'
+     ```
+3. Adapt the `build.gradle` file of your project to use the subproject; this step is described in the next section
+
+## Constructing a Gradle Build File
+
+The preprocessor and its test infrastructure are included as project dependencies.
+
+```groovy
+dependencies {
+    implementation project(':relast.preprocessor')
+    testImplementation testFixtures(project(":relast.preprocessor"))
+}
+```
+
+Next, the RelAST preprocessor must be called using files provided by the framework and potentially other extensions of the RelAST gramamr DSL.
+
+```groovy
+// Input and output files for relast
+def relastInputFiles = [
+        "relast.preprocessor/src/main/jastadd/RelAst.relast",
+        "relast.preprocessor/src/main/jastadd/mustache/Mustache.relast"
+        // add more files here
+]
+def relastOutputFiles = [
+        "src/gen/jastadd/RelAst.ast",
+        "src/gen/jastadd/RelAst.jadd",
+        "src/gen/jastadd/RelAstRefResolver.jadd",
+        "src/gen/jastadd/RelAstResolverStubs.jrag"
+]
+
+task relast(type: JavaExec) {
+    classpath = files("relast.preprocessor/libs/relast.jar")
+    group = 'Build'
+
+    doFirst {
+        delete relastOutputFiles
+        mkdir "src/gen/jastadd/"
+    }
+
+    args = [
+            "--listClass=java.util.ArrayList",
+            "--jastAddList=JastAddList",
+            "--useJastAddNames",
+            "--file",
+            "--resolverHelper",
+            "--grammarName=./src/gen/jastadd/RelAst"
+    ] + relastInputFiles
+
+    inputs.files relastInputFiles
+    outputs.files relastOutputFiles
+}
+```
+
+Finally, JastAdd must be configured to use the correct files.
+Note that the scanner includes are split into several files to allow for an extensible lexical analysis.
+
+```groovy
+jastadd {
+    configureModuleBuild()
+    modules {
+        //noinspection GroovyAssignabilityCheck
+        module("Preprocessor") {
+
+            jastadd {
+                basedir "."
+                include "relast.preprocessor/src/main/jastadd/**/*.ast"
+                include "relast.preprocessor/src/main/jastadd/**/*.jadd"
+                include "relast.preprocessor/src/main/jastadd/**/*.jrag"
+                include "src/main/jastadd/**/*.ast"
+                include "src/main/jastadd/**/*.jadd"
+                include "src/main/jastadd/**/*.jrag"
+                include "src/gen/jastadd/**/*.ast"
+                include "src/gen/jastadd/**/*.jadd"
+                include "src/gen/jastadd/**/*.jrag"
+            }
+
+            scanner {
+                include "relast.preprocessor/src/main/jastadd/scanner/Header.flex",         [-4]
+                include "relast.preprocessor/src/main/jastadd/scanner/Preamble.flex",       [-3]
+                include "relast.preprocessor/src/main/jastadd/scanner/Macros.flex",         [-2]
+                include "relast.preprocessor/src/main/jastadd/scanner/RulesPreamble.flex",  [-1]
+                include "relast.preprocessor/src/main/jastadd/scanner/Keywords.flex",       [ 0]
+                include "relast.preprocessor/src/main/jastadd/scanner/Symbols.flex",        [ 1]
+                include "relast.preprocessor/src/main/jastadd/scanner/RulesPostamble.flex", [ 2]
+            }
+
+            parser {
+                include "relast.preprocessor/src/main/jastadd/parser/Preamble.parser"
+                include "relast.preprocessor/src/main/jastadd/parser/RelAst.parser"
+            }
+        }
+    }
+
+    cleanGen.doFirst {
+        delete "src/gen"
+        delete "src/gen-res"
+    }
+
+    preprocessParser.doFirst {
+        args += ["--no-beaver-symbol"]
+    }
+
+    module = "Preprocessor"
+    astPackage = 'org.jastadd.relast.ast'
+    parser.name = 'RelAstParser'
+    genDir = 'src/gen/java'
+    buildInfoDir = 'src/gen-res'
+    scanner.genDir = "src/gen/java/org/jastadd/relast/scanner"
+    parser.genDir = "src/gen/java/org/jastadd/relast/parser"
+    jastaddOptions = ["--lineColumnNumbers", "--List=JastAddList", "--safeLazy", "--visitCheck=true", "--rewrite=cnta", "--cache=all"]
+}
+```
+
+## Writing Tests
+
+The preprocessor provides a test framework based on on JUnit 5.
+This framework can be used to write acceptance tests, comparing input and output of runs of the constructed preprocessor.
+Hereby, no java code has to be written - only a simple configuration file and some input and expected data have to be provided. 
+
+### Test Class
+
+Create a new class inheriting from `RelAstProcessorTestBase` and set the `mainClass` field to the correct class.
+
+```java
+import org.jastadd.relast.tests.RelAstProcessorTestBase;
+import org.junit.jupiter.api.BeforeAll;
+
+public class MyPreprocessorTest extends RelAstProcessorTestBase {
+  @BeforeAll
+  static void init() {
+    mainClass = Main.class;
+  }
+}
+```
+
+### Test DSL
+
+Tests are described by a config file. Each directory in `src/main/resources` containing a `config.yaml` is assumed to contain test data.
+
+A test configuration must contain a list of objects containing the following properties:
+
+| Name           | Type                  | Required | Default Value | Description                                          |
+| -------------- | --------------------- | -------- | ------------- | ---------------------------------------------------- |
+|                |                       |          |               |                                                      |
+| `name`         | string                | yes      | --            | name of the test case                                |
+| `args`         | list of strings       | yes      | --            | arguments passed to the preprocessor                 |
+| `fail`         | boolean               | no       | `false`       | is the test a negative test                          |
+|                |                       |          |               |                                                      |
+| `in`           | string                | no       | `"in"`        | directory containing input data                      |
+| `out`          | string                | no       | `"out"`       | directory the output is written to                   |
+| `expected`     | string                | no       | `"expected"`  | directory containing expected data                   |
+|                |                       |          |               |                                                      |
+| `compare`      | boolean               | no       | `false`       | should `out` and `expected` directories be compared? |
+|                |                       |          |               |                                                      |
+| `out-contains` | list of strings       | no       | empty list    | string that must be contained in the `stdout` stream |
+| `err-contains` | list of strings       | no       | empty list    | string that must be contained in the `stderr` stream |
+| `out-matches`  | list of regex strings | no       | empty list    | regular expression the `stdout` stream must match    |
+| `err-matches`  | list of regex strings | no       | empty list    | regular expression the `stderr` stream must match    |
+|                |                       |          |               |                                                      |
+
+Example:
+
+```yaml
+- name: "SimpleInheritance (null)"
+  compare: true
+  out: "null/out"
+  expected: "null/expected"
+  args:
+    - "--inputBaseDir=in"
+    - "--outputBaseDir=null/out"
+    - "--printYaml"
+    - "--errorHandling=null"
+    - "Example.relast"
+- name: "SimpleInheritance (wrong exception)"
+  fail: true
+  err-matches:
+    - "(?s).*Invalid.*"
+  err-contains:
+    - "Invalid argument 'not.a.Class' for parameter 'errorHandling': Class not found (name must be qualified)."
+  args:
+    - "--inputBaseDir=in"
+    - "--outputBaseDir=out/does_not_matter_will_fail"
+    - "--errorHandling=not.a.Class"
+    - "Example.relast"
+```
diff --git a/pages/mkdocs.yml b/pages/mkdocs.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d31d6f91a1d8d278541c5e299ad0c06a5040194e
--- /dev/null
+++ b/pages/mkdocs.yml
@@ -0,0 +1,18 @@
+site_name: Relational RAGs Preprocessor Documentation
+nav:
+  - using.md
+  - API documentation: ragdoc/index.html
+theme:
+  name: readthedocs
+  custom_dir: custom_theme/
+plugins:
+  - search
+  - git-revision-date-localized:
+      type: datetime
+      timezone: Europe/Berlin
+      locale: en
+      fallback_to_build_date: True
+  - macros
+repo_url: https://git-st.inf.tu-dresden.de/jastadd/relast-preprocessor
+site_dir: ../public
+
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 0000000000000000000000000000000000000000..5f99c5a83ca1b73205acb1de3960ccc501af1cc4
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,5 @@
+pluginManagement {
+    plugins {
+        id 'org.jastadd' version '1.13.3'
+    }
+}
diff --git a/src/main/jastadd/backend/AbstractGrammar.jadd b/src/main/jastadd/backend/AbstractGrammar.jadd
index da803c5448c6129d4123bc55ad80cc1abedcc6e9..3683fe27c2fba45459a5e7eb325f62ab1ad8e102 100644
--- a/src/main/jastadd/backend/AbstractGrammar.jadd
+++ b/src/main/jastadd/backend/AbstractGrammar.jadd
@@ -43,14 +43,16 @@ aspect BackendAbstractGrammar {
     if (getAbstract()) {
       b.append("abstract ");
     }
-    b.append(getName()).append(" ");
+    b.append(getName());
     if (hasSuperType()) {
-      b.append(": ").append(getSuperType().getName()).append(" ");
+      b.append(" : ").append(getSuperType().getName());
     }
-    b.append("::=");
-    for (Component component : getComponentList()) {
-      b.append(" ");
-      component.generateAbstractGrammar(b);
+    if (getNumComponent() > 0) {
+      b.append(" ::=");
+      for (Component component : getComponentList()) {
+        b.append(" ");
+        component.generateAbstractGrammar(b);
+      }
     }
     b.append(";");
     super.generateAbstractGrammar(b);
@@ -62,8 +64,7 @@ aspect BackendAbstractGrammar {
     if (getNTA()) {
       b.append("/");
     }
-
-    if (!getName().equals("")) {
+    if (!getName().equals("") && !getName().equals(getTypeDecl().getName())) {
       b.append(getName()).append(":");
     }
     b.append(getTypeDecl().getName());
@@ -76,8 +77,7 @@ aspect BackendAbstractGrammar {
     if (getNTA()) {
       b.append("/");
     }
-
-    if (!getName().equals("")) {
+    if (!getName().equals("") && !getName().equals(getTypeDecl().getName())) {
       b.append(getName()).append(":");
     }
     b.append(getTypeDecl().getName()).append("*");
@@ -91,7 +91,7 @@ aspect BackendAbstractGrammar {
       b.append("/");
     }
     b.append("[");
-    if (!getName().equals("")) {
+    if (!getName().equals("") && !getName().equals(getTypeDecl().getName())) {
       b.append(getName()).append(":");
     }
     b.append(getTypeDecl().getName()).append("]");
@@ -187,7 +187,7 @@ aspect BackendAbstractGrammar {
   }
 
   public void SingleLineComment.generateAbstractGrammar(StringBuilder b) {
-    b.append("//").append(getText()).append("\n");
+    b.append("//").append(getText());
   }
 
   public void MultiLineComment.generateAbstractGrammar(StringBuilder b) {
diff --git a/src/main/jastadd/mustache b/src/main/jastadd/mustache
new file mode 160000
index 0000000000000000000000000000000000000000..c10bed0d03e3fa18b8133ce1de48de7646899615
--- /dev/null
+++ b/src/main/jastadd/mustache
@@ -0,0 +1 @@
+Subproject commit c10bed0d03e3fa18b8133ce1de48de7646899615
diff --git a/src/main/java/org/jastadd/JastAddConfiguration.java b/src/main/java/org/jastadd/PreprocessorConfiguration.java
similarity index 74%
rename from src/main/java/org/jastadd/JastAddConfiguration.java
rename to src/main/java/org/jastadd/PreprocessorConfiguration.java
index 30774005356c0eb30354b7fb76e1bafe67d00873..39fd43963ecdc1cece0649cf3e1123ed228e4505 100644
--- a/src/main/java/org/jastadd/JastAddConfiguration.java
+++ b/src/main/java/org/jastadd/PreprocessorConfiguration.java
@@ -28,60 +28,90 @@
 package org.jastadd;
 
 import org.jastadd.option.ArgumentParser;
+import org.jastadd.option.FlagOption;
 import org.jastadd.option.Option;
 
 import java.io.PrintStream;
 import java.util.*;
-import java.util.stream.Collectors;
 
 /**
  * Tracks JastAdd configuration options.
  *
  * @author Jesper Öqvist <jesper.oqvist@cs.lth.se>
  */
-public class JastAddConfiguration extends org.jastadd.Configuration {
+public class PreprocessorConfiguration extends org.jastadd.Configuration {
 
   /**
    * Indicates if there were unknown command-line options
    */
   final boolean unknownOptions;
-
-  private boolean isJastAddCompliant;
-
-  public boolean isJastAddCompliant() {
-    return isJastAddCompliant;
-  }
-
+  private final Map<String, Option<?>> options = new HashMap<>();
+  private final boolean isJastAddCompliant;
+  private final ArgumentParser argParser;
   /**
    * Parse options from an argument list.
    *
    * @param args Command-line arguments to build configuration from
    * @param err  output stream to print configuration warnings to
    */
-  public JastAddConfiguration(String[] args, PrintStream err, boolean isJastAddCompliant, Collection<Option<?>> extraOptions) {
-    ArgumentParser argParser = new ArgumentParser();
+  public PreprocessorConfiguration(String[] args, PrintStream err, boolean isJastAddCompliant, Collection<Option<?>> extraOptions) {
+    argParser = new ArgumentParser();
     this.isJastAddCompliant = isJastAddCompliant;
+
     if (isJastAddCompliant) {
       Collection<Option<?>> jastAddOptions = allJastAddOptions();
+      for (Option<?> o : jastAddOptions) {
+        options.put(o.name(), o);
+      }
       argParser.addOptions(jastAddOptions);
+    }
 
-      // if the JastAdd options are supported, we have to check for duplicates!
-      Set<String> jastAddOptionNames = jastAddOptions.stream().map(o -> o.name()).collect(Collectors.toSet());
-      for (Option option : extraOptions) {
-        if (jastAddOptionNames.contains(option.name())) {
-          System.err.println("Unable to add option '" + option.name() + "', because there is a JastAdd option with the same name.");
-        } else {
-          argParser.addOption(option);
+    // if the JastAdd options are supported, we have to check for duplicates!
+    for (Option option : extraOptions) {
+      if (options.containsKey(option.name())) {
+        System.err.println("Unable to add option '" + option.name() + "', because there is a JastAdd option with the same name.");
+      } else {
+        if (option.name().equals("help") && option instanceof FlagOption) {
+          this.helpOption = (FlagOption) option;
+        } else if (option.name().equals("version") && option instanceof FlagOption) {
+          this.versionOption = (FlagOption) option;
         }
+        argParser.addOption(option);
+        options.put(option.name(), option);
       }
-    } else {
-      argParser.addOptions(extraOptions);
     }
 
     unknownOptions = !argParser.parseArgs(args, err);
     filenames = argParser.getFilenames();
   }
 
+  public ArgumentParser getArgParser() {
+    return argParser;
+  }
+
+  public Optional<Option> getOption(String name) {
+    return options.containsKey(name) ? Optional.of(options.get(name)) : Optional.empty();
+  }
+
+  public boolean isJastAddCompliant() {
+    return isJastAddCompliant;
+  }
+
+  /**
+   * Print help
+   *
+   * @param out Output stream to print help to.
+   */
+  @Override
+  public void printHelp(PrintStream out) {
+    out.println("This program reads a number of .jrag, .jadd, and .ast files");
+    out.println("Options:");
+    argParser.printHelp(out);
+    out.println();
+    out.println("Arguments:");
+    out.println("  Names of abstract grammr (.ast) and aspect (.jrag and .jadd) files.");
+  }
+
   /**
    * @return all files
    */
diff --git a/src/main/java/org/jastadd/relast/compiler/AbstractCompiler.java b/src/main/java/org/jastadd/relast/compiler/AbstractCompiler.java
index adc9bde23565b83df6fe6885d6a5d92d3bbad8b3..e9d55b5665735b74f05abe433c3bf1edc980ab57 100644
--- a/src/main/java/org/jastadd/relast/compiler/AbstractCompiler.java
+++ b/src/main/java/org/jastadd/relast/compiler/AbstractCompiler.java
@@ -1,27 +1,26 @@
 package org.jastadd.relast.compiler;
 
-import org.jastadd.JastAddConfiguration;
+import org.jastadd.PreprocessorConfiguration;
+import org.jastadd.option.FlagOption;
 import org.jastadd.option.Option;
 
 import java.util.ArrayList;
+import java.util.MissingResourceException;
+import java.util.ResourceBundle;
 
 public abstract class AbstractCompiler {
 
   private final boolean jastAddCompliant;
-  protected ArrayList<Option<?>> options;
   private final String name;
+  protected ArrayList<Option<?>> options;
+  private PreprocessorConfiguration configuration;
 
-  private JastAddConfiguration configuration;
-
-  public AbstractCompiler(String name, boolean jastaddCompliant) {
+  protected AbstractCompiler(String name, boolean jastaddCompliant) {
     this.name = name;
     this.jastAddCompliant = jastaddCompliant;
   }
 
-  public JastAddConfiguration getConfiguration() throws CompilerException {
-    if (configuration == null) {
-      throw new CompilerException("Configuration only supported for JastAdd-compliant compilers!");
-    }
+  public PreprocessorConfiguration getConfiguration() {
     return configuration;
   }
 
@@ -29,7 +28,22 @@ public abstract class AbstractCompiler {
 
     options = new ArrayList<>();
     initOptions();
-    configuration = new JastAddConfiguration(args, System.err, jastAddCompliant, options);
+    configuration = new PreprocessorConfiguration(args, System.err, jastAddCompliant, options);
+
+    if (configuration.shouldPrintHelp()) {
+      configuration.printHelp(System.out);
+      return 0;
+    }
+
+    if (configuration.shouldPrintVersion()) {
+      try {
+        ResourceBundle resources = ResourceBundle.getBundle("preprocessor");
+        System.out.println(getName() + ", version " + resources.getString("version"));
+      } catch (MissingResourceException e) {
+        System.out.println(getName() + ", unknown version");
+      }
+      return 0;
+    }
 
     return compile();
   }
@@ -37,10 +51,13 @@ public abstract class AbstractCompiler {
   protected abstract int compile() throws CompilerException;
 
   protected void initOptions() {
-    // there are no options by default
+    if (!jastAddCompliant) {
+      addOption(new FlagOption("version", "print version info"));
+      addOption(new FlagOption("help", "print command-line usage info"));
+    }
   }
 
-  protected <OptionType extends Option<?>> OptionType addOption(OptionType option) {
+  protected <O extends Option<?>> O addOption(O option) {
     options.add(option);
     return option;
   }
diff --git a/src/main/java/org/jastadd/relast/compiler/Mustache.java b/src/main/java/org/jastadd/relast/compiler/Mustache.java
new file mode 100644
index 0000000000000000000000000000000000000000..24d368abf9d13cbca1882caef1a49084943499a9
--- /dev/null
+++ b/src/main/java/org/jastadd/relast/compiler/Mustache.java
@@ -0,0 +1,50 @@
+package org.jastadd.relast.compiler;
+
+import com.github.jknack.handlebars.Handlebars;
+import com.github.jknack.handlebars.Template;
+import com.github.jknack.handlebars.io.ClassPathTemplateLoader;
+import com.github.jknack.handlebars.io.TemplateLoader;
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.*;
+import java.nio.file.Paths;
+
+public class Mustache {
+
+  private Mustache() {
+    // hide public constructor
+  }
+
+  public static void javaMustache(String templateFileName, File yamlFile, String outputFileName) throws IOException {
+
+    //noinspection ResultOfMethodCallIgnored
+    Paths.get(outputFileName).getParent().toFile().mkdirs(); // create directory structure if necessary
+
+    Object context = new Yaml().load(new FileReader(yamlFile));
+    applyTemplate(templateFileName, outputFileName, context);
+  }
+
+  public static void javaMustache(String templateFileName, String yaml, String outputFileName) throws IOException {
+
+    //noinspection ResultOfMethodCallIgnored
+    Paths.get(outputFileName).getParent().toFile().mkdirs(); // create directory structure if necessary
+
+    Object context = new Yaml().load(new StringReader(yaml));
+    applyTemplate(templateFileName, outputFileName, context);
+  }
+
+  private static void applyTemplate(String templateFileName, String outputFileName, Object context) throws IOException {
+    TemplateLoader loader = new ClassPathTemplateLoader();
+    loader.setSuffix(".mustache"); // the default is ".hbs"
+
+    Handlebars handlebars = new Handlebars(loader);
+    handlebars.prettyPrint(true); // set handlebars to mustache mode (skip some whitespace)
+    Template template = handlebars.compile(templateFileName);
+
+    try (Writer w = new FileWriter(outputFileName)) {
+      template.apply(context, w);
+      w.flush();
+    }
+  }
+
+}
diff --git a/src/main/java/org/jastadd/relast/compiler/RelAstProcessor.java b/src/main/java/org/jastadd/relast/compiler/RelAstProcessor.java
new file mode 100644
index 0000000000000000000000000000000000000000..943583517f406e9b557e3bb34262b15185b38438
--- /dev/null
+++ b/src/main/java/org/jastadd/relast/compiler/RelAstProcessor.java
@@ -0,0 +1,133 @@
+package org.jastadd.relast.compiler;
+
+import org.jastadd.option.ValueOption;
+import org.jastadd.relast.ast.GrammarFile;
+import org.jastadd.relast.ast.Program;
+import org.jastadd.relast.parser.RelAstParser;
+import org.jastadd.relast.scanner.RelAstScanner;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Optional;
+
+public abstract class RelAstProcessor extends AbstractCompiler {
+
+  protected ValueOption optionOutputBaseDir;
+  protected ValueOption optionInputBaseDir;
+
+  protected RelAstProcessor(String name, boolean jastAddCompliant) {
+    super(name, jastAddCompliant);
+  }
+
+  protected boolean isGrammarFile(String fileName) {
+    String extension = fileName.subSequence(fileName.lastIndexOf('.'), fileName.length()).toString();
+    return extension.equals(".relast") || extension.equals(".ast");
+  }
+
+  @Override
+  protected void initOptions() {
+    optionOutputBaseDir = addOption(new ValueOption("outputBaseDir", "base directory for generated files"));
+    optionInputBaseDir = addOption(new ValueOption("inputBaseDir", "base directory for input files"));
+    super.initOptions();
+  }
+
+  @Override
+  protected int compile() throws CompilerException {
+    final Path inputBasePath;
+    if (optionInputBaseDir.isMatched()) {
+      inputBasePath = Paths.get(optionInputBaseDir.value()).toAbsolutePath();
+    } else {
+      inputBasePath = Paths.get(".").toAbsolutePath();
+      printMessage("No input base dir is set. Assuming current directory '" + inputBasePath.toAbsolutePath().toString() + "'.");
+    }
+
+    if (!inputBasePath.toFile().exists()) {
+      printMessage("Input path '" + inputBasePath.toAbsolutePath().toString() + "' does not exist. Exiting...");
+      System.exit(-1);
+    } else if (!inputBasePath.toFile().isDirectory()) {
+      printMessage("Input path '" + inputBasePath.toAbsolutePath().toString() + "' is not a directory. Exiting...");
+      System.exit(-1);
+    }
+
+    final Path outputBasePath;
+    if (optionOutputBaseDir.isMatched()) {
+      outputBasePath = Paths.get(optionOutputBaseDir.value()).toAbsolutePath();
+    } else {
+      throw new CompilerException("No output base dir is set.");
+    }
+
+    if (outputBasePath.toFile().exists() && !outputBasePath.toFile().isDirectory()) {
+      printMessage("Output path '" + inputBasePath.toAbsolutePath().toString() + "' exists, but is not a directory. Exiting...");
+    }
+
+    printMessage("Running " + getName());
+
+    // gather all files
+    Collection<Path> inputFiles = new ArrayList<>();
+    getConfiguration().getFiles().forEach(name -> relativizeFileName(inputBasePath, Paths.get(name)).ifPresent(inputFiles::add));
+
+
+    Program program = parseProgram(inputFiles);
+
+    return processGrammar(program, inputBasePath, outputBasePath);
+  }
+
+  protected abstract int processGrammar(Program program, Path inputBasePath, Path outputBasePath) throws CompilerException;
+
+  private Optional<Path> relativizeFileName(Path inputBasePath, Path filePath) {
+    if (filePath.isAbsolute()) {
+      if (filePath.startsWith(inputBasePath)) {
+        return Optional.of(filePath.relativize(inputBasePath));
+      } else {
+        printMessage("Path '" + filePath + "' is not contained in the base path '" + inputBasePath + "'.");
+        return Optional.empty();
+      }
+    } else {
+      return Optional.of(inputBasePath.resolve(filePath));
+    }
+  }
+
+  protected void printMessage(String message) {
+    System.out.println(message);
+  }
+
+  protected void writeToFile(Path path, String str) throws CompilerException {
+    //noinspection ResultOfMethodCallIgnored
+    path.getParent().toFile().mkdirs(); // create directory structure if necessary
+    try (PrintWriter writer = new PrintWriter(path.toFile())) {
+      writer.print(str);
+    } catch (Exception e) {
+      throw new CompilerException("Could not write to file " + path, e);
+    }
+  }
+
+  private Program parseProgram(Collection<Path> inputFiles) {
+    Program program = new Program();
+
+    RelAstParser parser = new RelAstParser();
+
+    inputFiles.stream().filter(path -> isGrammarFile(path.toString())).forEach(
+        path -> {
+          try (BufferedReader reader = Files.newBufferedReader(path)) {
+            RelAstScanner scanner = new RelAstScanner(reader);
+            GrammarFile inputGrammar = (GrammarFile) parser.parse(scanner);
+            inputGrammar.setFileName(path.toString());
+            program.addGrammarFile(inputGrammar);
+            inputGrammar.treeResolveAll();
+          } catch (IOException | beaver.Parser.Exception e) {
+            printMessage("Could not parse grammar file " + path);
+            e.printStackTrace();
+          }
+        }
+    );
+
+    return program;
+  }
+}
+
diff --git a/src/main/java/org/jastadd/relast/compiler/RelastSourceToSourceCompiler.java b/src/main/java/org/jastadd/relast/compiler/RelastSourceToSourceCompiler.java
index 7d6949f5ff7ec74d2e1963e564bff9d633d4fbec..696c3c17ef59872ffaf174d4b8e4b4b7e8e9bdd1 100644
--- a/src/main/java/org/jastadd/relast/compiler/RelastSourceToSourceCompiler.java
+++ b/src/main/java/org/jastadd/relast/compiler/RelastSourceToSourceCompiler.java
@@ -1,144 +1,44 @@
 package org.jastadd.relast.compiler;
 
-import beaver.Parser;
-import org.jastadd.option.ValueOption;
 import org.jastadd.relast.ast.GrammarFile;
 import org.jastadd.relast.ast.Program;
-import org.jastadd.relast.parser.RelAstParser;
-import org.jastadd.relast.scanner.RelAstScanner;
 
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Optional;
 
-public class RelastSourceToSourceCompiler extends AbstractCompiler {
+public class RelastSourceToSourceCompiler extends RelAstProcessor {
 
 
-  protected ValueOption optionOutputBaseDir;
-  protected ValueOption optionInputBaseDir;
-
   public RelastSourceToSourceCompiler(String name, boolean jastAddCompliant) {
     super(name, jastAddCompliant);
   }
 
   public static void main(String[] args) {
     try {
-      new RelastSourceToSourceCompiler("relast-preprocessor", true).run(args);
+      new RelastSourceToSourceCompiler("Relational RAGs Source-To-Source Compiler", false).run(args);
     } catch (CompilerException e) {
       System.err.println(e.getMessage());
       System.exit(-1);
     }
   }
 
-  protected static boolean isGrammarFile(String fileName) {
+  @Override
+  protected boolean isGrammarFile(String fileName) {
     String extension = fileName.subSequence(fileName.lastIndexOf("."), fileName.length()).toString();
     return extension.equals(".relast") || extension.equals(".ast");
   }
 
   @Override
-  protected void initOptions() {
-    optionOutputBaseDir = addOption(new ValueOption("outputBaseDir", "base directory for generated files"));
-    optionInputBaseDir = addOption(new ValueOption("inputBaseDir", "base directory for input files"));
-  }
-
-  @Override
-  protected int compile() throws CompilerException {
-    final Path inputBasePath;
-    if (optionInputBaseDir.isMatched()) {
-      inputBasePath = Paths.get(optionInputBaseDir.value()).toAbsolutePath();
-    } else {
-      inputBasePath = Paths.get(".").toAbsolutePath();
-      printMessage("No input base dir is set. Assuming current directory '" + inputBasePath.toAbsolutePath().toString() + "'.");
-    }
-
-    if (!inputBasePath.toFile().exists()) {
-      printMessage("Input path '" + inputBasePath.toAbsolutePath().toString() + "' does not exist. Exiting...");
-      System.exit(-1);
-    } else if (!inputBasePath.toFile().isDirectory()) {
-      printMessage("Input path '" + inputBasePath.toAbsolutePath().toString() + "' is not a directory. Exiting...");
-      System.exit(-1);
-    }
-
-    final Path outputBasePath;
-    if (optionOutputBaseDir.isMatched()) {
-      outputBasePath = Paths.get(optionOutputBaseDir.value()).toAbsolutePath();
-    } else {
-      throw new CompilerException("No output base dir is set.");
-    }
-
-    if (outputBasePath.toFile().exists() && !outputBasePath.toFile().isDirectory()) {
-      printMessage("Output path '" + inputBasePath.toAbsolutePath().toString() + "' exists, but is not a directory. Exiting...");
-    }
-
-    printMessage("Running RelAST Preprocessor");
-
-    // gather all files
-    Collection<Path> inputFiles = new ArrayList<>();
-    getConfiguration().getFiles().forEach(name -> relativizeFileName(inputBasePath, Paths.get(name)).ifPresent(path -> inputFiles.add(path)));
-
-
-    Program program = parseProgram(inputFiles);
+  protected int processGrammar(Program program, Path inputBasePath, Path outputBasePath) throws CompilerException {
 
     printMessage("Writing output files");
 
     for (GrammarFile grammarFile : program.getGrammarFileList()) {
+      printMessage("Writing output file " + grammarFile.getFileName());
       // TODO decide and document what the file name should be, the full path or a simple name?
       writeToFile(outputBasePath.resolve(inputBasePath.relativize(Paths.get(grammarFile.getFileName()))), grammarFile.generateAbstractGrammar());
     }
     return 0;
   }
-
-  private Optional<Path> relativizeFileName(Path inputBasePath, Path filePath) {
-    if (filePath.isAbsolute()) {
-      if (filePath.startsWith(inputBasePath)) {
-        return Optional.of(filePath.relativize(inputBasePath));
-      } else {
-        printMessage("Path '" + filePath + "' is not contained in the base path '" + inputBasePath + "'.");
-        return Optional.empty();
-      }
-    } else {
-      return Optional.of(inputBasePath.resolve(filePath));
-    }
-  }
-
-  private void printMessage(String message) {
-    System.out.println(message);
-  }
-
-  private void writeToFile(Path path, String str) throws CompilerException {
-    try (PrintWriter writer = new PrintWriter(path.toFile())) {
-      writer.print(str);
-    } catch (Exception e) {
-      throw new CompilerException("Could not write to file " + path, e);
-    }
-  }
-
-  private Program parseProgram(Collection<Path> inputFiles) throws CompilerException {
-    Program program = new Program();
-
-    RelAstParser parser = new RelAstParser();
-    inputFiles.stream().filter(path -> isGrammarFile(path.toString())).forEach(
-        path -> {
-          try (BufferedReader reader = Files.newBufferedReader(path)) {
-            RelAstScanner scanner = new RelAstScanner(reader);
-            GrammarFile inputGrammar = (GrammarFile) parser.parse(scanner);
-            inputGrammar.setFileName(path.toString());
-            program.addGrammarFile(inputGrammar);
-            inputGrammar.treeResolveAll();
-          } catch (IOException | Parser.Exception e) {
-            printMessage("Could not parse grammar file " + path);
-            e.printStackTrace();
-          }
-        }
-    );
-
-    return program;
-}
 }
 
diff --git a/src/main/java/org/jastadd/relast/compiler/Utils.java b/src/main/java/org/jastadd/relast/compiler/Utils.java
deleted file mode 100644
index 8e2ef0c8bcb8266fe5789b2608f4a6f78c47548a..0000000000000000000000000000000000000000
--- a/src/main/java/org/jastadd/relast/compiler/Utils.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package org.jastadd.relast.compiler;
-
-import java.util.*;
-import java.util.function.Predicate;
-
-import static java.util.stream.Collectors.toList;
-
-public class Utils {
-  public static <T> List<T> filterToList(Collection<T> collection, Predicate<T> predicate) {
-    return collection.stream().filter(predicate).collect(toList());
-  }
-
-  public static <T> Set<T> asSet(T... t) {
-    return new HashSet<T>(Arrays.asList(t));
-  }
-}
diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml
deleted file mode 100644
index 98cfd73c75df58d8598521bc10b043e214ec4ad8..0000000000000000000000000000000000000000
--- a/src/main/resources/log4j2.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<Configuration status="INFO">
-    <Appenders>
-        <Console name="Console" target="SYSTEM_OUT">
-            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/>
-        </Console>
-    </Appenders>
-    <Loggers>
-        <Root level="info">
-            <AppenderRef ref="Console"/>
-        </Root>
-    </Loggers>
-</Configuration>
\ No newline at end of file
diff --git a/src/main/resources/preprocessor.properties b/src/main/resources/preprocessor.properties
new file mode 100644
index 0000000000000000000000000000000000000000..fb55bf09edd40713c458341e14b57714f11924db
--- /dev/null
+++ b/src/main/resources/preprocessor.properties
@@ -0,0 +1 @@
+version=1.0.0-pre-release
diff --git a/src/test/java/org/jastadd/relast/tests/PreprocessorTest.java b/src/test/java/org/jastadd/relast/tests/PreprocessorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..b58349632e19b3caf3848ae3fff836673c441221
--- /dev/null
+++ b/src/test/java/org/jastadd/relast/tests/PreprocessorTest.java
@@ -0,0 +1,13 @@
+package org.jastadd.relast.tests;
+
+import org.jastadd.relast.compiler.RelastSourceToSourceCompiler;
+import org.junit.jupiter.api.BeforeAll;
+
+public class PreprocessorTest extends RelAstProcessorTestBase {
+
+  @BeforeAll
+  static void init() {
+    mainClass = RelastSourceToSourceCompiler.class;
+  }
+
+}
diff --git a/src/test/java/org/jastadd/ros2rag/tests/RelAstTest.java b/src/test/java/org/jastadd/ros2rag/tests/RelAstTest.java
deleted file mode 100644
index c87e51f7dbc70644b621998548d4f942160e0ea9..0000000000000000000000000000000000000000
--- a/src/test/java/org/jastadd/ros2rag/tests/RelAstTest.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package org.jastadd.ros2rag.tests;
-
-import org.jastadd.relast.compiler.CompilerException;
-import org.jastadd.relast.compiler.RelastSourceToSourceCompiler;
-import org.junit.jupiter.api.Test;
-
-import java.io.File;
-import java.nio.file.Paths;
-import java.util.Objects;
-
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-public class RelAstTest {
-
-  void transform(boolean jastAddCompliant, String inputDir, String outputDir) throws CompilerException {
-
-    System.out.println("Running test in directory '" + Paths.get(".").toAbsolutePath() + "'.");
-    assertTrue(Paths.get(inputDir).toFile().exists(), "input directory does not exist");
-    assertTrue(Paths.get(inputDir).toFile().isDirectory(), "input directory is not a directory");
-
-    File outputDirFile = Paths.get(outputDir).toFile();
-    if (outputDirFile.exists()) {
-      assertTrue(outputDirFile.isDirectory());
-      if (Objects.requireNonNull(outputDirFile.list(), "Could not read output directory").length != 0) {
-        System.out.println("output directory is not empty!");
-      }
-    } else {
-      assertTrue(outputDirFile.mkdir());
-    }
-
-    String[] args = {
-        "--outputBaseDir=" + outputDir,
-        "--inputBaseDir=" + inputDir,
-        "Example.relast"
-    };
-
-    new RelastSourceToSourceCompiler("testCompiler", jastAddCompliant).run(args);
-  }
-
-  @Test
-  void transformMinimalExample() throws CompilerException {
-    transform(false,"src/test/resources/in", "src/test/resources/out-simple");
-    transform(true,"src/test/resources/in", "src/test/resources/out-compliant");
-  }
-}
diff --git a/src/test/resources/Comments/config.yaml b/src/test/resources/Comments/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..6095c8da742b0eaa2f636aeee218d77d37221859
--- /dev/null
+++ b/src/test/resources/Comments/config.yaml
@@ -0,0 +1,8 @@
+- name: "Parse and reprint tests"
+  args:
+    - "--inputBaseDir=in"
+    - "--outputBaseDir=out"
+    - "CommentsA.relast"
+  out: "out"
+  expected: "in"
+  compare: true
diff --git a/src/test/resources/Comments/in/CommentsA.relast b/src/test/resources/Comments/in/CommentsA.relast
new file mode 100644
index 0000000000000000000000000000000000000000..37e963fdf8a7ca9b1613deca9bc46923f29f00d2
--- /dev/null
+++ b/src/test/resources/Comments/in/CommentsA.relast
@@ -0,0 +1,15 @@
+// this file only contains comments
+//
+
+//
+
+// like this one
+/* or this one */
+/* or this
+   one */
+/**/
+//test
+/*test*/
+
+//
+
diff --git a/src/test/resources/MinimalGrammar/config.yaml b/src/test/resources/MinimalGrammar/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..135f6bd74b057a00cc5582b7ca78e83995167e48
--- /dev/null
+++ b/src/test/resources/MinimalGrammar/config.yaml
@@ -0,0 +1,9 @@
+- name: "Parse and reprint tests"
+  args:
+    - "--inputBaseDir=in"
+    - "--outputBaseDir=out"
+    - "Example.relast"
+    - "CommentInFront.relast"
+  out: "out"
+  expected: "in"
+  compare: true
diff --git a/src/test/resources/MinimalGrammar/in/CommentInFront.relast b/src/test/resources/MinimalGrammar/in/CommentInFront.relast
new file mode 100644
index 0000000000000000000000000000000000000000..96c5eecaae626aa6903b7d0b9641916688c7a997
--- /dev/null
+++ b/src/test/resources/MinimalGrammar/in/CommentInFront.relast
@@ -0,0 +1,2 @@
+// comment
+CommentInFront;
diff --git a/src/test/resources/MinimalGrammar/in/Example.relast b/src/test/resources/MinimalGrammar/in/Example.relast
new file mode 100644
index 0000000000000000000000000000000000000000..7c28b7bcd84c826aedd622460eb93da08ee80c3d
--- /dev/null
+++ b/src/test/resources/MinimalGrammar/in/Example.relast
@@ -0,0 +1,13 @@
+Model ::= RobotArm ZoneModel;
+
+ZoneModel ::= <Size:IntPosition> SafetyZone:Zone*;
+
+Zone ::= Coordinate*;
+
+RobotArm ::= Joint* EndEffector <_AttributeTestSource:int> /<_AppropriateSpeed:double>/; // normally this would be: <AttributeTestSource:int> ;
+
+Joint ::= <Name> <CurrentPosition:IntPosition>;  // normally this would be: <CurrentPosition:IntPosition>
+
+EndEffector : Joint;
+
+Coordinate ::= <Position:IntPosition>;
diff --git a/src/test/resources/in/Example.relast b/src/test/resources/in/Example.relast
deleted file mode 100644
index aa17ad814128cf155d844e0f36f1114b1b1afa33..0000000000000000000000000000000000000000
--- a/src/test/resources/in/Example.relast
+++ /dev/null
@@ -1,13 +0,0 @@
-Model ::= RobotArm ZoneModel ;
-
-ZoneModel ::= <Size:IntPosition> SafetyZone:Zone* ;
-
-Zone ::= Coordinate* ;
-
-RobotArm ::= Joint* EndEffector <_AttributeTestSource:int> /<_AppropriateSpeed:double>/ ; // normally this would be: <AttributeTestSource:int> ;
-
-Joint ::= <Name> <CurrentPosition:IntPosition> ;  // normally this would be: <CurrentPosition:IntPosition>
-
-EndEffector : Joint;
-
-Coordinate ::= <Position:IntPosition> ;
diff --git a/src/testFixtures/java/org/jastadd/relast/tests/RelAstProcessorTestBase.java b/src/testFixtures/java/org/jastadd/relast/tests/RelAstProcessorTestBase.java
new file mode 100644
index 0000000000000000000000000000000000000000..e83746c04a4a85327eabf5db0a1de580e6a42e3c
--- /dev/null
+++ b/src/testFixtures/java/org/jastadd/relast/tests/RelAstProcessorTestBase.java
@@ -0,0 +1,176 @@
+package org.jastadd.relast.tests;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.filefilter.FileFilterUtils;
+import org.apache.commons.io.filefilter.TrueFileFilter;
+import org.assertj.core.util.Files;
+import org.jastadd.relast.tests.config.Configuration;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.TestFactory;
+
+import java.io.*;
+import java.nio.charset.Charset;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Stream;
+
+public class RelAstProcessorTestBase {
+
+  protected static Class<?> mainClass;
+
+  protected static int runProcess(File workingDirectory, List<String> command, StringBuilder outStringBuider, StringBuilder errStringBuilder) throws IOException, InterruptedException {
+
+    File outFile = Files.newTemporaryFile();
+    File errFile = Files.newTemporaryFile();
+
+    ProcessBuilder pb = new ProcessBuilder(command).
+        directory(workingDirectory)
+        .redirectOutput(outFile)
+        .redirectError(errFile);
+
+    Process p = pb.start();
+    try {
+      p.waitFor();
+    } catch (InterruptedException e) {
+      if (Thread.interrupted())  // Clears interrupted status!
+        throw e;
+    }
+
+    try (BufferedReader outReader = new BufferedReader(new FileReader(outFile))) {
+      String outLine;
+      while ((outLine = outReader.readLine()) != null) {
+        outStringBuider.append(outLine).append("\n");
+      }
+    }
+
+    try (BufferedReader errReader = new BufferedReader(new FileReader(errFile))) {
+      String errLine;
+      while ((errLine = errReader.readLine()) != null) {
+        errStringBuilder.append(errLine).append("\n");
+      }
+    }
+
+    return p.exitValue();
+  }
+
+  protected static int runJavaProcess(Class<?> klass, File workingDirectory, List<String> args, StringBuilder outStringBuider, StringBuilder errStringBuilder) throws IOException, InterruptedException {
+    String javaHome = System.getProperty("java.home");
+    String javaBin = javaHome + File.separator + "bin" + File.separator + "java";
+    String classpath = System.getProperty("java.class.path");
+    String className = klass.getName();
+
+    List<String> command = new LinkedList<>();
+    command.add(javaBin);
+    command.add("-cp");
+    command.add(classpath);
+    command.add(className);
+    if (args != null) {
+      command.addAll(args);
+    }
+
+    System.out.println("Running java -jar -cp [...] " + className + " " + (args != null ? args.stream().reduce((s1, s2) -> s1 + " " + s2).orElse("") : ""));
+
+    return runProcess(workingDirectory, command, outStringBuider, errStringBuilder);
+  }
+
+  protected void directoryTest(Class<?> mainClass, Path dir) throws IOException, InterruptedException {
+    dir = dir.toAbsolutePath();
+    Path configFile = dir.resolve("config.yaml");
+    ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
+    List<Configuration> configs = mapper.readValue(configFile.toFile(), new TypeReference<List<Configuration>>() {
+    });
+
+    for (Configuration config : configs) {
+
+      FileUtils.forceMkdir(dir.resolve(config.getOut()).toFile());
+      FileUtils.cleanDirectory(dir.resolve(config.getOut()).toFile());
+
+      StringBuilder outBuilder = new StringBuilder();
+      StringBuilder errBuilder = new StringBuilder();
+      int returnValue = runJavaProcess(mainClass, dir.toFile(), Arrays.asList(config.getArgs()), outBuilder, errBuilder);
+      String out = outBuilder.toString();
+      String err = errBuilder.toString();
+
+      System.out.println(out);
+      System.err.println(err);
+
+      if (config.shouldFail()) {
+        Assertions.assertNotEquals(0, returnValue, config.getName() + ": Zero return value of preprocessor for negative test.");
+      } else {
+        Assertions.assertEquals(0, returnValue, config.getName() + ": Non-Zero return value of preprocessor for positive test.");
+      }
+
+      checkOutput(config, out, err);
+
+      if (config.shouldCompare()) {
+        Path outPath = dir.resolve(config.getOut());
+        Path expectedPath = dir.resolve(config.getExpected());
+        comparePaths(outPath, expectedPath);
+      }
+    }
+  }
+
+  private void checkOutput(Configuration config, String out, String err) {
+    for (String errMatchString : config.getErrMatches()) {
+      if (!err.matches(errMatchString)) {
+        Assertions.fail("Error stream does not match '" + errMatchString + "'");
+      }
+    }
+
+    for (String errContainsString : config.getErrContains()) {
+      if (!err.contains(errContainsString)) {
+        Assertions.fail("Error stream does not contain '" + errContainsString + "'");
+      }
+    }
+
+    for (String outMatchString : config.getOutMatches()) {
+      if (!out.matches(outMatchString)) {
+        Assertions.fail("Output stream does not match '" + outMatchString + "'");
+      }
+    }
+
+    for (String outContainsString : config.getOutContains()) {
+      if (!out.contains(outContainsString)) {
+        Assertions.fail("Output stream does not contain '" + outContainsString + "'");
+      }
+    }
+  }
+
+  private void comparePaths(Path outPath, Path expectedPath) {
+    final Collection<File> files = FileUtils.listFiles(expectedPath.toFile(), TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE);
+    files.forEach(f -> {
+      final Path relative = expectedPath.relativize(f.toPath());
+      final String relativePath = relative.toFile().getPath();
+      final File bfile = new File(outPath.toFile(), relativePath);
+      if (bfile.exists()) {
+        try {
+          final Charset charset = Charset.defaultCharset();
+          final String expected = FileUtils.readFileToString(f, charset);
+          final String result = FileUtils.readFileToString(bfile, charset);
+          Assertions.assertEquals(expected, result);
+        } catch (IOException e) {
+          Assertions.fail("Unable to compare input files '" + f + "' and '" + bfile + "'", e);
+        }
+      } else {
+        Assertions.fail(relativePath + " expected to exist");
+      }
+    });
+  }
+
+  @TestFactory
+  Stream<DynamicTest> testAll() {
+    File baseDir = new File("src/test/resources/");
+
+    Assertions.assertTrue(baseDir.exists());
+    Assertions.assertTrue(baseDir.isDirectory());
+    File[] files = baseDir.listFiles((FileFilter) FileFilterUtils.directoryFileFilter());
+    Assertions.assertNotNull(files);
+    return Arrays.stream(files).map(File::toPath).map(f -> DynamicTest.dynamicTest(f.getFileName().toString(),
+        () -> directoryTest(mainClass, f)));
+  }
+
+}
diff --git a/src/testFixtures/java/org/jastadd/relast/tests/config/Configuration.java b/src/testFixtures/java/org/jastadd/relast/tests/config/Configuration.java
new file mode 100644
index 0000000000000000000000000000000000000000..faeb91a1065ecf7530e299189444ce4d094086c5
--- /dev/null
+++ b/src/testFixtures/java/org/jastadd/relast/tests/config/Configuration.java
@@ -0,0 +1,117 @@
+package org.jastadd.relast.tests.config;
+
+public class Configuration {
+
+  private String name;
+  private String[] args = new String[]{};
+  private boolean fail = false;
+  private String[] errMatches = new String[]{};
+  private String[] errContains = new String[]{};
+  private String[] outMatches = new String[]{};
+  private String[] outContains = new String[]{};
+  private String in = "in";
+  private String out = "out";
+  private String expected = "expected";
+
+  private boolean compare = false;
+
+  public String getIn() {
+    return in;
+  }
+
+  public void setIn(String in) {
+    this.in = in;
+  }
+
+  public String getOut() {
+    return out;
+  }
+
+  public void setOut(String out) {
+    this.out = out;
+  }
+
+  public String getExpected() {
+    return expected;
+  }
+
+  public void setExpected(String expected) {
+    this.expected = expected;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonGetter("out-matches")
+  public String[] getOutMatches() {
+    return outMatches;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonSetter("out-matches")
+  public void setOutMatches(String[] outMatches) {
+    this.outMatches = outMatches;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonGetter("out-contains")
+  public String[] getOutContains() {
+    return outContains;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonSetter("out-contains")
+  public void setOutContains(String[] outContains) {
+    this.outContains = outContains;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonGetter("err-matches")
+  public String[] getErrMatches() {
+    return errMatches;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonSetter("err-matches")
+  public void setErrMatches(String[] errMatches) {
+    this.errMatches = errMatches;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonGetter("err-contains")
+  public String[] getErrContains() {
+    return errContains;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonSetter("err-contains")
+  public void setErrContains(String[] errContains) {
+    this.errContains = errContains;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public void setName(String name) {
+    this.name = name;
+  }
+
+  public String[] getArgs() {
+    return args;
+  }
+
+  public void setArgs(String[] args) {
+    this.args = args;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonGetter("fail")
+  public boolean shouldFail() {
+    return fail;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonSetter("fail")
+  public void shouldFail(boolean fail) {
+    this.fail = fail;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonGetter("compare")
+  public boolean shouldCompare() {
+    return compare;
+  }
+
+  @com.fasterxml.jackson.annotation.JsonSetter("compare")
+  public void shouldCompare(boolean compare) {
+    this.compare = compare;
+  }
+}