SubmissionSetBuilder.java 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. package de.jplag;
  2. import java.io.File;
  3. import java.io.IOException;
  4. import java.util.ArrayList;
  5. import java.util.Arrays;
  6. import java.util.Collection;
  7. import java.util.Collections;
  8. import java.util.HashMap;
  9. import java.util.HashSet;
  10. import java.util.List;
  11. import java.util.Map;
  12. import java.util.Optional;
  13. import java.util.Set;
  14. import java.util.function.Function;
  15. import org.slf4j.Logger;
  16. import org.slf4j.LoggerFactory;
  17. import de.jplag.exceptions.ExitException;
  18. import de.jplag.exceptions.RootDirectoryException;
  19. import de.jplag.exceptions.SubmissionException;
  20. import de.jplag.options.JPlagOptions;
  21. /**
  22. * Builder class for the creation of a {@link SubmissionSet}.
  23. * @author Timur Saglam
  24. */
  25. public class SubmissionSetBuilder {
  26. private static final Logger logger = LoggerFactory.getLogger(SubmissionSetBuilder.class);
  27. private final Language language;
  28. private final JPlagOptions options;
  29. private final Set<String> excludedFileNames; // Set of file names to be excluded in comparison.
  30. /**
  31. * Creates a builder for submission sets.
  32. * @param language is the language of the submissions.
  33. * @param options are the configured options.
  34. */
  35. public SubmissionSetBuilder(Language language, JPlagOptions options) {
  36. this.language = language;
  37. this.options = options;
  38. this.excludedFileNames = options.excludedFiles();
  39. }
  40. /**
  41. * Builds a submission set for all submissions of a specific directory.
  42. * @return the newly built submission set.
  43. * @throws ExitException if the directory cannot be read.
  44. */
  45. public SubmissionSet buildSubmissionSet() throws ExitException {
  46. Set<File> submissionDirectories = verifyRootDirectories(options.submissionDirectories(), true);
  47. Set<File> oldSubmissionDirectories = verifyRootDirectories(options.oldSubmissionDirectories(), false);
  48. checkForNonOverlappingRootDirectories(submissionDirectories, oldSubmissionDirectories);
  49. // For backward compatibility, don't prefix submission names with their root directory
  50. // if there is only one root directory.
  51. int numberOfRootDirectories = submissionDirectories.size() + oldSubmissionDirectories.size();
  52. boolean multipleRoots = (numberOfRootDirectories > 1);
  53. // Collect valid looking entries from the root directories.
  54. Map<File, Submission> foundSubmissions = new HashMap<>();
  55. for (File directory : submissionDirectories) {
  56. processRootDirectoryEntries(directory, multipleRoots, foundSubmissions, true);
  57. }
  58. for (File oldDirectory : oldSubmissionDirectories) {
  59. processRootDirectoryEntries(oldDirectory, multipleRoots, foundSubmissions, false);
  60. }
  61. Optional<Submission> baseCodeSubmission = loadBaseCode(submissionDirectories, oldSubmissionDirectories);
  62. baseCodeSubmission.ifPresent(baseSubmission -> {
  63. foundSubmissions.remove(baseSubmission.getRoot());
  64. });
  65. // Merge everything in a submission set.
  66. List<Submission> submissions = new ArrayList<>(foundSubmissions.values());
  67. return new SubmissionSet(submissions, baseCodeSubmission.orElse(null), options);
  68. }
  69. /**
  70. * Verify that the given root directories exist and have no duplicate entries.
  71. */
  72. private Set<File> verifyRootDirectories(Set<File> rootDirectoryNames, boolean areNewDirectories) throws ExitException {
  73. if (areNewDirectories && rootDirectoryNames.isEmpty()) {
  74. throw new RootDirectoryException("No root directories specified with submissions to check for plagiarism!");
  75. }
  76. Set<File> canonicalRootDirectories = new HashSet<>(rootDirectoryNames.size());
  77. for (final File rootDirectory : rootDirectoryNames) {
  78. if (!rootDirectory.exists()) {
  79. throw new RootDirectoryException(String.format("Root directory \"%s\" does not exist!", rootDirectory));
  80. }
  81. if (!rootDirectory.isDirectory()) {
  82. throw new RootDirectoryException(String.format("Root directory \"%s\" is not a directory!", rootDirectory));
  83. }
  84. File canonicalRootDirectory = makeCanonical(rootDirectory,
  85. it -> new RootDirectoryException("Cannot read root directory: " + rootDirectory, it));
  86. if (!canonicalRootDirectories.add(canonicalRootDirectory)) {
  87. // Root directory was already added, report a warning.
  88. logger.warn("Root directory \"{}\" was specified more than once, duplicates will be ignored.", canonicalRootDirectory);
  89. }
  90. }
  91. return canonicalRootDirectories;
  92. }
  93. /**
  94. * Verify that the new and old directory sets are disjunct and modify the old submissions set if necessary.
  95. */
  96. private void checkForNonOverlappingRootDirectories(Set<File> submissionDirectories, Set<File> oldSubmissionDirectories) {
  97. Set<File> commonRootdirectories = new HashSet<>(submissionDirectories);
  98. commonRootdirectories.retainAll(oldSubmissionDirectories);
  99. if (commonRootdirectories.isEmpty()) {
  100. return;
  101. }
  102. // As old submission directories are only read while new submission directories are both read and checked, the
  103. // former use can be removed without affecting the result of the checks.
  104. oldSubmissionDirectories.removeAll(commonRootdirectories);
  105. for (File rootDirectory : commonRootdirectories) {
  106. logger.warn(
  107. "Root directory \"{}\" is specified both for plagiarism checking and for prior submissions, will perform plagiarism checking only.",
  108. rootDirectory);
  109. }
  110. }
  111. private Optional<Submission> loadBaseCode(Set<File> submissionDirectories, Set<File> oldSubmissionDirectories) throws ExitException {
  112. if (!options.hasBaseCode()) {
  113. return Optional.empty();
  114. }
  115. File baseCodeSubmissionDirectory = options.baseCodeSubmissionDirectory();
  116. Submission baseCodeSubmission = processSubmission(baseCodeSubmissionDirectory.getName(), baseCodeSubmissionDirectory, false);
  117. if (baseCodeSubmission != null) {
  118. logger.info("Basecode directory \"{}\" will be used.", baseCodeSubmission.getName());
  119. }
  120. return Optional.ofNullable(baseCodeSubmission);
  121. }
  122. /**
  123. * Read entries in the given root directory.
  124. */
  125. private String[] listSubmissionFiles(File rootDirectory) throws ExitException {
  126. if (!rootDirectory.isDirectory()) {
  127. throw new AssertionError("Given root is not a directory.");
  128. }
  129. String[] fileNames;
  130. try {
  131. fileNames = rootDirectory.list();
  132. } catch (SecurityException exception) {
  133. throw new RootDirectoryException("Cannot list files of the root directory! " + exception.getMessage(), exception);
  134. }
  135. if (fileNames == null) {
  136. throw new RootDirectoryException("Cannot list files of the root directory!");
  137. }
  138. Arrays.sort(fileNames);
  139. return fileNames;
  140. }
  141. /**
  142. * Check that the given submission entry is not invalid due to exclusion names or bad suffix.
  143. * @param submissionEntry Entry to check.
  144. * @return Error message if the entry should be ignored.
  145. */
  146. private String isExcludedEntry(File submissionEntry) {
  147. if (isFileExcluded(submissionEntry)) {
  148. return "Exclude submission: " + submissionEntry.getName();
  149. }
  150. if (submissionEntry.isFile() && !hasValidSuffix(submissionEntry)) {
  151. return "Ignore submission with invalid suffix: " + submissionEntry.getName();
  152. }
  153. return null;
  154. }
  155. /**
  156. * Process the given directory entry as a submission, the path MUST not be excluded.
  157. * @param submissionFile the file for the submission.
  158. * @param isNew states whether submissions found in the root directory must be checked for plagiarism.
  159. * @return The entry converted to a submission.
  160. * @throws ExitException when an error has been found with the entry.
  161. */
  162. private Submission processSubmission(String submissionName, File submissionFile, boolean isNew) throws ExitException {
  163. if (submissionFile.isDirectory() && options.subdirectoryName() != null) {
  164. // Use subdirectory instead
  165. submissionFile = new File(submissionFile, options.subdirectoryName());
  166. if (!submissionFile.exists()) {
  167. throw new SubmissionException(
  168. String.format("Submission %s does not contain the given subdirectory '%s'", submissionName, options.subdirectoryName()));
  169. }
  170. if (!submissionFile.isDirectory()) {
  171. throw new SubmissionException(String.format("The given subdirectory '%s' is not a directory!", options.subdirectoryName()));
  172. }
  173. }
  174. submissionFile = makeCanonical(submissionFile, it -> new SubmissionException("Cannot create submission: " + submissionName, it));
  175. return new Submission(submissionName, submissionFile, isNew, parseFilesRecursively(submissionFile), language);
  176. }
  177. /**
  178. * Process entries in the root directory to check whether they qualify as submissions.
  179. * @param rootDirectory is the root directory being examined.
  180. * @param foundSubmissions Submissions found so far, is updated in-place.
  181. * @param isNew states whether submissions found in the root directory must be checked for plagiarism.
  182. */
  183. private void processRootDirectoryEntries(File rootDirectory, boolean multipleRoots, Map<File, Submission> foundSubmissions, boolean isNew)
  184. throws ExitException {
  185. for (String fileName : listSubmissionFiles(rootDirectory)) {
  186. File submissionFile = new File(rootDirectory, fileName);
  187. String errorMessage = isExcludedEntry(submissionFile);
  188. if (errorMessage == null) {
  189. String rootDirectoryPrefix = multipleRoots ? (rootDirectory.getName() + File.separator) : "";
  190. String submissionName = rootDirectoryPrefix + fileName;
  191. Submission submission = processSubmission(submissionName, submissionFile, isNew);
  192. foundSubmissions.put(submission.getRoot(), submission);
  193. } else {
  194. logger.error(errorMessage);
  195. }
  196. }
  197. }
  198. /**
  199. * Checks if a file has a valid suffix for the current language.
  200. * @param file is the file to check.
  201. * @return true if the file suffix matches the language.
  202. */
  203. private boolean hasValidSuffix(File file) {
  204. List<String> validSuffixes = options.fileSuffixes();
  205. // This is the case if either the language modules or the CLI did not set the valid suffixes array in options
  206. if (validSuffixes == null || validSuffixes.isEmpty()) {
  207. return true;
  208. }
  209. return validSuffixes.stream().anyMatch(suffix -> file.getName().endsWith(suffix));
  210. }
  211. /**
  212. * Checks if a file is excluded or not.
  213. */
  214. private boolean isFileExcluded(File file) {
  215. return excludedFileNames.stream().anyMatch(excludedName -> file.getName().endsWith(excludedName));
  216. }
  217. /**
  218. * Recursively scan the given directory for nested files. Excluded files and files with an invalid suffix are ignored.
  219. * <p>
  220. * If the given file is not a directory, the input will be returned as a singleton list.
  221. * @param file - File to start the scan from.
  222. * @return a list of nested files.
  223. */
  224. private Collection<File> parseFilesRecursively(File file) {
  225. if (isFileExcluded(file)) {
  226. return Collections.emptyList();
  227. }
  228. if (file.isFile() && hasValidSuffix(file)) {
  229. return Collections.singletonList(file);
  230. }
  231. String[] nestedFileNames = file.list();
  232. if (nestedFileNames == null) {
  233. return Collections.emptyList();
  234. }
  235. Collection<File> files = new ArrayList<>();
  236. for (String fileName : nestedFileNames) {
  237. files.addAll(parseFilesRecursively(new File(file, fileName)));
  238. }
  239. return files;
  240. }
  241. /**
  242. * Computes the canonical file of a file, if an exception is thrown it is wrapped accordingly and re-thrown.
  243. */
  244. private File makeCanonical(File file, Function<Exception, ExitException> exceptionWrapper) throws ExitException {
  245. try {
  246. return file.getCanonicalFile();
  247. } catch (IOException exception) {
  248. throw exceptionWrapper.apply(exception);
  249. }
  250. }
  251. }