/*
* SonarQube Java
* Copyright (C) 2012-2016 SonarSource SA
* mailto:contact AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.sonar.java.se;
import com.google.common.annotations.Beta;
import com.google.common.base.Preconditions;
import com.google.common.collect.HashMultiset;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multiset;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.assertj.core.api.Fail;
import org.sonar.java.AnalyzerMessage;
import org.sonar.java.ast.JavaAstScanner;
import org.sonar.java.model.VisitorsBridgeForTests;
import org.sonar.plugins.java.api.JavaFileScanner;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import static java.util.stream.Collectors.joining;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
import static org.sonar.java.se.Expectations.IssueAttribute.EFFORT_TO_FIX;
import static org.sonar.java.se.Expectations.IssueAttribute.END_COLUMN;
import static org.sonar.java.se.Expectations.IssueAttribute.END_LINE;
import static org.sonar.java.se.Expectations.IssueAttribute.FLOWS;
import static org.sonar.java.se.Expectations.IssueAttribute.MESSAGE;
import static org.sonar.java.se.Expectations.IssueAttribute.SECONDARY_LOCATIONS;
import static org.sonar.java.se.Expectations.IssueAttribute.START_COLUMN;
/**
* It is possible to specify the absolute line number on which the issue should appear by appending {@literal "@<line>"} to "Noncompliant".
* But usually better to use line number relative to the current, this is possible to do by prefixing the number with either '+' or '-'.
* For example:
* <pre>
* // Noncompliant@+1 {{do not import "java.util.List"}}
* import java.util.List;
* </pre>
* Full syntax:
* <pre>
* // Noncompliant@+1 [[startColumn=1;endLine=+1;endColumn=2;effortToFix=4;secondary=3,4]] {{issue message}}
* </pre>
* Attributes between [[]] are optional:
* <ul>
* <li>startColumn: column where the highlight starts</li>
* <li>endLine: relative endLine where the highlight ends (i.e. +1), same line if omitted</li>
* <li>endColumn: column where the highlight ends</li>
* <li>effortToFix: the cost to fix as integer</li>
* <li>secondary: a comma separated list of integers identifying the lines of secondary locations if any</li>
* </ul>
*/
@Beta
public class JavaCheckVerifier {
/**
* Default location of the jars/zips to be taken into account when performing the analysis.
*/
private static final String DEFAULT_TEST_JARS_DIRECTORY = "target/test-jars";
private final String testJarsDirectory;
private final Expectations expectations;
private JavaCheckVerifier() {
this.testJarsDirectory = DEFAULT_TEST_JARS_DIRECTORY;
this.expectations = new Expectations();
}
private JavaCheckVerifier(Expectations expectations) {
this(DEFAULT_TEST_JARS_DIRECTORY, expectations);
}
private JavaCheckVerifier(String testJarsDirectory, Expectations expectations) {
this.testJarsDirectory = testJarsDirectory;
this.expectations = expectations;
}
/**
* Verifies that the provided file will raise all the expected issues when analyzed with the given check.
*
* <br /><br />
*
* By default, any jar or zip archive present in the folder defined by {@link JavaCheckVerifier#DEFAULT_TEST_JARS_DIRECTORY} will be used
* to add extra classes to the classpath. If this folder is empty or does not exist, then the analysis will be based on the source of
* the provided file.
*
* @param filename The file to be analyzed
* @param check The check to be used for the analysis
*/
public static void verify(String filename, JavaFileScanner... check) {
new JavaCheckVerifier().scanFile(filename, check);
}
/**
* Verifies that the provided file will raise all the expected issues when analyzed with the given check,
* but using having the classpath extended with a collection of files (classes/jar/zip).
*
* @param filename The file to be analyzed
* @param check The check to be used for the analysis
* @param classpath The files to be used as classpath
*/
public static void verify(String filename, JavaFileScanner check, Collection<File> classpath) {
new JavaCheckVerifier().scanFile(filename, new JavaFileScanner[] {check}, classpath);
}
/**
* Verifies that the provided file will raise all the expected issues when analyzed with the given check,
* using jars/zips files from the given directory to extends the classpath.
*
* @param filename The file to be analyzed
* @param check The check to be used for the analysis
* @param testJarsDirectory The directory containing jars and/or zip defining the classpath to be used
*/
public static void verify(String filename, JavaFileScanner check, String testJarsDirectory) {
JavaCheckVerifier javaCheckVerifier = new JavaCheckVerifier(testJarsDirectory, new Expectations());
javaCheckVerifier.scanFile(filename, new JavaFileScanner[] {check});
}
/**
* Verifies that the provided file will not raise any issue when analyzed with the given check.
*
* @param filename The file to be analyzed
* @param check The check to be used for the analysis
*/
public static void verifyNoIssue(String filename, JavaFileScanner check) {
JavaCheckVerifier javaCheckVerifier = new JavaCheckVerifier(new Expectations(true, null, null));
javaCheckVerifier.scanFile(filename, new JavaFileScanner[] {check});
}
/**
* Verifies that the provided file will only raise an issue on the file, with the given message, when analyzed using the given check.
*
* @param filename The file to be analyzed
* @param message The message expected to be raised on the file
* @param check The check to be used for the analysis
*/
public static void verifyIssueOnFile(String filename, String message, JavaFileScanner check) {
JavaCheckVerifier javaCheckVerifier = new JavaCheckVerifier(new Expectations(false, message, null));
javaCheckVerifier.scanFile(filename, new JavaFileScanner[] {check});
}
private void scanFile(String filename, JavaFileScanner[] checks) {
Collection<File> classpath = Lists.newLinkedList();
File testJars = new File(testJarsDirectory);
if (testJars.exists()) {
classpath = FileUtils.listFiles(testJars, new String[] {"jar", "zip"}, true);
} else if (!DEFAULT_TEST_JARS_DIRECTORY.equals(testJarsDirectory)) {
fail("The directory to be used to extend class path does not exists (" + testJars.getAbsolutePath() + ").");
}
classpath.add(new File("target/test-classes"));
scanFile(filename, checks, classpath);
}
private void scanFile(String filename, JavaFileScanner[] checks, Collection<File> classpath) {
List<JavaFileScanner> visitors = new ArrayList<>(Arrays.asList(checks));
visitors.add(expectations.parser());
VisitorsBridgeForTests visitorsBridge = new VisitorsBridgeForTests(visitors, Lists.newArrayList(classpath), null);
JavaAstScanner.scanSingleFileForTests(new File(filename), visitorsBridge);
VisitorsBridgeForTests.TestJavaFileScannerContext testJavaFileScannerContext = visitorsBridge.lastCreatedTestContext();
checkIssues(testJavaFileScannerContext.getIssues());
}
private void checkIssues(Set<AnalyzerMessage> issues) {
if (expectations.expectNoIssues) {
assertNoIssues(expectations.issues, issues);
} else if (StringUtils.isNotEmpty(expectations.expectFileIssue)) {
assertSingleIssue(expectations.expectFileIssueOnLine, expectations.expectFileIssue, issues);
} else {
assertMultipleIssue(issues);
}
}
private void assertMultipleIssue(Set<AnalyzerMessage> issues) throws AssertionError {
Preconditions.checkState(!issues.isEmpty(), "At least one issue expected");
List<Integer> unexpectedLines = Lists.newLinkedList();
Multimap<Integer, Expectations.Issue> expected = expectations.issues;
expectations.reverseFlows(); // platform expects the flows in reversed order compared to the order they appear in the file
for (AnalyzerMessage issue : issues) {
validateIssue(expected, unexpectedLines, issue);
}
if (!expected.isEmpty() || !unexpectedLines.isEmpty()) {
Collections.sort(unexpectedLines);
String expectedMsg = !expected.isEmpty() ? ("Expected " + expected) : "";
String unexpectedMsg = !unexpectedLines.isEmpty() ? ((expectedMsg.isEmpty() ? "" : ", ") + "Unexpected at " + unexpectedLines) : "";
fail(expectedMsg + unexpectedMsg);
}
assertSuperfluousFlows();
}
private void assertSuperfluousFlows() {
Set<String> unseenFlowIds = expectations.unseenFlowIds();
Map<String, String> unseenFlowWithLines = unseenFlowIds.stream()
.collect(Collectors.toMap(Function.identity(), expectations::flowToLines));
assertThat(unseenFlowWithLines).overridingErrorMessage("Following flow comments were observed, but not referenced by any issue: " + unseenFlowWithLines).isEmpty();
}
private void validateIssue(Multimap<Integer, Expectations.Issue> expected,
List<Integer> unexpectedLines, AnalyzerMessage issue) {
int line = issue.getLine();
if (expected.containsKey(line)) {
Expectations.Issue attrs = Iterables.getLast(expected.get(line));
assertAttributeMatch(issue, attrs, MESSAGE);
validateAnalyzerMessageAttributes(attrs, issue);
expected.remove(line, attrs);
} else {
unexpectedLines.add(line);
}
}
private void validateAnalyzerMessageAttributes(Expectations.Issue attrs, AnalyzerMessage analyzerMessage) {
Double effortToFix = analyzerMessage.getCost();
if (effortToFix != null) {
assertAttributeMatch(effortToFix, attrs, EFFORT_TO_FIX);
}
validateLocation(attrs, analyzerMessage.primaryLocation());
if (attrs.containsKey(SECONDARY_LOCATIONS)) {
List<AnalyzerMessage> actual = analyzerMessage.flows.stream().map(l -> l.isEmpty() ? null : l.get(0)).filter(Objects::nonNull).collect(Collectors.toList());
List<Integer> expected = (List<Integer>) attrs.get(SECONDARY_LOCATIONS);
validateSecondaryLocations(actual, expected);
}
if (attrs.containsKey(FLOWS)) {
validateFlows(analyzerMessage.flows, (List<String>) attrs.get(FLOWS));
}
}
private static void validateLocation(Map<Expectations.IssueAttribute, Object> attrs, AnalyzerMessage.TextSpan textSpan) {
Preconditions.checkNotNull(textSpan);
assertAttributeMatch(normalizeColumn(textSpan.startCharacter), attrs, START_COLUMN);
assertAttributeMatch(textSpan.endLine, attrs, END_LINE);
assertAttributeMatch(normalizeColumn(textSpan.endCharacter), attrs, END_COLUMN);
}
private void validateFlows(List<List<AnalyzerMessage>> actual, List<String> expectedFlowIds) {
Map<String, List<AnalyzerMessage>> foundFlows = new HashMap<>();
List<List<AnalyzerMessage>> unexpectedFlows = new ArrayList<>();
actual.forEach(f -> validateFlow(f, foundFlows, unexpectedFlows));
expectedFlowIds.removeAll(foundFlows.keySet());
assertExpectedAndMissingFlows(expectedFlowIds, unexpectedFlows);
validateFoundFlows(foundFlows);
}
private void assertExpectedAndMissingFlows(List<String> expectedFlowIds, List<List<AnalyzerMessage>> unexpectedFlows) {
if (expectedFlowIds.size() == 1 && expectedFlowIds.size() == unexpectedFlows.size()) {
assertSoleFlowDiscrepancy(expectedFlowIds.get(0), unexpectedFlows.get(0));
}
String unexpectedMsg = unexpectedFlows.stream()
.map(JavaCheckVerifier::flowToString)
.collect(joining("\n"));
String missingMsg = expectedFlowIds.stream().map(fid -> String.format("%s [%s]", fid, expectations.flowToLines(fid))).collect(joining(","));
if (!unexpectedMsg.isEmpty() || !missingMsg.isEmpty()) {
unexpectedMsg = unexpectedMsg.isEmpty() ? "" : String.format("Unexpected flows: %s. ", unexpectedMsg);
missingMsg = missingMsg.isEmpty() ? "" : String.format("Missing flows: %s.", missingMsg);
Fail.fail(unexpectedMsg + missingMsg);
}
}
private void assertSoleFlowDiscrepancy(String expectedId, List<AnalyzerMessage> actualFlow) {
Collection<Expectations.FlowComment> expected = expectations.flows.get(expectedId);
List<Integer> expectedLines = expected.stream().sorted(Comparator.comparingInt(Expectations.FlowComment::order)).map(f -> f.line).collect(Collectors.toList());
List<Integer> actualLines = actualFlow.stream().map(AnalyzerMessage::getLine).collect(Collectors.toList());
assertThat(actualLines).as("Flow " + expectedId + " has line differences").isEqualTo(expectedLines);
}
private void validateFlow(List<AnalyzerMessage> flow, Map<String, List<AnalyzerMessage>> foundFlows, List<List<AnalyzerMessage>> unexpectedFlows) {
Optional<String> flowId = expectations.containFlow(flow);
if (flowId.isPresent()) {
foundFlows.put(flowId.get(), flow);
} else {
unexpectedFlows.add(flow);
}
}
private void validateFoundFlows(Map<String, List<AnalyzerMessage>> foundFlows) {
foundFlows.forEach((flowId, flow) -> validateFlowAttributes(flow, flowId));
}
private void validateFlowAttributes(List<AnalyzerMessage> actual, String flowId) {
List<Expectations.FlowComment> expected = expectations.flows.get(flowId);
validateFlowMessages(actual, flowId, expected);
Iterator<AnalyzerMessage> actualIterator = actual.iterator();
Iterator<Expectations.FlowComment> expectedIterator = expected.iterator();
while (actualIterator.hasNext() && expectedIterator.hasNext()) {
AnalyzerMessage.TextSpan flowLocation = actualIterator.next().primaryLocation();
assertThat(flowLocation).isNotNull();
Expectations.FlowComment flowComment = expectedIterator.next();
validateLocation(flowComment.attributes, flowLocation);
}
}
private void validateFlowMessages(List<AnalyzerMessage> actual, String flowId, List<Expectations.FlowComment> expected) {
List<String> actualMessages = actual.stream().map(AnalyzerMessage::getMessage).collect(Collectors.toList());
List<String> expectedMessages = expected.stream().map(Expectations.FlowComment::message).collect(Collectors.toList());
replaceExpectedNullWithActual(actualMessages, expectedMessages);
assertThat(actualMessages).as("Wrong messages in flow " + flowId + " [" + expectations.flowToLines(flowId) + "]").isEqualTo(expectedMessages);
}
private void replaceExpectedNullWithActual(List<String> actualMessages, List<String> expectedMessages) {
if (actualMessages.size() == expectedMessages.size()) {
for (int i =0; i < actualMessages.size(); i++) {
if (expectedMessages.get(i) == null) {
expectedMessages.set(i, actualMessages.get(i));
}
}
}
}
private static String flowToString(List<AnalyzerMessage> flow) {
return flow.stream().map(m -> String.valueOf(m.getLine())).sorted().collect(joining(",","[","]"));
}
private static void validateSecondaryLocations(List<AnalyzerMessage> actual, List<Integer> expected) {
Multiset<Integer> actualLines = HashMultiset.create();
actualLines.addAll(actual.stream().map(secondaryLocation -> secondaryLocation.getLine()).collect(Collectors.toList()));
List<Integer> unexpected = new ArrayList<>();
for (Integer actualLine : actualLines) {
if (expected.contains(actualLine)) {
expected.remove(actualLine);
} else {
unexpected.add(actualLine);
}
}
if (!expected.isEmpty() || !unexpected.isEmpty()) {
fail("Secondary locations: expected: " + expected + " unexpected:" + unexpected);
}
}
private static int normalizeColumn(int startCharacter) {
return startCharacter + 1;
}
private static void assertAttributeMatch(Object value, Map<Expectations.IssueAttribute, Object> attributes, Expectations.IssueAttribute attribute) {
if (attributes.containsKey(attribute)) {
assertThat(value).as("attribute mismatch for " + attribute + ": " + attributes).isEqualTo(attribute.getter.apply(attributes.get(attribute)));
}
}
private static void assertAttributeMatch(AnalyzerMessage issue, Map<Expectations.IssueAttribute, Object> attributes, Expectations.IssueAttribute attribute) {
if (attributes.containsKey(attribute)) {
assertThat(issue.getMessage())
.as("line " + issue.getLine() + " attribute mismatch for " + attribute + ": " + attributes)
.isEqualTo(String.valueOf((Object) attribute.get(attributes)));
}
}
private static void assertSingleIssue(Integer expectFileIssueOnline, String expectFileIssue, Set<AnalyzerMessage> issues) {
Preconditions.checkState(issues.size() == 1, "A single issue is expected with line " + expectFileIssueOnline);
AnalyzerMessage issue = Iterables.getFirst(issues, null);
assertThat(issue.getLine()).isEqualTo(expectFileIssueOnline);
assertThat(issue.getMessage()).isEqualTo(expectFileIssue);
}
private static void assertNoIssues(Multimap<Integer, Expectations.Issue> expected, Set<AnalyzerMessage> issues) {
assertThat(issues).overridingErrorMessage("No issues expected but got: " + issues).isEmpty();
// make sure we do not copy&paste verifyNoIssue call when we intend to call verify
assertThat(expected.isEmpty()).overridingErrorMessage("The file should not declare noncompliants when no issues are expected").isTrue();
}
}