/* * 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; } }