blob: aa536edd2db16b88bd73a12aa6e02d3a2892e18d [file] [log] [blame]
package org.checkerframework.framework.test.diagnostics;
import java.io.File;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.checkerframework.javacutil.Pair;
import org.plumelib.util.CollectionsPlume;
/** A set of utilities and factory methods useful for working with TestDiagnostics. */
public class TestDiagnosticUtils {
/** How the diagnostics appear in Java source files. */
public static final String DIAGNOSTIC_IN_JAVA_REGEX =
"\\s*(error|fixable-error|warning|fixable-warning|other):\\s*(\\(?.*\\)?)\\s*";
/** How the diagnostics appear in Java source files. */
public static final Pattern DIAGNOSTIC_IN_JAVA_PATTERN =
Pattern.compile(DIAGNOSTIC_IN_JAVA_REGEX);
public static final String DIAGNOSTIC_WARNING_IN_JAVA_REGEX = "\\s*warning:\\s*(.*\\s*.*)\\s*";
public static final Pattern DIAGNOSTIC_WARNING_IN_JAVA_PATTERN =
Pattern.compile(DIAGNOSTIC_WARNING_IN_JAVA_REGEX);
// How the diagnostics appear in javax tools diagnostics from the compiler.
public static final String DIAGNOSTIC_REGEX = ":(\\d+):" + DIAGNOSTIC_IN_JAVA_REGEX;
public static final Pattern DIAGNOSTIC_PATTERN = Pattern.compile(DIAGNOSTIC_REGEX);
public static final String DIAGNOSTIC_WARNING_REGEX =
":(\\d+):" + DIAGNOSTIC_WARNING_IN_JAVA_REGEX;
public static final Pattern DIAGNOSTIC_WARNING_PATTERN =
Pattern.compile(DIAGNOSTIC_WARNING_REGEX);
// How the diagnostics appear in diagnostic files (.out).
public static final String DIAGNOSTIC_FILE_REGEX = ".+\\.java" + DIAGNOSTIC_REGEX;
public static final Pattern DIAGNOSTIC_FILE_PATTERN = Pattern.compile(DIAGNOSTIC_FILE_REGEX);
public static final String DIAGNOSTIC_FILE_WARNING_REGEX = ".+\\.java" + DIAGNOSTIC_WARNING_REGEX;
public static final Pattern DIAGNOSTIC_FILE_WARNING_PATTERN =
Pattern.compile(DIAGNOSTIC_FILE_WARNING_REGEX);
/**
* Instantiate the diagnostic based on a string that would appear in diagnostic files (i.e. files
* that only contain line after line of expected diagnostics).
*
* @param stringFromDiagnosticFile a single diagnostic string to parse
* @return a new TestDiagnostic
*/
public static TestDiagnostic fromDiagnosticFileString(String stringFromDiagnosticFile) {
return fromPatternMatching(
DIAGNOSTIC_FILE_PATTERN,
DIAGNOSTIC_WARNING_IN_JAVA_PATTERN,
"",
null,
stringFromDiagnosticFile);
}
/**
* Instantiate the diagnostic from a string that would appear in a Java file, e.g.: "error:
* (message)"
*
* @param filename the file containing the diagnostic (and the error)
* @param lineNumber the line number of the line immediately below the diagnostic comment in the
* Java file
* @param stringFromJavaFile the string containing the diagnostic
* @return a new TestDiagnostic
*/
public static TestDiagnostic fromJavaFileComment(
String filename, long lineNumber, String stringFromJavaFile) {
return fromPatternMatching(
DIAGNOSTIC_IN_JAVA_PATTERN,
DIAGNOSTIC_WARNING_IN_JAVA_PATTERN,
filename,
lineNumber,
stringFromJavaFile);
}
/**
* Instantiate a diagnostic from output produced by the Java compiler. The resulting diagnostic is
* never fixable and always has parentheses.
*/
public static TestDiagnostic fromJavaxToolsDiagnostic(
String diagnosticString, boolean noMsgText) {
// It would be nice not to parse this from the diagnostic string.
// However, diagnostic.toString() may contain "[unchecked]" even though getMessage() does not.
// Since we want to match the error messages reported by javac exactly, we must parse.
Pair<String, String> trimmed = formatJavaxToolString(diagnosticString, noMsgText);
return fromPatternMatching(
DIAGNOSTIC_PATTERN, DIAGNOSTIC_WARNING_PATTERN, trimmed.second, null, trimmed.first);
}
/**
* Instantiate the diagnostic from a JSpecify string that would appear in a Java file, e.g.:
* "jspecify_some_category".
*
* @param filename the file containing the diagnostic (and the error)
* @param lineNumber the line number of the line immediately below the diagnostic comment in the
* Java file
* @param stringFromJavaFile the string containing the diagnostic
* @return a new TestDiagnostic
*/
public static TestDiagnostic fromJSpecifyFileComment(
String filename, long lineNumber, String stringFromJavaFile) {
return new TestDiagnostic(
filename,
lineNumber,
DiagnosticKind.JSpecify,
stringFromJavaFile,
/*isFixable=*/ false,
/*omitParentheses=*/ true);
}
/**
* Instantiate the diagnostic via pattern-matching against patterns.
*
* @param diagnosticPattern a pattern that matches any diagnostic
* @param warningPattern a pattern that matches a warning diagnostic
* @param filename the file name
* @param lineNumber the line number
* @param diagnosticString the string to parse
* @return a diagnostic parsed from the given string
*/
@SuppressWarnings("nullness") // TODO: regular expression group access
protected static TestDiagnostic fromPatternMatching(
Pattern diagnosticPattern,
Pattern warningPattern,
String filename,
@Nullable Long lineNumber,
String diagnosticString) {
final DiagnosticKind kind;
final String message;
final boolean isFixable;
final boolean noParentheses;
long lineNo = -1;
int capturingGroupOffset = 1;
if (lineNumber != null) {
lineNo = lineNumber;
capturingGroupOffset = 0;
}
Matcher diagnosticMatcher = diagnosticPattern.matcher(diagnosticString);
if (diagnosticMatcher.matches()) {
Pair<DiagnosticKind, Boolean> categoryToFixable =
parseCategoryString(diagnosticMatcher.group(1 + capturingGroupOffset));
kind = categoryToFixable.first;
isFixable = categoryToFixable.second;
String msg = diagnosticMatcher.group(2 + capturingGroupOffset).trim();
noParentheses = msg.equals("") || msg.charAt(0) != '(' || msg.charAt(msg.length() - 1) != ')';
message = noParentheses ? msg : msg.substring(1, msg.length() - 1);
if (lineNumber == null) {
lineNo = Long.parseLong(diagnosticMatcher.group(1));
}
} else {
Matcher warningMatcher = warningPattern.matcher(diagnosticString);
if (warningMatcher.matches()) {
kind = DiagnosticKind.Warning;
isFixable = false;
message = warningMatcher.group(1 + capturingGroupOffset);
noParentheses = true;
if (lineNumber == null) {
lineNo = Long.parseLong(diagnosticMatcher.group(1));
}
} else if (diagnosticString.startsWith("warning:")) {
kind = DiagnosticKind.Warning;
isFixable = false;
message = diagnosticString.substring("warning:".length()).trim();
noParentheses = true;
if (lineNumber != null) {
lineNo = lineNumber;
} else {
lineNo = 0;
}
} else {
kind = DiagnosticKind.Other;
isFixable = false;
message = diagnosticString;
noParentheses = true;
// this should only happen if we are parsing a Java Diagnostic from the compiler
// that we did do not handle
if (lineNumber == null) {
lineNo = -1;
}
}
}
return new TestDiagnostic(filename, lineNo, kind, message, isFixable, noParentheses);
}
/**
* Given a javax diagnostic, return a pair of (trimmed, fileame), where "trimmed" is the first
* line of the message, without the leading filename.
*
* @param original a javax diagnostic
* @param noMsgText whether to do work; if false, this returns a pair of (argument, "").
* @return the diagnostic, split into message and filename
*/
public static Pair<String, String> formatJavaxToolString(String original, boolean noMsgText) {
String trimmed = original;
String filename = "";
if (noMsgText) {
if (!retainAllLines(trimmed)) {
int lineSepPos = trimmed.indexOf(System.lineSeparator());
if (lineSepPos != -1) {
trimmed = trimmed.substring(0, lineSepPos);
}
int extensionPos = trimmed.indexOf(".java:");
if (extensionPos != -1) {
int basenameStart = trimmed.lastIndexOf(File.separator);
filename = trimmed.substring(basenameStart + 1, extensionPos + 5).trim();
trimmed = trimmed.substring(extensionPos + 5).trim();
}
}
}
return Pair.of(trimmed, filename);
}
/**
* Returns true if all lines of the message should be shown, false if only the first line should
* be shown.
*
* @param message a diagnostic message
* @return true if all lines of the message should be shown
*/
private static boolean retainAllLines(String message) {
// Retain all if it is a thrown exception "unexpected Throwable" or it is a Checker Framework
// Error (contains "Compilation unit") or is OutOfMemoryError.
return message.contains("unexpected Throwable")
|| message.contains("Compilation unit")
|| message.contains("OutOfMemoryError");
}
/**
* Given a category string that may be prepended with "fixable-", return the category enum that
* corresponds with the category and whether or not it is a isFixable error
*/
private static Pair<DiagnosticKind, Boolean> parseCategoryString(String category) {
final String fixable = "fixable-";
final boolean isFixable = category.startsWith(fixable);
if (isFixable) {
category = category.substring(fixable.length());
}
DiagnosticKind categoryEnum = DiagnosticKind.fromParseString(category);
if (categoryEnum == null) {
throw new Error("Unparsable category: " + category);
}
return Pair.of(categoryEnum, isFixable);
}
/**
* Return true if this line in a Java file indicates an expected diagnostic that might be
* continued on the next line.
*/
public static boolean isJavaDiagnosticLineStart(String originalLine) {
final String trimmedLine = originalLine.trim();
return trimmedLine.startsWith("// ::") || trimmedLine.startsWith("// warning:");
}
/**
* Convert an end-of-line diagnostic message to a beginning-of-line one. Returns the argument
* unchanged if it does not contain an end-of-line diagnostic message.
*
* <p>Most diagnostics in Java files start at the beginning of a line. Occasionally, javac issues
* a warning about implicit code, such as an implicit constructor, on the line <em>immediately
* after</em> a curly brace. The only place to put the expected diagnostic message is on the line
* with the curly brace.
*
* <p>This implementation replaces "{ // ::" by "// ::", converting the end-of-line diagnostic
* message to a beginning-of-line one that the rest of the code can handle. It is rather specific
* (to avoid false positive matches, such as when "// ::" is commented out in source code). It
* could be extended in the future if such an extension is necessary.
*/
public static String handleEndOfLineJavaDiagnostic(String originalLine) {
int curlyIndex = originalLine.indexOf("{ // ::");
if (curlyIndex == -1) {
return originalLine;
} else {
return originalLine.substring(curlyIndex + 2);
}
}
/** Return true if this line in a Java file continues an expected diagnostic. */
@EnsuresNonNullIf(result = true, expression = "#1")
public static boolean isJavaDiagnosticLineContinuation(@Nullable String originalLine) {
if (originalLine == null) {
return false;
}
final String trimmedLine = originalLine.trim();
// Unlike with errors, there is no logic elsewhere for splitting multiple "warning:"s. So,
// avoid concatenating them. Also, each one must begin a line. They are allowed to wrap to
// the next line, though.
return trimmedLine.startsWith("// ") && !trimmedLine.startsWith("// warning:");
}
/**
* Return the continuation part. The argument is such that {@link
* #isJavaDiagnosticLineContinuation} returns true.
*/
public static String continuationPart(String originalLine) {
return originalLine.trim().substring(2).trim();
}
/**
* Convert a line in a Java source file to a TestDiagnosticLine.
*
* <p>The input {@code line} is possibly the concatenation of multiple source lines, if the
* diagnostic was split across lines in the source code.
*/
public static TestDiagnosticLine fromJavaSourceLine(
String filename, String line, long lineNumber) {
final String trimmedLine = line.trim();
long errorLine = lineNumber + 1;
if (trimmedLine.startsWith("// ::")) {
String restOfLine = trimmedLine.substring(5); // drop the "// ::"
String[] diagnosticStrs = restOfLine.split("::");
List<TestDiagnostic> diagnostics =
CollectionsPlume.mapList(
(String diagnostic) -> fromJavaFileComment(filename, errorLine, diagnostic),
diagnosticStrs);
return new TestDiagnosticLine(
filename, errorLine, line, Collections.unmodifiableList(diagnostics));
} else if (trimmedLine.startsWith("// warning:")) {
// This special diagnostic does not expect a line number nor a file name
String diagnosticString = trimmedLine.substring(2);
TestDiagnostic diagnostic = fromJavaFileComment("", 0, diagnosticString);
return new TestDiagnosticLine("", 0, line, Collections.singletonList(diagnostic));
} else if (trimmedLine.startsWith("//::")) {
TestDiagnostic diagnostic =
new TestDiagnostic(
filename,
lineNumber,
DiagnosticKind.Error,
"Use \"// ::\", not \"//::\"",
false,
true);
return new TestDiagnosticLine(
filename, lineNumber, line, Collections.singletonList(diagnostic));
} else if (trimmedLine.startsWith("// jspecify_")) {
TestDiagnostic diagnostic =
fromJSpecifyFileComment(filename, errorLine, trimmedLine.substring(3));
return new TestDiagnosticLine(
filename, errorLine, line, Collections.singletonList(diagnostic));
} else {
// It's a bit gross to create empty diagnostics (returning null might be more
// efficient), but they will be filtered out later.
return new TestDiagnosticLine(filename, errorLine, line, Collections.emptyList());
}
}
/** Convert a line in a DiagnosticFile to a TestDiagnosticLine. */
public static TestDiagnosticLine fromDiagnosticFileLine(String diagnosticLine) {
final String trimmedLine = diagnosticLine.trim();
if (trimmedLine.startsWith("#") || trimmedLine.isEmpty()) {
return new TestDiagnosticLine("", -1, diagnosticLine, Collections.emptyList());
}
TestDiagnostic diagnostic = fromDiagnosticFileString(diagnosticLine);
return new TestDiagnosticLine(
"", diagnostic.getLineNumber(), diagnosticLine, Arrays.asList(diagnostic));
}
public static Set<TestDiagnostic> fromJavaxDiagnosticList(
List<Diagnostic<? extends JavaFileObject>> javaxDiagnostics, boolean noMsgText) {
Set<TestDiagnostic> diagnostics = new LinkedHashSet<>(javaxDiagnostics.size());
for (Diagnostic<? extends JavaFileObject> diagnostic : javaxDiagnostics) {
// See TestDiagnosticUtils as to why we use diagnostic.toString rather
// than convert from the diagnostic itself
final String diagnosticString = diagnostic.toString();
// suppress Xlint warnings
if (diagnosticString.contains("uses unchecked or unsafe operations.")
|| diagnosticString.contains("Recompile with -Xlint:unchecked for details.")
|| diagnosticString.endsWith(" declares unsafe vararg methods.")
|| diagnosticString.contains("Recompile with -Xlint:varargs for details.")) {
continue;
}
diagnostics.add(TestDiagnosticUtils.fromJavaxToolsDiagnostic(diagnosticString, noMsgText));
}
return diagnostics;
}
/**
* Converts the given diagnostics to strings (as they would appear in a source file individually).
*
* @param diagnostics a list of diagnostics
* @return a list of the diagnastics as they would appear in a source file
*/
public static List<String> diagnosticsToString(List<TestDiagnostic> diagnostics) {
return CollectionsPlume.mapList(TestDiagnostic::toString, diagnostics);
}
public static void removeDiagnosticsOfKind(
DiagnosticKind kind, List<TestDiagnostic> expectedDiagnostics) {
for (int i = 0; i < expectedDiagnostics.size(); /*no-increment*/ ) {
if (expectedDiagnostics.get(i).getKind() == kind) {
expectedDiagnostics.remove(i);
} else {
++i;
}
}
}
}