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..c9f5a8d654fdd6a213e364528ce70ebe1173e63c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,38 +1,58 @@
-
-apply plugin: 'java'
-apply plugin: 'jastadd'
-apply plugin: 'application'
-apply plugin: "idea"
+plugins {
+    id 'java-library'
+    id 'org.jastadd'
+    id 'java'
+    id 'idea'
+    id 'java-test-fixtures'
+    id 'com.github.ben-manes.versions' version '0.34.0'
+}
 
 sourceCompatibility = 1.8
-
-mainClassName = 'org.jastadd.relast.compiler.RelastSourceToSourceCompiler'
+targetCompatibility = 1.8
 
 repositories {
     jcenter()
 }
 
-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'
+    from sourceSets.model.output
 }
 
-sourceSets {
-    main {
-        java.srcDir "src/gen/java"
-        java.srcDir "buildSrc/gen/java"
-    }
+artifacts {
+    archives modelJar
+}
+
+dependencies {
+
+    modelImplementation group: 'org.jastadd', name: 'jastadd', version: '2.3.4'
+    modelImplementation group: 'net.sf.beaver', name: 'beaver-rt', version: '0.9.11'
+
+    compileOnly 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'
 }
 
 test {
@@ -41,61 +61,47 @@ test {
     maxHeapSize = '1G'
 }
 
-jar {
-    manifest {
-        attributes "Main-Class": 'org.jastadd.relast.compiler.RelastSourceToSourceCompiler'
-    }
-
-    from {
-        configurations.compile.collect { it.isDirectory() ? it : zipTree(it) }
-    }
-}
+// 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 +111,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 +128,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 +138,7 @@ jastadd {
 
     }
 
-    module = "RelAst"
+    module = "Preprocessor"
 
     astPackage = 'org.jastadd.relast.ast'
 
@@ -149,3 +155,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/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/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 78%
rename from src/main/java/org/jastadd/JastAddConfiguration.java
rename to src/main/java/org/jastadd/PreprocessorConfiguration.java
index 30774005356c0eb30354b7fb76e1bafe67d00873..726aba32937e914fd6d10e0af8f20d8372c35acd 100644
--- a/src/main/java/org/jastadd/JastAddConfiguration.java
+++ b/src/main/java/org/jastadd/PreprocessorConfiguration.java
@@ -32,56 +32,80 @@ 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 {
+        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..7c136853f1dd1b7f33c134ac2425e320b1ff685c 100644
--- a/src/main/java/org/jastadd/relast/compiler/AbstractCompiler.java
+++ b/src/main/java/org/jastadd/relast/compiler/AbstractCompiler.java
@@ -1,6 +1,7 @@
 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;
@@ -8,20 +9,16 @@ import java.util.ArrayList;
 public abstract class AbstractCompiler {
 
   private final boolean jastAddCompliant;
-  protected ArrayList<Option<?>> options;
   private final String name;
-
-  private JastAddConfiguration configuration;
+  protected ArrayList<Option<?>> options;
+  private PreprocessorConfiguration configuration;
 
   public 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 +26,12 @@ 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;
+    }
 
     return compile();
   }
@@ -37,10 +39,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..3b428abf1f60b87ff004b7cb92aaefcbf99b03df
--- /dev/null
+++ b/src/main/java/org/jastadd/relast/compiler/Mustache.java
@@ -0,0 +1,32 @@
+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.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.Writer;
+
+public class Mustache {
+
+  public static void javaMustache(String templateFileName, String yamlFileName, String outputFileName) throws IOException {
+
+    Object context = new Yaml().load(new FileReader(yamlFileName));
+    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..b5624f2aee6e22179800de5e09c471df8b3e2071
--- /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;
+
+  public RelAstProcessor(String name, boolean jastAddCompliant) {
+    super(name, jastAddCompliant);
+  }
+
+  protected static 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..e919ba822c44bee59b935bbc8d7e553c571101c9 100644
--- a/src/main/java/org/jastadd/relast/compiler/RelastSourceToSourceCompiler.java
+++ b/src/main/java/org/jastadd/relast/compiler/RelastSourceToSourceCompiler.java
@@ -1,28 +1,14 @@
 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);
   }
@@ -42,103 +28,16 @@ public class RelastSourceToSourceCompiler extends AbstractCompiler {
   }
 
   @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/test/java/org/jastadd/relast/tests/PreprocessorTest.java b/src/test/java/org/jastadd/relast/tests/PreprocessorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..c6fde6f5331ebc9e94680dee87b54ce31e58bad6
--- /dev/null
+++ b/src/test/java/org/jastadd/relast/tests/PreprocessorTest.java
@@ -0,0 +1,15 @@
+package org.jastadd.relast.tests;
+
+import org.jastadd.relast.compiler.RelastSourceToSourceCompiler;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Paths;
+
+public class PreprocessorTest extends RelAstProcessorTestBase {
+
+  @Test
+  void testMinimalGrammar() throws IOException, InterruptedException {
+    directoryTest(RelastSourceToSourceCompiler.class, Paths.get("src/test/resources/MinimalGrammar"));
+  }
+}
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/MinimalGrammar/config.yaml b/src/test/resources/MinimalGrammar/config.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c22d8d533545dff1d7139fa62a5464506d703c13
--- /dev/null
+++ b/src/test/resources/MinimalGrammar/config.yaml
@@ -0,0 +1,10 @@
+- name: "MinimalGrammar"
+  args:
+    - "--inputBaseDir=in"
+    - "--outputBaseDir=out"
+    - "Example.relast"
+- name: "Comments in front"
+  args:
+    - "--inputBaseDir=in"
+    - "--outputBaseDir=out"
+    - "CommentInFront.relast"
diff --git a/src/test/resources/MinimalGrammar/in/CommentInFront.relast b/src/test/resources/MinimalGrammar/in/CommentInFront.relast
new file mode 100644
index 0000000000000000000000000000000000000000..99b677ef4d8481b99e4d9f2f366764e1de6a568b
--- /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..0d9c9069419cd5b38fd1f861e9152cce7ffeb750
--- /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..bdd620c19de7ae3d9891960c6191c51cfb6ef37d
--- /dev/null
+++ b/src/testFixtures/java/org/jastadd/relast/tests/RelAstProcessorTestBase.java
@@ -0,0 +1,141 @@
+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.assertj.core.util.Files;
+import org.jastadd.relast.tests.config.Configuration;
+import org.junit.jupiter.api.Assertions;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Objects;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class RelAstProcessorTestBase {
+
+  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.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) {
+
+      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, "Zero return value of preprocessor for negative test.");
+      } else {
+        Assertions.assertEquals(0, returnValue, "Non-Zero return value of preprocessor for positive test.");
+      }
+
+      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("Error stream does not match '" + outMatchString + "'");
+        }
+      }
+
+      for (String outContainsString : config.getOutContains()) {
+        if (!out.contains(outContainsString)) {
+          Assertions.fail("Error stream does not contain '" + outContainsString + "'");
+        }
+      }
+    }
+  }
+
+  protected void ensureOutputDir(String outputDir) {
+    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.mkdirs());
+    }
+  }
+}
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..7a1ec9419bc5bb39463941ff70ceb097ab33c5d5
--- /dev/null
+++ b/src/testFixtures/java/org/jastadd/relast/tests/config/Configuration.java
@@ -0,0 +1,78 @@
+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[]{};
+
+  @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[] errMatches) {
+    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;
+  }
+}