Преглед изворни кода

Merge remote-tracking branch 'upstream/master' into feature/general-improvements

# Conflicts:
#	jplag/src/main/java/de/jplag/CommandLineArgument.java
#	jplag/src/main/java/de/jplag/Submission.java
#	jplag/src/main/java/de/jplag/SubmissionSetBuilder.java
Dominik Fuchß пре 4 година
родитељ
комит
b600100dc3

+ 11 - 11
README.md

@@ -39,23 +39,23 @@ JPlag can either be used via the CLI or directly via its Java API. For more info
 ```
 JPlag - Detecting Software Plagiarism
 
-Usage: JPlag [ options ] [<root-dir>]
- <root-dir>        The root-directory that contains all submissions
+Usage: JPlag [ options ] [<root-dir> ...]
+ <root-dir>        Root-directory that contains submissions
 
 named arguments:
   -h, --help             show this help message and exit
-  -l                     {java,python3,cpp,csharp,char,text,scheme} Select the language to parse the submissions (Standard: java)
-  -bc BC                 Name of the subdirectory of the root directory which contains the base code (common framework used in all submissions)
-  -v                     {quiet,long} Verbosity of the logging (Standard: quiet)
-  -d                     Debug parser. Non-parsable files will be stored (Standard: false)
+  -l                     {java,python3,cpp,csharp,char,text,scheme} Select the language to parse the submissions (default: java)
+  -bc BC                 Path of the directory containing the base code (common framework used in all submissions)
+  -v                     {quiet,long} Verbosity of the logging (default: quiet)
+  -d                     Debug parser. Non-parsable files will be stored (default false)
   -S S                   Look in directories <root-dir>/*/<dir> for programs
   -p P                   comma-separated list of all filename suffixes that are included
   -x X                   All files named in this file will be ignored in the comparison (line-separated list)
   -t T                   Tunes the comparison sensitivity by adjusting the minimum token required to be counted as a matching section. A smaller <n> increases the sensitivity but might lead to more false-positives
-  -m M                   Match similarity threshold [0-100]: All matches above this threshold will be saved (Standard: 0.0)
-  -n N                   The maximum number of comparisons that will be shown in the generated report, if set to -1 all comparisons will be shown (Standard: 30)
-  -r R                   Name of the directory in which the comparison results will be stored (Standard: result)
-  -c                     {normal,parallel} Comparison mode used to compare the programs (Standard: normal)
+  -m M                   Comparison similarity threshold [0-100]: All comparisons above this threshold will be saved (default: 0.0)
+  -n N                   The maximum number of comparisons that will be shown in the generated report, if set to -1 all comparisons will be shown (default: 30)
+  -r R                   Name of the directory in which the comparison results will be stored (default: result)
+  -c                     {normal,parallel} Comparison mode used to compare the programs (default: normal)
 ```
 
 ### Java API
@@ -63,7 +63,7 @@ named arguments:
 The new API makes it easy to integrate JPlag's plagiarism detection into external Java projects:
 
 ```java
-JPlagOptions options = new JPlagOptions("/path/to/rootDir", LanguageOption.JAVA);
+JPlagOptions options = new JPlagOptions(List.of("/path/to/rootDir"), LanguageOption.JAVA);
 options.setBaseCodeSubmissionName("template");
 
 JPlag jplag = new JPlag(options);

+ 1 - 2
jplag.frontend.text/src/main/java/de/jplag/text/Language.java

@@ -22,8 +22,7 @@ public class Language implements de.jplag.Language {
 
     @Override
     public String[] suffixes() {
-        String[] res = {".TXT", ".txt", ".ASC", ".asc", ".TEX", ".tex"};
-        return res;
+        return new String[] {".TXT", ".txt", ".ASC", ".asc", ".TEX", ".tex"};
     }
 
     @Override

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

@@ -103,7 +103,7 @@ public class CLI {
             fileSuffixes = fileSuffixString.replaceAll("\\s+", "").split(",");
         }
         LanguageOption language = LanguageOption.fromDisplayName(LANGUAGE.getFrom(namespace));
-        JPlagOptions options = new JPlagOptions(ROOT_DIRECTORY.getFrom(namespace), language);
+        JPlagOptions options = new JPlagOptions(ROOT_DIRECTORY.getListFrom(namespace), language);
         options.setBaseCodeSubmissionName(BASE_CODE.getFrom(namespace));
         options.setVerbosity(Verbosity.fromOption(VERBOSITY.getFrom(namespace)));
         options.setDebugParser(DEBUG.getFrom(namespace));

+ 31 - 7
jplag/src/main/java/de/jplag/CommandLineArgument.java

@@ -21,7 +21,7 @@ import de.jplag.strategy.ComparisonMode;
  * @author Timur Saglam
  */
 public enum CommandLineArgument {
-    ROOT_DIRECTORY("rootDir", String.class),
+    ROOT_DIRECTORY("rootDir", NumberOfArgumentValues.ONE_OR_MORE_VALUES, String.class),
     LANGUAGE("-l", String.class, LanguageOption.getDefault().getDisplayName(), LanguageOption.getAllDisplayNames()),
     BASE_CODE("-bc", String.class),
     VERBOSITY("-v", String.class, "quiet", List.of("quiet", "long")), // TODO SH: Replace verbosity when integrating a real logging library
@@ -36,25 +36,32 @@ public enum CommandLineArgument {
     COMPARISON_MODE("-c", String.class, DEFAULT_COMPARISON_MODE.getName(), ComparisonMode.allNames());
 
     private final String flag;
+    private final NumberOfArgumentValues numberOfValues;
     private final String description;
     private final Optional<Object> defaultValue;
     private final Optional<Collection<String>> choices;
     private final Class<?> type;
 
     CommandLineArgument(String flag, Class<?> type) {
-        this(flag, type, Optional.empty(), Optional.empty());
+        this(flag, NumberOfArgumentValues.SINGLE_VALUE, type, Optional.empty(), Optional.empty());
+    }
+
+    CommandLineArgument(String flag, NumberOfArgumentValues numberOfValues, Class<?> type) {
+        this(flag, numberOfValues, type, Optional.empty(), Optional.empty());
     }
 
     CommandLineArgument(String flag, Class<?> type, Object defaultValue) {
-        this(flag, type, Optional.of(defaultValue), Optional.empty());
+        this(flag, NumberOfArgumentValues.SINGLE_VALUE, type, Optional.of(defaultValue), Optional.empty());
     }
 
     CommandLineArgument(String flag, Class<?> type, Object defaultValue, Collection<String> choices) {
-        this(flag, type, Optional.of(defaultValue), Optional.of(choices));
+        this(flag, NumberOfArgumentValues.SINGLE_VALUE, type, Optional.of(defaultValue), Optional.of(choices));
     }
 
-    CommandLineArgument(String flag, Class<?> type, Optional<Object> defaultValue, Optional<Collection<String>> choices) {
+    CommandLineArgument(String flag, NumberOfArgumentValues numberOfValues, Class<?> type, Optional<Object> defaultValue,
+            Optional<Collection<String>> choices) {
         this.flag = flag;
+        this.numberOfValues = numberOfValues;
         this.type = type;
         this.defaultValue = defaultValue;
         this.choices = choices;
@@ -76,8 +83,8 @@ public enum CommandLineArgument {
     }
 
     /**
-     * Returns the value of this argument. Convenience method for {@link Namespace#get(String)} and
-     * {@link CommandLineArgument#flagWithoutDash()}.
+     * Returns the value of this argument for arguments with a single value. Convenience method for
+     * {@link Namespace#get(String)} and {@link CommandLineArgument#flagWithoutDash()}.
      * @param <T> is the argument type.
      * @param namespace stores a value for the argument.
      * @return the argument value.
@@ -86,6 +93,20 @@ public enum CommandLineArgument {
         return namespace.get(flagWithoutDash());
     }
 
+    /**
+     * Returns the value of this argument for arguments that allow more than a single value. Convenience method for
+     * {@link Namespace#getList(String)} and {@link CommandLineArgument#flagWithoutDash()}.
+     * <p>
+     * Depending on the action of the option, result types may change.
+     * </p>
+     * @param <T> is the argument type.
+     * @param namespace stores a value for the argument.
+     * @return the argument value.
+     */
+    public <T> List<T> getListFrom(Namespace namespace) {
+        return namespace.getList(flagWithoutDash());
+    }
+
     /**
      * Parses the command line argument with a specific parser.
      * @param parser is that parser.
@@ -98,6 +119,9 @@ public enum CommandLineArgument {
         if (type == Boolean.class) {
             argument.action(storeTrue());
         }
+        if (numberOfValues == NumberOfArgumentValues.ONE_OR_MORE_VALUES) {
+            argument.nargs(numberOfValues.toString());
+        }
     }
 
     /**

+ 1 - 2
jplag/src/main/java/de/jplag/JPlag.java

@@ -36,9 +36,8 @@ public class JPlag {
     /**
      * Creates and initializes a JPlag instance, parameterized by a set of options.
      * @param options determines the parameterization.
-     * @throws ExitException if the initialization fails.
      */
-    public JPlag(JPlagOptions options) throws ExitException {
+    public JPlag(JPlagOptions options) {
         this.options = options;
         errorCollector = new ErrorCollector(options);
         coreAlgorithm = new GreedyStringTiling(options);

+ 20 - 0
jplag/src/main/java/de/jplag/NumberOfArgumentValues.java

@@ -0,0 +1,20 @@
+package de.jplag;
+
+/**
+ * Allowed number of values of a command-line argument in the CLI.
+ */
+public enum NumberOfArgumentValues {
+    SINGLE_VALUE(""),
+    ONE_OR_MORE_VALUES("+"),;
+
+    private final String representation;
+
+    private NumberOfArgumentValues(String representation) {
+        this.representation = representation;
+    }
+
+    @Override
+    public String toString() {
+        return representation;
+    }
+}

+ 17 - 23
jplag/src/main/java/de/jplag/Submission.java

@@ -13,9 +13,7 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
-import de.jplag.exceptions.ExitException;
 import de.jplag.exceptions.ReportGenerationException;
-import de.jplag.exceptions.SubmissionException;
 import de.jplag.options.JPlagOptions;
 
 /**
@@ -35,7 +33,7 @@ public class Submission implements Comparable<Submission> {
     /**
      * Root of the submission files (including the subdir if used).
      */
-    private final File submissionRoot;
+    private final File submissionRootFile;
 
     /**
      * Files of the submission.
@@ -63,14 +61,14 @@ public class Submission implements Comparable<Submission> {
     /**
      * Creates a submission.
      * @param name Identification of the submission (directory or filename).
-     * @param submissionRoot Root of the submission (either a file or a directory).
+     * @param submissionRootFile is the submission file, or the root of the submission itself.
      * @param files are the files of the submissions, if the root is a single file it should just contain one file.
      * @param language is the language of the submission.
      * @param errorCollector is the interface for error reporting.
      */
-    public Submission(String name, File submissionRoot, Collection<File> files, Language language, ErrorCollector errorCollector) {
+    public Submission(String name, File submissionRootFile, Collection<File> files, Language language, ErrorCollector errorCollector) {
         this.name = name;
-        this.submissionRoot = submissionRoot;
+        this.submissionRootFile = submissionRootFile;
         this.files = files;
         this.language = language;
         this.errorCollector = errorCollector;
@@ -84,22 +82,18 @@ public class Submission implements Comparable<Submission> {
     }
 
     /**
-     * @return Identification of the submission (often a directory or file name).
+     * @return name of the submission (directory or file name).
      */
     public String getName() {
         return name;
     }
 
+    /**
+     * @return the unique file of the submission, which is either in a root folder or a subfolder of root folder when the
+     * subdirectory option is used.
+     */
     public File getRoot() {
-        return submissionRoot;
-    }
-
-    public File getCanonicalRoot() throws ExitException {
-        try {
-            return submissionRoot.getCanonicalFile();
-        } catch (IOException exception) {
-            throw new SubmissionException(String.format("Cannot compute canonical file path of \"%s\".", submissionRoot), exception);
-        }
+        return submissionRootFile;
     }
 
     /**
@@ -166,15 +160,15 @@ public class Submission implements Comparable<Submission> {
      */
     public boolean parse(boolean debugParser) {
         if (files == null || files.size() == 0) {
-            errorCollector.print("ERROR: nothing to parse for submission \"" + name, null);
+            errorCollector.print("ERROR: nothing to parse for submission \"" + name + "\"", null);
             tokenList = null;
             hasErrors = true; // invalidate submission
             return false;
         }
 
-        String[] relativeFilePaths = getRelativeFilePaths(submissionRoot, files);
+        String[] relativeFilePaths = getRelativeFilePaths(submissionRootFile, files);
 
-        tokenList = language.parse(submissionRoot, relativeFilePaths);
+        tokenList = language.parse(submissionRootFile, relativeFilePaths);
         if (!language.hasErrors()) {
             if (tokenList.size() < 3) {
                 errorCollector.print("Submission \"" + name + "\" is too short!", null);
@@ -205,7 +199,7 @@ public class Submission implements Comparable<Submission> {
             text.clear();
 
             try {
-                FileInputStream fileInputStream = new FileInputStream(new File(submissionRoot, files[i]));
+                FileInputStream fileInputStream = new FileInputStream(new File(submissionRootFile, files[i]));
                 InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, JPlagOptions.CHARSET);
                 BufferedReader in = new BufferedReader(inputStreamReader);
 
@@ -221,7 +215,7 @@ public class Submission implements Comparable<Submission> {
                 inputStreamReader.close();
                 fileInputStream.close();
             } catch (FileNotFoundException e) {
-                System.out.println("File not found: " + ((new File(submissionRoot, files[i])).toString()));
+                System.out.println("File not found: " + ((new File(submissionRootFile, files[i]))));
             } catch (IOException e) {
                 throw new ReportGenerationException("I/O exception!", e);
             }
@@ -243,7 +237,7 @@ public class Submission implements Comparable<Submission> {
             // If the token path is absolute, ignore the provided directory
             File file = new File(files[i]);
             if (!file.isAbsolute()) {
-                file = new File(submissionRoot, files[i]);
+                file = new File(submissionRootFile, files[i]);
             }
 
             try {
@@ -303,7 +297,7 @@ public class Submission implements Comparable<Submission> {
      * This method is used to copy files that can not be parsed to a special folder.
      */
     private void copySubmission() {
-        File rootDirectory = submissionRoot.getParentFile();
+        File rootDirectory = submissionRootFile.getParentFile();
         assert rootDirectory != null;
         File submissionDirectory = createSubdirectory(rootDirectory, ERROR_FOLDER, language.getShortName(), name);
         for (File file : files) {

+ 113 - 85
jplag/src/main/java/de/jplag/SubmissionSetBuilder.java

@@ -1,17 +1,18 @@
 package de.jplag;
 
 import java.io.File;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Function;
 
 import de.jplag.exceptions.BasecodeException;
 import de.jplag.exceptions.ExitException;
@@ -35,7 +36,7 @@ public class SubmissionSetBuilder {
      * @param language is the language of the submissions.
      * @param options are the configured options.
      * @param errorCollector is the interface for error reporting.
-     * @param excludedFileNames a list of filenames or paths to be excluded
+     * @param excludedFileNames a list of file names to be excluded
      */
     public SubmissionSetBuilder(Language language, JPlagOptions options, ErrorCollector errorCollector, Set<String> excludedFileNames) {
         this.language = language;
@@ -50,62 +51,74 @@ public class SubmissionSetBuilder {
      * @throws ExitException if the directory cannot be read.
      */
     public SubmissionSet buildSubmissionSet() throws ExitException {
-        // Read the root directory and collect valid looking submission entries from it.
-        File rootDirectory = new File(options.getRootDirectoryName());
-        verifyRootdirExistence(rootDirectory);
-        String[] fileNames = readSubmissionRootNames(rootDirectory);
-        Map<String, Submission> foundSubmissions = processRootDirEntries(rootDirectory, fileNames);
+        Set<File> rootDirectoryNames = verifyRootDirectories(options.getRootDirectoryNames());
 
-        // Extract the basecode submission if necessary.
-        Optional<Submission> baseCodeSubmission = Optional.empty();
-        if (options.hasBaseCode()) {
-            Submission baseCode = tryLoadBaseCodeAsPath();
-            if (baseCode == null) {
-                baseCode = tryLoadBaseCodeAsRootSubDirectory(foundSubmissions);
-
-                String baseCodeName = options.getBaseCodeSubmissionName().get();
-                if (baseCode == null) {
-                    // No base code found at all, report an error.
-                    throw new BasecodeException(
-                            String.format("Basecode path \"%s\" relative to the working directory could not be found.", baseCodeName));
-                } else {
-                    // Found a base code as a submission, report about obsolete usage.
-                    System.out.printf("Deprecated use of the -bc option found, please specify the basecode as \"%s%s%s\" instead.\n",
-                            rootDirectory.toString(), File.separator, baseCodeName);
-                }
-            }
-            baseCodeSubmission = Optional.of(baseCode);
-            System.out.printf("Basecode directory \"%s\" will be used.%n", baseCode.getRoot().toString());
+        // For backward compatibility, don't prefix submission names with their root directory
+        // if there is only one root directory.
+        boolean multipleRoots = (rootDirectoryNames.size() > 1);
 
-            // Basecode may also be registered as a user submission. If so, remove the latter.
-            File baseCodeRoot = baseCode.getCanonicalRoot(); // Use canonical form for a more sane equality notion.
-            Iterator<Entry<String, Submission>> submissionIterator = foundSubmissions.entrySet().iterator();
-            while (submissionIterator.hasNext()) {
-                Entry<String, Submission> entry = submissionIterator.next();
-                if (baseCodeRoot.equals(entry.getValue().getCanonicalRoot())) {
-                    submissionIterator.remove();
-                    System.out.printf("Skipping \"%s\" as user submission.%n", entry.getValue().getRoot().toString());
-                    break;
-                }
-            }
+        // Collect valid looking entries from the root directories.
+        Map<File, Submission> foundSubmissions = new HashMap<>();
+        for (File rootDirectory : rootDirectoryNames) {
+            processRootDirectoryEntries(rootDirectory, multipleRoots, foundSubmissions);
         }
 
+        Optional<Submission> baseCodeSubmission = loadBaseCode(rootDirectoryNames, foundSubmissions);
+
         // Merge everything in a submission set.
         List<Submission> submissions = new ArrayList<>(foundSubmissions.values());
         return new SubmissionSet(submissions, baseCodeSubmission, errorCollector, options);
     }
 
     /**
-     * Verify that the given root directory exists.
+     * Verify that the given root directories exist and have no duplicate entries.
      */
-    private void verifyRootdirExistence(File rootDir) throws ExitException {
-        String rootDirectoryName = rootDir.getName();
-        if (!rootDir.exists()) {
-            throw new RootDirectoryException(String.format("Root directory \"%s\" does not exist!", rootDirectoryName));
+    private Set<File> verifyRootDirectories(List<String> rootDirectoryNames) throws ExitException {
+        Set<File> canonicalRootDirectories = new HashSet<>(rootDirectoryNames.size());
+        for (String rootDirectoryName : rootDirectoryNames) {
+            File rootDirectory = new File(rootDirectoryName);
+
+            if (!rootDirectory.exists()) {
+                throw new RootDirectoryException(String.format("Root directory \"%s\" does not exist!", rootDirectoryName));
+            }
+            if (!rootDirectory.isDirectory()) {
+                throw new RootDirectoryException(String.format("Root directory \"%s\" is not a directory!", rootDirectoryName));
+            }
+
+            rootDirectory = makeCanonical(rootDirectory, it -> new RootDirectoryException("Cannot read root directory: " + rootDirectoryName, it));
+            if (!canonicalRootDirectories.add(rootDirectory)) {
+                // Root directory was already added, report a warning.
+                System.out.printf("Warning: Root directory \"%s\" was specified more than once, duplicates will be ignored.", rootDirectoryName);
+            }
         }
-        if (!rootDir.isDirectory()) {
-            throw new RootDirectoryException(String.format("Root directory \"%s\" is not a directory!", rootDirectoryName));
+        return canonicalRootDirectories;
+    }
+
+    private Optional<Submission> loadBaseCode(Set<File> rootDirectories, Map<File, Submission> foundSubmissions) throws ExitException {
+        // Extract the basecode submission if necessary.
+        Optional<Submission> baseCodeSubmission = Optional.empty();
+        if (options.hasBaseCode()) {
+            String baseCodeName = options.getBaseCodeSubmissionName().get();
+            Submission baseCode = loadBaseCodeAsPath(baseCodeName);
+            if (baseCode == null) {
+                if (rootDirectories.size() > 1) {
+                    throw new BasecodeException("The base code submission needs to be specified by path instead of by name!");
+                }
+                File rootDirectory = rootDirectories.iterator().next();
+                // Single root-directory, try the legacy way of specifying basecode.
+                baseCode = loadBaseCodeViaName(baseCodeName, rootDirectory, foundSubmissions);
+            }
+            // TODO Optional.of() will cause a NPTR-Exception of baseCode is null. Is this a bug?
+            baseCodeSubmission = Optional.of(baseCode);
+            System.out.printf("Basecode directory \"%s\" will be used.%n", baseCode.getName());
+
+            // Basecode may also be registered as a user submission. If so, remove the latter.
+            Submission removed = foundSubmissions.remove(baseCode.getRoot());
+            if (removed != null) {
+                System.out.printf("Submission \"%s\" is the specified basecode, it will be skipped during comparison.%n", removed.getName());
+            }
         }
+        return baseCodeSubmission;
     }
 
     /**
@@ -113,8 +126,7 @@ public class SubmissionSetBuilder {
      * @return Base code submission if the option value can be interpreted as global path, else {@code null}.
      * @throws ExitException when the option value is a path with errors.
      */
-    private Submission tryLoadBaseCodeAsPath() throws ExitException {
-        String baseCodeName = options.getBaseCodeSubmissionName().get();
+    private Submission loadBaseCodeAsPath(String baseCodeName) throws ExitException {
         File basecodeSubmission = new File(baseCodeName);
         if (!basecodeSubmission.exists()) {
             return null;
@@ -126,22 +138,22 @@ public class SubmissionSetBuilder {
         }
 
         try {
-            return processDirEntry(basecodeSubmission);
-
+            // Use an unlikely short name for the base code. If all is well, this name should not appear
+            // in the output since basecode matches are removed from it
+            return processSubmission(basecodeSubmission.getName(), basecodeSubmission);
         } catch (SubmissionException exception) {
             throw new BasecodeException(exception.getMessage(), exception); // Change thrown exception to basecode exception.
-
         }
     }
 
     /**
-     * Try to load the basecode under the assumption of being a sub-directory in the user submissions directory.
-     * @return Base code submission if the option value can be interpreted as a sub-directory, else {@code null}.
+     * Try to load the basecode by looking up the basecode name in the root directory.
+     * @return the base code submission if a fitting subdirectory was found, else {@code null}.
      * @throws ExitException when the option value is a sub-directory with errors.
      */
-    private Submission tryLoadBaseCodeAsRootSubDirectory(Map<String, Submission> foundSubmissions) throws ExitException {
+    private Submission loadBaseCodeViaName(String baseCodeName, File rootDirectory, Map<File, Submission> foundSubmissions) throws ExitException {
         // Is the option value a single name after trimming spurious separators?
-        String name = options.getBaseCodeSubmissionName().get();
+        String name = baseCodeName;
         while (name.startsWith(File.separator)) {
             name = name.substring(1);
         }
@@ -150,21 +162,33 @@ public class SubmissionSetBuilder {
         }
 
         // If it is not a name of a single sub-directory, bail out.
-        if (name.isEmpty() || name.contains(File.separator))
+        if (name.isEmpty() || name.contains(File.separator)) {
             return null;
+        }
 
         if (name.contains(".")) {
             throw new BasecodeException("The basecode directory name \"" + name + "\" cannot contain dots!");
         }
 
         // Grab the basecode submission from the regular submissions.
-        return foundSubmissions.get(name);
+        File basecodePath = new File(rootDirectory, baseCodeName);
+
+        Submission baseCode = foundSubmissions
+                .get(makeCanonical(basecodePath, it -> new BasecodeException("Cannot compute canonical file path: " + basecodePath, it)));
+        if (baseCode == null) {
+            // No base code found at all, report an error.
+            throw new BasecodeException(String.format("Basecode path \"%s\" relative to the working directory could not be found.", baseCodeName));
+        } else {
+            // Found a base code as a submission, report about legacy usage.
+            System.out.println("Legacy use of the -bc option found, please specify the basecode by path instead of by name!");
+        }
+        return baseCode;
     }
 
     /**
      * Read entries in the given root directory.
      */
-    private String[] readSubmissionRootNames(File rootDirectory) throws ExitException {
+    private String[] listSubmissionFiles(File rootDirectory) throws ExitException {
         if (!rootDirectory.isDirectory()) {
             throw new AssertionError("Given root is not a directory.");
         }
@@ -202,56 +226,50 @@ public class SubmissionSetBuilder {
     }
 
     /**
-     * Process the given directory entry as a submission root, the path MUST not be excluded.
-     * @param submissionEntry Entry to process.
+     * Process the given directory entry as a submission, the path MUST not be excluded.
+     * @param submissionFile the file for the submission.
      * @return The entry converted to a submission.
      * @throws ExitException when an error has been found with the entry.
      */
-    private Submission processDirEntry(File submissionEntry) throws ExitException {
-        if (isExcludedEntry(submissionEntry) != null) {
-            throw new AssertionError("Pre-condition of non-exclusion is violated.");
-        }
+    private Submission processSubmission(String submissionName, File submissionFile) throws ExitException {
 
-        String fileName = submissionEntry.getName();
-        if (submissionEntry.isDirectory() && options.getSubdirectoryName() != null) {
+        if (submissionFile.isDirectory() && options.getSubdirectoryName() != null) {
             // Use subdirectory instead
-            submissionEntry = new File(submissionEntry, options.getSubdirectoryName());
+            submissionFile = new File(submissionFile, options.getSubdirectoryName());
 
-            if (!submissionEntry.exists()) {
+            if (!submissionFile.exists()) {
                 throw new SubmissionException(
-                        String.format("Submission %s does not contain the given subdirectory '%s'", fileName, options.getSubdirectoryName()));
+                        String.format("Submission %s does not contain the given subdirectory '%s'", submissionName, options.getSubdirectoryName()));
             }
 
-            if (!submissionEntry.isDirectory()) {
+            if (!submissionFile.isDirectory()) {
                 throw new SubmissionException(String.format("The given subdirectory '%s' is not a directory!", options.getSubdirectoryName()));
             }
         }
 
-        return new Submission(fileName, submissionEntry, parseFilesRecursively(submissionEntry), language, errorCollector);
+        submissionFile = makeCanonical(submissionFile, it -> new SubmissionException("Cannot create submission: " + submissionName, it));
+        return new Submission(submissionName, submissionFile, parseFilesRecursively(submissionFile), language, errorCollector);
     }
 
     /**
      * Process entries in the root directory to check whether they qualify as submissions.
-     * @param rootDirectory Root directory being examined.
-     * @param fileNames Entries found in the root directory.
-     * @return Candidate submissions ordered by their name.
+     * @param rootDirectory is the root directory being examined.
+     * @param foundSubmissions Submissions found so far, is updated in-place.
      */
-    private Map<String, Submission> processRootDirEntries(File rootDirectory, String[] fileNames) throws ExitException {
-        Map<String, Submission> foundSubmissions = new LinkedHashMap<>(fileNames.length); // Capacity is an over-estimate.
-
-        for (String fileName : fileNames) {
+    private void processRootDirectoryEntries(File rootDirectory, boolean multipleRoots, Map<File, Submission> foundSubmissions) throws ExitException {
+        for (String fileName : listSubmissionFiles(rootDirectory)) {
             File submissionFile = new File(rootDirectory, fileName);
 
             String errorMessage = isExcludedEntry(submissionFile);
-            if (errorMessage != null) {
+            if (errorMessage == null) {
+                String rootDirectoryPrefix = multipleRoots ? (rootDirectory.getName() + File.separator) : "";
+                String submissionName = rootDirectoryPrefix + fileName;
+                Submission submission = processSubmission(submissionName, submissionFile);
+                foundSubmissions.put(submission.getRoot(), submission);
+            } else {
                 System.out.println(errorMessage);
-                continue;
             }
-
-            foundSubmissions.put(fileName, processDirEntry(submissionFile));
         }
-
-        return foundSubmissions;
     }
 
     /**
@@ -307,4 +325,14 @@ public class SubmissionSetBuilder {
         return files;
     }
 
+    /**
+     * Computes the canonical file of a file, if an exception is thrown it is wrapped accordingly and re-thrown.
+     */
+    private File makeCanonical(File file, Function<Exception, ExitException> exceptionWrapper) throws ExitException {
+        try {
+            return file.getCanonicalFile();
+        } catch (IOException exception) {
+            throw exceptionWrapper.apply(exception);
+        }
+    }
 }

+ 8 - 7
jplag/src/main/java/de/jplag/options/JPlagOptions.java

@@ -6,6 +6,7 @@ import java.io.File;
 import java.nio.charset.Charset;
 import java.nio.charset.StandardCharsets;
 import java.util.Collections;
+import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
@@ -80,7 +81,7 @@ public class JPlagOptions {
     /**
      * Directory that contains all submissions.
      */
-    private String rootDirectoryName;
+    private List<String> rootDirectoryNames;
 
     /**
      * Path name of the directory containing the base code.
@@ -117,8 +118,8 @@ public class JPlagOptions {
     /**
      * Constructor with required attributes.
      */
-    public JPlagOptions(String rootDirectoryName, LanguageOption languageOption) {
-        this.rootDirectoryName = rootDirectoryName;
+    public JPlagOptions(List<String> rootDirectoryNames, LanguageOption languageOption) {
+        this.rootDirectoryNames = rootDirectoryNames;
         this.languageOption = languageOption;
     }
 
@@ -158,8 +159,8 @@ public class JPlagOptions {
         return minimumTokenMatch;
     }
 
-    public String getRootDirectoryName() {
-        return rootDirectoryName;
+    public List<String> getRootDirectoryNames() {
+        return rootDirectoryNames;
     }
 
     public SimilarityMetric getSimilarityMetric() {
@@ -249,8 +250,8 @@ public class JPlagOptions {
         }
     }
 
-    public void setRootDirectoryName(String rootDirectoryName) {
-        this.rootDirectoryName = rootDirectoryName;
+    public void setRootDirectoryNames(List<String> rootDirectoryNames) {
+        this.rootDirectoryNames = rootDirectoryNames;
     }
 
     public void setSimilarityMetric(SimilarityMetric similarityMetric) {

+ 1 - 1
jplag/src/main/resources/de/jplag/messages.properties

@@ -6,7 +6,7 @@ CommandLineArgument.ExcludeFile=All files named in this file will be ignored in
 CommandLineArgument.Language=Select the language to parse the submissions
 CommandLineArgument.MinTokenMatch=Tunes the comparison sensitivity by adjusting the minimum token required to be counted as a matching section. A smaller <n> increases the sensitivity but might lead to more false-positives
 CommandLineArgument.ResultFolder=Name of the directory in which the comparison results will be stored
-CommandLineArgument.RootDirectory=The root-directory that contains all submissions
+CommandLineArgument.RootDirectory=Root-directory that contains submissions
 CommandLineArgument.ShownComparisons=The maximum number of comparisons that will be shown in the generated report, if set to -1 all comparisons will be shown
 CommandLineArgument.SimilarityThreshold=Comparison similarity threshold [0-100]: All comparisons above this threshold will be saved
 CommandLineArgument.Subdirectory=Look in directories <root-dir>/*/<dir> for programs

+ 2 - 1
jplag/src/test/java/de/jplag/BaseCodeTest.java

@@ -3,6 +3,7 @@ package de.jplag;
 import static org.junit.Assert.assertEquals;
 
 import java.io.File;
+import java.util.List;
 
 import org.junit.Test;
 
@@ -51,7 +52,7 @@ public class BaseCodeTest extends TestBase {
 
     @Test(expected = RootDirectoryException.class)
     public void testInvalidRoot() throws ExitException {
-        runJPlag("basecode", it -> it.setRootDirectoryName("WrongRoot"));
+        runJPlag("basecode", it -> it.setRootDirectoryNames(List.of("WrongRoot")));
     }
 
     @Test(expected = BasecodeException.class)

+ 35 - 0
jplag/src/test/java/de/jplag/NormalComparisonTest.java

@@ -1,11 +1,14 @@
 package de.jplag;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
 
+import java.util.List;
 import java.util.Optional;
 
 import org.junit.Test;
 
+import de.jplag.exceptions.BasecodeException;
 import de.jplag.exceptions.ExitException;
 
 public class NormalComparisonTest extends TestBase {
@@ -83,4 +86,36 @@ public class NormalComparisonTest extends TestBase {
                         || comparison.getFirstSubmission().getName().equals(nameB) && comparison.getSecondSubmission().getName().equals(nameA))
                 .findFirst();
     }
+
+    @Test
+    public void testMultiRootDirNoBasecode() throws ExitException {
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate")); // 3 + 2 submissions.
+        JPlagResult result = runJPlag(paths, options -> {
+        });
+        assertEquals(5, result.getNumberOfSubmissions());
+    }
+
+    @Test
+    public void testMultiRootDirSeparateBasecode() throws ExitException {
+        String basecodePath = getBasePath("basecode-base");
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate")); // 3 + 2 submissions.
+        JPlagResult result = runJPlag(paths, it -> it.setBaseCodeSubmissionName(basecodePath));
+        assertEquals(5, result.getNumberOfSubmissions());
+    }
+
+    @Test
+    public void testMultiRootDirBasecodeInSubmissionDir() throws ExitException {
+        String basecodePath = getBasePath("basecode", "base");
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate")); // 2 + 2 submissions.
+        JPlagResult result = runJPlag(paths, it -> it.setBaseCodeSubmissionName(basecodePath));
+        assertEquals(4, result.getNumberOfSubmissions());
+    }
+
+    @Test(expected = BasecodeException.class)
+    public void testMultiRootDirBasecodeName() throws ExitException {
+        List<String> paths = List.of(getBasePath("basecode"), getBasePath("SimpleDuplicate"));
+        String basecodePath = "base"; // Should *not* find basecode/base
+        runJPlag(paths, it -> it.setBaseCodeSubmissionName(basecodePath));
+        fail("No basecode exception was thrown!");
+    }
 }

+ 6 - 1
jplag/src/test/java/de/jplag/TestBase.java

@@ -2,6 +2,7 @@ package de.jplag;
 
 import java.io.File;
 import java.nio.file.Path;
+import java.util.List;
 import java.util.function.Consumer;
 
 import de.jplag.exceptions.ExitException;
@@ -37,7 +38,11 @@ public abstract class TestBase {
     }
 
     protected JPlagResult runJPlag(String testSampleName, Consumer<JPlagOptions> customization) throws ExitException {
-        JPlagOptions options = new JPlagOptions(Path.of(BASE_PATH, testSampleName).toString(), LanguageOption.JAVA);
+        return runJPlag(List.of(getBasePath(testSampleName)), customization);
+    }
+
+    protected JPlagResult runJPlag(List<String> testPaths, Consumer<JPlagOptions> customization) throws ExitException {
+        JPlagOptions options = new JPlagOptions(testPaths, LanguageOption.JAVA);
         options.setVerbosity(Verbosity.LONG);
         customization.accept(options);
         JPlag jplag = new JPlag(options);

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

@@ -31,7 +31,7 @@ public class CommandLineInterfaceTest {
     private String parsedKeys(String... arguments) {
         var keys = namespace.getAttrs().keySet().stream()
                 .filter(key -> key.equals(ROOT_DIRECTORY.flag()) || Arrays.stream(arguments).anyMatch(arg -> arg.contains("-" + key)));
-        return keys.map(it -> it + "=" + namespace.get(it)).collect(toSet()).toString();
+        return keys.map(it -> it.toString() + "=" + namespace.get(it)).collect(toSet()).toString();
     }
 
     /**

+ 2 - 2
pom.xml

@@ -81,7 +81,7 @@
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <maven.compiler.source>14</maven.compiler.source>
         <maven.compiler.target>14</maven.compiler.target>
-        <spotless.version>2.20.1</spotless.version>
+        <spotless.version>2.20.2</spotless.version>
     </properties>
     <dependencyManagement>
         <dependencies>
@@ -328,7 +328,7 @@
             <plugin>
                 <groupId>org.sonatype.plugins</groupId>
                 <artifactId>nexus-staging-maven-plugin</artifactId>
-                <version>1.6.8</version>
+                <version>1.6.11</version>
                 <extensions>true</extensions>
                 <configuration>
                     <serverId>ossrh</serverId>