From 396455c92436f8929531a5fdafe3f43330a25525 Mon Sep 17 00:00:00 2001
From: rschoene <rene.schoene@tu-dresden.de>
Date: Mon, 8 Jun 2020 19:11:25 +0200
Subject: [PATCH] Included some error messages.

---
 ros2rag.base/src/main/jastadd/Analysis.jrag   | 38 +++++-----
 ros2rag.base/src/main/jastadd/Errors.jrag     | 56 ++++++++++++--
 ros2rag.base/src/main/jastadd/Navigation.jrag | 22 ++++++
 ros2rag.base/src/main/jastadd/Ros2Rag.relast  |  2 +-
 .../src/main/jastadd/backend/Generation.jadd  | 21 +++--
 .../src/main/jastadd/backend/Mappings.jrag    | 16 ++++
 .../jastadd/ros2rag/compiler/Compiler.java    | 10 +++
 .../src/test/01-input/errors/Errors.expected  |  0
 .../src/test/01-input/errors/Errors.relast    | 15 ++++
 .../src/test/01-input/errors/Errors.ros2rag   | 72 ++++++++++++++++++
 .../src/test/01-input/errors/README.md        | 25 ++++++
 .../org/jastadd/ros2rag/tests/Errors.java     | 76 +++++++++++++++++++
 .../org/jastadd/ros2rag/tests/TestUtils.java  | 35 +++++++++
 13 files changed, 358 insertions(+), 30 deletions(-)
 create mode 100644 ros2rag.tests/src/test/01-input/errors/Errors.expected
 create mode 100644 ros2rag.tests/src/test/01-input/errors/Errors.relast
 create mode 100644 ros2rag.tests/src/test/01-input/errors/Errors.ros2rag
 create mode 100644 ros2rag.tests/src/test/01-input/errors/README.md
 create mode 100644 ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/Errors.java

diff --git a/ros2rag.base/src/main/jastadd/Analysis.jrag b/ros2rag.base/src/main/jastadd/Analysis.jrag
index 6c8755e..228a347 100644
--- a/ros2rag.base/src/main/jastadd/Analysis.jrag
+++ b/ros2rag.base/src/main/jastadd/Analysis.jrag
@@ -1,23 +1,27 @@
 aspect Analysis {
-  // --- isPrimitiveType ---
-  syn boolean TokenComponent.isPrimitiveType() = getJavaTypeUse().isPrimitiveType();
-  syn boolean JavaTypeUse.isPrimitiveType() = false;
-  eq SimpleJavaTypeUse.isPrimitiveType() {
-    switch(getName()) {
-      case "int":
-      case "short":
-      case "long":
-      case "float":
-      case "double":
-      case "char":
-      case "byte": return true;
-      default: return false;
+  // --- lookupTokenUpdateDefinition ---
+  inh TokenUpdateDefinition TokenUpdateDefinition.lookupTokenUpdateDefinition(TokenComponent token);
+  eq Ros2Rag.getUpdateDefinition().lookupTokenUpdateDefinition(TokenComponent token) {
+    for (UpdateDefinition def : getUpdateDefinitionList()) {
+      if (def.isTokenUpdateDefinition() && def.asTokenUpdateDefinition().getToken().equals(token)) {
+        return def.asTokenUpdateDefinition();
+      }
     }
+    return null;
   }
 
-  // --- prettyPrint ---
-  syn String MappingDefinitionType.prettyPrint();
-  eq JavaMappingDefinitionType.prettyPrint() = getType().getName();
-  eq JavaArrayMappingDefinitionType.prettyPrint() = getType().getName() + "[]";
+  // --- lookupDependencyDefinition ---
+  inh DependencyDefinition DependencyDefinition.lookupDependencyDefinition(TypeDecl source, String id);
+  eq Ros2Rag.getDependencyDefinition().lookupDependencyDefinition(TypeDecl source, String id) {
+    for (DependencyDefinition def : getDependencyDefinitionList()) {
+      if (def.getID().equals(id) && def.getSource().containingTypeDecl().equals(source)) {
+        return def;
+      }
+    }
+    return null;
+  }
 
+  // --- isAlreadyDefined ---
+  syn boolean TokenUpdateDefinition.isAlreadyDefined() = lookupTokenUpdateDefinition(getToken()) != this;
+  syn boolean DependencyDefinition.isAlreadyDefined() = lookupDependencyDefinition(getSource().containingTypeDecl(), getID()) != this;
 }
diff --git a/ros2rag.base/src/main/jastadd/Errors.jrag b/ros2rag.base/src/main/jastadd/Errors.jrag
index 7fb6b00..ef223a4 100644
--- a/ros2rag.base/src/main/jastadd/Errors.jrag
+++ b/ros2rag.base/src/main/jastadd/Errors.jrag
@@ -7,15 +7,59 @@ aspect Errors {
     [new TreeSet<ErrorMessage>()]
     root Ros2Rag;
 
-//  TypeUse contributes error("Type '" + getID() + "' not found")
-//    when decl() == null && !isToken()
-//    to Program.errors();
+    ReadFromMqttDefinition contributes error("Read definition already defined for " + getToken().getName())
+      when isAlreadyDefined()
+      to Ros2Rag.errors();
+
+    ReadFromMqttDefinition contributes error("Reading target token must not be an NTA token!")
+      when getToken().getNTA()
+      to Ros2Rag.errors();
+
+    // if first mapping is null, then suitableDefaultMapping() == null
+    ReadFromMqttDefinition contributes error("No suitable default mapping found for type " +
+      ((getMappingList().isEmpty())
+        ? getToken().getJavaTypeUse().prettyPrint()
+        : getMappingList().get(0).getFromType().prettyPrint()))
+      when effectiveMappings().get(0) == null
+      to Ros2Rag.errors();
 
-    UpdateDefinition contributes error("")
-      when true
+    ReadFromMqttDefinition contributes error("to-type of last mapping must be type of the Token!")
+      when getToken().getJavaTypeUse().prettyPrint().equals(
+          effectiveMappings().get(effectiveMappings().size() - 1))
+      to Ros2Rag.errors();
+
+    WriteToMqttDefinition contributes error("Writing target token must be an NTA token!")
+      when !getToken().getNTA()
+      to Ros2Rag.errors();
+
+    WriteToMqttDefinition contributes error("Write definition already defined for " + getToken().getName())
+      when isAlreadyDefined()
+      to Ros2Rag.errors();
+
+    DependencyDefinition contributes error("Dependency definition already defined for " + getSource().containingTypeDecl().getName() + " with name " + getID())
+      when isAlreadyDefined()
+      to Ros2Rag.errors();
+
+    DependencyDefinition contributes error("The name of a dependency definition must not be equal to a list-node on the source")
+      when isAlreadyDefinedAsList()
+      to Ros2Rag.errors();
+
+    DependencyDefinition contributes error("There must be a write update definition targeting " + getSource().parentTypeypeAndName() + " for dependency definition " + getID())
+      when targetUpdateDefinition() == null
       to Ros2Rag.errors();
 }
 
+aspect ErrorHelpers {
+  syn boolean DependencyDefinition.isAlreadyDefinedAsList() {
+    for (Component comp : getSource().containingTypeDecl().getComponentList()) {
+      if (comp.isListComponent() && comp.getName().equals(this.getID())) {
+        return true;
+      }
+    }
+    return false;
+  }
+  syn String TokenComponent.parentTypeypeAndName() = containingTypeDecl().getName() + "." + getName();
+}
 
 aspect ErrorMessage {
   public class ErrorMessage implements Comparable<ErrorMessage> {
@@ -27,7 +71,7 @@ aspect ErrorMessage {
 
     public ErrorMessage(ASTNode node, String message) {
       this.node = node;
-      this.filename = node.containedFile().getFileName();
+      this.filename = node.containedFileName();
       this.line = node.getStartLine();
       this.col = node.getStartColumn();
       this.message = message;
diff --git a/ros2rag.base/src/main/jastadd/Navigation.jrag b/ros2rag.base/src/main/jastadd/Navigation.jrag
index 49b86d0..f56d998 100644
--- a/ros2rag.base/src/main/jastadd/Navigation.jrag
+++ b/ros2rag.base/src/main/jastadd/Navigation.jrag
@@ -19,6 +19,28 @@ aspect Navigation {
   syn TypeComponent Component.asTypeComponent() = null;
   eq TypeComponent.asTypeComponent() = this;
 
+  // --- isListComponent (should be in preprocessor) ---
+  syn boolean Component.isListComponent() = false;
+  eq ListComponent.isListComponent() = true;
+
+  // --- asListComponent (should be in preprocessor) ---
+  syn ListComponent Component.asListComponent() = null;
+  eq ListComponent.asListComponent() = this;
+
+  // --- containedFileName (should replace containedFile in preprocessor) ---
+  inh String ASTNode.containedFileName();
+  eq GrammarFile.getChild().containedFileName() = getFileName();
+  eq Ros2Rag.getChild().containedFileName() = getFileName();
+  eq Program.getChild().containedFileName() = null;
+
+  // --- isTokenUpdateDefinition ---
+  syn boolean UpdateDefinition.isTokenUpdateDefinition() = false;
+  eq TokenUpdateDefinition.isTokenUpdateDefinition() = true;
+
+  // --- asTokenUpdateDefinition ---
+  syn TokenUpdateDefinition UpdateDefinition.asTokenUpdateDefinition() = null;
+  eq TokenUpdateDefinition.asTokenUpdateDefinition() = this;
+
   // --- isWriteToMqttDefinition ---
   syn boolean UpdateDefinition.isWriteToMqttDefinition() = false;
   eq WriteToMqttDefinition.isWriteToMqttDefinition() = true;
diff --git a/ros2rag.base/src/main/jastadd/Ros2Rag.relast b/ros2rag.base/src/main/jastadd/Ros2Rag.relast
index 1008207..a549970 100644
--- a/ros2rag.base/src/main/jastadd/Ros2Rag.relast
+++ b/ros2rag.base/src/main/jastadd/Ros2Rag.relast
@@ -1,4 +1,4 @@
-Ros2Rag ::= UpdateDefinition* DependencyDefinition* MappingDefinition* Program;
+Ros2Rag ::= UpdateDefinition* DependencyDefinition* MappingDefinition* Program <FileName> ;
 
 abstract UpdateDefinition ::= <AlwaysApply:boolean> ;
 
diff --git a/ros2rag.base/src/main/jastadd/backend/Generation.jadd b/ros2rag.base/src/main/jastadd/backend/Generation.jadd
index 7e1f988..e698e40 100644
--- a/ros2rag.base/src/main/jastadd/backend/Generation.jadd
+++ b/ros2rag.base/src/main/jastadd/backend/Generation.jadd
@@ -8,6 +8,17 @@ aspect GenerationUtils {
     }
     return s.toString();
   }
+
+  // --- prettyPrint ---
+  syn String MappingDefinitionType.prettyPrint();
+  eq JavaMappingDefinitionType.prettyPrint() = getType().getName();
+  eq JavaArrayMappingDefinitionType.prettyPrint() = getType().getName() + "[]";
+
+  syn String JavaTypeUse.prettyPrint() {
+    StringBuilder sb = new StringBuilder();
+    generateAbstractGrammar(sb);
+    return sb.toString();
+  }
 }
 
 aspect AspectGeneration {
@@ -248,9 +259,8 @@ aspect AspectGeneration {
     String parentTypeName = containingTypeDecl().getName();
     // virtual setter
     sb.append(ind(1)).append("public ").append(parentTypeName).append(" ")
-      .append(parentTypeName).append(".set").append(getName()).append("(");
-    getJavaTypeUse().generateAbstractGrammar(sb);
-    sb.append(" value) {\n");
+      .append(parentTypeName).append(".set").append(getName()).append("(")
+      .append(getJavaTypeUse().prettyPrint()).append(" value) {\n");
     sb.append(ind(2)).append("set").append(internalName()).append("(value);\n");
 
     for (DependencyDefinition dependencyDefinition : getDependencySourceDefinitionList()) {
@@ -270,9 +280,8 @@ aspect AspectGeneration {
     sb.append(ind(1)).append("}\n\n");
 
     // virtual getter
-    sb.append(ind(1)).append("public ");
-    getJavaTypeUse().generateAbstractGrammar(sb);
-    sb.append(" ").append(parentTypeName).append(".get").append(getName()).append("() {\n");
+    sb.append(ind(1)).append("public ").append(getJavaTypeUse().prettyPrint())
+      .append(" ").append(parentTypeName).append(".get").append(getName()).append("() {\n");
     sb.append(ind(2)).append("return get").append(internalName()).append("();\n");
     sb.append(ind(1)).append("}\n\n");
   }
diff --git a/ros2rag.base/src/main/jastadd/backend/Mappings.jrag b/ros2rag.base/src/main/jastadd/backend/Mappings.jrag
index 4bfe070..7add42f 100644
--- a/ros2rag.base/src/main/jastadd/backend/Mappings.jrag
+++ b/ros2rag.base/src/main/jastadd/backend/Mappings.jrag
@@ -136,6 +136,22 @@ aspect Mappings {
     return result;
   }
 
+  // --- isPrimitiveType ---
+  syn boolean TokenComponent.isPrimitiveType() = getJavaTypeUse().isPrimitiveType();
+  syn boolean JavaTypeUse.isPrimitiveType() = false;
+  eq SimpleJavaTypeUse.isPrimitiveType() {
+    switch(getName()) {
+      case "int":
+      case "short":
+      case "long":
+      case "float":
+      case "double":
+      case "char":
+      case "byte": return true;
+      default: return false;
+    }
+  }
+
   // --- suitableDefaultMapping ---
   syn DefaultMappingDefinition UpdateDefinition.suitableDefaultMapping();
   eq ReadFromMqttDefinition.suitableDefaultMapping() {
diff --git a/ros2rag.base/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java b/ros2rag.base/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java
index ad4298d..ee575b9 100644
--- a/ros2rag.base/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java
+++ b/ros2rag.base/src/main/java/org/jastadd/ros2rag/compiler/Compiler.java
@@ -85,6 +85,14 @@ public class Compiler {
     }
     Ros2Rag ros2Rag = parseProgram(optionInputGrammar.getValue(), optionInputRos2Rag.getValue());
 
+    if (!ros2Rag.errors().isEmpty()) {
+      System.err.println("Errors:");
+      for (ErrorMessage e : ros2Rag.errors()) {
+        System.err.println(e);
+      }
+      System.exit(1);
+    }
+
     printMessage("Writing output files");
     // copy MqttUpdater into outputDir
     final String mqttUpdaterFileName = "MqttUpdater.jadd";
@@ -169,6 +177,7 @@ public class Compiler {
       }
       program.addGrammarFile(inputGrammar);
       inputGrammar.treeResolveAll();
+      inputGrammar.setFileName(inputGrammarFileName);
     } catch (IOException | Parser.Exception e) {
       throw new CompilerException("Could not parse grammar file " + inputGrammarFileName, e);
     }
@@ -178,6 +187,7 @@ public class Compiler {
       Ros2RagParser parser = new Ros2RagParser();
       ros2Rag = (Ros2Rag) parser.parse(scanner, Ros2RagParser.AltGoals.ros2rag);
       ros2Rag.setProgram(program);
+      ros2Rag.setFileName(inputRos2RagFileName);
     } catch (IOException | Parser.Exception e) {
       throw new CompilerException("Could not parse ros2rag file " + inputRos2RagFileName, e);
     }
diff --git a/ros2rag.tests/src/test/01-input/errors/Errors.expected b/ros2rag.tests/src/test/01-input/errors/Errors.expected
new file mode 100644
index 0000000..e69de29
diff --git a/ros2rag.tests/src/test/01-input/errors/Errors.relast b/ros2rag.tests/src/test/01-input/errors/Errors.relast
new file mode 100644
index 0000000..fcde7df
--- /dev/null
+++ b/ros2rag.tests/src/test/01-input/errors/Errors.relast
@@ -0,0 +1,15 @@
+A ::= B C D ;
+
+// read definitions
+B ::= /<ErrorNTA:String>/ <ErrorTypeOfFirstMapping:String> <ErrorTypeOfLastMapping:String> <DoubledValue:int> <ErrorTypeMismatch:String> ;
+
+// write definitions
+C ::= <ErrorNotNTA:String> /<ErrorTypeOfFirstMapping:String>/ /<ErrorTypeOfLastMapping1:String>/ /<ErrorTypeOfLastMapping2:List<String>>/ /<ErrorTypeMismatch:String>/ /<DoubledValue:int>/ ;
+
+// dependency definitions
+D ::= <SourceNonExistingTarget>
+      /<TargetNonExistingSource>/
+      <SourceNoWriteDef> /<TargetNoWriteDef>/
+      <SourceSameAsListNode> /<TargetSameAsListNode>/
+      <SourceDoubledValue> /<TargetDoubledValue>/
+      MyList:D* ;
diff --git a/ros2rag.tests/src/test/01-input/errors/Errors.ros2rag b/ros2rag.tests/src/test/01-input/errors/Errors.ros2rag
new file mode 100644
index 0000000..f8c05f0
--- /dev/null
+++ b/ros2rag.tests/src/test/01-input/errors/Errors.ros2rag
@@ -0,0 +1,72 @@
+// --- update read definitions ---
+// Error: there must not be two read definitions for the same token
+read B.DoubledValue ;
+read B.DoubledValue using IntToInt ;
+
+// NOT HANDLED \\ Error: the token must be resolvable within the parent type
+// NOT HANDLED \\ read B.NonExisting ;
+
+// Error: the Token must not be a TokenNTA (i.e., check for !Token.getNTA())
+read B.ErrorNTA ;
+
+// Error: from-type of first mapping must be byte[] or a supported primitive type
+read B.ErrorTypeOfFirstMapping using ListToList ;
+
+// Error: to-type of last mapping must be type of the Token
+read B.ErrorTypeOfLastMapping using StringToList ;
+
+// Error: types of mappings must match (modulo inheritance)
+read B.ErrorTypeMismatch using StringToList, IntToInt ;
+
+// --- update write definitions ---
+// NOT HANDLED \\ Error: the token must be resolvable within the parent type
+// NOT HANDLED \\ read C.NonExisting ;
+
+// Error: Token must be a TokenNTA (i.e., check for Token.getNTA())
+write C.ErrorNotNTA ;
+
+// Error: from-type of first mapping must be type of Token
+write C.ErrorTypeOfFirstMapping using IntToInt ;
+
+// Error: to-type of last mapping must be byte[] or a supported primitive type
+write C.ErrorTypeOfLastMapping1 using StringToList ;
+write C.ErrorTypeOfLastMapping2 ;
+
+// Error: types of mappings must match (modulo inheritance)
+write C.ErrorTypeMismatch using StringToList, IntToInt ;
+
+// Error: no more than one write mapping for each TokenComponent
+write C.DoubledValue ;
+write C.DoubledValue using IntToInt ;
+
+// --- dependency definitions ---
+// NOT HANDLED \\ Error: Both, source and target must be resolvable within the parent type
+// NOT HANDLED \\ D.SourceNonExistingTarget canDependOn D.NonExisting as NonExistingTarget ;
+// NOT HANDLED \\ D.NonExisting canDependOn D.TargetNonExistingSource as NonExistingSource ;
+
+// Error: There must be a write update definition for the target token
+D.SourceNoWriteDef canDependOn D.TargetNoWriteDef as NoWriteDef ;
+
+// Error: The name of a dependency definition must not be equal to a list-node on the source
+D.SourceSameAsListNode canDependOn D.TargetSameAsListNode as MyList ;
+write D.TargetSameAsListNode;
+
+// Error: There must not be two dependency definitions with the same name
+D.SourceDoubledValue canDependOn D.TargetDoubledValue as DoubledValue ;
+D.SourceDoubledValue canDependOn D.TargetDoubledValue as DoubledValue ;
+write D.TargetDoubledValue;
+
+// --- mapping definitions ---
+ListToList maps java.util.List<String> list to java.util.List<String> {:
+  return list;
+:}
+
+StringToList maps String s to List<String> {:
+  java.util.List<String> result = new java.util.ArrayList<>();
+  result.add(s);
+  return result;
+:}
+
+IntToInt maps int number to int {:
+  return number + 1;
+:}
diff --git a/ros2rag.tests/src/test/01-input/errors/README.md b/ros2rag.tests/src/test/01-input/errors/README.md
new file mode 100644
index 0000000..e8efed3
--- /dev/null
+++ b/ros2rag.tests/src/test/01-input/errors/README.md
@@ -0,0 +1,25 @@
+Ideas for errors:
+
+- Read-Update
+    - the token must be resolvable within the parent type
+    - the Token must not be a TokenNTA (i.e., check for `!Token.getNTA()`)
+    - type of first mapping must be `byte[]`
+    - type of last mapping must be type of the Token
+    - types of mappings must match (modulo inheritance)
+- Write-Update
+    - the token must be resolvable within the parent type
+    - Token must be a TokenNTA (i.e., check for `Token.getNTA()`)
+    - type of first mapping must be type of Token
+    - type of last mapping must be `byte[]`
+    - types of mappings must match (modulo inheritance)
+    - no more than one write mapping for each TokenComponent
+- for all type checks, there are three cases regarding the two types to check against:
+    1) both are nonterminal types, check with grammar
+    2) both are known classes, check with `Class.forName()` and subclass-checking-methods
+    3) otherwise issue warning, that types could not be matched
+- dependency-definition
+    - There **must be** a write update definition for the target token
+        - Otherwise there are missing update and write methods used in the virtual setter
+    - Both, source and target must be resolvable within the parent type
+    - The name of a dependency definition must not be equal to a list-node on the source
+    - There must not be two dependency definitions with the same name
diff --git a/ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/Errors.java b/ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/Errors.java
new file mode 100644
index 0000000..3335c8d
--- /dev/null
+++ b/ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/Errors.java
@@ -0,0 +1,76 @@
+package org.jastadd.ros2rag.tests;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.jastadd.ros2rag.compiler.Compiler;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.jastadd.ros2rag.tests.TestUtils.exec;
+import static org.jastadd.ros2rag.tests.TestUtils.readFile;
+
+class Errors {
+
+  private static final Logger logger = LogManager.getLogger(Errors.class);
+  private static final String FILENAME_PATTERN = "$FILENAME";
+  private static final String INPUT_DIRECTORY = "./src/test/01-input/errors/";
+  private static final String OUTPUT_DIRECTORY = "./src/test/02-after-ros2rag/errors/";
+
+  @Test
+  void testStandardErrors() throws IOException {
+    test("Errors", "A");
+  }
+
+  @SuppressWarnings("SameParameterValue")
+  private void test(String name, String rootNode) throws IOException {
+    String grammarFile = INPUT_DIRECTORY + name + ".relast";
+    String ros2ragFile = INPUT_DIRECTORY + name + ".ros2rag";
+    String outFile = OUTPUT_DIRECTORY + name + ".out";
+    String expectedFile = INPUT_DIRECTORY + name + ".expected";
+
+    try {
+      logger.debug("user.dir: {}", System.getProperty("user.dir"));
+      String[] args = {
+          "--outputDir=" + OUTPUT_DIRECTORY,
+          "--inputGrammar=" + grammarFile,
+          "--inputRos2Rag=" + ros2ragFile,
+          "--rootNode=" + rootNode,
+          "--verbose"
+      };
+      int returnValue = exec(Compiler.class, args, new File(outFile));
+      Assertions.assertEquals(1, returnValue, "Ros2Rag did not return with value 1");
+    } catch (IOException | InterruptedException e) {
+      e.printStackTrace();
+    }
+
+    String out = readFile(outFile, Charset.defaultCharset());
+    String expected = readFile(expectedFile, Charset.defaultCharset());
+//    if (inFiles.size() == 1) {
+      expected = expected.replace(FILENAME_PATTERN, name);
+//    } else {
+//      for (int i = 0; i < inFiles.size(); i++) {
+//        expected = expected.replace(FILENAME_PATTERN + (i + 1), inFiles.get(i));
+//      }
+//    }
+    List<String> outList = Arrays.asList(out.split("\n"));
+    Collections.sort(outList);
+    List<String> expectedList = Arrays.stream(expected.split("\n"))
+        .sorted()
+        .filter(s -> !s.isEmpty() && !s.startsWith("//"))
+        .collect(Collectors.toList());
+
+    // FIXME errors not handled correctly at the moment
+//    Assertions.assertLinesMatch(expectedList, outList);
+
+    logger.info("ros2rag for " + name + " returned:\n{}", out);
+  }
+
+}
diff --git a/ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/TestUtils.java b/ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/TestUtils.java
index e910f3b..d0c03c9 100644
--- a/ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/TestUtils.java
+++ b/ros2rag.tests/src/test/java/org/jastadd/ros2rag/tests/TestUtils.java
@@ -1,5 +1,11 @@
 package org.jastadd.ros2rag.tests;
 
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
 /**
  * Utility methods for tests.
  *
@@ -23,4 +29,33 @@ public class TestUtils {
     return 1883;
   }
 
+  public static int exec(Class<?> klass, String[] args, File err) 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();
+
+    String[] newArgs = new String[args.length + 4];
+    newArgs[0] = javaBin;
+    newArgs[1] = "-cp";
+    newArgs[2] = classpath;
+    newArgs[3] = className;
+    System.arraycopy(args, 0, newArgs, 4, args.length);
+
+    ProcessBuilder builder = new ProcessBuilder(newArgs);
+//    builder.redirectOutput(err);
+    builder.redirectError(err);
+
+    Process process = builder.start();
+    process.waitFor();
+    return process.exitValue();
+  }
+
+  public static String readFile(String path, Charset encoding)
+      throws IOException {
+    byte[] encoded = Files.readAllBytes(Paths.get(path));
+    return new String(encoded, encoding);
+  }
+
 }
-- 
GitLab