Browse Source

Merge branch 'master' into feature/metainf

# Conflicts:
#	jplag.frontend-utils/src/main/java/de/jplag/Language.java
#	jplag/src/test/java/de/jplag/special/TokenPrinterTest.java
Dominik Fuchß 4 years ago
parent
commit
a3554e48d6
54 changed files with 1238 additions and 744 deletions
  1. 3 2
      jplag.cli/src/main/java/de/jplag/CLI.java
  2. 1 1
      jplag.cli/src/main/java/de/jplag/CommandLineArgument.java
  3. 14 1
      jplag.cli/src/test/java/de/jplag/cli/ClusteringTest.java
  4. 1 1
      jplag.cli/src/test/java/de/jplag/cli/CommandLineInterfaceTest.java
  5. 3 4
      jplag.frontend-testutils/src/test/java/de/jplag/testutils/TokenUtils.java
  6. 3 4
      jplag.frontend-utils/src/main/java/de/jplag/Language.java
  7. 0 5
      jplag.frontend-utils/src/main/java/de/jplag/Token.java
  8. 2 1
      jplag.frontend-utils/src/main/java/de/jplag/TokenList.java
  9. 242 146
      jplag.frontend-utils/src/main/java/de/jplag/TokenPrinter.java
  10. 19 0
      jplag.frontend-utils/src/test/java/de/jplag/TestToken.java
  11. 98 0
      jplag.frontend-utils/src/test/java/de/jplag/TokenPrinterTest.java
  12. 5 0
      jplag.frontend-utils/src/test/java/de/jplag/simple/TestTokenConstants.java
  13. 26 0
      jplag.frontend-utils/src/test/resources/de/jplag/samples/TokenPrinterTest.txt
  14. 2 4
      jplag.frontend.csharp-6/src/test/java/de/jplag/csharp/MinimalCSharpFrontendTest.java
  15. 1 4
      jplag.frontend.emf-metamodel-dynamic/src/test/java/de/jplag/emf/dynamic/MinimalDynamicMetamodelTest.java
  16. 2 6
      jplag.frontend.emf-metamodel/src/test/java/de/jplag/emf/MinimalMetamodelTest.java
  17. 2 3
      jplag.frontend.golang/src/test/java/de/jplag/golang/GoFrontendTest.java
  18. 2 3
      jplag.frontend.kotlin/src/test/java/de/jplag/kotlin/KotlinFrontendTest.java
  19. 4 4
      jplag.frontend.rlang/src/test/java/de/jplag/rlang/RFrontendTest.java
  20. 2 3
      jplag.frontend.scala/src/test/java/de/jplag/scala/ScalaFrontendTest.java
  21. 2 4
      jplag.frontend.text/src/test/java/jplag/text/TextFrontendTest.java
  22. 1 1
      jplag/src/main/java/de/jplag/Submission.java
  23. 13 7
      jplag/src/main/java/de/jplag/clustering/Cluster.java
  24. 5 0
      jplag/src/main/java/de/jplag/clustering/ClusteringAlgorithm.java
  25. 23 1
      jplag/src/main/java/de/jplag/clustering/ClusteringFactory.java
  26. 5 0
      jplag/src/main/java/de/jplag/clustering/Preprocessing.java
  27. 101 0
      jplag/src/main/java/de/jplag/reporting/jsonfactory/ComparisonReportWriter.java
  28. 93 0
      jplag/src/main/java/de/jplag/reporting/jsonfactory/DirectoryManager.java
  29. 13 0
      jplag/src/main/java/de/jplag/reporting/jsonfactory/DummyWriter.java
  30. 2 26
      jplag/src/main/java/de/jplag/reporting/jsonfactory/FileWriter.java
  31. 30 0
      jplag/src/main/java/de/jplag/reporting/jsonfactory/ToDiskWriter.java
  32. 102 88
      jplag/src/main/java/de/jplag/reporting/reportobject/ReportObjectFactory.java
  33. 8 1
      jplag/src/main/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapper.java
  34. 0 60
      jplag/src/main/java/de/jplag/reporting/reportobject/mapper/ComparisonReportMapper.java
  35. 13 6
      jplag/src/main/java/de/jplag/reporting/reportobject/mapper/MetricMapper.java
  36. 43 0
      jplag/src/main/java/de/jplag/reporting/reportobject/mapper/SubmissionNameToIdMapper.java
  37. 15 154
      jplag/src/main/java/de/jplag/reporting/reportobject/model/OverviewReport.java
  38. 5 3
      jplag/src/test/java/de/jplag/TestBase.java
  39. 1 1
      jplag/src/test/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapperTest.java
  40. 58 0
      jplag/src/test/java/de/jplag/reporting/reportobject/mapper/ComparisonReportWriterTest.java
  41. 1 1
      jplag/src/test/java/de/jplag/reporting/reportobject/mapper/MetricMapperTest.java
  42. 2 6
      jplag/src/test/java/de/jplag/special/TokenPrinterTest.java
  43. 11 0
      jplag/src/test/resources/de/jplag/samples/FilesAsSubmissions/Submission1.java
  44. 12 0
      jplag/src/test/resources/de/jplag/samples/FilesAsSubmissions/Submission2.java
  45. 1 12
      pom.xml
  46. 99 87
      report-viewer/package-lock.json
  47. 3 3
      report-viewer/package.json
  48. 21 9
      report-viewer/src/components/ComparisonsTable.vue
  49. 12 11
      report-viewer/src/components/IDsList.vue
  50. 7 7
      report-viewer/src/model/Overview.ts
  51. 27 5
      report-viewer/src/model/factories/OverviewFactory.ts
  52. 36 0
      report-viewer/src/store/store.ts
  53. 14 32
      report-viewer/src/views/ComparisonView.vue
  54. 27 27
      report-viewer/src/views/OverviewView.vue

+ 3 - 2
jplag.cli/src/main/java/de/jplag/CLI.java

@@ -63,7 +63,8 @@ public final class CLI {
             JPlag program = new JPlag(options);
             logger.info("JPlag initialized");
             JPlagResult result = program.run();
-            ReportObjectFactory.createAndSaveReport(result, arguments.getString(RESULT_FOLDER.flagWithoutDash()));
+            ReportObjectFactory reportObjectFactory = new ReportObjectFactory();
+            reportObjectFactory.createAndSaveReport(result, arguments.getString(RESULT_FOLDER.flagWithoutDash()));
 
         } catch (ExitException exception) {
             logger.error(exception.getMessage(), exception);
@@ -138,7 +139,7 @@ public final class CLI {
                 () -> logger.warn("Unknown comparison mode, using default mode!"));
 
         ClusteringOptions.Builder clusteringBuilder = new ClusteringOptions.Builder();
-        Optional.ofNullable((Boolean) CLUSTER_ENABLE.getFrom(namespace)).ifPresent(clusteringBuilder::enabled);
+        Optional.ofNullable(!(Boolean) CLUSTER_DISABLE.getFrom(namespace)).ifPresent(clusteringBuilder::enabled);
         Optional.ofNullable((ClusteringAlgorithm) CLUSTER_ALGORITHM.getFrom(namespace)).ifPresent(clusteringBuilder::algorithm);
         Optional.ofNullable((SimilarityMetric) CLUSTER_METRIC.getFrom(namespace)).ifPresent(clusteringBuilder::similarityMetric);
         Optional.ofNullable((Float) CLUSTER_SPECTRAL_BANDWIDTH.getFrom(namespace)).ifPresent(clusteringBuilder::spectralKernelBandwidth);

+ 1 - 1
jplag.cli/src/main/java/de/jplag/CommandLineArgument.java

@@ -48,7 +48,7 @@ public enum CommandLineArgument {
     SHOWN_COMPARISONS(new Builder("-n", Integer.class).defaultsTo(DEFAULT_SHOWN_COMPARISONS)),
     RESULT_FOLDER(new Builder("-r", String.class).defaultsTo("result")),
     COMPARISON_MODE(new Builder("-c", String.class).defaultsTo(DEFAULT_COMPARISON_MODE.getName()).choices(ComparisonMode.allNames())),
-    CLUSTER_ENABLE(new Builder("--cluster-skip", Boolean.class).argumentGroup(CLUSTERING_GROUP_NAME).action(Arguments.storeTrue())),
+    CLUSTER_DISABLE(new Builder("--cluster-skip", Boolean.class).argumentGroup(CLUSTERING_GROUP_NAME).action(Arguments.storeTrue())),
     CLUSTER_ALGORITHM(
             new Builder("--cluster-alg", ClusteringAlgorithm.class).argumentGroup(CLUSTERING_GROUP_NAME)
                     .defaultsTo(ClusteringOptions.DEFAULTS.getAlgorithm())),

+ 14 - 1
jplag.cli/src/test/java/de/jplag/cli/ClusteringTest.java

@@ -11,9 +11,22 @@ class ClusteringTest extends CommandLineInterfaceTest {
 
     private static final double EPSILON = 0.000001;
 
+    @Test
+    void parseSkipClustering() {
+        String argument = CommandLineArgument.CLUSTER_DISABLE.flag();
+        buildOptionsFromCLI(argument, CURRENT_DIRECTORY);
+        assertEquals(false, options.getClusteringOptions().isEnabled());
+    }
+
+    @Test
+    void parseDefaultClustering() {
+        buildOptionsFromCLI(CURRENT_DIRECTORY);
+        assertEquals(true, options.getClusteringOptions().isEnabled());
+    }
+
     @Test
     void parsePercentilePreProcessor() {
-        String argument = CommandLineArgument.CLUSTER_PREPROCESSING_PERCENTILE.flag() + "=" + 0.5f;
+        String argument = buildArgument(CommandLineArgument.CLUSTER_PREPROCESSING_PERCENTILE, Float.toString(0.5f));
         buildOptionsFromCLI(argument, CURRENT_DIRECTORY);
         assertEquals(Preprocessing.PERCENTILE, options.getClusteringOptions().getPreprocessor());
         assertEquals(0.5, options.getClusteringOptions().getPreprocessorPercentile(), EPSILON);

+ 1 - 1
jplag.cli/src/test/java/de/jplag/cli/CommandLineInterfaceTest.java

@@ -26,7 +26,7 @@ public abstract class CommandLineInterfaceTest {
     protected JPlagOptions options;
 
     /**
-     * Creates a string for all arguments and their values that have been succesfully parsed.F
+     * Creates a string for all arguments and their values that have been succesfully parsed.
      */
     private String parsedKeys(String... arguments) {
         var keys = namespace.getAttrs().keySet().stream()

+ 3 - 4
jplag.frontend-testutils/src/test/java/de/jplag/testutils/TokenUtils.java

@@ -1,7 +1,6 @@
 package de.jplag.testutils;
 
 import java.util.List;
-import java.util.stream.StreamSupport;
 
 import de.jplag.Token;
 import de.jplag.TokenList;
@@ -14,7 +13,7 @@ public final class TokenUtils {
 
     /**
      * Returns the type of all tokens in a {@link TokenList} that belong to a file.
-     * @param tokens is the {@link TokenList}.
+     * @param tokenList is the {@link TokenList}.
      * @param name is the name of the target file.
      * @return the immutable list of token types.
      */
@@ -25,12 +24,12 @@ public final class TokenUtils {
 
     /**
      * Returns the tokens in a {@link TokenList} that belong to a file.
-     * @param tokens is the {@link TokenList}.
+     * @param tokenList is the {@link TokenList}.
      * @param name is the name of the target file.
      * @return the immutable list of tokens.
      */
     public static List<Token> tokensByFile(TokenList tokenList, String name) {
-        var tokens = StreamSupport.stream(tokenList.allTokens().spliterator(), false);
+        var tokens = tokenList.allTokens().stream();
         return tokens.filter(it -> it.getFile().startsWith(name)).toList();
     }
 

+ 3 - 4
jplag.frontend-utils/src/main/java/de/jplag/Language.java

@@ -18,7 +18,7 @@ public interface Language {
     String getName();
 
     /**
-     * Identifier of the language for CLI options and for dynamic loading of language.
+     * Identifier of the language used for CLI options and dynamic loading. You should use some name within {@code [a-z_-]+}
      */
     String getIdentifier();
 
@@ -41,10 +41,9 @@ public interface Language {
     boolean hasErrors();
 
     /**
-     * Determines whether the parser provide column information. If that is the case, line and column indices are used
-     * instead of a single token index.
+     * Determines whether a fixed-width font should be used to display that language.
      */
-    default boolean supportsColumns() {
+    default boolean isPreformatted() {
         return true;
     }
 

+ 0 - 5
jplag.frontend-utils/src/main/java/de/jplag/Token.java

@@ -58,11 +58,6 @@ public abstract class Token {
         return file;
     }
 
-    // this is made to distinguish the character front end. Maybe other front ends can use it too?
-    public int getIndex() {
-        return -1;
-    }
-
     /**
      * Gives the length if the code sections represented by this token.
      * @return the length in characters.

+ 2 - 1
jplag.frontend-utils/src/main/java/de/jplag/TokenList.java

@@ -1,6 +1,7 @@
 package de.jplag;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 
 /**
@@ -46,7 +47,7 @@ public class TokenList {
      * Returns a view on all tokens.
      * @return all tokens.
      */
-    public Iterable<Token> allTokens() {
+    public Collection<Token> allTokens() {
         return new ArrayList<>(tokens);
     }
 

+ 242 - 146
jplag.frontend-utils/src/main/java/de/jplag/TokenPrinter.java

@@ -1,16 +1,16 @@
 package de.jplag;
 
-import static java.util.stream.Collectors.toMap;
-
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.NoSuchFileException;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -29,14 +29,16 @@ public final class TokenPrinter {
     private static final String BAR = "|";
     private static final String TAB = "\t";
     private static final String SPACE = " ";
-    public static final String NON_WHITESPACE = "\\S";
-    public static final int MIN_PADDING = 1;
+    private static final String NON_WHITESPACE = "\\S";
+    private static final int MIN_PADDING = 1;
     private static final int TAB_LENGTH = 8;
     private static final String TAB_REPLACEMENT = SPACE.repeat(TAB_LENGTH); // might depend on files
 
     // Configuration:
-    private static final boolean INDICATE_TINY_TOKEN = true; // print token with length <= 1 in lowercase
+    private static final boolean INDICATE_TINY_TOKEN = true;    // print token with length <= 1 in lowercase
     private static final boolean REPLACE_TABS = false;
+    private static final boolean PRINT_EMPTY_LINES = true;      // print code lines with no tokens
+    private static final boolean SPACIOUS = true;               // print empty line after last token of a line
 
     private TokenPrinter() {
         // Utility class, no public constructor.
@@ -45,157 +47,93 @@ public final class TokenPrinter {
     /**
      * Creates a string representation of a set of files line by line and adds the tokens under the lines.
      * @param tokens is the set of tokens parsed from the files.
-     * @param directory is the common directory of the files.
-     * @param fileNames is a collection of the file names.
-     * @param suffix is the optional view file suffix.
+     * @param rootDirectory is the common rootDirectory of the files.
      * @return the string representation.
      */
-    public static String printTokens(TokenList tokens, File directory, Collection<String> fileNames, Optional<String> suffix) {
-        Collection<File> files = fileNames.stream().map(name -> new File(directory, name)).toList();
-        return printTokens(tokens, files, directory, suffix);
-    }
-
-    /**
-     * Creates a string representation of a set of files line by line and adds the tokens under the lines.
-     * @param tokens is the set of tokens parsed from the files.
-     * @param directory is the common directory of the files.
-     * @param fileNames is a collection of the file names.
-     * @return the string representation.
-     */
-    public static String printTokens(TokenList tokens, File directory, Collection<String> fileNames) {
-        Collection<File> files = fileNames.stream().map(name -> new File(directory, name)).toList();
-        return printTokens(tokens, files, directory, Optional.empty());
+    public static String printTokens(TokenList tokens, File rootDirectory) {
+        return printTokens(tokens, rootDirectory, Optional.empty());
     }
 
     /**
      * Creates a string representation of a collection of files line by line and adds the tokens under the lines.
-     * @param tokens is the set of tokens parsed from the files.
-     * @param files are the parsed files.
-     * @return the string representation.
-     */
-    public static String printTokens(TokenList tokens, Collection<File> files, File root) {
-        return printTokens(tokens, files, root, Optional.empty());
-    }
-
-    /**
-     * Creates a string representation of a collection of files line by line and adds the tokens under the lines.
-     * @param tokens is the set of tokens parsed from the files.
-     * @param files are the parsed files.
+     * @param tokenList is the set of tokens parsed from the files.
+     * @param rootDirectory is the common directory of the files.
      * @param suffix is the optional view file suffix.
      * @return the string representation.
      */
-    public static String printTokens(TokenList tokens, Collection<File> files, File root, Optional<String> suffix) {
-        Map<String, List<String>> filesToLines = readFiles(files);
-        StringBuilder builder = new StringBuilder();
-        int lineIndex = 0;
-        int columnIndex = 1;
-        String file = null; // no file yet
-        String currentLine = null;
-
-        for (Token token : tokens.allTokens()) {
-            // New code file:
-            if (!token.getFile().equals(file)) {
-                if (file != null) {
-                    builder.append(System.lineSeparator());
-                }
-                file = token.getFile();
-                builder.append(file);
-                lineIndex = 0;
-            }
+    public static String printTokens(TokenList tokenList, File rootDirectory, Optional<String> suffix) {
+        PrinterOutputBuilder builder = new PrinterOutputBuilder();
+        Map<String, List<Token>> fileToTokens = groupTokensByFile(tokenList);
+
+        fileToTokens.forEach((String fileName, List<Token> fileTokens) -> {
+            builder.append(fileName);
+
+            List<LineData> lineDatas = getLineData(fileTokens, rootDirectory, suffix);
+            lineDatas.forEach(lineData -> {
+                builder.setLine(lineData.lineNumber());
 
-            // New code line:
-            if (token.getLine() > lineIndex) {
-                lineIndex = token.getLine();
-                columnIndex = 1;
+                // Print (prefix and) code line
+                String currentLine = lineData.text();
+                builder.appendCodeLine(currentLine);
 
-                String fileName = token.getFile().isEmpty() ? root.getName() : token.getFile();
-                fileName = fileName + suffix.orElse("");
-                if (!filesToLines.containsKey(fileName)) {
-                    throw new IllegalStateException("Cannot find file " + fileName + " in " + filesToLines.keySet());
+                List<Token> tokens = lineData.tokens();
+                if (tokens.isEmpty()) {
+                    return;
                 }
 
-                currentLine = filesToLines.get(fileName).get(lineIndex - 1);
+                builder.appendTokenLinePrefix();
 
-                appendCodeLinePrefix(builder, lineIndex);
-                appendCodeLine(builder, currentLine);
-                appendTokenLinePrefix(builder, lineIndex);
-            }
+                // Print tokens
+                for (Token token : tokens) {
+                    // Move to token index, possibly adding a new line:
+                    builder.advanceToTokenPosition(currentLine, token.getColumn(), true);
+
+                    // Print the actual token:
+                    String stringRepresentation = getStringRepresentation(token);
+                    builder.append(BAR).append(stringRepresentation);
+
+                    // Move up to token end:
+                    int tokenEndIndex = token.getColumn() + token.getLength() - 1;
+                    builder.advanceToTokenPosition(currentLine, tokenEndIndex, false);
+
+                    // Print token end if not already past it:
+                    if (builder.positionBeforeOrEqualTo(tokenEndIndex)) {
+                        builder.append(BAR);
+                    }
+                }
+
+                builder.appendTokenLineSuffix();
+                builder.advanceToNextLine();
+            });
+            builder.advanceToNextLine();
+        });
 
-            // New line if already past token index:
-            if (columnIndex > token.getColumn()) {
-                appendTokenLinePrefix(builder, lineIndex);
-                columnIndex = 1;
-            }
-            // Move to token index:
-            columnIndex = appendPadding(builder, currentLine, columnIndex, token.getColumn());
-
-            // Print the actual token:
-            String stringRepresentation = getStringRepresentation(token);
-            builder.append(BAR).append(stringRepresentation);
-            columnIndex += stringRepresentation.length() + 1;
-
-            // Move up to token end:
-            int tokenEndIndex = token.getColumn() + token.getLength() - 1;
-            columnIndex = appendPadding(builder, currentLine, columnIndex, tokenEndIndex);
-
-            // Print token end if not already past it:
-            if (columnIndex == tokenEndIndex) {
-                builder.append(BAR);
-                columnIndex++;
-            }
-        }
         return builder.toString();
     }
 
-    /**
-     * Appends the code line to the StringBuilder, applying the configuration of this TokenPrinter.
-     * @param builder The StringBuilder to add code to
-     * @param currentLine The line of code to append
-     * @return a reference to that same StringBuilder
-     */
-    private static StringBuilder appendCodeLine(StringBuilder builder, String currentLine) {
-        if (REPLACE_TABS) {
-            currentLine = currentLine.replaceAll(TAB, TAB_REPLACEMENT);
-        }
-        return builder.append(currentLine);
-    }
+    private static List<LineData> getLineData(List<Token> fileTokens, File root, Optional<String> suffix) {
+        // We expect that all fileTokens share the same Token.file!
 
-    /**
-     * Appends whitespace padding to the given StringBuilder in order to reach the targetPosition. Note that <b>the indices
-     * are 1-based</b>, whereas the positions in currentLine are 0-based. The convention that lines start at position 1
-     * comes from the frontends, specifically the Java frontend.
-     * @param builder The StringBuilder to add padding to
-     * @param currentLine The current line in the code file, indicating where it containes tab characters
-     * @param currentPosition The (1-based) index of the last character within the currentLine that is labeled, or 1.
-     * @param targetPosition The (1-based) index of the next character within the currentLine that should be labeled
-     * @return the new position, which is the targetPosition or the end of the line.
-     */
-    private static int appendPadding(StringBuilder builder, String currentLine, int currentPosition, int targetPosition) {
-        targetPosition = Math.min(targetPosition, currentLine.length());
-        if (currentPosition >= targetPosition) {
-            return currentPosition;
+        String fileName = fileTokens.get(0).getFile();
+        // handle 'files as submissions' mode
+        File file = fileName.isEmpty() ? root : new File(root, fileName);
+        if (suffix.isPresent()) {
+            file = new File(file.getPath() + suffix.get());
         }
 
-        // The replacement operation preserves TAB characters, which is essential for correct alignment
-        String padding = currentLine.substring(currentPosition - 1, targetPosition - 1).replaceAll(TAB, REPLACE_TABS ? TAB_REPLACEMENT : TAB)
-                .replaceAll(NON_WHITESPACE, SPACE);
-        builder.append(padding);
-
-        return targetPosition;
-    }
+        // Sort tokens by file and line -> tokens can be processed without any further checks
+        List<String> lines = linesFromFile(file);
 
-    private static StringBuilder appendCodeLinePrefix(StringBuilder builder, int index) {
-        builder.append(System.lineSeparator()).append(index);
+        Map<Integer, List<Token>> lineNumbersToTokens = fileTokens.stream().collect(Collectors.groupingBy(Token::getLine));
 
-        // Code line should be padded up to at least one tab length, and no less than MIN_PADDING
-        int paddingLength = Math.max(TAB_LENGTH - digitCount(index), MIN_PADDING);
-        return builder.append(SPACE.repeat(paddingLength));
+        // create LineData for each line -- 1-based line index
+        Stream<Integer> lineNumbers = PRINT_EMPTY_LINES ? IntStream.range(1, lines.size() + 1).boxed() : lineNumbersToTokens.keySet().stream();
+        return lineNumbers.map(lineIndex -> new LineData(lineIndex, lines.get(lineIndex - 1), lineNumbersToTokens.getOrDefault(lineIndex, List.of())))
+                .toList();
     }
 
-    private static StringBuilder appendTokenLinePrefix(StringBuilder builder, int index) {
-        int paddingLength = Math.max(digitCount(index) + MIN_PADDING, TAB_LENGTH);
-        String padding = SPACE.repeat(paddingLength);
-        return builder.append(System.lineSeparator()).append(padding);
+    private static Map<String, List<Token>> groupTokensByFile(TokenList tokenList) {
+        return tokenList.allTokens().stream().collect(Collectors.groupingBy(Token::getFile));
     }
 
     /**
@@ -205,13 +143,6 @@ public final class TokenPrinter {
         return token.getLength() <= 1 && INDICATE_TINY_TOKEN ? token.toString().toLowerCase() : token.toString();
     }
 
-    /**
-     * Turns a list of files into a map which maps the file names to a list of the contained lines of that file.
-     */
-    private static Map<String, List<String>> readFiles(Collection<File> allFiles) {
-        return allFiles.stream().collect(toMap(File::getName, TokenPrinter::linesFromFile));
-    }
-
     /**
      * Parses a file and returns a list of the contained lines.
      */
@@ -227,14 +158,179 @@ public final class TokenPrinter {
     }
 
     /**
-     * Returns the number of digits (including a minus) of the given number.
+     * This contains all data concerning a line of code in a file and the tokens found in that line.
+     * @param lineNumber the line number inside the file
+     * @param text the code line
+     * @param tokens the tokens found in the code line
      */
-    private static int digitCount(int number) {
-        if (number == 0) {
-            return 1;
+    private record LineData(Integer lineNumber, String text, List<Token> tokens) {
+
+    }
+
+    /**
+     * A proxy for the StringBuilder that keeps track of the position inside the output.
+     */
+    private static class PrinterOutputBuilder {
+        public static final String LINE_SEPARATOR = System.lineSeparator();
+        private final StringBuilder builder = new StringBuilder();
+        private int columnIndex = 1;
+        private int lineNumber;
+        private int trailingLineSeparators = 0;
+
+        /**
+         * Returns the number of digits (including a minus) of the given number.
+         */
+        private static int digitCount(int number) {
+            if (number == 0) {
+                return 1;
+            }
+            int minusLength = number < 0 ? 1 : 0;
+            // The 'log10' variant is supposedly faster than the 'toString' variant.
+            return (int) Math.log10(Math.abs(number)) + minusLength + 1;
+        }
+
+        private void resetLinePosition() {
+            columnIndex = 1;
+        }
+
+        /**
+         * Appends the given string to the output
+         * @param str the string to append
+         * @return this
+         */
+        public PrinterOutputBuilder append(String str) {
+            // Avoid too many blank lines
+            trailingLineSeparators = str.equals(LINE_SEPARATOR) ? trailingLineSeparators + 1 : 0;
+            if (trailingLineSeparators >= 3)
+                return this;
+
+            builder.append(str);
+            columnIndex += str.length();
+            return this;
+        }
+
+        /**
+         * Appends the given integer to the output.
+         * @param i the integer to append
+         * @return this
+         */
+        public PrinterOutputBuilder append(int i) {
+            return append(Integer.toString(i));
+        }
+
+        /**
+         * In SPACIOUS mode, appends an empty line before the next code line.
+         * @return a reference to this
+         */
+        private PrinterOutputBuilder appendTokenLineSuffix() {
+            return SPACIOUS ? advanceToNextLine() : this;
+        }
+
+        /**
+         * Appends the code line to the StringBuilder, applying the configuration of this TokenPrinter.
+         * @param currentLine The line of code to append
+         * @return a reference to this
+         */
+        private PrinterOutputBuilder appendCodeLine(String currentLine) {
+            // Prefix
+            advanceToNextLine().append(lineNumber);
+
+            // Code line should be padded up to at least one tab length, and no less than MIN_PADDING
+            int paddingLength = Math.max(TAB_LENGTH - digitCount(lineNumber), MIN_PADDING);
+            appendPadding(paddingLength);
+
+            // Code line
+            if (REPLACE_TABS) {
+                currentLine = currentLine.replaceAll(TAB, TAB_REPLACEMENT);
+            }
+
+            return append(currentLine);
+        }
+
+        /**
+         * Appends whitespace padding to the given StringBuilder in order to reach the targetPosition. Note that <b>the indices
+         * are 1-based</b>, whereas the positions in currentLine are 0-based. The convention that lines start at position 1
+         * comes from the frontends, specifically the Java frontend.
+         * @param currentLine The current line in the code file, indicating where it containes tab characters
+         * @param targetPosition The (1-based) index of the next character within the currentLine that should be labeled
+         * @param breakLine If true, a lineSeparator will be added if the currentPosition is past targetPosition.
+         * @return the new position, which is the targetPosition or the end of the line.
+         */
+        PrinterOutputBuilder advanceToTokenPosition(String currentLine, int targetPosition, boolean breakLine) {
+            targetPosition = Math.min(targetPosition, currentLine.length());
+            if (!positionBeforeOrEqualTo(targetPosition)) {
+                if (!breakLine) {
+                    // we are past targetPosition, do nothing
+                    return this;
+                }
+
+                // new line
+                appendTokenLinePrefix();
+                if (!positionBeforeOrEqualTo(targetPosition)) {
+                    // still past targetPosition -> negative targetPosition
+                    return this;
+                }
+            }
+
+            // The replacement operation preserves TAB characters, which is essential for correct alignment
+            String padding = currentLine.substring(columnIndex - 1, targetPosition - 1)
+                    // TAB is a string, so use replace
+                    .replace(TAB, REPLACE_TABS ? TAB_REPLACEMENT : TAB)
+                    // NON_WHITESPACE is a pattern, so use replaceAll
+                    .replaceAll(NON_WHITESPACE, SPACE);
+            append(padding);
+
+            return this;
+        }
+
+        /**
+         * Returns true if the current position is before or at the target position.
+         * @param targetPosition the target position
+         * @return true if before or at target position
+         */
+        private boolean positionBeforeOrEqualTo(int targetPosition) {
+            return columnIndex <= targetPosition;
+        }
+
+        /**
+         * Appends a newline, plus the amount of padding necessary to align tokens correctly.
+         * @return this
+         */
+        PrinterOutputBuilder appendTokenLinePrefix() {
+            int paddingLength = Math.max(digitCount(lineNumber) + MIN_PADDING, TAB_LENGTH);
+            advanceToNextLine().appendPadding(paddingLength);
+            resetLinePosition();
+            return this;
+        }
+
+        /**
+         * Appends the amount of padding given.
+         * @param paddingLength Length of padding to add
+         * @return this
+         */
+        private PrinterOutputBuilder appendPadding(int paddingLength) {
+            String padding = SPACE.repeat(paddingLength);
+            append(padding);
+            return this;
+        }
+
+        /**
+         * Appends a newline.
+         * @return this
+         */
+        private PrinterOutputBuilder advanceToNextLine() {
+            append(LINE_SEPARATOR);
+            resetLinePosition();
+            return this;
+        }
+
+        public void setLine(int lineNumber) {
+            this.lineNumber = lineNumber;
+        }
+
+        @Override
+        public String toString() {
+            return builder.toString();
         }
-        int minusLength = number < 0 ? 1 : 0;
-        // The 'log10' variant is supposedly faster than the 'toString' variant.
-        return (int) Math.log10(Math.abs(number)) + minusLength + 1;
     }
 }

+ 19 - 0
jplag.frontend-utils/src/test/java/de/jplag/TestToken.java

@@ -0,0 +1,19 @@
+package de.jplag;
+
+import static de.jplag.TokenConstants.FILE_END;
+import static de.jplag.simple.TestTokenConstants.STRING;
+
+public class TestToken extends Token {
+    public TestToken(int tokenType, String file, int line, int column, int length) {
+        super(tokenType, file, line, column, length);
+    }
+
+    @Override
+    protected String type2string() {
+        return switch (type) {
+            case STRING -> "STRING";
+            case FILE_END -> "<EOF>";
+            default -> "<UNKNOWN%d>".formatted(type);
+        };
+    }
+}

+ 98 - 0
jplag.frontend-utils/src/test/java/de/jplag/TokenPrinterTest.java

@@ -0,0 +1,98 @@
+package de.jplag;
+
+import static de.jplag.TokenConstants.FILE_END;
+import static de.jplag.simple.TestTokenConstants.STRING;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class TokenPrinterTest {
+
+    private static final Path TEST_FILE_LOCATION = Path.of("src", "test", "resources", "de", "jplag", "samples");
+    private static final String TEST_FILE_NAME = "TokenPrinterTest.txt";
+    private static final String TOKEN_SEPARATOR = "(\\|)?\s*\\|";
+
+    private static final Logger logger = LoggerFactory.getLogger(TokenPrinterTest.class);
+
+    /**
+     * Tests the function of the TokenPrinter
+     */
+    @Test
+    void printMockDirectoriesAsSubmissions() {
+
+        // See TokenPrinterTest.txt for the intended behaviour
+        TokenList tokens = new TokenList();
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 1, 1, "STRING".length()));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 2, 1, "STRING".length() + 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 3, 1, "STRING".length() + 2));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 4, 1, "STRING".length() + 10));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 6, 3, 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 7, 9, 1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 9, 1, 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 9, 10, 1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 10, 1, 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 10, 5, 1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 12, 1, 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 10, 5, 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 12, 10, 1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 14, 10, 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 14, 5, 1));
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 14, 1, 1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 16, -5, 1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 19, 100, 1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 22, 1, 100));
+
+        tokens.addToken(new TestToken(FILE_END, TEST_FILE_NAME, 24, 1, -1));
+
+        tokens.addToken(new TestToken(STRING, TEST_FILE_NAME, 100, 1, 1));
+
+        String output = TokenPrinter.printTokens(tokens, TEST_FILE_LOCATION.toFile());
+        logger.debug(output); // no additional newline required
+
+        testOutputCorrectness(TEST_FILE_NAME, tokens, output);
+    }
+
+    private static void testOutputCorrectness(String fileName, TokenList tokens, String output) {
+        int lineIndex = 0;
+        int tokenIndex = 0;
+        boolean seenFileName = false;
+        for (String line : output.lines().toList()) {
+            if (line.isEmpty()) {
+                continue;
+            } else if (lineIndex == 0 && line.equals(fileName)) {
+                seenFileName = true;
+            } else if (line.startsWith("" + (lineIndex + 1))) {
+                lineIndex++;
+            } else {
+                line = line.trim();
+                String[] lineTokens = line.split(TOKEN_SEPARATOR);
+                for (String lineToken : lineTokens) {
+                    if (lineToken.isEmpty()) {
+                        continue;
+                    }
+                    Token currentToken = tokens.getToken(tokenIndex);
+                    if (lineToken.equalsIgnoreCase(currentToken.toString()) && currentToken.getLine() == lineIndex) {
+                        tokenIndex++;
+                    } else {
+                        fail("Expected token %s, but found %s".formatted(currentToken, lineToken));
+                    }
+                }
+            }
+        }
+        if (!seenFileName) {
+            fail("Expected file name");
+        }
+    }
+}

+ 5 - 0
jplag.frontend-utils/src/test/java/de/jplag/simple/TestTokenConstants.java

@@ -0,0 +1,5 @@
+package de.jplag.simple;
+
+public interface TestTokenConstants {
+    int STRING = 2;
+}

+ 26 - 0
jplag.frontend-utils/src/test/resources/de/jplag/samples/TokenPrinterTest.txt

@@ -0,0 +1,26 @@
+|----| 				token shorter than label, no bar printed
+|-----| 			token exactly as long as label, no bar printed
+|------|			token at minimal length for bar to be printed
+|--------------|	token much longer than label, padding + bar
+
+		|			token starts after TAB indentation
+        |			token starts after SPACE indentation
+
+|        | 			multiple tokens fitting in one line
+|   |      			multiple tokens where the labels overlap
+
+|   |    | 			token in the middle is set to previous line -- saved by TokenList::addToken method
+
+|   |    | 			tokens in this line are in reverse order -- that's okay
+
+|					negative column index leads to redundant blank line
+
+	|      |		one line below: column index greater than line length -- beginning printed at last character of the line
+           #
+
+|              |	one line below: length of token exceeds line length -- ending bar printed at last character of the line
+               #
+
+|					negative length -- no effect
+
+					token with position after EOF is not printed

+ 2 - 4
jplag.frontend.csharp-6/src/test/java/de/jplag/csharp/MinimalCSharpFrontendTest.java

@@ -7,9 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.File;
 import java.nio.file.Path;
-import java.util.Arrays;
 import java.util.List;
-import java.util.stream.StreamSupport;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -47,11 +45,11 @@ class MinimalCSharpFrontendTest {
         // Parse test input
         String[] input = new String[] {TEST_SUBJECT};
         TokenList result = frontend.parse(baseDirectory, input);
-        logger.info(TokenPrinter.printTokens(result, baseDirectory, Arrays.asList(input)));
+        logger.info(TokenPrinter.printTokens(result, baseDirectory));
 
         // Compare parsed tokens:
         assertEquals(expectedToken.size(), result.size());
-        List<Integer> actualToken = StreamSupport.stream(result.allTokens().spliterator(), false).map(Token::getType).collect(toList());
+        List<Integer> actualToken = result.allTokens().stream().map(Token::getType).collect(toList());
         assertEquals(expectedToken, actualToken);
     }
 

+ 1 - 4
jplag.frontend.emf-metamodel-dynamic/src/test/java/de/jplag/emf/dynamic/MinimalDynamicMetamodelTest.java

@@ -6,8 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.File;
 import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.List;
 import java.util.Optional;
 
 import org.junit.jupiter.api.AfterEach;
@@ -40,8 +38,7 @@ class MinimalDynamicMetamodelTest {
     @Test
     void testBookstoreMetamodels() {
         TokenList result = frontend.parse(baseDirectory, TEST_SUBJECTS);
-        List<String> treeViewFiles = Arrays.stream(TEST_SUBJECTS).map(it -> it + Language.VIEW_FILE_SUFFIX).toList();
-        logger.debug(TokenPrinter.printTokens(result, baseDirectory, treeViewFiles, Optional.of(Language.VIEW_FILE_SUFFIX)));
+        logger.debug(TokenPrinter.printTokens(result, baseDirectory, Optional.of(Language.VIEW_FILE_SUFFIX)));
         logger.info(("Dynamic token set: " + DynamicMetamodelTokenConstants.getTokenStrings()));
         logger.info("parsed tokens: " + result.allTokens().toString());
         assertEquals(7, DynamicMetamodelTokenConstants.getTokenStrings().size());

+ 2 - 6
jplag.frontend.emf-metamodel/src/test/java/de/jplag/emf/MinimalMetamodelTest.java

@@ -1,14 +1,11 @@
 package de.jplag.emf;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertIterableEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.*;
 
 import java.io.File;
 import java.lang.reflect.Field;
 import java.nio.file.Path;
 import java.util.Arrays;
-import java.util.List;
 import java.util.Optional;
 
 import org.junit.jupiter.api.AfterEach;
@@ -41,9 +38,8 @@ class MinimalMetamodelTest {
     @Test
     void testBookstoreMetamodels() {
         TokenList result = frontend.parse(baseDirectory, TEST_SUBJECTS);
-        List<String> treeViewFiles = Arrays.stream(TEST_SUBJECTS).map(it -> it + Language.VIEW_FILE_SUFFIX).toList();
 
-        logger.debug(TokenPrinter.printTokens(result, baseDirectory, treeViewFiles, Optional.of(Language.VIEW_FILE_SUFFIX)));
+        logger.debug(TokenPrinter.printTokens(result, baseDirectory, Optional.of(Language.VIEW_FILE_SUFFIX)));
         Field[] fields = MetamodelTokenConstants.class.getFields();
         var constants = Arrays.stream(fields).map(Field::getName).filter(it -> !it.equals("NUM_DIFF_TOKENS")).toList();
         logger.info(("Handcrafted token set: " + constants));

+ 2 - 3
jplag.frontend.golang/src/test/java/de/jplag/golang/GoFrontendTest.java

@@ -13,7 +13,6 @@ import java.util.Objects;
 import java.util.OptionalInt;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
-import java.util.stream.StreamSupport;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -60,7 +59,7 @@ class GoFrontendTest {
     void parseTestFiles() {
         for (String fileName : testFiles) {
             TokenList tokens = language.parse(testFileLocation, new String[] {fileName});
-            String output = TokenPrinter.printTokens(tokens, testFileLocation, List.of(fileName));
+            String output = TokenPrinter.printTokens(tokens, testFileLocation);
             logger.info(output);
 
             testSourceCoverage(fileName, tokens);
@@ -158,7 +157,7 @@ class GoFrontendTest {
      * @param fileName The file name of the complete code example
      */
     private void testTokenCoverage(TokenList tokens, String fileName) {
-        var foundTokens = StreamSupport.stream(tokens.allTokens().spliterator(), true).mapToInt(Token::getType).sorted().distinct().boxed().toList();
+        var foundTokens = tokens.allTokens().stream().parallel().mapToInt(Token::getType).sorted().distinct().boxed().toList();
 
         // Exclude SEPARATOR_TOKEN, as it does not occur
         var missingTokenTypes = IntStream.range(0, GoTokenConstants.NUM_DIFF_TOKENS).filter(i -> i != TokenConstants.SEPARATOR_TOKEN).boxed()

+ 2 - 3
jplag.frontend.kotlin/src/test/java/de/jplag/kotlin/KotlinFrontendTest.java

@@ -10,7 +10,6 @@ import java.nio.file.Path;
 import java.util.List;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
-import java.util.stream.StreamSupport;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -60,7 +59,7 @@ class KotlinFrontendTest {
     void parseTestFiles() {
         for (String fileName : testFiles) {
             TokenList tokens = language.parse(testFileLocation, new String[] {fileName});
-            String output = TokenPrinter.printTokens(tokens, testFileLocation, List.of(fileName));
+            String output = TokenPrinter.printTokens(tokens, testFileLocation);
             logger.info(output);
 
             testSourceCoverage(fileName, tokens);
@@ -154,7 +153,7 @@ class KotlinFrontendTest {
      * @param fileName The file name of the complete code example
      */
     private void testTokenCoverage(TokenList tokens, String fileName) {
-        var foundTokens = StreamSupport.stream(tokens.allTokens().spliterator(), true).mapToInt(Token::getType).sorted().distinct().toArray();
+        var foundTokens = tokens.allTokens().stream().parallel().mapToInt(Token::getType).sorted().distinct().toArray();
         // Exclude SEPARATOR_TOKEN, as it does not occur
         var allTokens = IntStream.range(0, KotlinTokenConstants.NUMBER_DIFF_TOKENS).filter(i -> i != TokenConstants.SEPARATOR_TOKEN).toArray();
 

+ 4 - 4
jplag.frontend.rlang/src/test/java/de/jplag/rlang/RFrontendTest.java

@@ -3,12 +3,12 @@ package de.jplag.rlang;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.fail;
 
-import java.io.*;
+import java.io.File;
+import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
 import java.util.stream.IntStream;
-import java.util.stream.StreamSupport;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -47,7 +47,7 @@ class RFrontendTest {
     void parseTestFiles() {
         for (String fileName : testFiles) {
             TokenList tokens = language.parse(testFileLocation, new String[] {fileName});
-            String output = TokenPrinter.printTokens(tokens, testFileLocation, List.of(fileName));
+            String output = TokenPrinter.printTokens(tokens, testFileLocation);
             logger.info(output);
 
             testSourceCoverage(fileName, tokens);
@@ -92,7 +92,7 @@ class RFrontendTest {
      * @param fileName The file name of the complete code example
      */
     private void testTokenCoverage(TokenList tokens, String fileName) {
-        var foundTokens = StreamSupport.stream(tokens.allTokens().spliterator(), true).mapToInt(Token::getType).sorted().distinct().toArray();
+        var foundTokens = tokens.allTokens().stream().parallel().mapToInt(Token::getType).sorted().distinct().toArray();
         // Exclude SEPARATOR_TOKEN, as it does not occur
         var allTokens = IntStream.range(0, RTokenConstants.NUM_DIFF_TOKENS).filter(i -> i != TokenConstants.SEPARATOR_TOKEN).toArray();
 

+ 2 - 3
jplag.frontend.scala/src/test/java/de/jplag/scala/ScalaFrontendTest.java

@@ -10,7 +10,6 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
-import java.util.stream.StreamSupport;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -61,7 +60,7 @@ class ScalaFrontendTest {
     void parseTestFiles() {
         for (String fileName : testFiles) {
             TokenList tokens = language.parse(testFileLocation, new String[] {fileName});
-            String output = TokenPrinter.printTokens(tokens, testFileLocation, List.of(fileName));
+            String output = TokenPrinter.printTokens(tokens, testFileLocation);
             logger.info(output);
 
             if (fileName.equals(COMPLETE_TEST_FILE)) {
@@ -162,7 +161,7 @@ class ScalaFrontendTest {
      * @param fileName The file name of the complete code example
      */
     private void testTokenCoverage(TokenList tokens, String fileName) {
-        var foundTokens = StreamSupport.stream(tokens.allTokens().spliterator(), true).mapToInt(Token::getType).distinct().boxed().toList();
+        var foundTokens = tokens.allTokens().stream().parallel().mapToInt(Token::getType).distinct().boxed().toList();
         var allTokens = IntStream.range(0, ScalaTokenConstants.numberOfTokens()).boxed().toList();
         allTokens = new ArrayList<>(allTokens);
 

+ 2 - 4
jplag.frontend.text/src/test/java/jplag/text/TextFrontendTest.java

@@ -5,10 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.File;
 import java.nio.file.Path;
-import java.util.Arrays;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.stream.StreamSupport;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -41,11 +39,11 @@ class TextFrontendTest {
         // Parse test input
         String[] input = new String[] {TEST_SUBJECT};
         TokenList result = frontend.parse(baseDirectory, input);
-        logger.info(TokenPrinter.printTokens(result, baseDirectory, Arrays.asList(input)));
+        logger.info(TokenPrinter.printTokens(result, baseDirectory));
 
         // Compare parsed tokens:
         Map<Integer, Token> tokenTypes = new HashMap<>();
-        StreamSupport.stream(result.allTokens().spliterator(), false).forEach(it -> tokenTypes.put(it.getType(), it));
+        result.allTokens().forEach(it -> tokenTypes.put(it.getType(), it));
 
         assertEquals(293, result.size());
         assertEquals(156, tokenTypes.values().size());

+ 1 - 1
jplag/src/main/java/de/jplag/Submission.java

@@ -204,7 +204,7 @@ public class Submission implements Comparable<Submission> {
      * @return the annotated code as string.
      */
     public String getTokenAnnotatedSourceCode() {
-        return TokenPrinter.printTokens(tokenList, files, submissionRootFile);
+        return TokenPrinter.printTokens(tokenList, submissionRootFile);
     }
 
     @Override

+ 13 - 7
jplag/src/main/java/de/jplag/clustering/Cluster.java

@@ -27,11 +27,16 @@ public class Cluster<T> {
         this.averageSimilarity = averageSimilarity;
     }
 
+    /**
+     * @return a view on the members of this cluster.
+     */
     public Collection<T> getMembers() {
-        // TODO Check why access to local attribute.
-        return members;
+        return new ArrayList<>(members);
     }
 
+    /**
+     * @return average similarity between all tuple comparisons of the members in this cluster.
+     */
     public float getAverageSimilarity() {
         return averageSimilarity;
     }
@@ -57,7 +62,7 @@ public class Cluster<T> {
      * @return How much each member of this cluster contributes to the {@link ClusteringResult#getCommunityStrength}.
      */
     public float getCommunityStrengthPerConnection() {
-        int size = getMembers().size();
+        int size = members.size();
         if (size < 2)
             return 0;
         return getCommunityStrength() / connections();
@@ -84,7 +89,7 @@ public class Cluster<T> {
      */
     public double getWorth(BiFunction<T, T, Float> similarity) {
         double communityStrength = getCommunityStrength();
-        if (getMembers().size() > 1) {
+        if (members.size() > 1) {
             communityStrength /= connections();
         }
         double averageSimilarity = averageSimilarity(similarity);
@@ -97,7 +102,7 @@ public class Cluster<T> {
      * @return average similarity
      */
     private float averageSimilarity(BiFunction<T, T, Float> similarity) {
-        List<T> members = new ArrayList<>(getMembers());
+        List<T> members = new ArrayList<>(this.members);
         if (members.size() < 2) {
             return 1;
         }
@@ -111,7 +116,7 @@ public class Cluster<T> {
     }
 
     private int connections() {
-        int size = getMembers().size();
+        int size = members.size();
         return ((size - 1) * size) / 2;
     }
 
@@ -120,6 +125,7 @@ public class Cluster<T> {
      * @return is bad
      */
     public boolean isBadCluster() {
-        return getMembers().size() < 2 || getCommunityStrength() < 0;
+        return members.size() < 2 || getCommunityStrength() < 0;
     }
+
 }

+ 5 - 0
jplag/src/main/java/de/jplag/clustering/ClusteringAlgorithm.java

@@ -39,4 +39,9 @@ public enum ClusteringAlgorithm {
     public interface ClusteringAlgorithmSupplier {
         GenericClusteringAlgorithm create(ClusteringOptions options);
     }
+
+    @Override
+    public String toString() {
+        return super.toString().toLowerCase();
+    }
 }

+ 23 - 1
jplag/src/main/java/de/jplag/clustering/ClusteringFactory.java

@@ -1,10 +1,14 @@
 package de.jplag.clustering;
 
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import de.jplag.JPlagComparison;
 import de.jplag.Submission;
 import de.jplag.clustering.algorithm.GenericClusteringAlgorithm;
@@ -13,9 +17,19 @@ import de.jplag.clustering.algorithm.GenericClusteringAlgorithm;
  * Runs the clustering according to an options object.
  */
 public class ClusteringFactory {
+    private static final String CLUSTER_PATTERN = " cluster strength: {}, avg similarity: {}%, members: {}";
+    private static final String CLUSTERING_RESULT = "{} clusters were found:";
+    private static final String CLUSTERING_PARAMETERS = "Calculating clusters via {} clustering with {} pre-processing...";
+    private static final String CLUSTERING_DISABLED = "Cluster calculation disabled (as requested)!";
+    private static final Logger logger = LoggerFactory.getLogger(ClusteringFactory.class);
+
     public static List<ClusteringResult<Submission>> getClusterings(Collection<JPlagComparison> comparisons, ClusteringOptions options) {
-        if (!options.isEnabled())
+        if (!options.isEnabled()) {
+            logger.warn(CLUSTERING_DISABLED);
             return Collections.emptyList();
+        } else {
+            logger.info(CLUSTERING_PARAMETERS, options.getAlgorithm(), options.getPreprocessor());
+        }
 
         // init algorithm
         GenericClusteringAlgorithm clusteringAlgorithm = options.getAlgorithm().create(options);
@@ -36,6 +50,7 @@ public class ClusteringFactory {
 
         // remove bad clusters
         result = removeBadClusters(result);
+        logClusters(result);
 
         return List.of(result);
     }
@@ -44,4 +59,11 @@ public class ClusteringFactory {
         List<Cluster<Submission>> filtered = clustering.getClusters().stream().filter(cluster -> !cluster.isBadCluster()).toList();
         return new ClusteringResult<>(filtered, clustering.getCommunityStrength());
     }
+
+    private static void logClusters(ClusteringResult<Submission> result) {
+        var clusters = new ArrayList<>(result.getClusters());
+        Collections.sort(clusters, (first, second) -> Float.compare(second.getCommunityStrength(), first.getCommunityStrength()));
+        logger.info(CLUSTERING_RESULT, clusters.size());
+        clusters.forEach(it -> logger.info(CLUSTER_PATTERN, it.getCommunityStrength(), it.getAverageSimilarity(), it.getMembers()));
+    }
 }

+ 5 - 0
jplag/src/main/java/de/jplag/clustering/Preprocessing.java

@@ -28,4 +28,9 @@ public enum Preprocessing {
     public Optional<ClusteringPreprocessor> constructPreprocessor(ClusteringOptions options) {
         return Optional.ofNullable(constructor.apply(options));
     }
+
+    @Override
+    public String toString() {
+        return super.toString().toLowerCase().replace('_', ' ');
+    }
 }

+ 101 - 0
jplag/src/main/java/de/jplag/reporting/jsonfactory/ComparisonReportWriter.java

@@ -0,0 +1,101 @@
+package de.jplag.reporting.jsonfactory;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import de.jplag.*;
+import de.jplag.reporting.reportobject.model.ComparisonReport;
+import de.jplag.reporting.reportobject.model.Match;
+
+/**
+ * Writes {@link ComparisonReport}s of given {@link JPlagResult} to the disk under the specified path. Instantiated with
+ * a function that associates a submission to its id.
+ */
+public class ComparisonReportWriter {
+
+    private final FileWriter fileWriter;
+    private final Function<Submission, String> submissionToIdFunction;
+    private final Map<String, Map<String, String>> submissionIdToComparisonFileName = new HashMap<>();
+
+    public ComparisonReportWriter(Function<Submission, String> submissionToIdFunction, FileWriter fileWriter) {
+        this.submissionToIdFunction = submissionToIdFunction;
+        this.fileWriter = fileWriter;
+    }
+
+    /**
+     * Generates detailed ComparisonReport DTO for each comparison in a JPlagResult and writes them to the disk as json
+     * files.
+     * @param jPlagResult The JPlagResult to generate the comparison reports from. contains information about a comparison
+     * @param path The path to write the comparison files to
+     * @return Nested map that associates each pair of submissions (by their ids) to their comparison file name. The
+     * comparison file name for submission with id id1 and id2 can be fetched by executing get two times:
+     * map.get(id1).get(id2). The nested map is symmetrical therefore, both map.get(id1).get(id2) and map.get(id2).get(id1)
+     * yield the same result.
+     */
+    public Map<String, Map<String, String>> writeComparisonReports(JPlagResult jPlagResult, String path) {
+        int numberOfComparisons = jPlagResult.getOptions().getMaximumNumberOfComparisons();
+        List<JPlagComparison> comparisons = jPlagResult.getComparisons(numberOfComparisons);
+        writeComparisons(path, comparisons);
+        return submissionIdToComparisonFileName;
+    }
+
+    private void writeComparisons(String path, List<JPlagComparison> comparisons) {
+        for (JPlagComparison comparison : comparisons) {
+            String firstSubmissionId = submissionToIdFunction.apply(comparison.getFirstSubmission());
+            String secondSubmissionId = submissionToIdFunction.apply(comparison.getSecondSubmission());
+            String fileName = generateComparisonName(firstSubmissionId, secondSubmissionId);
+            addToLookUp(firstSubmissionId, secondSubmissionId, fileName);
+            var comparisonReport = new ComparisonReport(firstSubmissionId, secondSubmissionId, comparison.similarity(),
+                    convertMatchesToReportMatches(comparison));
+            fileWriter.saveAsJSON(comparisonReport, path, fileName);
+        }
+    }
+
+    private void addToLookUp(String firstSubmissionId, String secondSubmissionId, String fileName) {
+        writeToMap(secondSubmissionId, firstSubmissionId, fileName);
+        writeToMap(firstSubmissionId, secondSubmissionId, fileName);
+    }
+
+    private void writeToMap(String id1, String id2, String comparisonFileName) {
+        submissionIdToComparisonFileName.putIfAbsent(id1, new HashMap<>());
+        submissionIdToComparisonFileName.get(id1).put(id2, comparisonFileName);
+    }
+
+    private String generateComparisonName(String firstSubmissionId, String secondSubmissionId) {
+        String name = concatenate(firstSubmissionId, secondSubmissionId);
+        String finalName = name;
+        long timesNameAlreadyExists = submissionIdToComparisonFileName.values().stream().filter(map -> map.containsValue(finalName)).count();
+        if (timesNameAlreadyExists > 0) {
+            name = concatenate(firstSubmissionId, secondSubmissionId, timesNameAlreadyExists + 1);
+        }
+        return name;
+
+    }
+
+    private String concatenate(String firstSubmissionId, String secondSubmissionId, long index) {
+        return firstSubmissionId.concat("-").concat(secondSubmissionId).concat(index > 0 ? "-" + index : "").concat(".json");
+    }
+
+    private String concatenate(String firstSubmissionId, String secondSubmissionId) {
+        return concatenate(firstSubmissionId, secondSubmissionId, 0);
+    }
+
+    private List<Match> convertMatchesToReportMatches(JPlagComparison comparison) {
+        return comparison.getMatches().stream().map(match -> convertMatchToReportMatch(comparison, match)).toList();
+    }
+
+    private Match convertMatchToReportMatch(JPlagComparison comparison, de.jplag.Match match) {
+        TokenList tokensFirst = comparison.getFirstSubmission().getTokenList();
+        TokenList tokensSecond = comparison.getSecondSubmission().getTokenList();
+        Token startOfFirst = tokensFirst.getToken(match.startOfFirst());
+        Token endOfFirst = tokensFirst.getToken(match.startOfFirst() + match.length() - 1);
+        Token startOfSecond = tokensSecond.getToken(match.startOfSecond());
+        Token endOfSecond = tokensSecond.getToken(match.startOfSecond() + match.length() - 1);
+
+        return new Match(startOfFirst.getFile(), startOfSecond.getFile(), startOfFirst.getLine(), endOfFirst.getLine(), startOfSecond.getLine(),
+                endOfSecond.getLine(), match.length());
+    }
+
+}

+ 93 - 0
jplag/src/main/java/de/jplag/reporting/jsonfactory/DirectoryManager.java

@@ -0,0 +1,93 @@
+package de.jplag.reporting.jsonfactory;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Comparator;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Provides Methods for creating directories.
+ */
+public class DirectoryManager {
+    private static final Logger logger = LoggerFactory.getLogger(DirectoryManager.class);
+
+    /**
+     * Creates a directory.
+     * @param path The path under which the new directory ought to be created
+     * @param name The name of the new directory
+     * @return The created directory
+     */
+    public static File createDirectory(String path, String name) throws IOException {
+        File directory = new File(path.concat("/").concat(name));
+        if (!directory.exists() && !directory.mkdirs()) {
+            throw new IOException("Failed to create dir.");
+        }
+        return directory;
+    }
+
+    /**
+     * Create a directory with the given path
+     * @param path The path of the new directory
+     */
+    public static void createDirectory(String path) throws IOException {
+        createDirectory(path, "");
+    }
+
+    /**
+     * Delete the directory and all of its contents, identified by the given path
+     * @param path The path that identifies the directory to delete
+     */
+    public static void deleteDirectory(String path) {
+        try (var f = Files.walk(Path.of(path))) {
+            f.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);
+        } catch (IOException e) {
+            logger.error("Could not delete folder " + path, e);
+        }
+    }
+
+    /**
+     * Zip the directory identified by the given path
+     * @param path The path that identifies the directory to zip
+     * @return True if zip was successful, false otherwise
+     */
+    public static boolean zipDirectory(String path) {
+        Path p = Path.of(path);
+        String zipName = path + ".zip";
+
+        try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipName))) {
+
+            Files.walkFileTree(p, new SimpleFileVisitor<>() {
+                @Override
+                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
+                    Path targetFile = p.relativize(file);
+                    zos.putNextEntry(new ZipEntry(targetFile.toString()));
+
+                    byte[] bytes = Files.readAllBytes(file);
+                    zos.write(bytes, 0, bytes.length);
+                    return FileVisitResult.CONTINUE;
+                }
+
+                @Override
+                public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
+                    logger.error("Unable to zip " + file, exc);
+                    throw exc;
+                }
+            });
+        } catch (IOException e) {
+            logger.error(e.getMessage(), e);
+            deleteDirectory(zipName);
+            return false;
+        }
+        return true;
+    }
+}

+ 13 - 0
jplag/src/main/java/de/jplag/reporting/jsonfactory/DummyWriter.java

@@ -0,0 +1,13 @@
+package de.jplag.reporting.jsonfactory;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DummyWriter implements FileWriter {
+    private static final Logger logger = LoggerFactory.getLogger(DummyWriter.class);
+
+    @Override
+    public void saveAsJSON(Object fileToSave, String folderPath, String fileName) {
+        logger.info("DummyWriter writes object " + fileToSave + " to path " + folderPath + " with name " + fileName + " as JSON.");
+    }
+}

+ 2 - 26
jplag/src/main/java/de/jplag/reporting/jsonfactory/FileWriter.java

@@ -1,29 +1,5 @@
 package de.jplag.reporting.jsonfactory;
 
-import java.io.IOException;
-import java.nio.file.Path;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-
-public class FileWriter {
-    private static final Logger logger = LoggerFactory.getLogger(FileWriter.class);
-
-    private static final ObjectMapper objectMapper = new ObjectMapper();
-
-    /**
-     * Saves the provided object to the provided path under the provided name
-     * @param fileToSave The object to save
-     * @param folderPath The path to save the object to
-     * @param fileName The name to save the object under
-     */
-    public void saveAsJSON(Object fileToSave, String folderPath, String fileName) {
-        try {
-            objectMapper.writeValue(Path.of(folderPath, fileName).toFile(), fileToSave);
-        } catch (IOException e) {
-            logger.error("Failed to save json file " + fileName + ": " + e.getMessage(), e);
-        }
-    }
+public interface FileWriter {
+    void saveAsJSON(Object fileToSave, String folderPath, String fileName);
 }

+ 30 - 0
jplag/src/main/java/de/jplag/reporting/jsonfactory/ToDiskWriter.java

@@ -0,0 +1,30 @@
+package de.jplag.reporting.jsonfactory;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class ToDiskWriter implements FileWriter {
+    private static final Logger logger = LoggerFactory.getLogger(ToDiskWriter.class);
+
+    private static final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * Saves the provided object to the provided path under the provided name
+     * @param fileToSave The object to save
+     * @param folderPath The path to save the object to
+     * @param fileName The name to save the object under
+     */
+    @Override
+    public void saveAsJSON(Object fileToSave, String folderPath, String fileName) {
+        try {
+            objectMapper.writeValue(Path.of(folderPath, fileName).toFile(), fileToSave);
+        } catch (IOException e) {
+            logger.error("Failed to save json file " + fileName + ": " + e.getMessage(), e);
+        }
+    }
+}

+ 102 - 88
jplag/src/main/java/de/jplag/reporting/reportobject/ReportObjectFactory.java

@@ -1,15 +1,15 @@
 package de.jplag.reporting.reportobject;
 
+import static de.jplag.reporting.jsonfactory.DirectoryManager.*;
+import static de.jplag.reporting.reportobject.mapper.SubmissionNameToIdMapper.buildSubmissionNameToIdMap;
+
 import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.StandardCopyOption;
 import java.text.SimpleDateFormat;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
+import java.util.*;
+import java.util.function.Function;
 import java.util.stream.Collectors;
 
 import org.slf4j.Logger;
@@ -19,87 +19,79 @@ import de.jplag.JPlagComparison;
 import de.jplag.JPlagResult;
 import de.jplag.Language;
 import de.jplag.Submission;
-import de.jplag.reporting.jsonfactory.FileWriter;
+import de.jplag.reporting.jsonfactory.ComparisonReportWriter;
+import de.jplag.reporting.jsonfactory.ToDiskWriter;
 import de.jplag.reporting.reportobject.mapper.ClusteringResultMapper;
-import de.jplag.reporting.reportobject.mapper.ComparisonReportMapper;
 import de.jplag.reporting.reportobject.mapper.MetricMapper;
 import de.jplag.reporting.reportobject.model.Metric;
 import de.jplag.reporting.reportobject.model.OverviewReport;
 
 /**
- * Factory class, responsible for converting a JPlagResult object to Overview and Comparison DTO classes.
+ * Factory class, responsible for converting a JPlagResult object to Overview and Comparison DTO classes and writing it
+ * to the disk.
  */
 public class ReportObjectFactory {
     private static final Logger logger = LoggerFactory.getLogger(ReportObjectFactory.class);
 
-    private static final ClusteringResultMapper clusteringResultMapper = new ClusteringResultMapper();
-    private static final MetricMapper metricMapper = new MetricMapper();
-    private static final ComparisonReportMapper comparisonReportMapper = new ComparisonReportMapper();
-    private static final FileWriter fileWriter = new FileWriter();
+    private static final ToDiskWriter fileWriter = new ToDiskWriter();
     public static final String OVERVIEW_FILE_NAME = "overview.json";
     public static final String SUBMISSIONS_FOLDER = "submissions";
+    private Map<String, String> submissionNameToIdMap;
+    private Function<Submission, String> submissionToIdFunction;
+    private Map<String, Map<String, String>> submissionNameToNameToComparisonFileName;
 
     /**
+     * Creates all necessary report viewer files, writes them to the disk as zip.
      * @param result The JPlagResult to be converted into a report.
      * @param path The Path to save the report to
      */
-    public static void createAndSaveReport(JPlagResult result, String path) {
-        createDirectory(path);
-        writeOverview(result, path);
-        copySubmissionFilesToReport(path, result);
-        comparisonReportMapper.writeComparisonReports(result, path);
-    }
+    public void createAndSaveReport(JPlagResult result, String path) {
 
-    private static File createDirectory(String path, String name) {
-        File directory = new File(path.concat("/").concat(name));
-        if (!directory.exists() && !directory.mkdirs()) {
-            logger.error("Failed to create dir.");
-        }
-        return directory;
-    }
+        try {
+            createDirectory(path);
+            buildSubmissionToIdMap(result);
 
-    private static void createDirectory(String path) {
-        createDirectory(path, "");
-    }
+            copySubmissionFilesToReport(path, result);
 
-    private static void writeOverview(JPlagResult result, String path) {
-        List<JPlagComparison> comparisons = getComparisons(result);
-        OverviewReport overviewReport = new OverviewReport();
+            writeComparisons(result, path);
+            writeOverview(result, path);
 
-        // TODO: Consider to treat entries that were checked differently from old entries with prior work.
-        List<String> folders = new ArrayList<>();
-        folders.addAll(result.getOptions().getSubmissionDirectories());
-        folders.addAll(result.getOptions().getOldSubmissionDirectories());
-        overviewReport.setSubmissionFolderPath(folders);
+            zipAndDelete(path);
+        } catch (IOException e) {
+            logger.error("Could not create directory " + path + " for report viewer generation", e);
+        }
 
-        String baseCodePath = result.getOptions().hasBaseCode() ? result.getOptions().getBaseCodeSubmissionName().orElse("") : "";
-        overviewReport.setBaseCodeFolderPath(baseCodePath);
-
-        overviewReport.setLanguage(result.getOptions().getLanguage().getName());
-        overviewReport.setFileExtensions(List.of(result.getOptions().getFileSuffixes()));
-        overviewReport.setSubmissionIds(extractSubmissionNames(comparisons));
-        overviewReport.setFailedSubmissionNames(List.of());  // No number of failed submissions
-        overviewReport.setExcludedFiles(result.getOptions().getExcludedFiles());
-        overviewReport.setMatchSensitivity(result.getOptions().getMinimumTokenMatch());
-        overviewReport.setDateOfExecution(getDate());
-        overviewReport.setExecutionTime(result.getDuration());
-        overviewReport.setComparisonNames(getComparisonNames(comparisons));
-        overviewReport.setMetrics(getMetrics(result));
-        overviewReport.setClusters(clusteringResultMapper.map(result));
+    }
 
-        fileWriter.saveAsJSON(overviewReport, path, OVERVIEW_FILE_NAME);
+    private void zipAndDelete(String path) {
+        boolean zipWasSuccessful = zipDirectory(path);
+        if (zipWasSuccessful) {
+            deleteDirectory(path);
+        } else {
+            logger.error("Could not zip results. The results are still available uncompressed at " + path);
+        }
+    }
 
+    private void buildSubmissionToIdMap(JPlagResult result) {
+        submissionNameToIdMap = buildSubmissionNameToIdMap(result);
+        submissionToIdFunction = (Submission submission) -> submissionNameToIdMap.get(submission.getName());
     }
 
-    private static void copySubmissionFilesToReport(String path, JPlagResult result) {
+    private void copySubmissionFilesToReport(String path, JPlagResult result) {
         List<JPlagComparison> comparisons = result.getComparisons(result.getOptions().getMaximumNumberOfComparisons());
-        var submissions = getSubmissions(comparisons);
-        var submissionsPath = createDirectory(path, SUBMISSIONS_FOLDER);
+        Set<Submission> submissions = getSubmissions(comparisons);
+        File submissionsPath = createSubmissionsDirectory(path);
+        if (submissionsPath == null) {
+            return;
+        }
         Language language = result.getOptions().getLanguage();
-        for (var submission : submissions) {
-            File directory = createDirectory(submissionsPath.getPath(), submission.getName());
-            for (var file : submission.getFiles()) {
-                var fileToCopy = language.useViewFiles() ? new File(file.getPath() + language.viewFileSuffix()) : file;
+        for (Submission submission : submissions) {
+            File directory = createSubmissionDirectory(path, submissionsPath, submission);
+            if (directory == null) {
+                continue;
+            }
+            for (File file : submission.getFiles()) {
+                File fileToCopy = language.useViewFiles() ? new File(file.getPath() + language.viewFileSuffix()) : file;
                 try {
                     Files.copy(fileToCopy.toPath(), (new File(directory, file.getName())).toPath(), StandardCopyOption.REPLACE_EXISTING);
                 } catch (IOException e) {
@@ -109,40 +101,61 @@ public class ReportObjectFactory {
         }
     }
 
-    private static Set<Submission> getSubmissions(List<JPlagComparison> comparisons) {
-        var submissions = comparisons.stream().map(JPlagComparison::getFirstSubmission).collect(Collectors.toSet());
-        Set<Submission> secondSubmissions = comparisons.stream().map(JPlagComparison::getSecondSubmission).collect(Collectors.toSet());
-        submissions.addAll(secondSubmissions);
-        return submissions;
+    private File createSubmissionDirectory(String path, File submissionsPath, Submission submission) {
+        try {
+            return createDirectory(submissionsPath.getPath(), submissionToIdFunction.apply(submission));
+        } catch (IOException e) {
+            logger.error("Could not create directory " + path + " for report viewer generation", e);
+            return null;
+        }
     }
 
-    private static List<JPlagComparison> getComparisons(JPlagResult result) {
-        int numberOfComparisons = result.getOptions().getMaximumNumberOfComparisons();
-        return result.getComparisons(numberOfComparisons);
+    private File createSubmissionsDirectory(String path) {
+        try {
+            return createDirectory(path, SUBMISSIONS_FOLDER);
+        } catch (IOException e) {
+            logger.error("Could not create directory " + path + " for report viewer generation", e);
+            return null;
+        }
     }
 
-    /**
-     * Gets the names of all submissions.
-     * @return A list containing all submission names.
-     */
-    private static List<String> extractSubmissionNames(List<JPlagComparison> comparisons) {
-        HashSet<String> names = new HashSet<>();
-        comparisons.forEach(comparison -> {
-            names.add(comparison.getFirstSubmission().getName());
-            names.add(comparison.getSecondSubmission().getName());
-        });
-        return new ArrayList<>(names);
+    private void writeComparisons(JPlagResult result, String path) {
+        ComparisonReportWriter comparisonReportWriter = new ComparisonReportWriter(submissionToIdFunction, fileWriter);
+        submissionNameToNameToComparisonFileName = comparisonReportWriter.writeComparisonReports(result, path);
     }
 
-    /**
-     * Gets the names of all comparison.
-     * @return A list containing all comparisons.
-     */
-    private static List<String> getComparisonNames(List<JPlagComparison> comparisons) {
-        List<String> names = new ArrayList<>();
-        comparisons.forEach(
-                comparison -> names.add(String.join("-", comparison.getFirstSubmission().getName(), comparison.getSecondSubmission().getName())));
-        return names;
+    private void writeOverview(JPlagResult result, String path) {
+
+        List<String> folders = new ArrayList<>();
+        folders.addAll(result.getOptions().getSubmissionDirectories());
+        folders.addAll(result.getOptions().getOldSubmissionDirectories());
+
+        String baseCodePath = result.getOptions().hasBaseCode() ? result.getOptions().getBaseCodeSubmissionName().orElse("") : "";
+        ClusteringResultMapper clusteringResultMapper = new ClusteringResultMapper(submissionToIdFunction);
+
+        OverviewReport overviewReport = new OverviewReport(folders, // submissionFolderPath
+                baseCodePath, // baseCodeFolderPath
+                result.getOptions().getLanguage().getName(), // language
+                List.of(result.getOptions().getFileSuffixes()), // fileExtensions
+                submissionNameToIdMap.entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)), // submissionIds
+                submissionNameToNameToComparisonFileName, // result.getOptions().getMinimumTokenMatch(),
+                List.of(), // failedSubmissionNames
+                result.getOptions().getExcludedFiles(), // excludedFiles
+                result.getOptions().getMinimumTokenMatch(), // matchSensitivity
+                getDate(),// dateOfExecution
+                result.getDuration(), // executionTime
+                getMetrics(result),// metrics
+                clusteringResultMapper.map(result)); // clusters
+
+        fileWriter.saveAsJSON(overviewReport, path, OVERVIEW_FILE_NAME);
+
+    }
+
+    private Set<Submission> getSubmissions(List<JPlagComparison> comparisons) {
+        Set<Submission> submissions = comparisons.stream().map(JPlagComparison::getFirstSubmission).collect(Collectors.toSet());
+        Set<Submission> secondSubmissions = comparisons.stream().map(JPlagComparison::getSecondSubmission).collect(Collectors.toSet());
+        submissions.addAll(secondSubmissions);
+        return submissions;
     }
 
     /**
@@ -150,11 +163,12 @@ public class ReportObjectFactory {
      * Metric.
      * @return A list contains Metric DTOs.
      */
-    private static List<Metric> getMetrics(JPlagResult result) {
+    private List<Metric> getMetrics(JPlagResult result) {
+        MetricMapper metricMapper = new MetricMapper(submissionToIdFunction);
         return List.of(metricMapper.getAverageMetric(result), metricMapper.getMaxMetric(result));
     }
 
-    private static String getDate() {
+    private String getDate() {
         SimpleDateFormat dateFormat = new SimpleDateFormat("dd/MM/yy");
         Date date = new Date();
         return dateFormat.format(date);

+ 8 - 1
jplag/src/main/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapper.java

@@ -2,6 +2,7 @@ package de.jplag.reporting.reportobject.mapper;
 
 import java.util.Collection;
 import java.util.List;
+import java.util.function.Function;
 
 import de.jplag.JPlagResult;
 import de.jplag.Submission;
@@ -12,6 +13,12 @@ import de.jplag.reporting.reportobject.model.Cluster;
  * Extracts and maps the clusters from the JPlagResult to the corresponding JSON DTO
  */
 public class ClusteringResultMapper {
+    private final Function<Submission, String> submissionToIdFunction;
+
+    public ClusteringResultMapper(Function<Submission, String> submissionToIdFunction) {
+        this.submissionToIdFunction = submissionToIdFunction;
+    }
+
     public List<Cluster> map(JPlagResult result) {
         var clusteringResult = result.getClusteringResult();
         return clusteringResult.stream().map(ClusteringResult::getClusters).flatMap(Collection::stream).map(this::convertCluster).toList();
@@ -20,7 +27,7 @@ public class ClusteringResultMapper {
     private Cluster convertCluster(de.jplag.clustering.Cluster<Submission> from) {
         var strength = from.getCommunityStrength();
         var avgSimilarity = from.getAverageSimilarity();
-        var member = from.getMembers().stream().map(Submission::getName).toList();
+        var member = from.getMembers().stream().map(submissionToIdFunction).toList();
         return new Cluster(avgSimilarity, strength, member);
     }
 }

+ 0 - 60
jplag/src/main/java/de/jplag/reporting/reportobject/mapper/ComparisonReportMapper.java

@@ -1,60 +0,0 @@
-package de.jplag.reporting.reportobject.mapper;
-
-import java.util.List;
-
-import de.jplag.*;
-import de.jplag.reporting.jsonfactory.FileWriter;
-import de.jplag.reporting.reportobject.model.ComparisonReport;
-import de.jplag.reporting.reportobject.model.Match;
-
-public class ComparisonReportMapper {
-
-    private static final FileWriter FILE_WRITER = new FileWriter();
-
-    /**
-     * Generates detailed ComparisonReport DTO for each comparison in a JPlagResult.
-     * @param jPlagResult The JPlagResult to generate the comparison reports from. contains information about a comparison
-     * between two submission. The JPlagResult is used to extract the information on matches between two submissions.
-     */
-    public void writeComparisonReports(JPlagResult jPlagResult, String path) {
-        int numberOfComparisons = jPlagResult.getOptions().getMaximumNumberOfComparisons();
-        List<JPlagComparison> comparisons = jPlagResult.getComparisons(numberOfComparisons);
-        writeComparisons(jPlagResult, path, comparisons);
-    }
-
-    private void writeComparisons(JPlagResult jPlagResult, String path, List<JPlagComparison> comparisons) {
-        for (JPlagComparison comparison : comparisons) {
-            var comparisonReport = new ComparisonReport(comparison.getFirstSubmission().getName(), comparison.getSecondSubmission().getName(),
-                    comparison.similarity(), convertMatchesToReportMatches(jPlagResult, comparison));
-            String fileName = comparisonReport.firstSubmissionId().concat("-").concat(comparisonReport.secondSubmissionId()).concat(".json");
-            FILE_WRITER.saveAsJSON(comparisonReport, path, fileName);
-        }
-    }
-
-    private List<Match> convertMatchesToReportMatches(JPlagResult result, JPlagComparison comparison) {
-        return comparison.getMatches().stream()
-                .map(match -> convertMatchToReportMatch(comparison, match, result.getOptions().getLanguage().supportsColumns())).toList();
-    }
-
-    private Match convertMatchToReportMatch(JPlagComparison comparison, de.jplag.Match match, boolean languageSupportsColumnsAndLines) {
-        TokenList tokensFirst = comparison.getFirstSubmission().getTokenList();
-        TokenList tokensSecond = comparison.getSecondSubmission().getTokenList();
-        Token startTokenFirst = tokensFirst.getToken(match.startOfFirst());
-        Token endTokenFirst = tokensFirst.getToken(match.startOfFirst() + match.length() - 1);
-        Token startTokenSecond = tokensSecond.getToken(match.startOfSecond());
-        Token endTokenSecond = tokensSecond.getToken(match.startOfSecond() + match.length() - 1);
-
-        int startFirst = getPosition(languageSupportsColumnsAndLines, startTokenFirst);
-        int endFirst = getPosition(languageSupportsColumnsAndLines, endTokenFirst);
-        int startSecond = getPosition(languageSupportsColumnsAndLines, startTokenSecond);
-        int endSecond = getPosition(languageSupportsColumnsAndLines, endTokenSecond);
-        int tokens = match.length();
-
-        return new Match(startTokenFirst.getFile(), startTokenSecond.getFile(), startFirst, endFirst, startSecond, endSecond, tokens);
-    }
-
-    private int getPosition(boolean languageSupportsColumnsAndLines, Token token) {
-        return languageSupportsColumnsAndLines ? token.getLine() : token.getIndex();
-    }
-
-}

+ 13 - 6
jplag/src/main/java/de/jplag/reporting/reportobject/mapper/MetricMapper.java

@@ -9,6 +9,7 @@ import java.util.stream.Collectors;
 import de.jplag.JPlagComparison;
 import de.jplag.JPlagResult;
 import de.jplag.Messages;
+import de.jplag.Submission;
 import de.jplag.options.SimilarityMetric;
 import de.jplag.reporting.reportobject.model.Metric;
 import de.jplag.reporting.reportobject.model.TopComparison;
@@ -17,6 +18,12 @@ import de.jplag.reporting.reportobject.model.TopComparison;
  * Extracts and maps metrics from the JPlagResult to the corresponding JSON DTO
  */
 public class MetricMapper {
+    private final Function<Submission, String> submissionToIdFunction;
+
+    public MetricMapper(Function<Submission, String> submissionToIdFunction) {
+        this.submissionToIdFunction = submissionToIdFunction;
+    }
+
     public Metric getAverageMetric(JPlagResult result) {
         return new Metric(SimilarityMetric.AVG.name(), intArrayToList(result.getSimilarityDistribution()), getTopComparisons(getComparisons(result)),
                 Messages.getString("SimilarityMetric.Avg.Description"));
@@ -32,22 +39,22 @@ public class MetricMapper {
         return result.getComparisons(maxNumberOfComparisons);
     }
 
-    private static List<Integer> intArrayToList(int[] array) {
+    private List<Integer> intArrayToList(int[] array) {
         return Arrays.stream(array).boxed().collect(Collectors.toList());
     }
 
-    private static List<TopComparison> getTopComparisons(List<JPlagComparison> comparisons, Function<JPlagComparison, Float> similarityExtractor) {
+    private List<TopComparison> getTopComparisons(List<JPlagComparison> comparisons, Function<JPlagComparison, Float> similarityExtractor) {
         return comparisons.stream().sorted(Comparator.comparing(similarityExtractor).reversed())
-                .map(comparison -> new TopComparison(comparison.getFirstSubmission().getName(), comparison.getSecondSubmission().getName(),
-                        similarityExtractor.apply(comparison)))
+                .map(comparison -> new TopComparison(submissionToIdFunction.apply(comparison.getFirstSubmission()),
+                        submissionToIdFunction.apply(comparison.getSecondSubmission()), similarityExtractor.apply(comparison)))
                 .toList();
     }
 
-    private static List<TopComparison> getTopComparisons(List<JPlagComparison> comparisons) {
+    private List<TopComparison> getTopComparisons(List<JPlagComparison> comparisons) {
         return getTopComparisons(comparisons, JPlagComparison::similarity);
     }
 
-    private static List<TopComparison> getMaxSimilarityTopComparisons(List<JPlagComparison> comparisons) {
+    private List<TopComparison> getMaxSimilarityTopComparisons(List<JPlagComparison> comparisons) {
         return getTopComparisons(comparisons, JPlagComparison::maximalSimilarity);
     }
 

+ 43 - 0
jplag/src/main/java/de/jplag/reporting/reportobject/mapper/SubmissionNameToIdMapper.java

@@ -0,0 +1,43 @@
+package de.jplag.reporting.reportobject.mapper;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import de.jplag.JPlagComparison;
+import de.jplag.JPlagResult;
+import de.jplag.Submission;
+
+/**
+ * Responsible for creating a mapping of all Submissions to their respective report viewer ids. This mapping is achieved
+ * by associating the {@link Submission#getName()} (JPlag's internal Submission uid) to a report viewer id. Currently,
+ * the sanitized version of the {@link Submission#getName()} serves as report viewer id.
+ */
+public class SubmissionNameToIdMapper {
+
+    private static final String FILE_SEPARATOR_REPLACEMENT = "_";
+
+    /**
+     * Builds a map that associates a Submission by its JPlag Id ({@link Submission#getName()}) to its report viewer id.
+     * @return A Map of containing an entry [name of submission -> report viewer id of submission] for each submission of
+     * the submission set.
+     */
+    public static Map<String, String> buildSubmissionNameToIdMap(JPlagResult result) {
+        HashMap<String, String> idToName = new HashMap<>();
+        getComparisons(result).forEach(comparison -> {
+            idToName.put(comparison.getFirstSubmission().getName(), sanitizeNameOf(comparison.getFirstSubmission()));
+            idToName.put(comparison.getSecondSubmission().getName(), sanitizeNameOf(comparison.getSecondSubmission()));
+        });
+        return idToName;
+    }
+
+    private static String sanitizeNameOf(Submission comparison) {
+        return comparison.getName().replace(File.separator, FILE_SEPARATOR_REPLACEMENT);
+    }
+
+    private static List<JPlagComparison> getComparisons(JPlagResult result) {
+        int numberOfComparisons = result.getOptions().getMaximumNumberOfComparisons();
+        return result.getComparisons(numberOfComparisons);
+    }
+}

+ 15 - 154
jplag/src/main/java/de/jplag/reporting/reportobject/model/OverviewReport.java

@@ -1,174 +1,35 @@
 package de.jplag.reporting.reportobject.model;
 
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 
-public class OverviewReport {
+public record OverviewReport(
 
-    @JsonProperty("submission_folder_path")
-    private List<String> submissionFolderPath;
+        @JsonProperty("submission_folder_path") List<String> submissionFolderPath,
 
-    @JsonProperty("base_code_folder_path")
-    private String baseCodeFolderPath;
+        @JsonProperty("base_code_folder_path") String baseCodeFolderPath,
 
-    @JsonProperty("language")
-    private String language;
+        @JsonProperty("language") String language,
 
-    @JsonProperty("file_extensions")
-    private List<String> fileExtensions;
+        @JsonProperty("file_extensions") List<String> fileExtensions,
 
-    @JsonProperty("submission_ids")
-    private List<String> submissionIds;
+        @JsonProperty("submission_id_to_display_name") Map<String, String> submissionIds,
+        @JsonProperty("submission_ids_to_comparison_file_name") Map<String, Map<String, String>> submissionIdsToComparisonFileName,
 
-    @JsonProperty("failed_submission_names")
-    private List<String> failedSubmissionNames;
+        @JsonProperty("failed_submission_names") List<String> failedSubmissionNames,
 
-    @JsonProperty("excluded_files")
-    private Set<String> excludedFiles;
+        @JsonProperty("excluded_files") Set<String> excludedFiles,
 
-    @JsonProperty("match_sensitivity")
-    private int matchSensitivity;
+        @JsonProperty("match_sensitivity") int matchSensitivity,
 
-    @JsonProperty("date_of_execution")
-    private String dateOfExecution;
+        @JsonProperty("date_of_execution") String dateOfExecution,
 
-    @JsonProperty("execution_time")
-    private long executionTime;
+        @JsonProperty("execution_time") long executionTime,
 
-    @JsonProperty("comparison_names")
-    private List<String> comparisonNames;
+        @JsonProperty("metrics") List<Metric> metrics,
 
-    @JsonProperty("metrics")
-    private List<Metric> metrics;
-
-    @JsonProperty("clusters")
-    private List<Cluster> clusters;
-
-    public OverviewReport() {
-        submissionFolderPath = List.of();
-        baseCodeFolderPath = "";
-        language = "";
-        fileExtensions = List.of();
-        submissionIds = List.of();
-        failedSubmissionNames = List.of();
-        excludedFiles = Set.of();
-        matchSensitivity = 0;
-        dateOfExecution = "";
-        executionTime = 0;
-        clusters = List.of();
-    }
-
-    /*******************************
-     * GETTERS
-     *******************************/
-
-    public List<String> getSubmissionFolderPath() {
-        return submissionFolderPath;
-    }
-
-    public String getBaseCodeFolderPath() {
-        return baseCodeFolderPath;
-    }
-
-    public String getLanguage() {
-        return language;
-    }
-
-    public List<String> getFileExtensions() {
-        return fileExtensions;
-    }
-
-    public List<String> getSubmissionIds() {
-        return submissionIds;
-    }
-
-    public List<String> getFailedSubmissionNames() {
-        return failedSubmissionNames;
-    }
-
-    public Set<String> getExcludedFiles() {
-        return excludedFiles;
-    }
-
-    public int getMatchSensitivity() {
-        return matchSensitivity;
-    }
-
-    public String getDateOfExecution() {
-        return dateOfExecution;
-    }
-
-    public long getExecutionTime() {
-        return executionTime;
-    }
-
-    public List<String> getComparisonNames() {
-        return comparisonNames;
-    }
-
-    public List<Metric> getMetrics() {
-        return metrics;
-    }
-
-    public List<Cluster> getClusters() {
-        return clusters;
-    }
-
-    /*******************************
-     * SETTERS
-     *******************************/
-
-    public void setSubmissionFolderPath(List<String> submissionFolderPath) {
-        this.submissionFolderPath = submissionFolderPath;
-    }
-
-    public void setBaseCodeFolderPath(String baseCodeFolderPath) {
-        this.baseCodeFolderPath = baseCodeFolderPath;
-    }
-
-    public void setLanguage(String language) {
-        this.language = language;
-    }
-
-    public void setFileExtensions(List<String> fileExtensions) {
-        this.fileExtensions = List.copyOf(fileExtensions);
-    }
-
-    public void setSubmissionIds(List<String> submissionIds) {
-        this.submissionIds = List.copyOf(submissionIds);
-    }
-
-    public void setFailedSubmissionNames(List<String> failedSubmissionNames) {
-        this.failedSubmissionNames = List.copyOf(failedSubmissionNames);
-    }
-
-    public void setExcludedFiles(Set<String> excludedFiles) {
-        this.excludedFiles = Set.copyOf(excludedFiles);
-    }
-
-    public void setMatchSensitivity(int matchSensitivity) {
-        this.matchSensitivity = matchSensitivity;
-    }
-
-    public void setDateOfExecution(String dateOfExecution) {
-        this.dateOfExecution = dateOfExecution;
-    }
-
-    public void setExecutionTime(long executionTime) {
-        this.executionTime = executionTime;
-    }
-
-    public void setComparisonNames(List<String> comparisonNames) {
-        this.comparisonNames = List.copyOf(comparisonNames);
-    }
-
-    public void setMetrics(List<Metric> metrics) {
-        this.metrics = List.copyOf(metrics);
-    }
-
-    public void setClusters(List<Cluster> clusters) {
-        this.clusters = List.copyOf(clusters);
-    }
+        @JsonProperty("clusters") List<Cluster> clusters) {
 }

+ 5 - 3
jplag/src/test/java/de/jplag/TestBase.java

@@ -3,6 +3,7 @@ package de.jplag;
 import java.io.File;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.StringJoiner;
 import java.util.function.Consumer;
 
 import de.jplag.exceptions.ExitException;
@@ -20,11 +21,12 @@ public abstract class TestBase {
     }
 
     protected String getBasePath(String... subs) {
-        String path = BASE_PATH;
+        StringJoiner joiner = new StringJoiner(File.separator);
+        joiner.add(BASE_PATH);
         for (String sub : subs) {
-            path += File.separator + sub;
+            joiner.add(sub);
         }
-        return path;
+        return joiner.toString();
     }
 
     protected JPlagResult runJPlagWithExclusionFile(String testSampleName, String exclusionFileName) throws ExitException {

+ 1 - 1
jplag/src/test/java/de/jplag/reporting/reportobject/mapper/ClusteringResultMapperTest.java

@@ -15,7 +15,7 @@ import de.jplag.clustering.Cluster;
 import de.jplag.clustering.ClusteringResult;
 
 public class ClusteringResultMapperTest {
-    private final ClusteringResultMapper clusteringResultMapper = new ClusteringResultMapper();
+    private final ClusteringResultMapper clusteringResultMapper = new ClusteringResultMapper(Submission::getName);
 
     @Test
     public void test() {

+ 58 - 0
jplag/src/test/java/de/jplag/reporting/reportobject/mapper/ComparisonReportWriterTest.java

@@ -0,0 +1,58 @@
+package de.jplag.reporting.reportobject.mapper;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import de.jplag.JPlagResult;
+import de.jplag.Submission;
+import de.jplag.TestBase;
+import de.jplag.exceptions.ExitException;
+import de.jplag.reporting.jsonfactory.ComparisonReportWriter;
+import de.jplag.reporting.jsonfactory.DummyWriter;
+import de.jplag.reporting.jsonfactory.FileWriter;
+
+public class ComparisonReportWriterTest extends TestBase {
+    private final FileWriter fileWriter = new DummyWriter();
+
+    @Test
+    public void firsLevelOfLookupMapComplete() throws ExitException {
+        JPlagResult result = runJPlagWithDefaultOptions("PartialPlagiarism");
+        var mapper = new ComparisonReportWriter(Submission::getName, fileWriter);
+
+        Map<String, Map<String, String>> stringMapMap = mapper.writeComparisonReports(result, "");
+
+        firstLevelOfMapContains(stringMapMap, "A", "B", "C", "D", "E");
+    }
+
+    @Test
+    public void secondLevelOfLookupMapComplete() throws ExitException {
+        JPlagResult result = runJPlagWithDefaultOptions("PartialPlagiarism");
+        var mapper = new ComparisonReportWriter(Submission::getName, fileWriter);
+
+        Map<String, Map<String, String>> stringMapMap = mapper.writeComparisonReports(result, "");
+
+        secondLevelOfMapContains(stringMapMap, "A", "B", "C", "D", "E");
+        secondLevelOfMapContains(stringMapMap, "B", "A", "C", "D", "E");
+        secondLevelOfMapContains(stringMapMap, "C", "B", "A", "D", "E");
+        secondLevelOfMapContains(stringMapMap, "D", "B", "C", "A", "E");
+        secondLevelOfMapContains(stringMapMap, "E", "B", "C", "D", "A");
+    }
+
+    private void secondLevelOfMapContains(Map<String, Map<String, String>> stringMapMap, String firstLevelSubmission,
+            String... secondLevelSubmissions) {
+        for (String secondLevelSubmission : secondLevelSubmissions) {
+            Assertions.assertNotNull(stringMapMap.get(firstLevelSubmission).get(secondLevelSubmission));
+            Assertions.assertFalse(stringMapMap.get(firstLevelSubmission).get(secondLevelSubmission).isEmpty());
+
+        }
+
+    }
+
+    private void firstLevelOfMapContains(Map<String, Map<String, String>> stringMapMap, String... names) {
+        for (String name : names) {
+            Assertions.assertNotNull(stringMapMap.get(name));
+        }
+    }
+}

+ 1 - 1
jplag/src/test/java/de/jplag/reporting/reportobject/mapper/MetricMapperTest.java

@@ -15,7 +15,7 @@ import de.jplag.options.JPlagOptions;
 import de.jplag.reporting.reportobject.model.TopComparison;
 
 public class MetricMapperTest {
-    private final MetricMapper metricMapper = new MetricMapper();
+    private final MetricMapper metricMapper = new MetricMapper(Submission::getName);
 
     @Test
     public void test_getAverageMetric() {

+ 2 - 6
jplag/src/test/java/de/jplag/special/TokenPrinterTest.java

@@ -7,11 +7,7 @@ import java.util.function.Consumer;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
-import de.jplag.JPlagResult;
-import de.jplag.LanguageLoader;
-import de.jplag.Submission;
-import de.jplag.TestBase;
-import de.jplag.TokenPrinter;
+import de.jplag.*;
 import de.jplag.exceptions.ExitException;
 import de.jplag.options.JPlagOptions;
 
@@ -88,7 +84,7 @@ class TokenPrinterTest extends TestBase {
         System.out.println(LINE);
         System.out.println(LARGE_SPACE + submission.getName());
         System.out.println(LINE);
-        System.out.println(TokenPrinter.printTokens(submission.getTokenList(), submission.getFiles(), submission.getRoot()));
+        System.out.println(TokenPrinter.printTokens(submission.getTokenList(), submission.getRoot()));
     }
 
 }

+ 11 - 0
jplag/src/test/resources/de/jplag/samples/FilesAsSubmissions/Submission1.java

@@ -0,0 +1,11 @@
+public class SimpleDuplicate {
+
+    public static void main(String[] args) {
+        System.out.println("Hello World!");
+
+        for(int i = 0; i < 10; i++) {
+            System.out.println("Number is " + i);
+        }
+    }
+
+}

+ 12 - 0
jplag/src/test/resources/de/jplag/samples/FilesAsSubmissions/Submission2.java

@@ -0,0 +1,12 @@
+public class SimpleDuplicate {
+
+    public static void main(String[] args) {
+        System.out.println("Hello Plagiarism!");
+
+        int max = 10;
+        for(int j = 0; j < max; j++) {
+            System.out.println("Number is " + j);
+        }
+    }
+
+}

+ 1 - 12
pom.xml

@@ -74,12 +74,11 @@
 
         <maven.compiler.source>17</maven.compiler.source>
         <maven.compiler.target>17</maven.compiler.target>
-        <spotless.version>2.24.1</spotless.version>
+        <spotless.version>2.25.0</spotless.version>
         <slf4j.version>1.7.36</slf4j.version>
         <junit.version>5.9.0</junit.version>
 
         <antlr2.version>2.7.7</antlr2.version>
-        <antlr3.version>3.5</antlr3.version>
         <antlr4.version>4.10.1</antlr4.version>
         <emf.version>2.27.0</emf.version>
         <emf.ecore.version>2.25.0</emf.ecore.version>
@@ -107,16 +106,6 @@
                 <artifactId>antlr4</artifactId>
                 <version>${antlr4.version}</version>
             </dependency>
-            <dependency>
-                <groupId>org.antlr</groupId>
-                <artifactId>antlr3</artifactId>
-                <version>${antlr3.version}</version>
-            </dependency>
-            <dependency>
-                <groupId>org.antlr</groupId>
-                <artifactId>antlr3-runtime</artifactId>
-                <version>${antlr3.version}</version>
-            </dependency>
             <dependency>
                 <groupId>net.sourceforge.argparse4j</groupId>
                 <artifactId>argparse4j</artifactId>

+ 99 - 87
report-viewer/package-lock.json

@@ -19,13 +19,13 @@
         "vue": "^3.2.37",
         "vue-chart-3": "^3.1.8",
         "vue-draggable-next": "^2.1.1",
-        "vue-router": "^4.1.3",
+        "vue-router": "^4.1.4",
         "vue3-highlightjs": "^1.0.5",
         "vuex": "^4.0.2"
       },
       "devDependencies": {
-        "@typescript-eslint/eslint-plugin": "^5.33.1",
-        "@typescript-eslint/parser": "^5.33.1",
+        "@typescript-eslint/eslint-plugin": "^5.34.0",
+        "@typescript-eslint/parser": "^5.34.0",
         "@vue/cli-plugin-babel": "~5.0.8",
         "@vue/cli-plugin-eslint": "~5.0.8",
         "@vue/cli-plugin-router": "~5.0.8",
@@ -2221,14 +2221,14 @@
       }
     },
     "node_modules/@typescript-eslint/eslint-plugin": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz",
-      "integrity": "sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.34.0.tgz",
+      "integrity": "sha512-eRfPPcasO39iwjlUAMtjeueRGuIrW3TQ9WseIDl7i5UWuFbf83yYaU7YPs4j8+4CxUMIsj1k+4kV+E+G+6ypDQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.33.1",
-        "@typescript-eslint/type-utils": "5.33.1",
-        "@typescript-eslint/utils": "5.33.1",
+        "@typescript-eslint/scope-manager": "5.34.0",
+        "@typescript-eslint/type-utils": "5.34.0",
+        "@typescript-eslint/utils": "5.34.0",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -2269,14 +2269,14 @@
       }
     },
     "node_modules/@typescript-eslint/parser": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.33.1.tgz",
-      "integrity": "sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.34.0.tgz",
+      "integrity": "sha512-SZ3NEnK4usd2CXkoV3jPa/vo1mWX1fqRyIVUQZR4As1vyp4fneknBNJj+OFtV8WAVgGf+rOHMSqQbs2Qn3nFZQ==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/scope-manager": "5.33.1",
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/typescript-estree": "5.33.1",
+        "@typescript-eslint/scope-manager": "5.34.0",
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/typescript-estree": "5.34.0",
         "debug": "^4.3.4"
       },
       "engines": {
@@ -2296,13 +2296,13 @@
       }
     },
     "node_modules/@typescript-eslint/scope-manager": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz",
-      "integrity": "sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.34.0.tgz",
+      "integrity": "sha512-HNvASMQlah5RsBW6L6c7IJ0vsm+8Sope/wu5sEAf7joJYWNb1LDbJipzmdhdUOnfrDFE6LR1j57x1EYVxrY4ow==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/visitor-keys": "5.33.1"
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/visitor-keys": "5.34.0"
       },
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -2313,12 +2313,12 @@
       }
     },
     "node_modules/@typescript-eslint/type-utils": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz",
-      "integrity": "sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.34.0.tgz",
+      "integrity": "sha512-Pxlno9bjsQ7hs1pdWRUv9aJijGYPYsHpwMeCQ/Inavhym3/XaKt1ZKAA8FIw4odTBfowBdZJDMxf2aavyMDkLg==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/utils": "5.33.1",
+        "@typescript-eslint/utils": "5.34.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       },
@@ -2339,9 +2339,9 @@
       }
     },
     "node_modules/@typescript-eslint/types": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.33.1.tgz",
-      "integrity": "sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.34.0.tgz",
+      "integrity": "sha512-49fm3xbbUPuzBIOcy2CDpYWqy/X7VBkxVN+DC21e0zIm3+61Z0NZi6J9mqPmSW1BDVk9FIOvuCFyUPjXz93sjA==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -2352,13 +2352,13 @@
       }
     },
     "node_modules/@typescript-eslint/typescript-estree": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz",
-      "integrity": "sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.34.0.tgz",
+      "integrity": "sha512-mXHAqapJJDVzxauEkfJI96j3D10sd567LlqroyCeJaHnu42sDbjxotGb3XFtGPYKPD9IyLjhsoULML1oI3M86A==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/visitor-keys": "5.33.1",
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/visitor-keys": "5.34.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -2394,15 +2394,15 @@
       }
     },
     "node_modules/@typescript-eslint/utils": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.33.1.tgz",
-      "integrity": "sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.34.0.tgz",
+      "integrity": "sha512-kWRYybU4Rn++7lm9yu8pbuydRyQsHRoBDIo11k7eqBWTldN4xUdVUMCsHBiE7aoEkFzrUEaZy3iH477vr4xHAQ==",
       "dev": true,
       "dependencies": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.33.1",
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/typescript-estree": "5.33.1",
+        "@typescript-eslint/scope-manager": "5.34.0",
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/typescript-estree": "5.34.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       },
@@ -2418,12 +2418,12 @@
       }
     },
     "node_modules/@typescript-eslint/visitor-keys": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz",
-      "integrity": "sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.34.0.tgz",
+      "integrity": "sha512-O1moYjOSrab0a2fUvFpsJe0QHtvTC+cR+ovYpgKrAVXzqQyc74mv76TgY6z+aEtjQE2vgZux3CQVtGryqdcOAw==",
       "dev": true,
       "dependencies": {
-        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/types": "5.34.0",
         "eslint-visitor-keys": "^3.3.0"
       },
       "engines": {
@@ -11771,6 +11771,12 @@
         "websocket-driver": "^0.7.4"
       }
     },
+    "node_modules/sortablejs": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
+      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==",
+      "peer": true
+    },
     "node_modules/source-map-js": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@@ -12960,9 +12966,9 @@
       }
     },
     "node_modules/vue-router": {
-      "version": "4.1.3",
-      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.3.tgz",
-      "integrity": "sha512-XvK81bcYglKiayT7/vYAg/f36ExPC4t90R/HIpzrZ5x+17BOWptXLCrEPufGgZeuq68ww4ekSIMBZY1qdUdfjA==",
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.4.tgz",
+      "integrity": "sha512-UgYen33gOtwT3cOG1+yRen+Brk9py8CSlC9LEa3UjvKZ4EAoSo8NjZPDeDnmNerfazorHIJG1NC7qdi1SuQJnQ==",
       "dependencies": {
         "@vue/devtools-api": "^6.1.4"
       },
@@ -15428,14 +15434,14 @@
       }
     },
     "@typescript-eslint/eslint-plugin": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.33.1.tgz",
-      "integrity": "sha512-S1iZIxrTvKkU3+m63YUOxYPKaP+yWDQrdhxTglVDVEVBf+aCSw85+BmJnyUaQQsk5TXFG/LpBu9fa+LrAQ91fQ==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.34.0.tgz",
+      "integrity": "sha512-eRfPPcasO39iwjlUAMtjeueRGuIrW3TQ9WseIDl7i5UWuFbf83yYaU7YPs4j8+4CxUMIsj1k+4kV+E+G+6ypDQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.33.1",
-        "@typescript-eslint/type-utils": "5.33.1",
-        "@typescript-eslint/utils": "5.33.1",
+        "@typescript-eslint/scope-manager": "5.34.0",
+        "@typescript-eslint/type-utils": "5.34.0",
+        "@typescript-eslint/utils": "5.34.0",
         "debug": "^4.3.4",
         "functional-red-black-tree": "^1.0.1",
         "ignore": "^5.2.0",
@@ -15456,52 +15462,52 @@
       }
     },
     "@typescript-eslint/parser": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.33.1.tgz",
-      "integrity": "sha512-IgLLtW7FOzoDlmaMoXdxG8HOCByTBXrB1V2ZQYSEV1ggMmJfAkMWTwUjjzagS6OkfpySyhKFkBw7A9jYmcHpZA==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.34.0.tgz",
+      "integrity": "sha512-SZ3NEnK4usd2CXkoV3jPa/vo1mWX1fqRyIVUQZR4As1vyp4fneknBNJj+OFtV8WAVgGf+rOHMSqQbs2Qn3nFZQ==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/scope-manager": "5.33.1",
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/typescript-estree": "5.33.1",
+        "@typescript-eslint/scope-manager": "5.34.0",
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/typescript-estree": "5.34.0",
         "debug": "^4.3.4"
       }
     },
     "@typescript-eslint/scope-manager": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.33.1.tgz",
-      "integrity": "sha512-8ibcZSqy4c5m69QpzJn8XQq9NnqAToC8OdH/W6IXPXv83vRyEDPYLdjAlUx8h/rbusq6MkW4YdQzURGOqsn3CA==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.34.0.tgz",
+      "integrity": "sha512-HNvASMQlah5RsBW6L6c7IJ0vsm+8Sope/wu5sEAf7joJYWNb1LDbJipzmdhdUOnfrDFE6LR1j57x1EYVxrY4ow==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/visitor-keys": "5.33.1"
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/visitor-keys": "5.34.0"
       }
     },
     "@typescript-eslint/type-utils": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.33.1.tgz",
-      "integrity": "sha512-X3pGsJsD8OiqhNa5fim41YtlnyiWMF/eKsEZGsHID2HcDqeSC5yr/uLOeph8rNF2/utwuI0IQoAK3fpoxcLl2g==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.34.0.tgz",
+      "integrity": "sha512-Pxlno9bjsQ7hs1pdWRUv9aJijGYPYsHpwMeCQ/Inavhym3/XaKt1ZKAA8FIw4odTBfowBdZJDMxf2aavyMDkLg==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/utils": "5.33.1",
+        "@typescript-eslint/utils": "5.34.0",
         "debug": "^4.3.4",
         "tsutils": "^3.21.0"
       }
     },
     "@typescript-eslint/types": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.33.1.tgz",
-      "integrity": "sha512-7K6MoQPQh6WVEkMrMW5QOA5FO+BOwzHSNd0j3+BlBwd6vtzfZceJ8xJ7Um2XDi/O3umS8/qDX6jdy2i7CijkwQ==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.34.0.tgz",
+      "integrity": "sha512-49fm3xbbUPuzBIOcy2CDpYWqy/X7VBkxVN+DC21e0zIm3+61Z0NZi6J9mqPmSW1BDVk9FIOvuCFyUPjXz93sjA==",
       "dev": true
     },
     "@typescript-eslint/typescript-estree": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.1.tgz",
-      "integrity": "sha512-JOAzJ4pJ+tHzA2pgsWQi4804XisPHOtbvwUyqsuuq8+y5B5GMZs7lI1xDWs6V2d7gE/Ez5bTGojSK12+IIPtXA==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.34.0.tgz",
+      "integrity": "sha512-mXHAqapJJDVzxauEkfJI96j3D10sd567LlqroyCeJaHnu42sDbjxotGb3XFtGPYKPD9IyLjhsoULML1oI3M86A==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/visitor-keys": "5.33.1",
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/visitor-keys": "5.34.0",
         "debug": "^4.3.4",
         "globby": "^11.1.0",
         "is-glob": "^4.0.3",
@@ -15521,26 +15527,26 @@
       }
     },
     "@typescript-eslint/utils": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.33.1.tgz",
-      "integrity": "sha512-uphZjkMaZ4fE8CR4dU7BquOV6u0doeQAr8n6cQenl/poMaIyJtBu8eys5uk6u5HiDH01Mj5lzbJ5SfeDz7oqMQ==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.34.0.tgz",
+      "integrity": "sha512-kWRYybU4Rn++7lm9yu8pbuydRyQsHRoBDIo11k7eqBWTldN4xUdVUMCsHBiE7aoEkFzrUEaZy3iH477vr4xHAQ==",
       "dev": true,
       "requires": {
         "@types/json-schema": "^7.0.9",
-        "@typescript-eslint/scope-manager": "5.33.1",
-        "@typescript-eslint/types": "5.33.1",
-        "@typescript-eslint/typescript-estree": "5.33.1",
+        "@typescript-eslint/scope-manager": "5.34.0",
+        "@typescript-eslint/types": "5.34.0",
+        "@typescript-eslint/typescript-estree": "5.34.0",
         "eslint-scope": "^5.1.1",
         "eslint-utils": "^3.0.0"
       }
     },
     "@typescript-eslint/visitor-keys": {
-      "version": "5.33.1",
-      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.1.tgz",
-      "integrity": "sha512-nwIxOK8Z2MPWltLKMLOEZwmfBZReqUdbEoHQXeCpa+sRVARe5twpJGHCB4dk9903Yaf0nMAlGbQfaAH92F60eg==",
+      "version": "5.34.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.34.0.tgz",
+      "integrity": "sha512-O1moYjOSrab0a2fUvFpsJe0QHtvTC+cR+ovYpgKrAVXzqQyc74mv76TgY6z+aEtjQE2vgZux3CQVtGryqdcOAw==",
       "dev": true,
       "requires": {
-        "@typescript-eslint/types": "5.33.1",
+        "@typescript-eslint/types": "5.34.0",
         "eslint-visitor-keys": "^3.3.0"
       }
     },
@@ -22499,6 +22505,12 @@
         "websocket-driver": "^0.7.4"
       }
     },
+    "sortablejs": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
+      "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==",
+      "peer": true
+    },
     "source-map-js": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@@ -23400,9 +23412,9 @@
       }
     },
     "vue-router": {
-      "version": "4.1.3",
-      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.3.tgz",
-      "integrity": "sha512-XvK81bcYglKiayT7/vYAg/f36ExPC4t90R/HIpzrZ5x+17BOWptXLCrEPufGgZeuq68ww4ekSIMBZY1qdUdfjA==",
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.1.4.tgz",
+      "integrity": "sha512-UgYen33gOtwT3cOG1+yRen+Brk9py8CSlC9LEa3UjvKZ4EAoSo8NjZPDeDnmNerfazorHIJG1NC7qdi1SuQJnQ==",
       "requires": {
         "@vue/devtools-api": "^6.1.4"
       }

+ 3 - 3
report-viewer/package.json

@@ -19,13 +19,13 @@
     "vue": "^3.2.37",
     "vue-chart-3": "^3.1.8",
     "vue-draggable-next": "^2.1.1",
-    "vue-router": "^4.1.3",
+    "vue-router": "^4.1.4",
     "vue3-highlightjs": "^1.0.5",
     "vuex": "^4.0.2"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^5.33.1",
-    "@typescript-eslint/parser": "^5.33.1",
+    "@typescript-eslint/eslint-plugin": "^5.34.0",
+    "@typescript-eslint/parser": "^5.34.0",
     "@vue/cli-plugin-babel": "~5.0.8",
     "@vue/cli-plugin-eslint": "~5.0.8",
     "@vue/cli-plugin-router": "~5.0.8",

+ 21 - 9
report-viewer/src/components/ComparisonsTable.vue

@@ -30,20 +30,20 @@
         {{ index + 1 }}.
       </td>
       <td
-        :class="{
-          'anonymous-style': isAnonymous(comparison.firstSubmissionId),
-        }"
         @click="
           navigateToComparisonView(
             comparison.firstSubmissionId,
             comparison.secondSubmissionId
           )
         "
+        :class="{
+          'anonymous-style': isAnonymous(comparison.firstSubmissionId),
+        }"
       >
         {{
           isAnonymous(comparison.firstSubmissionId)
             ? "Hidden"
-            : comparison.firstSubmissionId
+            : displayName(comparison.firstSubmissionId)
         }}
       </td>
       <td
@@ -57,23 +57,32 @@
         <img alt=">>" src="@/assets/double_arrow_black_18dp.svg" />
       </td>
       <td
-        :class="{
-          'anonymous-style': isAnonymous(comparison.secondSubmissionId),
-        }"
         @click="
           navigateToComparisonView(
             comparison.firstSubmissionId,
             comparison.secondSubmissionId
           )
         "
+        :class="{
+          'anonymous-style': isAnonymous(comparison.secondSubmissionId),
+        }"
       >
         {{
           isAnonymous(comparison.secondSubmissionId)
             ? "Hidden"
-            : comparison.secondSubmissionId
+            : displayName(comparison.secondSubmissionId)
         }}
       </td>
-      <td>{{ formattedMatchPercentage(comparison.matchPercentage) }}</td>
+      <td
+        @click="
+          navigateToComparisonView(
+            comparison.firstSubmissionId,
+            comparison.secondSubmissionId
+          )
+        "
+      >
+        {{ formattedMatchPercentage(comparison.matchPercentage) }}
+      </td>
       <td>
         <img
           v-if="
@@ -138,6 +147,8 @@ export default defineComponent({
     let formattedMatchPercentage = (num: number) => num.toFixed(2);
     const dialog: Ref<Array<boolean>> = ref([]);
     props.topComparisons.forEach(() => dialog.value.push(false));
+    const displayName = (submissionId: string) =>
+      store.getters.submissionDisplayName(submissionId);
 
     const toggleDialog = (index: number) => {
       dialog.value[index] = true;
@@ -223,6 +234,7 @@ export default defineComponent({
       clustersWithParticipatingMatches,
       dialog,
 
+      displayName,
       isAnonymous,
       getClustersFor,
       toggleDialog,

+ 12 - 11
report-viewer/src/components/IDsList.vue

@@ -6,32 +6,33 @@
     <div class="column-list">
       <p v-for="(id, index) in ids" :key="index"
          :class="{'selected' : !store.state.anonymous.has(id) && store.state.anonymous.size !== 0 }"
-         @click="$emit('idSent', [id])">
-        {{ id }}
+         @click="emitIdsSent([id])">
+        {{ store.getters.submissionDisplayName(id) }}
       </p>
     </div>
-    <button @click="$emit('idSent', ids)">Hide/Show all</button>
+    <button @click="emitIdsSent(ids)">Hide/Show all</button>
   </div>
 </template>
 
-<script>
+<script lang="ts">
 import {defineComponent} from "vue";
-import store from "@/store/store.ts"
+import store from "@/store/store"
 
 export default defineComponent({
+  emits: ["idSent"],
   name: "IDsList",
   props: {
     ids: {
-      type: Array,
+      type: Array<string>,
       required: true
     }
   },
-  setup(props) {
-    // eslint-disable-next-line vue/no-setup-props-destructure
-    const savedIds = props.ids
+  setup(_, {emit}) {
+    const emitIdsSent = (ids: string[]) => {
+      emit('idSent', ids);}
     return {
-      savedIds,
-      store
+      store,
+      emitIdsSent
     }
   }
 })

+ 7 - 7
report-viewer/src/model/Overview.ts

@@ -7,7 +7,7 @@ export class Overview {
   private readonly _language: string;
   private readonly _fileExtensions: Array<string>;
   private readonly _matchSensitivity: number;
-  private readonly _submissionIds: Array<string>;
+  private readonly _submissionIdsToComparisonFileName: Map<string,Map<string,string>>;
   private readonly _dateOfExecution: string;
   private readonly _durationOfExecution: number;
   private readonly _metrics: Array<Metric>;
@@ -19,22 +19,25 @@ export class Overview {
     language: string,
     fileExtensions: Array<string>,
     matchSensitivity: number,
-    submissionIds: Array<string>,
     dateOfExecution: string,
     durationOfExecution: number,
     metrics: Array<Metric>,
-    clusters: Array<Cluster>
+    clusters: Array<Cluster>,
+    submissionIdsToComparisonFileName: Map<string,Map<string,string>>
   ) {
     this._submissionFolderPath = submissionFolderPath;
     this._baseCodeFolderPath = baseCodeFolderPath;
     this._language = language;
     this._fileExtensions = fileExtensions;
     this._matchSensitivity = matchSensitivity;
-    this._submissionIds = submissionIds;
     this._dateOfExecution = dateOfExecution;
     this._durationOfExecution = durationOfExecution;
     this._metrics = metrics;
     this._clusters = clusters;
+    this. _submissionIdsToComparisonFileName = submissionIdsToComparisonFileName;
+  }
+  get submissionIdsToComparisonFileName(): Map<string,Map<string,string>> {
+    return this.submissionIdsToComparisonFileName;
   }
 
   get submissionFolderPath(): Array<string> {
@@ -57,9 +60,6 @@ export class Overview {
     return this._matchSensitivity;
   }
 
-  get submissionIds(): Array<string> {
-    return this._submissionIds;
-  }
 
   get dateOfExecution(): string {
     return this._dateOfExecution;

+ 27 - 5
report-viewer/src/model/factories/OverviewFactory.ts

@@ -2,15 +2,18 @@ import { Overview } from "../Overview";
 import { Metric } from "../Metric";
 import { ComparisonListElement } from "../ComparisonListElement";
 import { Cluster } from "@/model/Cluster";
+import store from "@/store/store";
 
 export class OverviewFactory {
+
   static getOverview(json: Record<string, unknown>): Overview {
     const submissionFolder = json.submission_folder_path as Array<string>;
     const baseCodeFolder = "";
     const language = json.language as string;
     const fileExtensions = json.file_extensions as Array<string>;
     const matchSensitivity = json.match_sensitivity as number;
-    const submissions = json.submission_ids as Array<string>;
+    const jsonSubmissions = json.submission_id_to_display_name as Map<string, string>;
+    const map = new Map<string, string>(Object.entries(jsonSubmissions));
     const dateOfExecution = json.date_of_execution as string;
     const duration = json.execution_time as number as number;
     const metrics = [] as Array<Metric>;
@@ -37,7 +40,7 @@ export class OverviewFactory {
         description: metric.description as string,
       });
     });
-
+    store.commit("saveSubmissionNames", map);
     if (json.clusters) {
       (json.clusters as Array<unknown>).forEach((jsonCluster) => {
         const cluster = jsonCluster as Record<string, unknown>;
@@ -49,18 +52,37 @@ export class OverviewFactory {
         clusters.push(newCluster);
       });
     }
-
+    
+    OverviewFactory.saveSubmissionsToComparisonNameMap(json);
     return new Overview(
       submissionFolder,
       baseCodeFolder,
       language,
       fileExtensions,
       matchSensitivity,
-      submissions,
       dateOfExecution,
       duration,
       metrics,
-      clusters
+      clusters,
+      new Map()
     );
   }
+
+  private static  saveSubmissionsToComparisonNameMap(json: Record<string, unknown>){
+    const submissionIdsToComparisonName =
+    json.submission_ids_to_comparison_file_name as Map<
+      string,
+      Map<string, string>
+    >;
+  const test: Array<Array<string | object>> = Object.entries(
+    submissionIdsToComparisonName
+  );
+  const comparisonMap = new Map<string, Map<string, string>>(
+  );
+  for (const [key, value] of test) {
+    comparisonMap.set(key as string, new Map(Object.entries(value as object)));
+  }
+
+  store.commit("saveComparisonFileLookup", comparisonMap);
+  }
 }

+ 36 - 0
report-viewer/src/store/store.ts

@@ -30,6 +30,9 @@ interface State {
    * Files string if single mode is used.
    */
   fileString: string;
+
+  fileIdToDisplayName: Map<string, string>;
+  submissionIdsToComparisonFileName: Map<string, Map<string, string>>;
 }
 
 interface File {
@@ -50,6 +53,7 @@ interface LoadConfiguration {
 
 const store = createStore<State>({
   state: {
+    submissionIdsToComparisonFileName: new Map<string, Map<string, string>>(),
     /**
      * The set of ids to be hidden.
      */
@@ -75,6 +79,7 @@ const store = createStore<State>({
      * Files string if single mode is used.
      */
     fileString: "",
+    fileIdToDisplayName: new Map(),
   },
   getters: {
     filesOfSubmission: (state) => (name: string) => {
@@ -83,6 +88,28 @@ const store = createStore<State>({
         value,
       }));
     },
+    submissionDisplayName: (state) => (id: string) => {
+      return state.fileIdToDisplayName.get(id);
+    },
+    getSubmissionIds(state): Array<string> {
+      return Array.from(state.fileIdToDisplayName.keys());
+    },
+    getComparisonFileName:
+      (state) => (submissionId: string, fileId: string) => {
+        return state.submissionIdsToComparisonFileName
+          .get(submissionId)
+          ?.get(fileId);
+      },
+      getComparisonFileForSubmissions: (state, getters) => (submissionId1: string, submissionId2: string) => {
+        const expectedFileName = getters.getComparisonFileName(submissionId1, submissionId2);
+        const index = Object.keys(store.state.files).find(
+          (name) =>
+             name.endsWith(expectedFileName)
+        );
+        return index != undefined
+          ? store.state.files[index]
+          : undefined
+      }
   },
   mutations: {
     addAnonymous(state: State, id) {
@@ -90,6 +117,12 @@ const store = createStore<State>({
         state.anonymous.add(id[i]);
       }
     },
+    saveComparisonFileLookup(
+      state: State,
+      map: Map<string, Map<string, string>>
+    ) {
+      state.submissionIdsToComparisonFileName = map;
+    },
     removeAnonymous(state, id) {
       for (let i = 0; i < id.length; i++) {
         state.anonymous.delete(id[i]);
@@ -101,6 +134,9 @@ const store = createStore<State>({
     saveFile(state, file: File) {
       state.files[file.fileName] = file.data;
     },
+    saveSubmissionNames(state, names: Map<string, string>) {
+      state.fileIdToDisplayName = names;
+    },
     saveSubmissionFile(state, submissionFile: SubmissionFile) {
       if (!state.submissions[submissionFile.name]) {
         state.submissions[submissionFile.name] = new Map();

+ 14 - 32
report-viewer/src/views/ComparisonView.vue

@@ -23,12 +23,12 @@
       </div>
       <TextInformation
         :anonymous="isAnonymous(firstId)"
-        :value="firstId"
+        :value="store.getters.submissionDisplayName(firstId)"
         label="Submission 1"
       />
       <TextInformation
         :anonymous="store.state.anonymous.has(secondId)"
-        :value="secondId"
+        :value="store.getters.submissionDisplayName(secondId)"
         label="Submission 2"
       />
       <TextInformation :value="comparison.match_percentage" label="Match %" />
@@ -85,12 +85,6 @@ export default defineComponent({
   setup(props) {
     const store = useStore();
     const router = useRouter();
-    /**
-     * Name of the comparison file. Comparison files should be named {ID1}-{ID2}
-     * @type {string}
-     */
-    const fileName1 = props.firstId.concat("-").concat(props.secondId);
-    const fileName2 = props.firstId.concat("-").concat(props.secondId);
 
     let comparison;
     //getting the comparison file based on the used mode (zip, local, single)
@@ -98,42 +92,31 @@ export default defineComponent({
       try {
         comparison = ComparisonFactory.getComparison(
           // eslint-disable-next-line @typescript-eslint/no-var-requires
-          require(`../files/${fileName1}.json`)
+          require(`../files/${store.getters.getComparisonFileName(
+            props.firstId,
+            props.secondId
+          )}.json`)
         );
       } catch (exception) {
-        try {
-          comparison = ComparisonFactory.getComparison(
-            // eslint-disable-next-line @typescript-eslint/no-var-requires
-            require(`../files/${fileName2}.json`)
-          );
-        } catch (exception) {
-          router.back();
-        }
+        router.back();
       }
     } else if (store.state.zip) {
-      const getComparisonFileFor = (id1: string, id2: string) => {
-        const index = Object.keys(store.state.files).find(
-          (name) =>
-            name.endsWith(id1.concat("-").concat(id2).concat(".json")) ||
-            name.endsWith(id2.concat("-").concat(id1).concat(".json"))
-        );
-        return index != undefined
-          ? store.state.files[index]
-          : console.log("Could not find comparison file."); // TODO introduce error page to navigate to
-      };
-
-      let comparisonFile = getComparisonFileFor(props.firstId, props.secondId);
+      let comparisonFile = store.getters.getComparisonFileForSubmissions(
+        props.firstId,
+        props.secondId
+      );
       if (comparisonFile) {
         comparison = ComparisonFactory.getComparison(
           JSON.parse(comparisonFile)
         );
+      } else {
+        console.log("Could not find comparison file."); // TODO introduce error page to navigate to
       }
     } else if (store.state.single) {
       comparison = ComparisonFactory.getComparison(
         JSON.parse(store.state.fileString)
       );
     }
-
     if (!comparison) {
       console.warn("Could not build comparison file");
       return;
@@ -219,6 +202,7 @@ export default defineComponent({
       filesOfFirst,
       filesOfSecond,
       hideLeftPanel,
+      store,
 
       toggleCollapseFirst,
       toggleCollapseSecond,
@@ -227,8 +211,6 @@ export default defineComponent({
       showMatch,
       togglePanel,
       isAnonymous,
-
-      store,
     };
   },
 });

+ 27 - 27
report-viewer/src/views/OverviewView.vue

@@ -4,7 +4,7 @@
 <template>
   <div class="container">
     <div class="column-container" style="width: 30%">
-        <h1>JPlag Report</h1>
+      <h1>JPlag Report</h1>
       <p class="section-title">Main Info:</p>
       <div id="basicInfo">
         <TextInformation
@@ -35,11 +35,11 @@
         />
         <TextInformation
           :has-additional-info="true"
-          :value="overview.submissionIds.length"
+          :value="store.getters.getSubmissionIds.size"
           additional-info-title="Submission IDs:"
           label="Submissions"
         >
-          <IDsList :ids="overview.submissionIds" @id-sent="handleId" />
+          <IDsList :ids="store.getters.getSubmissionIds" @id-sent="handleId" />
         </TextInformation>
         <TextInformation
           :value="overview.dateOfExecution"
@@ -56,19 +56,19 @@
     </div>
 
     <div class="column-container" style="width: 35%">
-        <div id="metrics">
+      <div id="metrics">
         <p class="section-title">Metric:</p>
-          <div id="metrics-list">
-            <MetricButton
-              v-for="(metric, index) in overview.metrics"
-              :id="metric.metricName"
-              :key="metric.metricName"
-              :is-selected="selectedMetric[index]"
-              :metric="metric"
-              @click="selectMetric(index)"
-            />
-          </div>
+        <div id="metrics-list">
+          <MetricButton
+            v-for="(metric, index) in overview.metrics"
+            :id="metric.metricName"
+            :key="metric.metricName"
+            :is-selected="selectedMetric[index]"
+            :metric="metric"
+            @click="selectMetric(index)"
+          />
         </div>
+      </div>
       <p class="section-title">Distribution:</p>
       <DistributionDiagram
         :distribution="distributions[selectedMetricIndex]"
@@ -78,13 +78,13 @@
     <div class="column-container" style="width: 35%">
       <p class="section-title">Top Comparisons:</p>
       <div id="comparisonsList">
-      <ComparisonsTable
-        :clusters="overview.clusters"
-        :top-comparisons="topComps[selectedMetricIndex]"
-      />
+        <ComparisonsTable
+          :clusters="overview.clusters"
+          :top-comparisons="topComps[selectedMetricIndex]"
+        />
+      </div>
     </div>
   </div>
-  </div>
 </template>
 
 <script lang="ts">
@@ -145,26 +145,26 @@ export default defineComponent({
      * Handles the selection of an Id to anonymize.
      * If all submission ids are provided as parameter it hides or displays them based on their previous state.
      * If a single id is provided it hides all of the other ids except for the chosen one.
-     * @param id
+     * @param ids
      */
-    const handleId = (id: string) => {
-      if (id.length === overview.submissionIds.length) {
+    const handleId = (ids: Array<string>) => {
+      if (ids.length === store.getters.getSubmissionIds.length) {
         if (store.state.anonymous.size > 0) {
           store.commit("resetAnonymous");
         } else {
-          store.commit("addAnonymous", id);
+          store.commit("addAnonymous", ids);
         }
       } else {
-        if (store.state.anonymous.has(id[0])) {
-          store.commit("removeAnonymous", id);
+        if (store.state.anonymous.has(ids[0])) {
+          store.commit("removeAnonymous", ids);
         } else {
           if (store.state.anonymous.size === 0) {
             store.commit(
               "addAnonymous",
-              overview.submissionIds.filter((s) => s !== id[0])
+              store.getters.getSubmissionIds.filter((s: string) => s !== ids[0])
             );
           } else {
-            store.commit("addAnonymous", id);
+            store.commit("addAnonymous", ids);
           }
         }
       }