blob: 8bcbedf75769c7c3aa183caccd07cb2413863ff4 [file] [log] [blame]
/*
* Copyright (C) 2013 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;
import static com.android.tools.lint.LintCliFlags.ERRNO_ERRORS;
import static com.android.tools.lint.LintCliFlags.ERRNO_SUCCESS;
import static com.android.tools.lint.client.api.IssueRegistry.LINT_ERROR;
import static com.android.tools.lint.client.api.IssueRegistry.PARSER_ERROR;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.tools.lint.checks.HardcodedValuesDetector;
import com.android.tools.lint.client.api.Configuration;
import com.android.tools.lint.client.api.DefaultConfiguration;
import com.android.tools.lint.client.api.IssueRegistry;
import com.android.tools.lint.client.api.JavaParser;
import com.android.tools.lint.client.api.LintClient;
import com.android.tools.lint.client.api.LintDriver;
import com.android.tools.lint.client.api.LintListener;
import com.android.tools.lint.client.api.LintRequest;
import com.android.tools.lint.client.api.XmlParser;
import com.android.tools.lint.detector.api.Context;
import com.android.tools.lint.detector.api.Issue;
import com.android.tools.lint.detector.api.LintUtils;
import com.android.tools.lint.detector.api.Location;
import com.android.tools.lint.detector.api.Position;
import com.android.tools.lint.detector.api.Project;
import com.android.tools.lint.detector.api.Severity;
import com.android.tools.lint.detector.api.TextFormat;
import com.google.common.annotations.Beta;
import com.google.common.base.Splitter;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.io.Closeables;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
/**
* Lint client for command line usage. Supports the flags in {@link LintCliFlags},
* and offers text, HTML and XML reporting, etc.
* <p>
* Minimal example:
* <pre>
* // files is a list of java.io.Files, typically a directory containing
* // lint projects or direct references to project root directories
* IssueRegistry registry = new BuiltinIssueRegistry();
* LintCliFlags flags = new LintCliFlags();
* LintCliClient client = new LintCliClient(flags);
* int exitCode = client.run(registry, files);
* </pre>
* <p>
* <b>NOTE: This is not a public or final API; if you rely on this be prepared
* to adjust your code for the next tools release.</b>
*/
@Beta
public class LintCliClient extends LintClient {
protected final List<Warning> mWarnings = new ArrayList<Warning>();
protected boolean mHasErrors;
protected int mErrorCount;
protected int mWarningCount;
protected IssueRegistry mRegistry;
protected LintDriver mDriver;
protected final LintCliFlags mFlags;
private Configuration mConfiguration;
private boolean mValidatedIds;
/** Creates a CLI driver */
public LintCliClient() {
mFlags = new LintCliFlags();
TextReporter reporter = new TextReporter(this, mFlags, new PrintWriter(System.out, true),
false);
mFlags.getReporters().add(reporter);
}
public LintCliClient(LintCliFlags flags) {
mFlags = flags;
}
/**
* Runs the static analysis command line driver. You need to add at least one error reporter
* to the command line flags.
*/
public int run(@NonNull IssueRegistry registry, @NonNull List<File> files) throws IOException {
assert !mFlags.getReporters().isEmpty();
mRegistry = registry;
mDriver = new LintDriver(registry, this);
mDriver.setAbbreviating(!mFlags.isShowEverything());
addProgressPrinter();
mDriver.addLintListener(new LintListener() {
@Override
public void update(@NonNull LintDriver driver, @NonNull EventType type,
@Nullable Context context) {
if (type == EventType.SCANNING_PROJECT && !mValidatedIds) {
// Make sure all the id's are valid once the driver is all set up and
// ready to run (such that custom rules are available in the registry etc)
validateIssueIds(context != null ? context.getProject() : null);
}
}
});
mDriver.analyze(createLintRequest(files));
Collections.sort(mWarnings);
boolean hasConsoleOutput = false;
for (Reporter reporter : mFlags.getReporters()) {
reporter.write(mErrorCount, mWarningCount, mWarnings);
if (reporter instanceof TextReporter && ((TextReporter)reporter).isWriteToConsole()) {
hasConsoleOutput = true;
}
}
if (!mFlags.isQuiet() && !hasConsoleOutput) {
System.out.println(String.format(
"Lint found %1$d errors and %2$d warnings", mErrorCount, mWarningCount));
}
return mFlags.isSetExitCode() ? (mHasErrors ? ERRNO_ERRORS : ERRNO_SUCCESS) : ERRNO_SUCCESS;
}
protected void addProgressPrinter() {
if (!mFlags.isQuiet()) {
mDriver.addLintListener(new ProgressPrinter());
}
}
/** Creates a lint request */
@NonNull
protected LintRequest createLintRequest(@NonNull List<File> files) {
return new LintRequest(this, files);
}
@Override
public void log(
@NonNull Severity severity,
@Nullable Throwable exception,
@Nullable String format,
@Nullable Object... args) {
System.out.flush();
if (!mFlags.isQuiet()) {
// Place the error message on a line of its own since we're printing '.' etc
// with newlines during analysis
System.err.println();
}
if (format != null) {
System.err.println(String.format(format, args));
}
if (exception != null) {
exception.printStackTrace();
}
}
@Override
public XmlParser getXmlParser() {
return new LintCliXmlParser();
}
@NonNull
@Override
public Configuration getConfiguration(@NonNull Project project, @Nullable LintDriver driver) {
return new CliConfiguration(getConfiguration(), project, mFlags.isFatalOnly());
}
/** File content cache */
private final Map<File, String> mFileContents = new HashMap<File, String>(100);
/** Read the contents of the given file, possibly cached */
private String getContents(File file) {
String s = mFileContents.get(file);
if (s == null) {
s = readFile(file);
mFileContents.put(file, s);
}
return s;
}
@Override
public JavaParser getJavaParser(@Nullable Project project) {
return new EcjParser(this, project);
}
@Override
public void report(
@NonNull Context context,
@NonNull Issue issue,
@NonNull Severity severity,
@Nullable Location location,
@NonNull String message,
@NonNull TextFormat format) {
assert context.isEnabled(issue) || issue == LINT_ERROR;
if (severity == Severity.IGNORE) {
return;
}
if (severity == Severity.ERROR || severity == Severity.FATAL) {
mHasErrors = true;
mErrorCount++;
} else {
mWarningCount++;
}
// Store the message in the raw format internally such that we can
// convert it to text for the text reporter, HTML for the HTML reporter
// and so on.
message = format.convertTo(message, TextFormat.RAW);
Warning warning = new Warning(issue, message, severity, context.getProject());
mWarnings.add(warning);
if (location != null) {
warning.location = location;
File file = location.getFile();
if (file != null) {
warning.file = file;
warning.path = getDisplayPath(context.getProject(), file);
}
Position startPosition = location.getStart();
if (startPosition != null) {
int line = startPosition.getLine();
warning.line = line;
warning.offset = startPosition.getOffset();
if (line >= 0) {
if (context.file == location.getFile()) {
warning.fileContents = context.getContents();
}
if (warning.fileContents == null) {
warning.fileContents = getContents(location.getFile());
}
if (mFlags.isShowSourceLines()) {
// Compute error line contents
warning.errorLine = getLine(warning.fileContents, line);
if (warning.errorLine != null) {
// Replace tabs with spaces such that the column
// marker (^) lines up properly:
warning.errorLine = warning.errorLine.replace('\t', ' ');
int column = startPosition.getColumn();
if (column < 0) {
column = 0;
for (int i = 0; i < warning.errorLine.length(); i++, column++) {
if (!Character.isWhitespace(warning.errorLine.charAt(i))) {
break;
}
}
}
StringBuilder sb = new StringBuilder(100);
sb.append(warning.errorLine);
sb.append('\n');
for (int i = 0; i < column; i++) {
sb.append(' ');
}
boolean displayCaret = true;
Position endPosition = location.getEnd();
if (endPosition != null) {
int endLine = endPosition.getLine();
int endColumn = endPosition.getColumn();
if (endLine == line && endColumn > column) {
for (int i = column; i < endColumn; i++) {
sb.append('~');
}
displayCaret = false;
}
}
if (displayCaret) {
sb.append('^');
}
sb.append('\n');
warning.errorLine = sb.toString();
}
}
}
}
}
}
/** Look up the contents of the given line */
static String getLine(String contents, int line) {
int index = getLineOffset(contents, line);
if (index != -1) {
return getLineOfOffset(contents, index);
} else {
return null;
}
}
static String getLineOfOffset(String contents, int offset) {
int end = contents.indexOf('\n', offset);
if (end == -1) {
end = contents.indexOf('\r', offset);
}
return contents.substring(offset, end != -1 ? end : contents.length());
}
/** Look up the contents of the given line */
static int getLineOffset(String contents, int line) {
int index = 0;
for (int i = 0; i < line; i++) {
index = contents.indexOf('\n', index);
if (index == -1) {
return -1;
}
index++;
}
return index;
}
@NonNull
@Override
public String readFile(@NonNull File file) {
try {
return LintUtils.getEncodedString(this, file);
} catch (IOException e) {
return ""; //$NON-NLS-1$
}
}
boolean isCheckingSpecificIssues() {
return mFlags.getExactCheckedIds() != null;
}
private Map<Project, ClassPathInfo> mProjectInfo;
@Override
@NonNull
protected ClassPathInfo getClassPath(@NonNull Project project) {
ClassPathInfo classPath = super.getClassPath(project);
List<File> sources = mFlags.getSourcesOverride();
List<File> classes = mFlags.getClassesOverride();
List<File> libraries = mFlags.getLibrariesOverride();
if (classes == null && sources == null && libraries == null) {
return classPath;
}
ClassPathInfo info;
if (mProjectInfo == null) {
mProjectInfo = Maps.newHashMap();
info = null;
} else {
info = mProjectInfo.get(project);
}
if (info == null) {
if (sources == null) {
sources = classPath.getSourceFolders();
}
if (classes == null) {
classes = classPath.getClassFolders();
}
if (libraries == null) {
libraries = classPath.getLibraries();
}
info = new ClassPathInfo(sources, classes, libraries,
classPath.getTestSourceFolders());
mProjectInfo.put(project, info);
}
return info;
}
@NonNull
@Override
public List<File> getResourceFolders(@NonNull Project project) {
List<File> resources = mFlags.getResourcesOverride();
if (resources == null) {
return super.getResourceFolders(project);
}
return resources;
}
/**
* Consult the lint.xml file, but override with the --enable and --disable
* flags supplied on the command line
*/
class CliConfiguration extends DefaultConfiguration {
private boolean mFatalOnly;
CliConfiguration(@NonNull Configuration parent, @NonNull Project project,
boolean fatalOnly) {
super(LintCliClient.this, project, parent);
mFatalOnly = fatalOnly;
}
CliConfiguration(File lintFile, boolean fatalOnly) {
super(LintCliClient.this, null /*project*/, null /*parent*/, lintFile);
mFatalOnly = fatalOnly;
}
@NonNull
@Override
public Severity getSeverity(@NonNull Issue issue) {
Severity severity = computeSeverity(issue);
if (mFatalOnly && severity != Severity.FATAL) {
return Severity.IGNORE;
}
if (mFlags.isWarningsAsErrors() && severity == Severity.WARNING) {
severity = Severity.ERROR;
}
if (mFlags.isIgnoreWarnings() && severity == Severity.WARNING) {
severity = Severity.IGNORE;
}
return severity;
}
@NonNull
@Override
protected Severity getDefaultSeverity(@NonNull Issue issue) {
if (mFlags.isCheckAllWarnings()) {
return issue.getDefaultSeverity();
}
return super.getDefaultSeverity(issue);
}
private Severity computeSeverity(@NonNull Issue issue) {
Severity severity = super.getSeverity(issue);
String id = issue.getId();
Set<String> suppress = mFlags.getSuppressedIds();
if (suppress.contains(id)) {
return Severity.IGNORE;
}
Severity manual = mFlags.getSeverityOverrides().get(id);
if (manual != null) {
return manual;
}
Set<String> enabled = mFlags.getEnabledIds();
Set<String> check = mFlags.getExactCheckedIds();
if (enabled.contains(id) || (check != null && check.contains(id))) {
// Overriding default
// Detectors shouldn't be returning ignore as a default severity,
// but in case they do, force it up to warning here to ensure that
// it's run
if (severity == Severity.IGNORE) {
severity = issue.getDefaultSeverity();
if (severity == Severity.IGNORE) {
severity = Severity.WARNING;
}
}
return severity;
}
if (check != null && issue != LINT_ERROR && issue != PARSER_ERROR) {
return Severity.IGNORE;
}
return severity;
}
}
/**
* Checks that any id's specified by id refer to valid, known, issues. This
* typically can't be done right away (in for example the Gradle code which
* handles DSL references to strings, or in the command line parser for the
* lint command) because the full set of valid id's is not known until lint
* actually starts running and for example gathers custom rules from all
* AAR dependencies reachable from libraries, etc.
*/
private void validateIssueIds(@Nullable Project project) {
if (mDriver != null) {
IssueRegistry registry = mDriver.getRegistry();
if (!registry.isIssueId(HardcodedValuesDetector.ISSUE.getId())) {
// This should not be necessary, but there have been some strange
// reports where lint has reported some well known builtin issues
// to not exist:
//
// Error: Unknown issue id "DuplicateDefinition" [LintError]
// Error: Unknown issue id "GradleIdeError" [LintError]
// Error: Unknown issue id "InvalidPackage" [LintError]
// Error: Unknown issue id "JavascriptInterface" [LintError]
// ...
//
// It's not clear how this can happen, though it's probably related
// to using 3rd party lint rules (where lint will create new composite
// issue registries to wrap the various additional issues) - but
// we definitely don't want to validate issue id's if we can't find
// well known issues.
return;
}
mValidatedIds = true;
validateIssueIds(project, registry, mFlags.getExactCheckedIds());
validateIssueIds(project, registry, mFlags.getEnabledIds());
validateIssueIds(project, registry, mFlags.getSuppressedIds());
validateIssueIds(project, registry, mFlags.getSeverityOverrides().keySet());
}
}
private void validateIssueIds(@Nullable Project project, @NonNull IssueRegistry registry,
@Nullable Collection<String> ids) {
if (ids != null) {
for (String id : ids) {
if (registry.getIssue(id) == null) {
reportNonExistingIssueId(project, id);
}
}
}
}
protected void reportNonExistingIssueId(@Nullable Project project, @NonNull String id) {
String message = String.format("Unknown issue id \"%1$s\"", id);
if (mDriver != null && project != null) {
Location location = Location.create(project.getDir());
if (!isSuppressed(IssueRegistry.LINT_ERROR)) {
report(new Context(mDriver, project, project, project.getDir()),
IssueRegistry.LINT_ERROR,
project.getConfiguration(mDriver).getSeverity(IssueRegistry.LINT_ERROR),
location, message, TextFormat.RAW);
}
} else {
log(Severity.ERROR, null, "Lint: %1$s", message);
}
}
private static class ProgressPrinter implements LintListener {
@Override
public void update(
@NonNull LintDriver lint,
@NonNull EventType type,
@Nullable Context context) {
switch (type) {
case SCANNING_PROJECT: {
String name = context != null ? context.getProject().getName() : "?";
if (lint.getPhase() > 1) {
System.out.print(String.format(
"\nScanning %1$s (Phase %2$d): ",
name,
lint.getPhase()));
} else {
System.out.print(String.format(
"\nScanning %1$s: ",
name));
}
break;
}
case SCANNING_LIBRARY_PROJECT: {
String name = context != null ? context.getProject().getName() : "?";
System.out.print(String.format(
"\n - %1$s: ",
name));
break;
}
case SCANNING_FILE:
System.out.print('.');
break;
case NEW_PHASE:
// Ignored for now: printing status as part of next project's status
break;
case CANCELED:
case COMPLETED:
System.out.println();
break;
case STARTING:
// Ignored for now
break;
}
}
}
/**
* Given a file, it produces a cleaned up path from the file.
* This will clean up the path such that
* <ul>
* <li> {@code foo/./bar} becomes {@code foo/bar}
* <li> {@code foo/bar/../baz} becomes {@code foo/baz}
* </ul>
*
* Unlike {@link java.io.File#getCanonicalPath()} however, it will <b>not</b> attempt
* to make the file canonical, such as expanding symlinks and network mounts.
*
* @param file the file to compute a clean path for
* @return the cleaned up path
*/
@VisibleForTesting
@NonNull
static String getCleanPath(@NonNull File file) {
String path = file.getPath();
StringBuilder sb = new StringBuilder(path.length());
if (path.startsWith(File.separator)) {
sb.append(File.separator);
}
elementLoop:
for (String element : Splitter.on(File.separatorChar).omitEmptyStrings().split(path)) {
if (element.equals(".")) { //$NON-NLS-1$
continue;
} else if (element.equals("..")) { //$NON-NLS-1$
if (sb.length() > 0) {
for (int i = sb.length() - 1; i >= 0; i--) {
char c = sb.charAt(i);
if (c == File.separatorChar) {
sb.setLength(i == 0 ? 1 : i);
continue elementLoop;
}
}
sb.setLength(0);
continue;
}
}
if (sb.length() > 1) {
sb.append(File.separatorChar);
} else if (sb.length() > 0 && sb.charAt(0) != File.separatorChar) {
sb.append(File.separatorChar);
}
sb.append(element);
}
if (path.endsWith(File.separator) && sb.length() > 0
&& sb.charAt(sb.length() - 1) != File.separatorChar) {
sb.append(File.separator);
}
return sb.toString();
}
String getDisplayPath(Project project, File file) {
String path = file.getPath();
if (!mFlags.isFullPath() && path.startsWith(project.getReferenceDir().getPath())) {
int chop = project.getReferenceDir().getPath().length();
if (path.length() > chop && path.charAt(chop) == File.separatorChar) {
chop++;
}
path = path.substring(chop);
if (path.isEmpty()) {
path = file.getName();
}
} else if (mFlags.isFullPath()) {
path = getCleanPath(file.getAbsoluteFile());
}
return path;
}
/** Returns whether all warnings are enabled, including those disabled by default */
boolean isAllEnabled() {
return mFlags.isCheckAllWarnings();
}
/** Returns the issue registry used by this client */
IssueRegistry getRegistry() {
return mRegistry;
}
/** Returns the driver running the lint checks */
LintDriver getDriver() {
return mDriver;
}
private static Set<File> sAlreadyWarned;
/** Returns the configuration used by this client */
Configuration getConfiguration() {
if (mConfiguration == null) {
File configFile = mFlags.getDefaultConfiguration();
if (configFile != null) {
if (!configFile.exists()) {
if (sAlreadyWarned == null || !sAlreadyWarned.contains(configFile)) {
log(Severity.ERROR, null,
"Warning: Configuration file %1$s does not exist", configFile);
}
if (sAlreadyWarned == null) {
sAlreadyWarned = Sets.newHashSet();
}
sAlreadyWarned.add(configFile);
}
mConfiguration = createConfigurationFromFile(configFile);
}
}
return mConfiguration;
}
/** Returns true if the given issue has been explicitly disabled */
boolean isSuppressed(Issue issue) {
return mFlags.getSuppressedIds().contains(issue.getId());
}
public Configuration createConfigurationFromFile(File file) {
return new CliConfiguration(file, mFlags.isFatalOnly());
}
@Nullable
String getRevision() {
File file = findResource("tools" + File.separator + //$NON-NLS-1$
"source.properties"); //$NON-NLS-1$
if (file != null && file.exists()) {
FileInputStream input = null;
try {
input = new FileInputStream(file);
Properties properties = new Properties();
properties.load(input);
String revision = properties.getProperty("Pkg.Revision"); //$NON-NLS-1$
if (revision != null && !revision.isEmpty()) {
return revision;
}
} catch (IOException e) {
// Couldn't find or read the version info: just print out unknown below
} finally {
try {
Closeables.close(input, true /* swallowIOException */);
} catch (IOException e) {
// cannot happen
}
}
}
return null;
}
@NonNull
public LintCliFlags getFlags() {
return mFlags;
}
public boolean haveErrors() {
return mErrorCount > 0;
}
}