blob: d6f97a5e4b6b8f824efbc6adb6c79be6affd943b [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* 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.android.tools.lint.checks.infrastructure;
import static com.android.SdkConstants.ANDROID_NS_NAME;
import static com.android.SdkConstants.ANDROID_URI;
import static com.android.SdkConstants.AUTO_URI;
import static com.android.SdkConstants.DOT_XML;
import static com.android.SdkConstants.TOOLS_URI;
import static com.android.SdkConstants.XMLNS_PREFIX;
import static com.android.tools.lint.detector.api.LintFix.ReplaceString.INSERT_BEGINNING;
import static com.android.tools.lint.detector.api.LintFix.ReplaceString.INSERT_END;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.ide.common.xml.XmlPrettyPrinter;
import com.android.testutils.TestUtils;
import com.android.tools.lint.Warning;
import com.android.tools.lint.detector.api.LintFix;
import com.android.tools.lint.detector.api.LintFix.DataMap;
import com.android.tools.lint.detector.api.LintFix.GroupType;
import com.android.tools.lint.detector.api.LintFix.LintFixGroup;
import com.android.tools.lint.detector.api.LintFix.ReplaceString;
import com.android.tools.lint.detector.api.LintFix.SetAttribute;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Position;
import com.android.utils.PositionXmlParser;
import com.android.utils.XmlUtils;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.xml.parsers.ParserConfigurationException;
import kotlin.text.StringsKt;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.xml.sax.SAXException;
/** Verifier which can simulate IDE quickfixes and check fix data */
public class LintFixVerifier {
private final TestLintTask task;
private final List<Warning> warnings;
private int diffWindow = 0;
private Boolean reformat;
LintFixVerifier(TestLintTask task, List<Warning> warnings) {
this.task = task;
this.warnings = warnings;
}
/** Sets up 2 lines of context in the diffs */
public LintFixVerifier window() {
diffWindow = 2;
return this;
}
/** Sets up a specific number of lines of contexts around diffs */
public LintFixVerifier window(int size) {
assertTrue(size >= 0 && size <= 100);
diffWindow = size;
return this;
}
/**
* Sets whether lint should reformat before and after files before diffing. If not set
* explicitly to true or false, it will default to true for XML files that set/remove attributes
* and false otherwise. (May not have any effect on other file types than XML.)
*/
public LintFixVerifier reformatDiffs(boolean reformatDiffs) {
this.reformat = reformatDiffs;
return this;
}
/**
* Checks what happens with the given fix in this result as applied to the given test file, and
* making sure that the result is the new contents
*
* @param fix the fix description, or null to pick the first one
* @param after the file after applying the fix
* @return this
*/
public LintFixVerifier checkFix(@Nullable String fix, @NonNull TestFile after) {
checkFixes(fix, after, null);
return this;
}
/**
* Applies the fixes and provides diffs of all the affected files, then compares it against the
* expected result.
*
* @param expected the diff description resulting from applying the diffs
* @return this
*/
public LintFixVerifier expectFixDiffs(@NonNull String expected) {
StringBuilder diff = new StringBuilder(100);
checkFixes(null, null, diff);
String actual = StringsKt.trimIndent(diff.toString().replace("\r\n", "\n"));
expected = StringsKt.trimIndent(expected);
if (!expected.equals(actual)) {
// Until 3.2 canary 10 the line numbers were off by one; try adjusting
if (!bumpFixLineNumbers(expected).trim().equals(actual.trim())) {
assertEquals(expected, actual);
}
}
return this;
}
/**
* Given fix-delta output, increases the line numbers by one (needed to gracefully handle older
* fix diffs where the line numbers were 0-based instead of 1-based like the error output.)
*/
@NonNull
private static String bumpFixLineNumbers(@NonNull String output) {
StringBuilder sb = new StringBuilder(output.length());
Pattern pattern = Pattern.compile("((Fix|Data) for .* line )(\\d+)(: .+)");
for (String line : output.split("\n")) {
Matcher matcher = pattern.matcher(line);
if (matcher.matches()) {
String prefix = matcher.group(1);
String lineNumber = matcher.group(3);
String suffix = matcher.group(4);
sb.append(prefix);
sb.append(Integer.toString(Integer.parseInt(lineNumber) + 1));
sb.append(suffix);
} else {
sb.append(line);
}
sb.append('\n');
}
return sb.toString();
}
@Nullable
private TestFile findTestFile(@NonNull String path) {
path = path.replace(File.separatorChar, '/');
for (ProjectDescription project : task.projects) {
for (TestFile file : project.getFiles()) {
if (file.getTargetPath().equals(path)) {
return file;
}
}
}
return null;
}
private void checkFixes(
@Nullable String fixName,
@Nullable TestFile expectedFile,
@Nullable StringBuilder diffs) {
assertTrue(expectedFile != null || diffs != null);
List<String> names = Lists.newArrayList();
for (Warning warning : warnings) {
LintFix data = warning.quickfixData;
List<LintFix> list;
if (data instanceof LintFixGroup) {
LintFixGroup group = (LintFixGroup) data;
if (group.type == GroupType.COMPOSITE) {
// separated out again in applyFix
list = Collections.singletonList(data);
} else {
list = group.fixes;
}
} else {
list = Collections.singletonList(data);
}
for (LintFix lintFix : list) {
String targetPath = warning.path;
TestFile file = findTestFile(targetPath);
if (file == null) {
fail("Didn't find test file " + targetPath);
}
String before = file.getContents();
assertNotNull(file.getTargetPath(), before);
if (lintFix instanceof DataMap && diffs != null) {
// Doesn't edit file, but include in diffs so fixes can verify the
// correct data is passed
appendDataMap(warning, (DataMap) lintFix, diffs);
}
String after;
Boolean reformat = this.reformat;
after = applyFix(warning, lintFix, before);
if (after == null) {
continue;
}
if (reformat == null && haveSetAttribute(lintFix)) {
reformat = true;
}
if (expectedFile != null) {
assertEquals(
StringsKt.trimIndent(expectedFile.getContents()),
StringsKt.trimIndent(after));
}
if (diffs != null) {
if (reformat != null && reformat && warning.path.endsWith(DOT_XML)) {
try {
before =
XmlPrettyPrinter.prettyPrint(
XmlUtils.parseDocument(before, true), true);
after =
XmlPrettyPrinter.prettyPrint(
XmlUtils.parseDocument(after, true), true);
} catch (SAXException | IOException e) {
throw new RuntimeException(e);
}
}
appendDiff(warning, lintFix.getDisplayName(), before, after, diffs);
}
String name = lintFix.getDisplayName();
if (fixName != null && !fixName.equals(name)) {
if (!names.contains(name)) {
names.add(name);
}
continue;
}
names.add(fixName);
}
}
}
@Nullable
private static String applyFix(
@NonNull Warning warning, @NonNull LintFix lintFix, @NonNull String before) {
if (lintFix instanceof ReplaceString) {
ReplaceString replaceFix = (ReplaceString) lintFix;
return checkReplaceString(replaceFix, warning, before);
} else if (lintFix instanceof SetAttribute) {
SetAttribute setFix = (SetAttribute) lintFix;
return checkSetAttribute(setFix, before, warning);
} else if (lintFix instanceof LintFixGroup
&& ((LintFixGroup) lintFix).type == GroupType.COMPOSITE) {
for (LintFix nested : ((LintFixGroup) lintFix).fixes) {
String after = applyFix(warning, nested, before);
if (after == null) {
return null;
}
before = after;
}
return before;
}
return null;
}
private static boolean haveSetAttribute(@NonNull LintFix lintFix) {
if (lintFix instanceof SetAttribute) {
return true;
} else if (lintFix instanceof LintFixGroup
&& ((LintFixGroup) lintFix).type == GroupType.COMPOSITE) {
for (LintFix nested : ((LintFixGroup) lintFix).fixes) {
if (haveSetAttribute(nested)) {
return true;
}
}
}
return false;
}
private static String checkSetAttribute(
@NonNull SetAttribute setFix, @NonNull String contents, @NonNull Warning warning) {
Location location = setFix.range != null ? setFix.range : warning.location;
Position start = location.getStart();
Position end = location.getEnd();
assert start != null;
assert end != null;
try {
org.w3c.dom.Document document;
document = PositionXmlParser.parse(contents);
Node node = PositionXmlParser.findNodeAtOffset(document, start.getOffset());
assertNotNull("No node found at offset " + start.getOffset(), node);
if (node.getNodeType() == Node.ATTRIBUTE_NODE) {
node = ((Attr) node).getOwnerElement();
} else if (node.getNodeType() != Node.ELEMENT_NODE) {
// text, comments
node = node.getParentNode();
}
if (node == null || node.getNodeType() != Node.ELEMENT_NODE) {
fail(
"Didn't find element at offset "
+ start.getOffset()
+ " (line "
+ start.getLine()
+ 1
+ ", column "
+ start.getColumn()
+ 1
+ ") in "
+ warning.path
+ ":\n"
+ contents);
}
Element element = (Element) node;
String value = setFix.value;
String namespace = setFix.namespace;
if (value == null) {
if (namespace != null) {
element.removeAttributeNS(namespace, setFix.attribute);
} else {
element.removeAttribute(setFix.attribute);
}
} else {
// Indicate the caret position by "|"
if (setFix.dot >= 0 && setFix.dot <= value.length()) {
if (setFix.mark >= 0 && setFix.mark != setFix.dot) {
// Selection
assert setFix.mark < setFix.dot;
value =
value.substring(0, setFix.mark)
+ "["
+ value.substring(setFix.mark, setFix.dot)
+ "]|"
+ value.substring(setFix.dot);
} else {
// Just caret
value = value.substring(0, setFix.dot) + "|" + value.substring(setFix.dot);
}
}
if (namespace != null) {
// Workaround for the fact that the namespace-setter method
// doesn't seem to work on these documents
String prefix = document.lookupPrefix(namespace);
if (prefix == null) {
String base = "ns";
if (ANDROID_URI.equals(namespace)) {
base = ANDROID_NS_NAME;
} else if (TOOLS_URI.equals(namespace)) {
base = "tools";
} else if (AUTO_URI.equals(namespace)) {
base = "app";
}
Element root = document.getDocumentElement();
int index = 1;
while (true) {
prefix = base + (index == 1 ? "" : Integer.toString(index));
if (!root.hasAttribute(XMLNS_PREFIX + prefix)) {
break;
}
index++;
}
root.setAttribute(XMLNS_PREFIX + prefix, namespace);
}
element.setAttribute(prefix + ":" + setFix.attribute, value);
} else {
element.setAttribute(setFix.attribute, value);
}
}
return XmlPrettyPrinter.prettyPrint(document, true);
} catch (ParserConfigurationException | SAXException | IOException e) {
throw new RuntimeException(e);
}
}
private static String checkReplaceString(
@NonNull ReplaceString replaceFix, @NonNull Warning warning, @NonNull String contents) {
String oldPattern = replaceFix.oldPattern;
String oldString = replaceFix.oldString;
Location location = replaceFix.range != null ? replaceFix.range : warning.location;
Position start = location.getStart();
Position end = location.getEnd();
assert start != null;
assert end != null;
String locationRange = contents.substring(start.getOffset(), end.getOffset());
int startOffset;
int endOffset;
String replacement = replaceFix.replacement;
if (oldString == null && oldPattern == null) {
// Replace the whole range
startOffset = start.getOffset();
endOffset = end.getOffset();
// See if there's nothing left on the line; if so, delete the whole line
boolean allSpace = true;
for (int offset = 0; offset < replacement.length(); offset++) {
char c = contents.charAt(offset);
if (!Character.isWhitespace(c)) {
allSpace = false;
break;
}
}
if (allSpace) {
int lineBegin = startOffset;
while (lineBegin > 0) {
char c = contents.charAt(lineBegin - 1);
if (c == '\n') {
break;
} else if (!Character.isWhitespace(c)) {
allSpace = false;
break;
}
lineBegin--;
}
int lineEnd = endOffset;
while (lineEnd < contents.length()) {
char c = contents.charAt(lineEnd);
lineEnd++;
if (c == '\n') {
break;
}
}
if (allSpace) {
startOffset = lineBegin;
endOffset = lineEnd;
}
}
} else if (oldString != null) {
int index = locationRange.indexOf(oldString);
if (index != -1) {
startOffset = start.getOffset() + index;
endOffset = start.getOffset() + index + oldString.length();
} else if (oldString.equals(INSERT_BEGINNING)) {
startOffset = start.getOffset();
endOffset = startOffset;
} else if (oldString.equals(INSERT_END)) {
startOffset = end.getOffset();
endOffset = startOffset;
} else {
fail(
"Did not find \""
+ oldString
+ "\" in \""
+ locationRange
+ "\" as suggested in the quickfix. Consider calling "
+ "ReplaceStringBuilder#range() to set a larger range to "
+ "search than the default highlight range.");
return null;
}
} else {
//noinspection ConstantConditions
assertTrue(oldPattern != null);
Pattern pattern = Pattern.compile(oldPattern);
Matcher matcher = pattern.matcher(locationRange);
if (!matcher.find()) {
fail(
"Did not match pattern \""
+ oldPattern
+ "\" in \""
+ locationRange
+ "\" as suggested in the quickfix");
return null;
} else {
startOffset = start.getOffset();
endOffset = startOffset;
if (matcher.groupCount() > 0) {
if (oldPattern.contains("target")) {
try {
startOffset += matcher.start("target");
endOffset += matcher.end("target");
} catch (IllegalArgumentException ignore) {
// Occurrence of "target" not actually a named group
startOffset += matcher.start(1);
endOffset += matcher.end(1);
}
} else {
startOffset += matcher.start(1);
endOffset += matcher.end(1);
}
} else {
startOffset += matcher.start();
endOffset += matcher.end();
}
replacement = replaceFix.expandBackReferences(matcher);
}
}
String s = contents.substring(0, startOffset) + replacement + contents.substring(endOffset);
// Insert selection/caret markers if configured for this fix
if (replaceFix.selectPattern != null) {
Pattern pattern = Pattern.compile(replaceFix.selectPattern);
Matcher matcher = pattern.matcher(s);
if (matcher.find(start.getOffset())) {
int selectStart;
int selectEnd;
if (matcher.groupCount() > 0) {
selectStart = matcher.start(1);
selectEnd = matcher.end(1);
} else {
selectStart = matcher.start();
selectEnd = matcher.end();
}
if (selectStart == selectEnd) {
s = s.substring(0, selectStart) + "|" + s.substring(selectEnd);
} else {
s =
s.substring(0, selectStart)
+ "["
+ s.substring(selectStart, selectEnd)
+ "]"
+ s.substring(selectEnd);
}
}
}
return s;
}
private void appendDiff(
@NonNull Warning warning,
@Nullable String fixDescription,
@NonNull String before,
@NonNull String after,
@NonNull StringBuilder diffs) {
String diff = TestUtils.getDiff(before, after, diffWindow);
if (!diff.isEmpty()) {
String targetPath = warning.path.replace(File.separatorChar, '/');
diffs.append("Fix for ")
.append(targetPath)
.append(" line ")
.append(warning.line + 1)
.append(": ");
if (fixDescription != null) {
diffs.append(fixDescription).append(":\n");
}
diffs.append(diff);
}
}
private static void appendDataMap(
@NonNull Warning warning, @NonNull DataMap map, @NonNull StringBuilder diffs) {
String targetPath = warning.path;
diffs.append("Data for ")
.append(targetPath.replace(File.separatorChar, '/'))
.append(" line ")
.append(warning.line + 1)
.append(": ");
String fixDescription = map.getDisplayName();
if (fixDescription != null) {
diffs.append(fixDescription).append(":\n");
}
List<Object> keys = Lists.newArrayList(map.keys());
keys.sort(Comparator.comparing(Object::toString));
for (Object key : keys) {
diffs.append(" ");
if (key instanceof Class<?>) {
diffs.append(((Class<?>) key).getSimpleName());
} else {
assert key instanceof String;
diffs.append(key.toString());
}
diffs.append(" : ");
if (key instanceof Class<?>) {
diffs.append(map.get((Class<?>) key));
} else {
diffs.append(map.get(key.toString()));
}
}
}
}