package de.jplag;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toMap;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Utility class for printing JPlag tokens from a submission. Each line of code is printed starting with the line
* number. Under these lines the tokens are annotated in the format |TOKEN|. The first vertical line marks
* the token start, while the last vertical line marks the token end. Tokens that are shorter than the name do not end
* with a vertical line, e.g. |TOKEN. Tokens with length 1 or 0 are printed in lower case, e.g.
* |token.
* @author Timur Saglam
*/
public final class TokenPrinter {
private static final Logger logger = LoggerFactory.getLogger(TokenPrinter.class);
// Representation:
private static final String BAR = "|";
private static final String TAB = "\t";
private static final String SPACE = " ";
public static final String NON_WHITESPACE = "\\S";
public static final int MIN_PADDING = 1;
private static final int TAB_LENGTH = 8;
private static final String TAB_REPLACEMENT = SPACE.repeat(TAB_LENGTH); // might depend on files
// Configuration:
private static final boolean INDICATE_TINY_TOKEN = true; // print token with length <= 1 in lowercase
private static final boolean REPLACE_TABS = false;
private TokenPrinter() {
// Utility class, no public constructor.
}
/**
* Creates a string representation of a set of files line by line and adds the tokens under the lines.
* @param tokens is the set of tokens parsed from the files.
* @param directory is the common directory of the files.
* @param fileNames is a collection of the file names.
* @return the string representation.
*/
public static String printTokens(TokenList tokens, File directory, Collection fileNames) {
Collection files = fileNames.stream().map(name -> new File(directory, name)).collect(toList());
return printTokens(tokens, files, directory);
}
/**
* Creates a string representation of a collection of files line by line and adds the tokens under the lines.
* @param tokens is the set of tokens parsed from the files.
* @param files are the parsed files.
* @return the string representation.
*/
public static String printTokens(TokenList tokens, Collection files, File root) {
Map> filesToLines = readFiles(files);
StringBuilder builder = new StringBuilder();
int lineIndex = 0;
int columnIndex = 1;
String file = null; // no file yet
String currentLine = null;
for (Token token : tokens.allTokens()) {
// New code file:
if (!token.getFile().equals(file)) {
if (file != null) {
builder.append(System.lineSeparator());
}
file = token.getFile();
builder.append(file);
lineIndex = 0;
}
// New code line:
if (token.getLine() > lineIndex) {
lineIndex = token.getLine();
columnIndex = 1;
String fileName = token.getFile().isEmpty() ? root.getName() : token.getFile();
currentLine = filesToLines.get(fileName).get(lineIndex - 1);
appendCodeLinePrefix(builder, lineIndex);
appendCodeLine(builder, currentLine);
appendTokenLinePrefix(builder, lineIndex);
}
assert currentLine != null;
// New line if already past token index:
if (columnIndex > token.getColumn()) {
appendTokenLinePrefix(builder, lineIndex);
columnIndex = 1;
}
// Move to token index:
columnIndex = appendPadding(builder, currentLine, columnIndex, token.getColumn());
// Print the actual token:
String stringRepresentation = getStringRepresentation(token);
builder.append(BAR).append(stringRepresentation);
columnIndex += stringRepresentation.length() + 1;
// Move up to token end:
int tokenEndIndex = token.getColumn() + token.getLength() - 1;
columnIndex = appendPadding(builder, currentLine, columnIndex, tokenEndIndex);
// Print token end if not already past it:
if (columnIndex == tokenEndIndex) {
builder.append(BAR);
columnIndex++;
}
}
return builder.toString();
}
/**
* Appends the code line to the StringBuilder, applying the configuration of this TokenPrinter.
* @param builder The StringBuilder to add code to
* @param currentLine The line of code to append
* @return a reference to that same StringBuilder
*/
private static StringBuilder appendCodeLine(StringBuilder builder, String currentLine) {
if (REPLACE_TABS) {
currentLine = currentLine.replaceAll(TAB, TAB_REPLACEMENT);
}
return builder.append(currentLine);
}
/**
* Appends whitespace padding to the given StringBuilder in order to reach the targetPosition. Note that the indices
* are 1-based, whereas the positions in currentLine are 0-based. The convention that lines start at position 1
* comes from the frontends, specifically the Java frontend.
* @param builder The StringBuilder to add padding to
* @param currentLine The current line in the code file, indicating where it containes tab characters
* @param currentPosition The (1-based) index of the last character within the currentLine that is labeled, or 1.
* @param targetPosition The (1-based) index of the next character within the currentLine that should be labeled
* @return the new position, which is the targetPosition or the end of the line.
*/
private static int appendPadding(StringBuilder builder, String currentLine, int currentPosition, int targetPosition) {
targetPosition = Math.min(targetPosition, currentLine.length());
if (currentPosition >= targetPosition) {
return currentPosition;
}
// The replacement operation preserves TAB characters, which is essential for correct alignment
String padding = currentLine.substring(currentPosition - 1, targetPosition - 1).replaceAll(TAB, REPLACE_TABS ? TAB_REPLACEMENT : TAB)
.replaceAll(NON_WHITESPACE, SPACE);
builder.append(padding);
return targetPosition;
}
private static StringBuilder appendCodeLinePrefix(StringBuilder builder, int index) {
builder.append(System.lineSeparator()).append(index);
// Code line should be padded up to at least one tab length, and no less than MIN_PADDING
int paddingLength = Math.max(TAB_LENGTH - digitCount(index), MIN_PADDING);
return builder.append(SPACE.repeat(paddingLength));
}
private static StringBuilder appendTokenLinePrefix(StringBuilder builder, int index) {
int paddingLength = Math.max(digitCount(index) + MIN_PADDING, TAB_LENGTH);
String padding = SPACE.repeat(paddingLength);
return builder.append(System.lineSeparator()).append(padding);
}
/**
* Determines the string representation of the token.
*/
private static String getStringRepresentation(Token token) {
return token.getLength() <= 1 && INDICATE_TINY_TOKEN ? token.toString().toLowerCase() : token.toString();
}
/**
* Turns a list of files into a map which maps the file names to a list of the contained lines of that file.
*/
private static Map> readFiles(Collection allFiles) {
return allFiles.stream().collect(toMap(File::getName, TokenPrinter::linesFromFile));
}
/**
* Parses a file and returns a list of the contained lines.
*/
private static List linesFromFile(File file) {
try {
return Files.readAllLines(file.toPath());
} catch (IOException exception) {
logger.error("Cannot read " + file.getAbsolutePath() + ":", exception);
}
return Collections.emptyList();
}
/**
* Returns the number of digits (including a minus) of the given number.
*/
private static int digitCount(int number) {
if (number == 0) {
return 1;
}
int minusLength = number < 0 ? 1 : 0;
// The 'log10' variant is supposedly faster than the 'toString' variant.
return (int) Math.log10(Math.abs(number)) + minusLength + 1;
}
}