/*
* Copyright 2023 The Bazel Authors. All rights reserved.
*
* 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.google.idea.blaze.qsync
import com.google.common.annotations.VisibleForTesting
import com.google.common.base.Preconditions
import com.google.common.collect.ImmutableMap
import com.google.common.collect.ImmutableSet
import com.google.common.collect.Maps
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListeningExecutorService
import com.google.common.util.concurrent.Uninterruptibles
import com.google.idea.blaze.common.Context
import com.google.idea.blaze.common.Label
import com.google.idea.blaze.common.PrintOutput
import com.google.idea.blaze.common.RuleKinds
import com.google.idea.blaze.exception.BuildException
import com.google.idea.blaze.qsync.java.PackageReader
import com.google.idea.blaze.qsync.project.BlazeProjectDataStorage
import com.google.idea.blaze.qsync.project.BuildGraphData
import com.google.idea.blaze.qsync.project.ProjectDefinition
import com.google.idea.blaze.qsync.project.ProjectProto
import com.google.idea.blaze.qsync.project.ProjectProto.ProjectPath.Base
import com.google.idea.blaze.qsync.project.ProjectTarget.SourceType
import com.google.idea.blaze.qsync.project.TestSourceGlobMatcher
import com.google.idea.blaze.qsync.query.PackageSet
import java.nio.file.Path
import java.util.Collections
import java.util.Comparator.comparingInt
import java.util.PriorityQueue
import java.util.TreeSet
import java.util.concurrent.ExecutionException
import kotlin.jvm.optionals.getOrNull
/** Converts a {@link BuildGraphDataImpl} instance into a project proto. */
class GraphToProjectConverter(
private val packageReader: PackageReader,
private val parallelPackageReader: PackageReader.ParallelReader,
private val fileExistenceCheck: (Path) -> Boolean,
private val context: Context<*>,
private val projectDefinition: ProjectDefinition,
private val executor: ListeningExecutorService,
) {
/**
* Calculates the source roots for all files in the project. While the vast majority of projects
* will fall into the standard java/javatest packages, there are projects that do not conform with
* this convention.
*
*
Mapping blaze projects to .imls will always be an aproximation, because blaze does not
* impose any restrictions on how the source files are on disk. IntelliJ does.
*
*
The code in .imls is organized as follows (simplified view):
*
*
A project is a collection of modules. (For now we only have one module, so we do not model
* dependencies yet). A module is a collection of content roots. A content root, is a directory
* were code of different kind is located. Inside a content root there can be different source
* roots. A source root is a directory inside the content root, that has a coherent group of
* source files. A source root can be test only. Source roots can be nested. These source files
* *must* be organized in a package-friendly directory structure. Most importantly, the directory
* structure does not have to start at the root of the package, for that source roots can have a
* package prefix that is a applied to the inner structure.
*
*
The algorithm implemented here makes one assumption over the code. All source files within
* the same blaze package that are children of other source files, are correctly structured. This
* is evidently not true for the general case, but even the most complex projects in our
* repository follow this rule. And this is a rule, easy to workaround by a user if it doesn't
* hold on their project.
*
*
* The algorithm works as follows:
* 1.- The top-most source files (most one per directory) is chosen per blaze package.
* 2.- Read the actual package of each java file, and use that as the directories prefix.
* 3.- Split all the packages by content root.
* 4.- Merge compatible packages. This is a heuristic step, where each source root
* is bubbled up as far as possible, merging compatible siblings. For a better description
* see the comment on that function.
*
*
* @param srcFiles all the files that should be included.
* @param packages the BUILD files to create source roots for.
* @return the content roots in the following form : Content Root -> Source Root -> package
* prefix. A content root contains multiple source roots, each one with a package prefix.
*/
@VisibleForTesting
@Throws(BuildException::class)
fun calculateJavaRootSources(
context: Context<*>,
srcFiles: Collection,
packages: PackageSet,
): Map> {
// A map from package to the file chosen to represent it.
val chosenFiles = chooseTopLevelFiles(srcFiles, packages)
// A map from a directory to its prefix
val prefixes = readPackages(context, chosenFiles)
// All packages split by their content roots
val rootToPrefix = splitByRoot(prefixes)
// Merging packages that can share the same prefix
return mergeCompatibleSourceRoots(rootToPrefix)
}
/**
* Calculates directories containing non-java source files.
*
* @param nonJavaSrcFiles all the sources in the project, excluding java.
* @return mapping of content roots (project includes) to directories (relative to the content
* root) containing proto files.
*/
@VisibleForTesting
fun nonJavaSourceFolders(nonJavaSrcFiles: Collection): Map> {
data class SourceFolder(val root: Path, val contentRoot: Path)
return nonJavaSrcFiles
.mapNotNull { it.parent }
.distinct()
.mapNotNull { SourceFolder(root = it, contentRoot = projectDefinition.getIncludingContentRoot(it).getOrNull() ?: return@mapNotNull null) }
.groupBy({ it.contentRoot }, { it.contentRoot.relativize(it.root) })
}
@VisibleForTesting
fun splitByRoot(prefixes: Map): ImmutableMap> {
val split: ImmutableMap.Builder> = ImmutableMap.builder()
for (root in projectDefinition.projectIncludes()) {
val inRoot: ImmutableMap.Builder = ImmutableMap.builder()
for (pkg in prefixes.entries) {
val rel = pkg.key
if (root.toString().isEmpty() || rel.startsWith(root)) {
val relToRoot = root.relativize(rel)
inRoot.put(relToRoot, pkg.value)
}
}
split.put(root, inRoot.buildKeepingLast())
}
return split.buildKeepingLast()
}
@Throws(BuildException::class)
private fun readPackages(context: Context<*>, files: List): ImmutableMap {
val now = System.currentTimeMillis()
val allPackages = parallelPackageReader.readPackages(context, packageReader, files)
val elapsed = System.currentTimeMillis() - now
context.output(PrintOutput.log("%-10d Java files read (%d ms)", files.size, elapsed))
val prefixes: ImmutableMap.Builder = ImmutableMap.builder()
allPackages.forEach { (path, pkg) -> prefixes.put(path.parent, pkg) }
return prefixes.buildOrThrow()
}
@VisibleForTesting
@Throws(BuildException::class)
protected fun chooseTopLevelFiles(files: Collection, packages: PackageSet): List {
val filesByPath = files.groupBy { it.parent }
// A map from directory to the candidate chosen to represent that directory
// We filter out non-existent files, but without checking for the existence of all files as
// that slows things down unnecessarily.
val candidates: MutableMap = Maps.newConcurrentMap()
val futures = filesByPath.keys.map { dir ->
executor.submit(
{
// We use a priority queue to find the first element without sorting, since in most
// cases we only need the first element.
val dirFiles: PriorityQueue = PriorityQueue(Comparator.comparing(Path::getFileName))
dirFiles.addAll(filesByPath[dir].orEmpty())
var candidate = dirFiles.poll()
while (candidate != null && !fileExistenceCheck(candidate)) {
candidate = dirFiles.poll()
}
if (candidate != null) {
candidates.put(dir, candidate)
}
})
}
try {
Uninterruptibles.getUninterruptibly(Futures.allAsList(futures))
}
catch (e: ExecutionException) {
throw BuildException(e)
}
// Filter the files that are top level files only
return candidates.values.filter { file -> isTopLevel(packages, candidates, file) }
}
companion object {
private fun isTopLevel(packages: PackageSet, candidates: Map, file: Path): Boolean {
var dir = relativeParentOf(file)
while (dir != null) {
val existing = candidates.get(dir)
if (existing != null && existing != file) {
return false
}
if (packages.contains(dir)) {
return true
}
dir = relativeParentOf(dir)
}
return false
}
private fun relativeParentOf(path: Path): Path? {
Preconditions.checkState(!path.isAbsolute())
if (path.toString().isEmpty()) {
return null
}
return path.parent ?: Path.of("")
}
private fun lastSubpackageOf(pkg: String): String {
return pkg.substring(pkg.lastIndexOf('.') + 1)
}
private fun parentPackageOf(pkg: String): String? {
if (pkg.isEmpty()) {
return null
}
val ix = pkg.lastIndexOf('.')
return if (ix == -1) "" else pkg.substring(0, ix)
}
/**
* Merges source roots that are compatible. Consider the following example, where source roots are
* written like "directory" ["prefix"]:
*
*
* 1.- Two sibling roots:
* "a/b/c/d" ["com.google.d"]
* "a/b/c/e" ["com.google.e"]
* Can be merged to:
* "a/b/c" ["com.google"]
*
* 2.- Nested roots:
* "a/b/c/d" ["com.google.d"]
* "a/b/c/d/e" ["com.google.d.e"]
* Can be merged to:
* "a/b/c" ["com.google"]
*
*
* This function works by trying to move a source root up as far as possible (until it reaches the
* content root). When it finds a source root above, there can be two scenarios: a) the parent
* source root is compatible (like example 2 above), in which case they are merged. b) the parent
* root is not compatible, in which case it needs to stop there and cannot be moved further up.
* This is true even if the parent source root is later moved up.
*/
@VisibleForTesting
@JvmStatic
fun mergeCompatibleSourceRoots(srcRoots: ImmutableMap>): ImmutableMap> {
val result: ImmutableMap.Builder> = ImmutableMap.builder()
for (contentRoot in srcRoots.entries) {
result.put(contentRoot.key, mergeSourceRoots(contentRoot.value))
}
return result.buildOrThrow()
}
/**
* Given directory to package mappings known to be true from the source code builds finds the root
* mappings that are sufficient for the IDE to derive the provided mappings, i.e. having
*
*
*/
private fun mergeSourceRoots(expectedDirectoryToPackageMap: Map): ImmutableMap {
val dirWants = addPossibleParentMatches(expectedDirectoryToPackageMap)
val dirAllResult = chooseFinalMappings(expectedDirectoryToPackageMap, dirWants)
return selectEssentialMappings(dirAllResult)
}
/**
* Given an unambiguous directory to package mapping that includes intermediate directories
* selects those root mappings that are required to establish top level mappings and drops any
* that can be derived from them.
*
*
*/
private fun selectEssentialMappings(dirAllResult: ImmutableMap): ImmutableMap {
val result: ImmutableMap.Builder = ImmutableMap.builder()
for (entry in dirAllResult.entries) {
val parentPath = relativeParentOf(entry.key)
val existingParentPkg = dirAllResult.get(parentPath)
if (existingParentPkg == null
|| !appendPackage(existingParentPkg, entry.key.getFileName().toString())
.equals(entry.value)) {
result.put(entry.key, entry.value)
}
}
return result.buildOrThrow()
}
/**
* Given expanded directory to package mappings and the originally expected directory to package
* map builds an unambiguous map from directories to packages.
*
*
If the expanded map contains conflicting entries (result of local package mapping and parent
* expansion) they are ignored and the local package mapping is used, if present.
*
*
`src/com/google/ => com.google; smth` is resolved as `com.google` if it is also a local
* mapping, which would later result in a new source folder created for `src/com/google/else =>
* smth.else`.
*/
private fun chooseFinalMappings(
expectedDirectoryToPackageMap: Map,
dirWants: Map>,
): ImmutableMap {
val dirAllResult: ImmutableMap.Builder = ImmutableMap.builder()
for (directory in TreeSet(dirWants.keys)) {
val wants = dirWants.get(directory)
val pkg = if (wants != null && wants.size == 1) {
wants.iterator().next()
}
else {
expectedDirectoryToPackageMap.get(directory)
}
if (pkg != null) {
dirAllResult.put(directory, pkg)
}
}
return dirAllResult.buildOrThrow()
}
/**
* Given a set of directory to package mappings expand them to all mappings that can be derived
* from parent directories.
*
*
i.e. in the presence of `src/com/google/smth => com.google.smth` add mappings like `src =>
* ""`, `src/com => com`, `src/com/google => com.google`, but stop if there is a mismatch between
* directory names and package names, i.e. when `java/src/smth => com.google.smth` is present
* expand it only to `java/src => com.google` as it would still correctly map sub-directories and
* when multiple similar sub-directories are present this is a preferred configuration.
*/
private fun addPossibleParentMatches(sourceRoots: Map): Map> {
val directories: Set = TreeSet(sourceRoots.keys)
val dirWants: MutableMap> = mutableMapOf()
for (directory in directories) {
val prefix = sourceRoots.get(directory)
var dir: Path? = directory
var pref = prefix
while (dir != null
&& pref != null
&& dir.getFileName().toString().equals(lastSubpackageOf(pref))) {
val wants = dirWants.computeIfAbsent(dir) { hashSetOf() }
wants.add(pref)
dir = relativeParentOf(dir)
pref = parentPackageOf(pref)
}
if (dir != null && pref != null) {
dirWants.computeIfAbsent(dir) { it -> hashSetOf() }.add(pref)
}
}
return dirWants
}
private fun appendPackage(parentPackage: String, subpackage: String): String {
return if (parentPackage.isEmpty()) subpackage else "$parentPackage.$subpackage"
}
/**
* Heuristic for determining Android resource directories, by searching for .xml source files with
* /res/ somewhere in the path under a build package. To be replaced by a more robust implementation.
*/
@VisibleForTesting
@JvmStatic
fun computeAndroidResourceDirectories(sourceFiles: ImmutableSet