TokenPrinter.java 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. package de.jplag;
  2. import static java.util.stream.Collectors.toMap;
  3. import java.io.File;
  4. import java.io.IOException;
  5. import java.nio.file.Files;
  6. import java.nio.file.NoSuchFileException;
  7. import java.util.*;
  8. import java.util.function.Function;
  9. import java.util.stream.Collectors;
  10. import java.util.stream.IntStream;
  11. import org.slf4j.Logger;
  12. import org.slf4j.LoggerFactory;
  13. /**
  14. * Utility class for printing JPlag tokens from a submission. Each line of code is printed starting with the line
  15. * number. Under these lines the tokens are annotated in the format <code>|TOKEN|</code>. The first vertical line marks
  16. * the token start, while the last vertical line marks the token end. Tokens that are shorter than the name do not end
  17. * with a vertical line, e.g. <code>|TOKEN</code>. Tokens with length 1 or 0 are printed in lower case, e.g.
  18. * <code>|token</code>.
  19. * @author Timur Saglam
  20. */
  21. public final class TokenPrinter {
  22. public static final String NON_WHITESPACE = "\\S";
  23. public static final int MIN_PADDING = 1;
  24. private static final Logger logger = LoggerFactory.getLogger(TokenPrinter.class);
  25. // Representation:
  26. private static final String BAR = "|";
  27. private static final String TAB = "\t";
  28. private static final String SPACE = " ";
  29. private static final int TAB_LENGTH = 8;
  30. private static final String TAB_REPLACEMENT = SPACE.repeat(TAB_LENGTH); // might depend on files
  31. // Configuration:
  32. private static final boolean INDICATE_TINY_TOKEN = true; // print token with length <= 1 in lowercase
  33. private static final boolean REPLACE_TABS = false;
  34. private static final boolean PRINT_EMPTY_LINES = true; // print code lines with no tokens
  35. private static final boolean SPACIOUS = true; // print empty line after last token of a line
  36. private TokenPrinter() {
  37. // Utility class, no public constructor.
  38. }
  39. /**
  40. * Creates a string representation of a set of files line by line and adds the tokens under the lines.
  41. * @param tokens is the set of tokens parsed from the files.
  42. * @param directory is the common directory of the files.
  43. * @param fileNames is a collection of the file names.
  44. * @param suffix is the optional view file suffix.
  45. * @return the string representation.
  46. */
  47. public static String printTokens(TokenList tokens, File directory, Collection<String> fileNames, Optional<String> suffix) {
  48. Collection<File> files = fileNames.stream().map(name -> new File(directory, name)).toList();
  49. return printTokens(tokens, files, directory, suffix);
  50. }
  51. /**
  52. * Creates a string representation of a set of files line by line and adds the tokens under the lines.
  53. * @param tokens is the set of tokens parsed from the files.
  54. * @param directory is the common directory of the files.
  55. * @param fileNames is a collection of the file names.
  56. * @return the string representation.
  57. */
  58. public static String printTokens(TokenList tokens, File directory, Collection<String> fileNames) {
  59. Collection<File> files = fileNames.stream().map(name -> new File(directory, name)).toList();
  60. return printTokens(tokens, files, directory, Optional.empty());
  61. }
  62. /**
  63. * Creates a string representation of a collection of files line by line and adds the tokens under the lines.
  64. * @param tokens is the set of tokens parsed from the files.
  65. * @param files are the parsed files.
  66. * @return the string representation.
  67. */
  68. public static String printTokens(TokenList tokens, Collection<File> files, File root) {
  69. return printTokens(tokens, files, root, Optional.empty());
  70. }
  71. /**
  72. * Creates a string representation of a collection of files line by line and adds the tokens under the lines.
  73. * @param tokenList is the set of tokens parsed from the files.
  74. * @param files are the parsed files.
  75. * @param root is the common directory of the files.
  76. * @param suffix is the optional view file suffix.
  77. * @return the string representation.
  78. */
  79. public static String printTokens(TokenList tokenList, Collection<File> files, File root, Optional<String> suffix) {
  80. PrinterOutputBuilder builder = new PrinterOutputBuilder();
  81. Map<String, List<LineData>> fileToLineData = getLineData(files, tokenList, suffix, root);
  82. fileToLineData.forEach((String fileName, List<LineData> lineDatas) -> {
  83. builder.append(fileName);
  84. LineData.forEachApply(lineDatas, (String file, Integer lineNumber, String currentLine, List<Token> tokens) -> {
  85. builder.setLine(lineNumber);
  86. // Print (prefix and) code line
  87. builder.appendCodeLine(currentLine);
  88. if (tokens.isEmpty()) {
  89. return;
  90. }
  91. builder.appendTokenLinePrefix();
  92. // Print tokens
  93. for (Token token : tokens) {
  94. // Move to token index, possibly adding a new line:
  95. builder.advanceToTokenPosition(currentLine, token.getColumn(), true);
  96. // Print the actual token:
  97. String stringRepresentation = getStringRepresentation(token);
  98. builder.append(BAR).append(stringRepresentation);
  99. // Move up to token end:
  100. int tokenEndIndex = token.getColumn() + token.getLength() - 1;
  101. builder.advanceToTokenPosition(currentLine, tokenEndIndex, false);
  102. // Print token end if not already past it:
  103. if (builder.positionBeforeOrEqualTo(tokenEndIndex)) {
  104. builder.append(BAR);
  105. }
  106. }
  107. builder.appendTokenLineSuffix();
  108. });
  109. builder.advanceToNextLine();
  110. });
  111. return builder.toString();
  112. }
  113. /**
  114. * Determines the string representation of the token.
  115. */
  116. private static String getStringRepresentation(Token token) {
  117. return token.getLength() <= 1 && INDICATE_TINY_TOKEN ? token.toString().toLowerCase() : token.toString();
  118. }
  119. /**
  120. * Turns a list of files into a map which maps the file names to a list of the contained lines of that file.
  121. */
  122. private static Map<String, List<String>> readFiles(Collection<File> allFiles) {
  123. return allFiles.stream().collect(toMap(File::getName, TokenPrinter::linesFromFile));
  124. }
  125. /**
  126. * Turns a list of files and a list of tokens into a map of the file name to [a list of LineData objects for the lines
  127. * of the file].
  128. * @param allFiles a collection of files
  129. * @param allTokens a TokenList containing all tokens of the files
  130. * @param suffix the optional view file suffix
  131. * @param root the root element of the submission
  132. * @return map of file -> [LineData]
  133. */
  134. private static Map<String, List<LineData>> getLineData(Collection<File> allFiles, TokenList allTokens, Optional<String> suffix, File root) {
  135. Map<String, List<Token>> filesToTokens = allTokens.stream().collect(Collectors.groupingBy(Token::getFile));
  136. Map<String, List<String>> filesToLines = readFiles(allFiles);
  137. // handle 'files as submissions' mode
  138. final String EMPTY_STRING = "";
  139. if (filesToTokens.containsKey(EMPTY_STRING)) {
  140. filesToTokens.put(root.getName(), filesToTokens.get(EMPTY_STRING));
  141. filesToTokens.remove(EMPTY_STRING);
  142. }
  143. // Sort tokens by file and line -> tokens can be processed without any further checks
  144. Function<File, List<LineData>> fileToLineDataList = file -> {
  145. String fileName = file.getName();
  146. String tokenFileName = restoreTokenFileName(file.getName(), suffix);
  147. List<String> lines = filesToLines.get(fileName);
  148. Map<Integer, List<Token>> lineNumbersToTokens = filesToTokens.get(tokenFileName).stream().collect(Collectors.groupingBy(Token::getLine));
  149. // create LineData for each line -- 1-based line index
  150. return IntStream.range(1, lines.size() + 1).mapToObj(
  151. lineIndex -> new LineData(fileName, lineIndex, lines.get(lineIndex - 1), lineNumbersToTokens.getOrDefault(lineIndex, List.of())))
  152. .filter(lineData -> PRINT_EMPTY_LINES || !lineData.tokens.isEmpty()).toList();
  153. };
  154. return allFiles.stream().collect(toMap(File::getName, fileToLineDataList));
  155. }
  156. /**
  157. * Returns the fileName with the given suffix cut off at the end, if any.
  158. * @param fileName the file name with a suffix
  159. * @param suffix the suffix to cut off
  160. * @return the shortened file name
  161. */
  162. private static String restoreTokenFileName(String fileName, Optional<String> suffix) {
  163. if (suffix.isEmpty() || !fileName.endsWith(suffix.get())) {
  164. return fileName;
  165. }
  166. int newLength = fileName.length() - suffix.get().length();
  167. return fileName.substring(0, newLength);
  168. }
  169. /**
  170. * Parses a file and returns a list of the contained lines.
  171. */
  172. private static List<String> linesFromFile(File file) {
  173. try {
  174. return Files.readAllLines(file.toPath());
  175. } catch (NoSuchFileException exception) {
  176. logger.error("File does not exist, thus no tokens are printed: " + file.getAbsolutePath());
  177. } catch (IOException exception) {
  178. logger.error("Cannot read " + file.getAbsolutePath() + ":", exception);
  179. }
  180. return Collections.emptyList();
  181. }
  182. /**
  183. * This contains all data concerning a line of code in a file and the tokens found in that line.
  184. * @param file the file
  185. * @param lineNumber the line number inside the file
  186. * @param text the code line
  187. * @param tokens the tokens found in the code line
  188. */
  189. private record LineData(String file, Integer lineNumber, String text, List<Token> tokens) {
  190. /**
  191. * Applies the given LineDataConsumer to this LineData.
  192. * @param consumer the LineDataConsumer
  193. */
  194. public void apply(LineDataConsumer consumer) {
  195. consumer.apply(file, lineNumber, text, tokens);
  196. }
  197. /**
  198. * Convenience method to apply a LineDataConsumer to all lineDatas in the given collection.
  199. * @param lineDatas the LineData objects to apply the consumer to
  200. * @param consumer the LineDataConsumer to apply
  201. */
  202. public static void forEachApply(Collection<LineData> lineDatas, LineDataConsumer consumer) {
  203. lineDatas.forEach(lineData -> lineData.apply(consumer));
  204. }
  205. /**
  206. * The LineDataConsumer interface allows to declare variables for all fields of a LineData object in a functional
  207. * interface, thus saving manually declaring variables for each one.
  208. */
  209. @FunctionalInterface
  210. interface LineDataConsumer {
  211. void apply(String file, Integer lineNumber, String text, List<Token> tokens);
  212. default void apply(LineData lineData) {
  213. apply(lineData.file, lineData.lineNumber, lineData.text, lineData.tokens);
  214. }
  215. }
  216. }
  217. /**
  218. * A proxy for the StringBuilder that keeps track of the position inside the output.
  219. */
  220. private static class PrinterOutputBuilder {
  221. public static final String LINE_SEPARATOR = System.lineSeparator();
  222. private final StringBuilder builder = new StringBuilder();
  223. private int columnIndex = 1;
  224. private int lineNumber;
  225. private int trailingLineSeparators = 0;
  226. /**
  227. * Returns the number of digits (including a minus) of the given number.
  228. */
  229. private static int digitCount(int number) {
  230. if (number == 0) {
  231. return 1;
  232. }
  233. int minusLength = number < 0 ? 1 : 0;
  234. // The 'log10' variant is supposedly faster than the 'toString' variant.
  235. return (int) Math.log10(Math.abs(number)) + minusLength + 1;
  236. }
  237. private void resetLinePosition() {
  238. columnIndex = 1;
  239. }
  240. /**
  241. * Appends the given string to the output
  242. * @param str the string to append
  243. * @return this
  244. */
  245. public PrinterOutputBuilder append(String str) {
  246. // Avoid too many blank lines
  247. trailingLineSeparators = str.equals(LINE_SEPARATOR) ? trailingLineSeparators + 1 : 0;
  248. if (trailingLineSeparators >= 2)
  249. return this;
  250. builder.append(str);
  251. columnIndex += str.length();
  252. return this;
  253. }
  254. /**
  255. * Appends the given integer to the output.
  256. * @param i the integer to append
  257. * @return this
  258. */
  259. public PrinterOutputBuilder append(int i) {
  260. return append(Integer.toString(i));
  261. }
  262. /**
  263. * In SPACIOUS mode, appends an empty line before the next code line.
  264. * @return a reference to this
  265. */
  266. private PrinterOutputBuilder appendTokenLineSuffix() {
  267. return SPACIOUS ? advanceToNextLine() : this;
  268. }
  269. /**
  270. * Appends the code line to the StringBuilder, applying the configuration of this TokenPrinter.
  271. * @param currentLine The line of code to append
  272. * @return a reference to this
  273. */
  274. private PrinterOutputBuilder appendCodeLine(String currentLine) {
  275. // Prefix
  276. advanceToNextLine().append(lineNumber);
  277. // Code line should be padded up to at least one tab length, and no less than MIN_PADDING
  278. int paddingLength = Math.max(TAB_LENGTH - digitCount(lineNumber), MIN_PADDING);
  279. appendPadding(paddingLength);
  280. // Code line
  281. if (REPLACE_TABS) {
  282. currentLine = currentLine.replaceAll(TAB, TAB_REPLACEMENT);
  283. }
  284. return append(currentLine);
  285. }
  286. /**
  287. * Appends whitespace padding to the given StringBuilder in order to reach the targetPosition. Note that <b>the indices
  288. * are 1-based</b>, whereas the positions in currentLine are 0-based. The convention that lines start at position 1
  289. * comes from the frontends, specifically the Java frontend.
  290. * @param currentLine The current line in the code file, indicating where it containes tab characters
  291. * @param targetPosition The (1-based) index of the next character within the currentLine that should be labeled
  292. * @param breakLine If true, a lineSeparator will be added if the currentPosition is past targetPosition.
  293. * @return the new position, which is the targetPosition or the end of the line.
  294. */
  295. PrinterOutputBuilder advanceToTokenPosition(String currentLine, int targetPosition, boolean breakLine) {
  296. targetPosition = Math.min(targetPosition, currentLine.length());
  297. if (positionBeforeOrEqualTo(targetPosition)) {
  298. if (!breakLine) {
  299. // we are past targetPosition, do nothing
  300. return this;
  301. }
  302. // new line
  303. appendTokenLinePrefix();
  304. if (positionBeforeOrEqualTo(targetPosition)) {
  305. // still past targetPosition -> negative targetPosition
  306. return this;
  307. }
  308. }
  309. // The replacement operation preserves TAB characters, which is essential for correct alignment
  310. String padding = currentLine.substring(columnIndex - 1, targetPosition - 1).replace(TAB, REPLACE_TABS ? TAB_REPLACEMENT : TAB)
  311. .replace(NON_WHITESPACE, SPACE);
  312. append(padding);
  313. return this;
  314. }
  315. /**
  316. * Returns true if the current position is before or at the target position.
  317. * @param targetPosition the target position
  318. * @return true if before or at target position
  319. */
  320. private boolean positionBeforeOrEqualTo(int targetPosition) {
  321. return columnIndex > targetPosition;
  322. }
  323. /**
  324. * Appends a newline, plus the amount of padding necessary to align tokens correctly.
  325. * @return this
  326. */
  327. PrinterOutputBuilder appendTokenLinePrefix() {
  328. int paddingLength = Math.max(digitCount(lineNumber) + MIN_PADDING, TAB_LENGTH);
  329. return advanceToNextLine().appendPadding(paddingLength);
  330. }
  331. /**
  332. * Appends the amount of padding given.
  333. * @param paddingLength Length of padding to add
  334. * @return this
  335. */
  336. private PrinterOutputBuilder appendPadding(int paddingLength) {
  337. String padding = SPACE.repeat(paddingLength);
  338. append(padding);
  339. return this;
  340. }
  341. /**
  342. * Appends a newline.
  343. * @return this
  344. */
  345. private PrinterOutputBuilder advanceToNextLine() {
  346. append(LINE_SEPARATOR);
  347. resetLinePosition();
  348. return this;
  349. }
  350. public void setLine(int lineNumber) {
  351. this.lineNumber = lineNumber;
  352. }
  353. @Override
  354. public String toString() {
  355. return builder.toString();
  356. }
  357. }
  358. }