/* * Copyright 2012 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.errorprone; import static com.google.common.truth.Truth.assertWithMessage; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.collect.ImmutableList; import com.google.errorprone.DiagnosticTestHelper.LookForCheckNameInDiagnostic; import com.google.errorprone.bugpatterns.BugChecker; import com.google.errorprone.scanner.ScannerSupplier; import com.sun.tools.javac.api.JavacTool; import com.sun.tools.javac.main.Main.Result; import java.io.BufferedWriter; import java.io.ByteArrayOutputStream; import java.io.IOError; import java.io.IOException; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintWriter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; import javax.tools.Diagnostic; import javax.tools.JavaCompiler; import javax.tools.JavaCompiler.CompilationTask; import javax.tools.JavaFileObject; import javax.tools.StandardLocation; /** * Helps test Error Prone bug checkers and compilations. */ public class CompilationTestHelper { private static final ImmutableList<String> DEFAULT_ARGS = ImmutableList.of( "-encoding", "UTF-8", // print stack traces for completion failures "-XDdev", "-XDsave-parameter-names"); private final DiagnosticTestHelper diagnosticHelper; private final BaseErrorProneCompiler compiler; private final ByteArrayOutputStream outputStream; private final ErrorProneInMemoryFileManager fileManager; private final List<JavaFileObject> sources = new ArrayList<>(); private List<String> args = ImmutableList.of(); private boolean expectNoDiagnostics = false; private Optional<Result> expectedResult = Optional.absent(); private boolean checkWellFormed = true; private LookForCheckNameInDiagnostic lookForCheckNameInDiagnostic = LookForCheckNameInDiagnostic.YES; private CompilationTestHelper(ScannerSupplier scannerSupplier, String checkName, Class<?> clazz) { this.fileManager = new ErrorProneInMemoryFileManager(clazz); try { fileManager.setLocation(StandardLocation.SOURCE_PATH, Collections.emptyList()); } catch (IOException e) { e.printStackTrace(); } this.diagnosticHelper = new DiagnosticTestHelper(checkName); this.outputStream = new ByteArrayOutputStream(); this.compiler = BaseErrorProneCompiler.builder() .report(scannerSupplier) .redirectOutputTo( new PrintWriter( new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8)), /*autoFlush=*/ true)) .listenToDiagnostics(diagnosticHelper.collector) .build(); } /** * Returns a new {@link CompilationTestHelper}. * * @param scannerSupplier the {@link ScannerSupplier} to test * @param clazz the class to use to locate file resources */ public static CompilationTestHelper newInstance(ScannerSupplier scannerSupplier, Class<?> clazz) { return new CompilationTestHelper(scannerSupplier, null, clazz); } /** * Returns a new {@link CompilationTestHelper}. * * @param checker the {@link BugChecker} to test * @param clazz the class to use to locate file resources */ public static CompilationTestHelper newInstance( Class<? extends BugChecker> checker, Class<?> clazz) { ScannerSupplier scannerSupplier = ScannerSupplier.fromBugCheckerClasses(checker); String checkName = checker.getAnnotation(BugPattern.class).name(); return new CompilationTestHelper(scannerSupplier, checkName, clazz); } /** * Pass -proc:none unless annotation processing is explicitly enabled, to avoid picking up * annotation processors via service loading. */ // TODO(cushon): test compilations should be isolated so they can't pick things up from the // ambient classpath. static List<String> disableImplicitProcessing(List<String> args) { if (args.indexOf("-processor") != -1 || args.indexOf("-processorpath") != -1) { return args; } return ImmutableList.<String>builder().addAll(args).add("-proc:none").build(); } /** * Creates a list of arguments to pass to the compiler, including the list of source files * to compile. Uses DEFAULT_ARGS as the base and appends the extraArgs passed in. */ private static List<String> buildArguments(List<String> extraArgs) { return ImmutableList.<String>builder() .addAll(DEFAULT_ARGS) .addAll(disableImplicitProcessing(extraArgs)) .build(); } /** * Adds a source file to the test compilation, from the string content of the file. * * <p>The diagnostics expected from compiling the file are inferred from the file contents. For * each line of the test file that contains the bug marker pattern "// BUG: Diagnostic contains: * foo", we expect to see a diagnostic on that line containing "foo". For each line of the test * file that does <i>not</i> contain the bug marker pattern, we expect no diagnostic to be * generated. You can also use "// BUG: Diagnostic matches: X" in tandem with * {@code expectErrorMessage("X", "foo")} to allow you to programatically construct the * error message. * * @param path a path for the source file * @param lines the content of the source file */ // TODO(eaftan): We could eliminate this path parameter and just infer the path from the // package and class name public CompilationTestHelper addSourceLines(String path, String... lines) { this.sources.add(fileManager.forSourceLines(path, lines)); return this; } /** * Adds a source file to the test compilation, from an existing resource file. * * <p>See {@link #addSourceLines} for how expected diagnostics should be specified. * * @param path the path to the source file */ public CompilationTestHelper addSourceFile(String path) { this.sources.add(fileManager.forResource(path)); return this; } /** * Sets custom command-line arguments for the compilation. These will be appended to the default * compilation arguments. */ public CompilationTestHelper setArgs(List<String> args) { this.args = args; return this; } /** * Tells the compilation helper to expect that no diagnostics will be generated, even if the * source file contains bug markers. Useful for testing that a check is actually disabled when the * proper command-line argument is passed. */ public CompilationTestHelper expectNoDiagnostics() { this.expectNoDiagnostics = true; return this; } /** * By default, the compilation helper will not run Error Prone on * compilations that fail with javac errors. This behaviour can be * disabled to test the interaction between Error Prone checks * and javac diagnostics. */ public CompilationTestHelper ignoreJavacErrors() { this.checkWellFormed = false; return this; } /** * By default, the compilation helper will only inspect diagnostics * generated by the check being tested. This behaviour can be * disabled to test the interaction between Error Prone checks * and javac diagnostics. */ public CompilationTestHelper matchAllDiagnostics() { this.lookForCheckNameInDiagnostic = LookForCheckNameInDiagnostic.NO; return this; } /** * Tells the compilation helper to expect a specific result from the compilation, e.g. success * or failure. */ public CompilationTestHelper expectResult(Result result) { expectedResult = Optional.of(result); return this; } /** * Expects an error message matching {@code matcher} at the line below a comment matching the key. * For example, given the source * <pre> * // BUG: Diagnostic matches: X * a = b + c; * </pre> * ... you can use * {@code expectErrorMessage("X", Predicates.containsPattern("Can't add b to c"));} * * <p>Error message keys that don't match any diagnostics will cause test to fail. */ public CompilationTestHelper expectErrorMessage(String key, Predicate<? super String> matcher) { diagnosticHelper.expectErrorMessage(key, matcher); return this; } /** * Performs a compilation and checks that the diagnostics and result match the expectations. */ // TODO(eaftan): any way to ensure that this is actually called? public void doTest() { Preconditions.checkState(!sources.isEmpty(), "No source files to compile"); List<String> allArgs = buildArguments(args); Result result = compile(sources, allArgs.toArray(new String[allArgs.size()])); for (Diagnostic<? extends JavaFileObject> diagnostic : diagnosticHelper.getDiagnostics()) { if (diagnostic.getCode().contains("error.prone.crash")) { fail(diagnostic.getMessage(Locale.ENGLISH)); } } if (expectNoDiagnostics) { List<Diagnostic<? extends JavaFileObject>> diagnostics = diagnosticHelper.getDiagnostics(); assertWithMessage(String.format( "Expected no diagnostics produced, but found %d: %s", diagnostics.size(), diagnostics)) .that(diagnostics.size()) .isEqualTo(0); } else { for (JavaFileObject source : sources) { try { diagnosticHelper.assertHasDiagnosticOnAllMatchingLines( source, lookForCheckNameInDiagnostic); } catch (IOException e) { throw new IOError(e); } } assertTrue("Unused error keys: " + diagnosticHelper.getUnusedLookupKeys(), diagnosticHelper.getUnusedLookupKeys().isEmpty()); } if (expectedResult.isPresent()) { assertWithMessage(String.format( "Expected compilation result %s, but was %s", expectedResult.get(), result)) .that(result) .isEqualTo(expectedResult.get()); } } private Result compile(Iterable<JavaFileObject> sources, String[] args) { if (checkWellFormed) { checkWellFormed(sources, args); } return compiler.run(args, fileManager, ImmutableList.copyOf(sources), null); } private void checkWellFormed(Iterable<JavaFileObject> sources, String[] args) { JavaCompiler compiler = JavacTool.create(); OutputStream outputStream = new ByteArrayOutputStream(); String[] remainingArgs = null; try { remainingArgs = ErrorProneOptions.processArgs(args).getRemainingArgs(); } catch (InvalidCommandLineOptionException e) { fail("Exception during argument processing: " + e); } CompilationTask task = compiler.getTask( new PrintWriter( new BufferedWriter(new OutputStreamWriter(outputStream, UTF_8)), /*autoFlush=*/ true), fileManager, null, buildArguments(Arrays.asList(remainingArgs)), null, sources); boolean result = task.call(); assertWithMessage(String.format( "Test program failed to compile with non Error Prone error: %s", outputStream.toString())) .that(result) .isTrue(); } }