blob: 3f60fd6cdbef37a8c7d1b8da9b8348c95b875bfa [file] [log] [blame]
/*
* Copyright (C) 2023 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.metalava
import com.android.SdkConstants
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.Project
import com.android.tools.metalava.model.PackageDocs
import com.android.tools.metalava.model.psi.PsiBasedCodebase
import com.android.tools.metalava.model.psi.PsiEnvironmentManager
import com.android.tools.metalava.model.psi.packageHtmlToJavadoc
import com.android.tools.metalava.reporter.Issues
import com.android.tools.metalava.reporter.Reporter
import com.google.common.collect.Lists
import com.google.common.io.Files
import com.intellij.pom.java.LanguageLevel
import java.io.File
import org.jetbrains.kotlin.config.ApiVersion
import org.jetbrains.kotlin.config.JVMConfigurationKeys
import org.jetbrains.kotlin.config.LanguageVersion
import org.jetbrains.kotlin.config.LanguageVersionSettings
import org.jetbrains.kotlin.config.LanguageVersionSettingsImpl
val defaultJavaLanguageLevel = LanguageLevel.JDK_1_8
// TODO(b/287343397): use the latest version once MetalavaRunner in androidx is ready
// LanguageVersionSettingsImpl.DEFAULT
val defaultKotlinLanguageLevel = kotlinLanguageVersionSettings("1.8")
fun kotlinLanguageVersionSettings(value: String?): LanguageVersionSettings {
val languageLevel =
LanguageVersion.fromVersionString(value)
?: throw IllegalStateException(
"$value is not a valid or supported Kotlin language level"
)
val apiVersion = ApiVersion.createByLanguageVersion(languageLevel)
return LanguageVersionSettingsImpl(languageLevel, apiVersion)
}
/**
* Parses a set of sources into a [PsiBasedCodebase].
*
* The codebases will use a project environment initialized according to the properties passed to
* the constructor and the paths passed to [parseSources].
*/
class PsiSourceParser(
private val psiEnvironmentManager: PsiEnvironmentManager,
private val reporter: Reporter,
private val javaLanguageLevel: LanguageLevel = defaultJavaLanguageLevel,
private val kotlinLanguageLevel: LanguageVersionSettings = defaultKotlinLanguageLevel,
private val allowImplicitRoot: Boolean = true,
private val useK2Uast: Boolean = false,
private val jdkHome: File? = null,
) {
/**
* Returns a codebase initialized from the given Java or Kotlin source files, with the given
* description.
*
* All supplied [File] objects will be mapped to [File.getAbsoluteFile].
*/
fun parseSources(
sources: List<File>,
description: String,
sourcePath: List<File>,
classpath: List<File>,
): PsiBasedCodebase {
val absoluteSources = sources.map { it.absoluteFile }
val absoluteSourceRoots =
sourcePath.filter { it.path.isNotBlank() }.map { it.absoluteFile }.toMutableList()
// Add in source roots implied by the source files
if (allowImplicitRoot) {
extractRoots(reporter, absoluteSources, absoluteSourceRoots)
}
val absoluteClasspath = classpath.map { it.absoluteFile }
return parseAbsoluteSources(
absoluteSources,
description,
absoluteSourceRoots,
absoluteClasspath,
)
}
/** Returns a codebase initialized from the given set of absolute files. */
private fun parseAbsoluteSources(
sources: List<File>,
description: String,
sourceRoots: List<File>,
classpath: List<File>,
): PsiBasedCodebase {
val config = UastEnvironment.Configuration.create(useFirUast = useK2Uast)
config.javaLanguageLevel = javaLanguageLevel
val rootDir = sourceRoots.firstOrNull() ?: File("").canonicalFile
// TODO(jsjeon): should set language version _per_ module (Lint Project)
val lintClient = MetalavaCliClient(kotlinLanguageLevel)
// From ...lint.detector.api.Project, `dir` is, e.g., /tmp/foo/dev/src/project1,
// and `referenceDir` is /tmp/foo/. However, in many use cases, they are just same.
// `referenceDir` is used to adjust `lib` dir accordingly if needed,
// but we set `classpath` anyway below.
val lintProject =
Project.create(lintClient, /* dir = */ rootDir, /* referenceDir = */ rootDir)
lintProject.javaSourceFolders.addAll(sourceRoots)
lintProject.javaLibraries.addAll(classpath)
config.addModules(
listOf(
UastEnvironment.Module(
lintProject,
// K2 UAST: building KtSdkModule for JDK
jdkHome,
includeTests = false,
includeTestFixtureSources = false,
isUnitTest = false
)
),
)
// K1 UAST: loading of JDK (via compiler config, i.e., only for FE1.0), when using JDK9+
jdkHome?.let {
if (isJdkModular(it)) {
config.kotlinCompilerConfig.put(JVMConfigurationKeys.JDK_HOME, it)
config.kotlinCompilerConfig.put(JVMConfigurationKeys.NO_JDK, false)
}
}
val environment = psiEnvironmentManager.createEnvironment(config)
val kotlinFiles = sources.filter { it.path.endsWith(SdkConstants.DOT_KT) }
environment.analyzeFiles(kotlinFiles)
val units = Extractor.createUnitsForFiles(environment.ideaProject, sources)
val packageDocs = gatherPackageJavadoc(sources, sourceRoots)
val codebase = PsiBasedCodebase(rootDir, description, options.annotationManager, reporter)
codebase.initialize(environment, units, packageDocs)
return codebase
}
private fun isJdkModular(homePath: File): Boolean {
return File(homePath, "jmods").isDirectory
}
/** Initializes a UAST environment using the [apiJars] as classpath roots. */
fun loadUastFromJars(apiJars: List<File>): UastEnvironment {
val config = UastEnvironment.Configuration.create(useFirUast = useK2Uast)
@Suppress("DEPRECATION") config.addClasspathRoots(apiJars)
val environment = psiEnvironmentManager.createEnvironment(config)
environment.analyzeFiles(emptyList()) // Initializes PSI machinery.
return environment
}
}
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, Charsets.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(SdkConstants.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)
}
const val PACKAGE_HTML = "package.html"
const val OVERVIEW_HTML = "overview.html"
private fun skippableDirectory(file: File): Boolean =
file.path.endsWith(".git") && file.name == ".git"
private fun addSourceFiles(reporter: Reporter, 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(reporter, list, child)
}
}
} else if (file.isFile) {
when {
file.name.endsWith(SdkConstants.DOT_JAVA) ||
file.name.endsWith(SdkConstants.DOT_KT) ||
file.name.equals(PACKAGE_HTML) ||
file.name.equals(OVERVIEW_HTML) -> list.add(file)
}
}
}
fun gatherSources(reporter: Reporter, 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(reporter, sources, file.absoluteFile)
}
return sources.sortedWith(compareBy { it.name })
}
fun extractRoots(
reporter: Reporter,
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(reporter, 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(reporter: Reporter, file: File): File? {
val path = file.path
if (path.endsWith(SdkConstants.DOT_JAVA) || path.endsWith(SdkConstants.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,
"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, Charsets.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
}