TokenPrinter.java 14 KB

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