blob: d5c85aebd3dace645889e9a7bada25dba934c949 [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.
*/
@file:JvmName("Driver")
package com.android.tools.metalava
import com.android.SdkConstants.DOT_JAR
import com.android.SdkConstants.DOT_JAVA
import com.android.SdkConstants.DOT_KT
import com.android.SdkConstants.DOT_TXT
import com.android.ide.common.process.CachedProcessOutputHandler
import com.android.ide.common.process.DefaultProcessExecutor
import com.android.ide.common.process.ProcessInfoBuilder
import com.android.ide.common.process.ProcessOutput
import com.android.ide.common.process.ProcessOutputHandler
import com.android.tools.lint.UastEnvironment
import com.android.tools.lint.annotations.Extractor
import com.android.tools.lint.checks.infrastructure.ClassName
import com.android.tools.lint.detector.api.assertionsEnabled
import com.android.tools.metalava.CompatibilityCheck.CheckRequest
import com.android.tools.metalava.apilevels.ApiGenerator
import com.android.tools.metalava.model.ClassItem
import com.android.tools.metalava.model.Codebase
import com.android.tools.metalava.model.Item
import com.android.tools.metalava.model.PackageDocs
import com.android.tools.metalava.model.psi.PsiBasedCodebase
import com.android.tools.metalava.model.psi.packageHtmlToJavadoc
import com.android.tools.metalava.model.text.TextCodebase
import com.android.tools.metalava.model.visitors.ApiVisitor
import com.android.tools.metalava.stub.StubWriter
import com.android.utils.StdLogger
import com.android.utils.StdLogger.Level.ERROR
import com.google.common.base.Stopwatch
import com.google.common.collect.Lists
import com.google.common.io.Files
import com.intellij.core.CoreApplicationEnvironment
import com.intellij.openapi.diagnostic.DefaultLogger
import com.intellij.openapi.util.Disposer
import com.intellij.pom.java.LanguageLevel
import com.intellij.psi.javadoc.CustomJavadocTagProvider
import com.intellij.psi.javadoc.JavadocTagInfo
import org.jetbrains.kotlin.config.CommonConfigurationKeys.MODULE_NAME
import org.jetbrains.kotlin.config.LanguageVersionSettings
import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.util.concurrent.TimeUnit.SECONDS
import java.util.function.Predicate
import kotlin.system.exitProcess
import kotlin.text.Charsets.UTF_8
const val PROGRAM_NAME = "metalava"
const val HELP_PROLOGUE = "$PROGRAM_NAME extracts metadata from source code to generate artifacts such as the " +
"signature files, the SDK stub files, external annotations etc."
const val PACKAGE_HTML = "package.html"
const val OVERVIEW_HTML = "overview.html"
@Suppress("PropertyName") // Can't mark const because trimIndent() :-(
val BANNER: String = """
_ _
_ __ ___ ___| |_ __ _| | __ ___ ____ _
| '_ ` _ \ / _ \ __/ _` | |/ _` \ \ / / _` |
| | | | | | __/ || (_| | | (_| |\ V / (_| |
|_| |_| |_|\___|\__\__,_|_|\__,_| \_/ \__,_|
""".trimIndent()
fun main(args: Array<String>) {
run(args, setExitCode = true)
}
internal var hasFileReadViolations = false
/**
* The metadata driver is a command line interface to extracting various metadata
* from a source tree (or existing signature files etc). Run with --help to see
* more details.
*/
fun run(
originalArgs: Array<String>,
stdout: PrintWriter = PrintWriter(OutputStreamWriter(System.out)),
stderr: PrintWriter = PrintWriter(OutputStreamWriter(System.err)),
setExitCode: Boolean = false
): Boolean {
var exitCode = 0
try {
val modifiedArgs = preprocessArgv(originalArgs)
progress("$PROGRAM_NAME started\n")
// Dump the arguments, and maybe generate a rerun-script.
maybeDumpArgv(stdout, originalArgs, modifiedArgs)
// Actual work begins here.
compatibility = Compatibility(compat = Options.useCompatMode(modifiedArgs))
options = Options(modifiedArgs, stdout, stderr)
maybeActivateSandbox()
processFlags()
if (options.allReporters.any { it.hasErrors() } && !options.passBaselineUpdates) {
// Repeat the errors at the end to make it easy to find the actual problems.
if (options.repeatErrorsMax > 0) {
repeatErrors(stderr, options.allReporters, options.repeatErrorsMax)
}
exitCode = -1
}
if (hasFileReadViolations) {
if (options.strictInputFiles.shouldFail) {
stderr.print("Error: ")
exitCode = -1
} else {
stderr.print("Warning: ")
}
stderr.println("$PROGRAM_NAME detected access to files that are not explicitly specified. See ${options.strictInputViolationsFile} for details.")
}
} catch (e: DriverException) {
stdout.flush()
stderr.flush()
val prefix = if (e.exitCode != 0) { "Aborting: " } else { "" }
if (e.stderr.isNotBlank()) {
stderr.println("\n${prefix}${e.stderr}")
}
if (e.stdout.isNotBlank()) {
stdout.println("\n${prefix}${e.stdout}")
}
exitCode = e.exitCode
} finally {
disposeUastEnvironment()
}
// Update and close all baseline files.
options.allBaselines.forEach { baseline ->
if (options.verbose) {
baseline.dumpStats(options.stdout)
}
if (baseline.close()) {
if (!options.quiet) {
stdout.println("$PROGRAM_NAME wrote updated baseline to ${baseline.updateFile}")
}
}
}
options.reportEvenIfSuppressedWriter?.close()
options.strictInputViolationsPrintWriter?.close()
// Show failure messages, if any.
options.allReporters.forEach {
it.writeErrorMessage(stderr)
}
stdout.flush()
stderr.flush()
if (setExitCode) {
exit(exitCode)
}
return exitCode == 0
}
private fun exit(exitCode: Int = 0) {
if (options.verbose) {
progress("$PROGRAM_NAME exiting with exit code $exitCode\n")
}
options.stdout.flush()
options.stderr.flush()
exitProcess(exitCode)
}
private fun maybeActivateSandbox() {
// Set up a sandbox to detect access to files that are not explicitly specified.
if (options.strictInputFiles == Options.StrictInputFileMode.PERMISSIVE) {
return
}
val writer = options.strictInputViolationsPrintWriter!!
// Writes all violations to [Options.strictInputFiles].
// If Options.StrictInputFile.Mode is STRICT, then all violations on reads are logged, and the
// tool exits with a negative error code if there are any file read violations. Directory read
// violations are logged, but are considered to be a "warning" and doesn't affect the exit code.
// If STRICT_WARN, all violations on reads are logged similar to STRICT, but the exit code is
// unaffected.
// If STRICT_WITH_STACK, similar to STRICT, but also logs the stack trace to
// Options.strictInputFiles.
// See [FileReadSandbox] for the details.
FileReadSandbox.activate(object : FileReadSandbox.Listener {
var seen = mutableSetOf<String>()
override fun onViolation(absolutePath: String, isDirectory: Boolean) {
if (!seen.contains(absolutePath)) {
val suffix = if (isDirectory) "/" else ""
writer.println("$absolutePath$suffix")
if (options.strictInputFiles == Options.StrictInputFileMode.STRICT_WITH_STACK) {
Throwable().printStackTrace(writer)
}
seen.add(absolutePath)
if (!isDirectory) {
hasFileReadViolations = true
}
}
}
})
}
private fun repeatErrors(writer: PrintWriter, reporters: List<Reporter>, max: Int) {
writer.println("Error: $PROGRAM_NAME detected the following problems:")
val totalErrors = reporters.sumOf { it.errorCount }
var remainingCap = max
var totalShown = 0
reporters.forEach {
val numShown = it.printErrors(writer, remainingCap)
remainingCap -= numShown
totalShown += numShown
}
if (totalShown < totalErrors) {
writer.println("${totalErrors - totalShown} more error(s) omitted. Search the log for 'error:' to find all of them.")
}
}
private fun processFlags() {
val stopwatch = Stopwatch.createStarted()
processNonCodebaseFlags()
val sources = options.sources
val codebase =
if (sources.isNotEmpty() && sources[0].path.endsWith(DOT_TXT)) {
// Make sure all the source files have .txt extensions.
sources.firstOrNull { !it.path.endsWith(DOT_TXT) }?. let {
throw DriverException("Inconsistent input file types: The first file is of $DOT_TXT, but detected different extension in ${it.path}")
}
SignatureFileLoader.loadFiles(sources, options.inputKotlinStyleNulls)
} else if (options.apiJar != null) {
loadFromJarFile(options.apiJar!!)
} else if (sources.size == 1 && sources[0].path.endsWith(DOT_JAR)) {
loadFromJarFile(sources[0])
} else if (sources.isNotEmpty() || options.sourcePath.isNotEmpty()) {
loadFromSources()
} else {
return
}
options.manifest?.let { codebase.manifest = it }
if (options.verbose) {
progress("$PROGRAM_NAME analyzed API in ${stopwatch.elapsed(SECONDS)} seconds\n")
}
options.subtractApi?.let {
progress("Subtracting API: ")
subtractApi(codebase, it)
}
val androidApiLevelXml = options.generateApiLevelXml
val apiLevelJars = options.apiLevelJars
if (androidApiLevelXml != null && apiLevelJars != null) {
progress("Generating API levels XML descriptor file, ${androidApiLevelXml.name}: ")
ApiGenerator.generate(apiLevelJars, androidApiLevelXml, codebase)
}
if (options.docStubsDir != null && codebase.supportsDocumentation()) {
progress("Enhancing docs: ")
val docAnalyzer = DocAnalyzer(codebase)
docAnalyzer.enhance()
val applyApiLevelsXml = options.applyApiLevelsXml
if (applyApiLevelsXml != null) {
progress("Applying API levels")
docAnalyzer.applyApiLevels(applyApiLevelsXml)
}
}
// Generate the documentation stubs *before* we migrate nullness information.
options.docStubsDir?.let {
createStubFiles(
it, codebase, docStubs = true,
writeStubList = options.docStubsSourceList != null
)
}
// Based on the input flags, generates various output files such
// as signature files and/or stubs files
options.apiFile?.let { apiFile ->
val apiType = ApiType.PUBLIC_API
val apiEmit = apiType.getEmitFilter()
val apiReference = apiType.getReferenceFilter()
createReportFile(codebase, apiFile, "API") { printWriter ->
SignatureWriter(printWriter, apiEmit, apiReference, codebase.preFiltered)
}
}
options.apiXmlFile?.let { apiFile ->
val apiType = ApiType.PUBLIC_API
val apiEmit = apiType.getEmitFilter()
val apiReference = apiType.getReferenceFilter()
createReportFile(codebase, apiFile, "XML API") { printWriter ->
JDiffXmlWriter(printWriter, apiEmit, apiReference, codebase.preFiltered)
}
}
options.removedApiFile?.let { apiFile ->
val unfiltered = codebase.original ?: codebase
val apiType = ApiType.REMOVED
val removedEmit = apiType.getEmitFilter()
val removedReference = apiType.getReferenceFilter()
createReportFile(unfiltered, apiFile, "removed API") { printWriter ->
SignatureWriter(printWriter, removedEmit, removedReference, codebase.original != null)
}
}
options.dexApiFile?.let { apiFile ->
val apiFilter = FilterPredicate(ApiPredicate())
val memberIsNotCloned: Predicate<Item> = Predicate { !it.isCloned() }
val apiReference = ApiPredicate(ignoreShown = true)
val dexApiEmit = memberIsNotCloned.and(apiFilter)
createReportFile(
codebase, apiFile, "DEX API"
) { printWriter -> DexApiWriter(printWriter, dexApiEmit, apiReference) }
}
options.removedDexApiFile?.let { apiFile ->
val unfiltered = codebase.original ?: codebase
val removedFilter = FilterPredicate(ApiPredicate(matchRemoved = true))
val removedReference = ApiPredicate(ignoreShown = true, ignoreRemoved = true)
val memberIsNotCloned: Predicate<Item> = Predicate { !it.isCloned() }
val removedDexEmit = memberIsNotCloned.and(removedFilter)
createReportFile(
unfiltered, apiFile, "removed DEX API"
) { printWriter -> DexApiWriter(printWriter, removedDexEmit, removedReference) }
}
options.proguard?.let { proguard ->
val apiEmit = FilterPredicate(ApiPredicate())
val apiReference = ApiPredicate(ignoreShown = true)
createReportFile(
codebase, proguard, "Proguard file"
) { printWriter -> ProguardWriter(printWriter, apiEmit, apiReference) }
}
options.sdkValueDir?.let { dir ->
dir.mkdirs()
SdkFileWriter(codebase, dir).generate()
}
for (check in options.compatibilityChecks) {
checkCompatibility(codebase, check)
}
val previousApiFile = options.migrateNullsFrom
if (previousApiFile != null) {
val previous =
if (previousApiFile.path.endsWith(DOT_JAR)) {
loadFromJarFile(previousApiFile)
} else {
SignatureFileLoader.load(
file = previousApiFile,
kotlinStyleNulls = options.inputKotlinStyleNulls
)
}
// If configured, checks for newly added nullness information compared
// to the previous stable API and marks the newly annotated elements
// as migrated (which will cause the Kotlin compiler to treat problems
// as warnings instead of errors
migrateNulls(codebase, previous)
previous.dispose()
}
convertToWarningNullabilityAnnotations(codebase, options.forceConvertToWarningNullabilityAnnotations)
// Now that we've migrated nullness information we can proceed to write non-doc stubs, if any.
options.stubsDir?.let {
createStubFiles(
it, codebase, docStubs = false,
writeStubList = options.stubsSourceList != null
)
val stubAnnotations = options.copyStubAnnotationsFrom
if (stubAnnotations != null) {
// Support pointing to both stub-annotations and stub-annotations/src/main/java
val src = File(stubAnnotations, "src${File.separator}main${File.separator}java")
val source = if (src.isDirectory) src else stubAnnotations
source.listFiles()?.forEach { file ->
RewriteAnnotations().copyAnnotations(codebase, file, File(it, file.name))
}
}
}
if (options.docStubsDir == null && options.stubsDir == null) {
val writeStubsFile: (File) -> Unit = { file ->
val root = File("").absoluteFile
val rootPath = root.path
val contents = sources.joinToString(" ") {
val path = it.path
if (path.startsWith(rootPath)) {
path.substring(rootPath.length)
} else {
path
}
}
file.writeText(contents)
}
options.stubsSourceList?.let(writeStubsFile)
options.docStubsSourceList?.let(writeStubsFile)
}
options.externalAnnotations?.let { extractAnnotations(codebase, it) }
// Coverage stats?
if (options.dumpAnnotationStatistics) {
progress("Measuring annotation statistics: ")
AnnotationStatistics(codebase).count()
}
if (options.annotationCoverageOf.isNotEmpty()) {
progress("Measuring annotation coverage: ")
AnnotationStatistics(codebase).measureCoverageOf(options.annotationCoverageOf)
}
if (options.verbose) {
val packageCount = codebase.size()
progress("$PROGRAM_NAME finished handling $packageCount packages in ${stopwatch.elapsed(SECONDS)} seconds\n")
}
invokeDocumentationTool()
}
fun subtractApi(codebase: Codebase, subtractApiFile: File) {
val path = subtractApiFile.path
val oldCodebase =
when {
path.endsWith(DOT_TXT) -> SignatureFileLoader.load(subtractApiFile)
path.endsWith(DOT_JAR) -> loadFromJarFile(subtractApiFile)
else -> throw DriverException("Unsupported $ARG_SUBTRACT_API format, expected .txt or .jar: ${subtractApiFile.name}")
}
CodebaseComparator().compare(object : ComparisonVisitor() {
override fun compare(old: ClassItem, new: ClassItem) {
new.emit = false
}
}, oldCodebase, codebase, ApiType.ALL.getReferenceFilter())
}
fun processNonCodebaseFlags() {
// --copy-annotations?
val privateAnnotationsSource = options.privateAnnotationsSource
val privateAnnotationsTarget = options.privateAnnotationsTarget
if (privateAnnotationsSource != null && privateAnnotationsTarget != null) {
val rewrite = RewriteAnnotations()
// Support pointing to both stub-annotations and stub-annotations/src/main/java
val src = File(privateAnnotationsSource, "src${File.separator}main${File.separator}java")
val source = if (src.isDirectory) src else privateAnnotationsSource
source.listFiles()?.forEach { file ->
rewrite.modifyAnnotationSources(null, file, File(privateAnnotationsTarget, file.name))
}
}
// --rewrite-annotations?
options.rewriteAnnotations?.let { RewriteAnnotations().rewriteAnnotations(it) }
// Convert android.jar files?
options.androidJarSignatureFiles?.let { root ->
// Generate API signature files for all the historical JAR files
ConvertJarsToSignatureFiles().convertJars(root)
}
for (convert in options.convertToXmlFiles) {
val signatureApi = SignatureFileLoader.load(
file = convert.fromApiFile,
kotlinStyleNulls = options.inputKotlinStyleNulls
)
val apiType = ApiType.ALL
val apiEmit = apiType.getEmitFilter()
val strip = convert.strip
val apiReference = if (strip) apiType.getEmitFilter() else apiType.getReferenceFilter()
val baseFile = convert.baseApiFile
val outputApi =
if (baseFile != null) {
// Convert base on a diff
val baseApi = SignatureFileLoader.load(
file = baseFile,
kotlinStyleNulls = options.inputKotlinStyleNulls
)
TextCodebase.computeDelta(baseFile, baseApi, signatureApi)
} else {
signatureApi
}
if (outputApi.isEmpty() && baseFile != null && compatibility.compat) {
// doclava compatibility: emits error warning instead of emitting empty <api/> element
options.stdout.println("No API change detected, not generating diff")
} else {
val output = convert.outputFile
if (convert.outputFormat == FileFormat.JDIFF) {
// See JDiff's XMLToAPI#nameAPI
val apiName = convert.outputFile.nameWithoutExtension.replace(' ', '_')
createReportFile(outputApi, output, "JDiff File") { printWriter ->
JDiffXmlWriter(printWriter, apiEmit, apiReference, signatureApi.preFiltered && !strip, apiName)
}
} else {
val prevOptions = options
val prevCompatibility = compatibility
try {
when (convert.outputFormat) {
FileFormat.V1 -> {
compatibility = Compatibility(true)
options = Options(emptyArray(), options.stdout, options.stderr)
FileFormat.V1.configureOptions(options)
}
FileFormat.V2 -> {
compatibility = Compatibility(false)
options = Options(emptyArray(), options.stdout, options.stderr)
FileFormat.V2.configureOptions(options)
}
else -> error("Unsupported format ${convert.outputFormat}")
}
createReportFile(outputApi, output, "Diff API File") { printWriter ->
SignatureWriter(
printWriter, apiEmit, apiReference, signatureApi.preFiltered && !strip
)
}
} finally {
options = prevOptions
compatibility = prevCompatibility
}
}
}
}
}
/**
* Checks compatibility of the given codebase with the codebase described in the
* signature file.
*/
fun checkCompatibility(
codebase: Codebase,
check: CheckRequest
) {
progress("Checking API compatibility ($check): ")
val signatureFile = check.file
val current =
if (signatureFile.path.endsWith(DOT_JAR)) {
loadFromJarFile(signatureFile)
} else {
SignatureFileLoader.load(
file = signatureFile,
kotlinStyleNulls = options.inputKotlinStyleNulls
)
}
if (current is TextCodebase && current.format > FileFormat.V1 && options.outputFormat == FileFormat.V1) {
throw DriverException("Cannot perform compatibility check of signature file $signatureFile in format ${current.format} without analyzing current codebase with $ARG_FORMAT=${current.format}")
}
var newBase: Codebase? = null
var oldBase: Codebase? = null
val releaseType = check.releaseType
val apiType = check.apiType
// If diffing with a system-api or test-api (or other signature-based codebase
// generated from --show-annotations), the API is partial: it's only listing
// the API that is *different* from the base API. This really confuses the
// codebase comparison when diffing with a complete codebase, since it looks like
// many classes and members have been added and removed. Therefore, the comparison
// is simpler if we just make the comparison with the same generated signature
// file. If we've only emitted one for the new API, use it directly, if not, generate
// it first
val new =
if (check.codebase != null) {
SignatureFileLoader.load(
file = check.codebase,
kotlinStyleNulls = options.inputKotlinStyleNulls
)
} else if (!options.showUnannotated || apiType != ApiType.PUBLIC_API) {
if (options.baseApiForCompatCheck != null) {
// This option does not make sense with showAnnotation, as the "base" in that case
// is the non-annotated APIs.
throw DriverException(ARG_CHECK_COMPATIBILITY_BASE_API +
" is not compatible with --showAnnotation.")
}
newBase = codebase
oldBase = newBase
codebase
} else {
// Fast path: if we've already generated a signature file and it's identical, we're good!
val apiFile = options.apiFile
if (apiFile != null && apiFile.readText(UTF_8) == signatureFile.readText(UTF_8)) {
return
}
val baseApiFile = options.baseApiForCompatCheck
if (baseApiFile != null) {
oldBase = SignatureFileLoader.load(
file = baseApiFile,
kotlinStyleNulls = options.inputKotlinStyleNulls
)
newBase = oldBase
}
codebase
}
// If configured, compares the new API with the previous API and reports
// any incompatibilities.
CompatibilityCheck.checkCompatibility(new, current, releaseType, apiType, oldBase, newBase)
// Make sure the text files are identical too? (only applies for *current.txt;
// last-released is expected to differ)
if (releaseType == ReleaseType.DEV && !options.allowCompatibleDifferences) {
val apiFile = if (new.location.isFile)
new.location
else
apiType.getSignatureFile(codebase, "compat-diff-signatures-$apiType")
fun getCanonicalSignatures(file: File): String {
// Get rid of trailing newlines and Windows line endings
val text = file.readText(UTF_8)
return text.replace("\r\n", "\n").trim()
}
val currentTxt = getCanonicalSignatures(signatureFile)
val newTxt = getCanonicalSignatures(apiFile)
if (newTxt != currentTxt) {
val diff = getNativeDiff(signatureFile, apiFile) ?: getDiff(currentTxt, newTxt, 1)
val updateApi = if (isBuildingAndroid())
"Run make update-api to update.\n"
else
""
val message =
"""
Your changes have resulted in differences in the signature file
for the ${apiType.displayName} API.
The changes may be compatible, but the signature file needs to be updated.
$updateApi
Diffs:
""".trimIndent() + "\n" + diff
throw DriverException(exitCode = -1, stderr = message)
}
}
}
fun createTempFile(namePrefix: String, nameSuffix: String): File {
val tempFolder = options.tempFolder
return if (tempFolder != null) {
val preferred = File(tempFolder, namePrefix + nameSuffix)
if (!preferred.exists()) {
return preferred
}
File.createTempFile(namePrefix, nameSuffix, tempFolder)
} else {
File.createTempFile(namePrefix, nameSuffix)
}
}
fun invokeDocumentationTool() {
if (options.noDocs) {
return
}
val args = options.invokeDocumentationToolArguments
if (args.isNotEmpty()) {
if (!options.quiet) {
options.stdout.println(
"Invoking external documentation tool ${args[0]} with arguments\n\"${
args.slice(1 until args.size).joinToString(separator = "\",\n\"") { it }}\""
)
options.stdout.flush()
}
val builder = ProcessInfoBuilder()
builder.setExecutable(File(args[0]))
builder.addArgs(args.slice(1 until args.size))
val processOutputHandler =
if (options.quiet) {
CachedProcessOutputHandler()
} else {
object : ProcessOutputHandler {
override fun handleOutput(processOutput: ProcessOutput?) {
}
override fun createOutput(): ProcessOutput {
val out = PrintWriterOutputStream(options.stdout)
val err = PrintWriterOutputStream(options.stderr)
return object : ProcessOutput {
override fun getStandardOutput(): OutputStream {
return out
}
override fun getErrorOutput(): OutputStream {
return err
}
override fun close() {
out.flush()
err.flush()
}
}
}
}
}
val result = DefaultProcessExecutor(StdLogger(ERROR))
.execute(builder.createProcess(), processOutputHandler)
val exitCode = result.exitValue
if (!options.quiet) {
options.stdout.println("${args[0]} finished with exitCode $exitCode")
options.stdout.flush()
}
if (exitCode != 0) {
val stdout = if (processOutputHandler is CachedProcessOutputHandler)
processOutputHandler.processOutput.standardOutputAsString
else ""
val stderr = if (processOutputHandler is CachedProcessOutputHandler)
processOutputHandler.processOutput.errorOutputAsString
else ""
throw DriverException(
stdout = "Invoking documentation tool ${args[0]} failed with exit code $exitCode\n$stdout",
stderr = stderr,
exitCode = exitCode
)
}
}
}
class PrintWriterOutputStream(private val writer: PrintWriter) : OutputStream() {
override fun write(b: ByteArray) {
writer.write(String(b, UTF_8))
}
override fun write(b: Int) {
write(byteArrayOf(b.toByte()), 0, 1)
}
override fun write(b: ByteArray, off: Int, len: Int) {
writer.write(String(b, off, len, UTF_8))
}
override fun flush() {
writer.flush()
}
override fun close() {
writer.close()
}
}
private fun migrateNulls(codebase: Codebase, previous: Codebase) {
previous.compareWith(NullnessMigration(), codebase)
}
private fun convertToWarningNullabilityAnnotations(codebase: Codebase, filter: PackageFilter?) {
if (filter != null) {
// Our caller has asked for these APIs to not trigger nullness errors (only warnings) if
// their callers make incorrect nullness assumptions (for example, calling a function on a
// reference of nullable type). The way to communicate this to kotlinc is to mark these
// APIs as RecentlyNullable/RecentlyNonNull
codebase.accept(MarkPackagesAsRecent(filter))
}
}
private fun loadFromSources(): Codebase {
progress("Processing sources: ")
val sources = options.sources.ifEmpty {
if (options.verbose) {
options.stdout.println("No source files specified: recursively including all sources found in the source path (${options.sourcePath.joinToString()}})")
}
gatherSources(options.sourcePath)
}
progress("Reading Codebase: ")
val codebase = parseSources(sources, "Codebase loaded from source folders")
progress("Analyzing API: ")
val analyzer = ApiAnalyzer(codebase)
analyzer.mergeExternalInclusionAnnotations()
analyzer.computeApi()
val filterEmit = ApiPredicate(ignoreShown = true, ignoreRemoved = false)
val apiEmit = ApiPredicate(ignoreShown = true)
val apiReference = ApiPredicate(ignoreShown = true)
// Copy methods from soon-to-be-hidden parents into descendant classes, when necessary. Do
// this before merging annotations or performing checks on the API to ensure that these methods
// can have annotations added and are checked properly.
progress("Insert missing stubs methods: ")
analyzer.generateInheritedStubs(apiEmit, apiReference)
analyzer.mergeExternalQualifierAnnotations()
options.nullabilityAnnotationsValidator?.validateAllFrom(codebase, options.validateNullabilityFromList)
options.nullabilityAnnotationsValidator?.report()
analyzer.handleStripping()
// General API checks for Android APIs
AndroidApiChecks().check(codebase)
if (options.checkApi) {
progress("API Lint: ")
val localTimer = Stopwatch.createStarted()
// See if we should provide a previous codebase to provide a delta from?
val previousApiFile = options.checkApiBaselineApiFile
val previous =
when {
previousApiFile == null -> null
previousApiFile.path.endsWith(DOT_JAR) -> loadFromJarFile(previousApiFile)
else -> SignatureFileLoader.load(
file = previousApiFile,
kotlinStyleNulls = options.inputKotlinStyleNulls
)
}
val apiLintReporter = options.reporterApiLint
ApiLint.check(codebase, previous, apiLintReporter)
progress("$PROGRAM_NAME ran api-lint in ${localTimer.elapsed(SECONDS)} seconds with ${apiLintReporter.getBaselineDescription()}")
}
// Compute default constructors (and add missing package private constructors
// to make stubs compilable if necessary). Do this after all the checks as
// these are not part of the API.
if (options.stubsDir != null || options.docStubsDir != null) {
progress("Insert missing constructors: ")
analyzer.addConstructors(filterEmit)
}
progress("Performing misc API checks: ")
analyzer.performChecks()
return codebase
}
/**
* Returns a codebase initialized from the given Java or Kotlin source files, with the given
* description. The codebase will use a project environment initialized according to the current
* [options].
*/
internal fun parseSources(
sources: List<File>,
description: String,
sourcePath: List<File> = options.sourcePath,
classpath: List<File> = options.classpath,
javaLanguageLevel: LanguageLevel = options.javaLanguageLevel,
kotlinLanguageLevel: LanguageVersionSettings = options.kotlinLanguageLevel,
manifest: File? = options.manifest,
currentApiLevel: Int = options.currentApiLevel + if (options.currentCodeName != null) 1 else 0
): PsiBasedCodebase {
val sourceRoots = mutableListOf<File>()
sourcePath.filterTo(sourceRoots) { it.path.isNotBlank() }
// Add in source roots implied by the source files
if (options.allowImplicitRoot) {
extractRoots(sources, sourceRoots)
}
val config = UastEnvironment.Configuration.create()
config.javaLanguageLevel = javaLanguageLevel
config.kotlinLanguageLevel = kotlinLanguageLevel
config.addSourceRoots(sourceRoots.map { it.absoluteFile })
config.addClasspathRoots(classpath.map { it.absoluteFile })
val environment = createProjectEnvironment(config)
val kotlinFiles = sources.filter { it.path.endsWith(DOT_KT) }
environment.analyzeFiles(kotlinFiles)
val rootDir = sourceRoots.firstOrNull() ?: sourcePath.firstOrNull() ?: File("").canonicalFile
val units = Extractor.createUnitsForFiles(environment.ideaProject, sources)
val packageDocs = gatherPackageJavadoc(sources, sourceRoots)
val codebase = PsiBasedCodebase(rootDir, description)
codebase.initialize(environment, units, packageDocs)
codebase.manifest = manifest
codebase.apiLevel = currentApiLevel
return codebase
}
fun loadFromJarFile(apiJar: File, manifest: File? = null, preFiltered: Boolean = false): Codebase {
progress("Processing jar file: ")
val config = UastEnvironment.Configuration.create()
config.addClasspathRoots(listOf(apiJar))
val environment = createProjectEnvironment(config)
environment.analyzeFiles(emptyList()) // Initializes PSI machinery.
val codebase = PsiBasedCodebase(apiJar, "Codebase loaded from $apiJar")
codebase.initialize(environment, apiJar, preFiltered)
if (manifest != null) {
codebase.manifest = options.manifest
}
val apiEmit = ApiPredicate(ignoreShown = true)
val apiReference = ApiPredicate(ignoreShown = true)
val analyzer = ApiAnalyzer(codebase)
analyzer.mergeExternalInclusionAnnotations()
analyzer.computeApi()
analyzer.mergeExternalQualifierAnnotations()
options.nullabilityAnnotationsValidator?.validateAllFrom(codebase, options.validateNullabilityFromList)
options.nullabilityAnnotationsValidator?.report()
analyzer.generateInheritedStubs(apiEmit, apiReference)
return codebase
}
internal const val METALAVA_SYNTHETIC_SUFFIX = "metalava_module"
private fun createProjectEnvironment(config: UastEnvironment.Configuration): UastEnvironment {
ensurePsiFileCapacity()
// Note: the Kotlin module name affects the naming of certain synthetic methods.
config.kotlinCompilerConfig.put(MODULE_NAME, METALAVA_SYNTHETIC_SUFFIX)
val environment = UastEnvironment.create(config)
uastEnvironments.add(environment)
if (!assertionsEnabled() &&
System.getenv(ENV_VAR_METALAVA_DUMP_ARGV) == null &&
!isUnderTest()
) {
DefaultLogger.disableStderrDumping(environment.ideaProject)
}
// Missing service needed in metalava but not in lint: javadoc handling
environment.ideaProject.registerService(
com.intellij.psi.javadoc.JavadocManager::class.java,
com.intellij.psi.impl.source.javadoc.JavadocManagerImpl::class.java
)
CoreApplicationEnvironment.registerExtensionPoint(
environment.ideaProject.extensionArea, JavadocTagInfo.EP_NAME, JavadocTagInfo::class.java
)
CoreApplicationEnvironment.registerApplicationExtensionPoint(
CustomJavadocTagProvider.EP_NAME, CustomJavadocTagProvider::class.java
)
return environment
}
private val uastEnvironments = mutableListOf<UastEnvironment>()
private fun disposeUastEnvironment() {
// Codebase.dispose() is not consistently called, so we dispose the environments here too.
for (env in uastEnvironments) {
if (!Disposer.isDisposed(env.ideaProject)) {
env.dispose()
}
}
uastEnvironments.clear()
UastEnvironment.disposeApplicationEnvironment()
}
private fun ensurePsiFileCapacity() {
val fileSize = System.getProperty("idea.max.intellisense.filesize")
if (fileSize == null) {
// Ensure we can handle large compilation units like android.R
System.setProperty("idea.max.intellisense.filesize", "100000")
}
}
private fun extractAnnotations(codebase: Codebase, file: File) {
val localTimer = Stopwatch.createStarted()
options.externalAnnotations?.let { outputFile ->
@Suppress("UNCHECKED_CAST")
ExtractAnnotations(
codebase,
outputFile
).extractAnnotations()
if (options.verbose) {
progress("$PROGRAM_NAME extracted annotations into $file in ${localTimer.elapsed(SECONDS)} seconds\n")
}
}
}
private fun createStubFiles(stubDir: File, codebase: Codebase, docStubs: Boolean, writeStubList: Boolean) {
// Generating stubs from a sig-file-based codebase is problematic
assert(codebase.supportsDocumentation())
// Temporary bug workaround for org.chromium.arc
if (options.sourcePath.firstOrNull()?.path?.endsWith("org.chromium.arc") == true) {
codebase.findClass("org.chromium.mojo.bindings.Callbacks")?.hidden = true
}
if (docStubs) {
progress("Generating documentation stub files: ")
} else {
progress("Generating stub files: ")
}
val localTimer = Stopwatch.createStarted()
val prevCompatibility = compatibility
if (compatibility.compat) {
compatibility = Compatibility(false)
}
val stubWriter =
StubWriter(
codebase = codebase,
stubsDir = stubDir,
generateAnnotations = options.generateAnnotations,
preFiltered = codebase.preFiltered,
docStubs = docStubs
)
codebase.accept(stubWriter)
if (docStubs) {
// Overview docs? These are generally in the empty package.
codebase.findPackage("")?.let { empty ->
val overview = codebase.getPackageDocs()?.getOverviewDocumentation(empty)
if (overview != null && overview.isNotBlank()) {
stubWriter.writeDocOverview(empty, overview)
}
}
}
if (writeStubList) {
// Optionally also write out a list of source files that were generated; used
// for example to point javadoc to the stubs output to generate documentation
val file = if (docStubs) {
options.docStubsSourceList ?: options.stubsSourceList
} else {
options.stubsSourceList
}
file?.let {
val root = File("").absoluteFile
stubWriter.writeSourceList(it, root)
}
}
compatibility = prevCompatibility
progress(
"$PROGRAM_NAME wrote ${if (docStubs) "documentation" else ""} stubs directory $stubDir in ${
localTimer.elapsed(SECONDS)} seconds\n"
)
}
fun createReportFile(
codebase: Codebase,
apiFile: File,
description: String?,
createVisitor: (PrintWriter) -> ApiVisitor
) {
if (description != null) {
progress("Writing $description file: ")
}
val localTimer = Stopwatch.createStarted()
try {
val writer = PrintWriter(Files.asCharSink(apiFile, UTF_8).openBufferedStream())
writer.use { printWriter ->
val apiWriter = createVisitor(printWriter)
codebase.accept(apiWriter)
}
} catch (e: IOException) {
reporter.report(Issues.IO_ERROR, apiFile, "Cannot open file for write.")
}
if (description != null && options.verbose) {
progress("$PROGRAM_NAME wrote $description file $apiFile in ${localTimer.elapsed(SECONDS)} seconds\n")
}
}
private fun skippableDirectory(file: File): Boolean = file.path.endsWith(".git") && file.name == ".git"
private fun addSourceFiles(list: MutableList<File>, file: File) {
if (file.isDirectory) {
if (skippableDirectory(file)) {
return
}
if (java.nio.file.Files.isSymbolicLink(file.toPath())) {
reporter.report(
Issues.IGNORING_SYMLINK, file,
"Ignoring symlink during source file discovery directory traversal"
)
return
}
val files = file.listFiles()
if (files != null) {
for (child in files) {
addSourceFiles(list, child)
}
}
} else if (file.isFile) {
when {
file.name.endsWith(DOT_JAVA) ||
file.name.endsWith(DOT_KT) ||
file.name.equals(PACKAGE_HTML) ||
file.name.equals(OVERVIEW_HTML) -> list.add(file)
}
}
}
fun gatherSources(sourcePath: List<File>): List<File> {
val sources = Lists.newArrayList<File>()
for (file in sourcePath) {
if (file.path.isBlank()) {
// --source-path "" means don't search source path; use "." for pwd
continue
}
addSourceFiles(sources, file.absoluteFile)
}
return sources.sortedWith(compareBy { it.name })
}
private fun gatherPackageJavadoc(sources: List<File>, sourceRoots: List<File>): PackageDocs {
val packageComments = HashMap<String, String>(100)
val overviewHtml = HashMap<String, String>(10)
val hiddenPackages = HashSet<String>(100)
val sortedSourceRoots = sourceRoots.sortedBy { -it.name.length }
for (file in sources) {
var javadoc = false
val map = when (file.name) {
PACKAGE_HTML -> {
javadoc = true; packageComments
}
OVERVIEW_HTML -> {
overviewHtml
}
else -> continue
}
var contents = Files.asCharSource(file, UTF_8).read()
if (javadoc) {
contents = packageHtmlToJavadoc(contents)
}
// Figure out the package: if there is a java file in the same directory, get the package
// name from the java file. Otherwise, guess from the directory path + source roots.
// NOTE: This causes metalava to read files other than the ones explicitly passed to it.
var pkg = file.parentFile?.listFiles()
?.filter { it.name.endsWith(DOT_JAVA) }
?.asSequence()?.mapNotNull { findPackage(it) }
?.firstOrNull()
if (pkg == null) {
// Strip the longest prefix source root.
val prefix = sortedSourceRoots.firstOrNull { file.startsWith(it) }?.path ?: ""
pkg = file.parentFile.path.substring(prefix.length).trim('/').replace("/", ".")
}
map[pkg] = contents
if (contents.contains("@hide")) {
hiddenPackages.add(pkg)
}
}
return PackageDocs(packageComments, overviewHtml, hiddenPackages)
}
fun extractRoots(sources: List<File>, sourceRoots: MutableList<File> = mutableListOf()): List<File> {
// Cache for each directory since computing root for a source file is
// expensive
val dirToRootCache = mutableMapOf<String, File>()
for (file in sources) {
val parent = file.parentFile ?: continue
val found = dirToRootCache[parent.path]
if (found != null) {
continue
}
val root = findRoot(file) ?: continue
dirToRootCache[parent.path] = root
if (!sourceRoots.contains(root)) {
sourceRoots.add(root)
}
}
return sourceRoots
}
/**
* If given a full path to a Java or Kotlin source file, produces the path to
* the source root if possible.
*/
private fun findRoot(file: File): File? {
val path = file.path
if (path.endsWith(DOT_JAVA) || path.endsWith(DOT_KT)) {
val pkg = findPackage(file) ?: return null
val parent = file.parentFile ?: return null
val endIndex = parent.path.length - pkg.length
val before = path[endIndex - 1]
if (before == '/' || before == '\\') {
return File(path.substring(0, endIndex))
} else {
reporter.report(
Issues.IO_ERROR, file, "$PROGRAM_NAME was unable to determine the package name. " +
"This usually means that a source file was where the directory does not seem to match the package " +
"declaration; we expected the path $path to end with /${pkg.replace('.', '/') + '/' + file.name}"
)
}
}
return null
}
/** Finds the package of the given Java/Kotlin source file, if possible */
fun findPackage(file: File): String? {
val source = Files.asCharSource(file, UTF_8).read()
return findPackage(source)
}
/** Finds the package of the given Java/Kotlin source code, if possible */
fun findPackage(source: String): String? {
return ClassName(source).packageName
}
/** Whether metalava is running unit tests */
fun isUnderTest() = java.lang.Boolean.getBoolean(ENV_VAR_METALAVA_TESTS_RUNNING)
/** Whether metalava is being invoked as part of an Android platform build */
fun isBuildingAndroid() = System.getenv("ANDROID_BUILD_TOP") != null && !isUnderTest()