TokenPrinter.java 13 KB

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