aswb/querysync/java/com/google/idea/blaze/qsync/GraphToProjectConverter.kt (357 lines of code) (raw):
/*
* 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.
*
* <p>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.
*
* <p>The code in .imls is organized as follows (simplified view):
*
* <p>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.
*
* <p>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.
*
* <pre>
* 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.
* </pre>
*
* @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<Path>,
packages: PackageSet,
): Map<Path, Map<Path, String>> {
// 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<Path>): Map<Path, Collection<Path>> {
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<Path, String>): ImmutableMap<Path, ImmutableMap<Path, String>> {
val split: ImmutableMap.Builder<Path, ImmutableMap<Path, String>> = ImmutableMap.builder()
for (root in projectDefinition.projectIncludes()) {
val inRoot: ImmutableMap.Builder<Path, String> = 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<Path>): ImmutableMap<Path, String> {
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<Path, String> = ImmutableMap.builder()
allPackages.forEach { (path, pkg) -> prefixes.put(path.parent, pkg) }
return prefixes.buildOrThrow()
}
@VisibleForTesting
@Throws(BuildException::class)
protected fun chooseTopLevelFiles(files: Collection<Path>, packages: PackageSet): List<Path> {
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<Path, Path> = 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<Path> = 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<Path, Path>, 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"]:
*
* <pre>
* 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"]
* </pre>
*
* 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<Path, ImmutableMap<Path, String>>): ImmutableMap<Path, ImmutableMap<Path, String>> {
val result: ImmutableMap.Builder<Path, ImmutableMap<Path, String>> = 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
*
* <pre>
* java/src/com/google/app => com.google.app
* java/src/com/google/lib => com.google.lib
* java/src/com/google/sample/else => com.example.else
* </pre>
*
* <p>produces:
*
* <pre>
* java/src => ""
* java/src/com/google/sample => com.example
* </pre>
*/
private fun mergeSourceRoots(expectedDirectoryToPackageMap: Map<Path, String>): ImmutableMap<Path, String> {
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.
*
* <p>i.e.
*
* <pre>
* src/ => ""
* src/com/ => com
* src/com/google/ => com.google
* src/com/google/lib => com.google.lib
* src/com/google/else => smth.else
* </pre>
*
* <p>results in
*
* <pre>
* src/ => ""
* src/com/google/else => smth.else
* </pre>
*/
private fun selectEssentialMappings(dirAllResult: ImmutableMap<Path, String>): ImmutableMap<Path, String> {
val result: ImmutableMap.Builder<Path, String> = 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.
*
* <p>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.
*
* <p>For example, in the following structure:
*
* <pre>
* src/ => ""
* src/com/ => com
* src/com/google/ => com.google; smth
* src/com/google/lib => com.google.lib
* src/com/google/else => smth.else
* </pre>
*
* <p>`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<Path, String>,
dirWants: Map<Path, Set<String>>,
): ImmutableMap<Path, String> {
val dirAllResult: ImmutableMap.Builder<Path, String> = 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.
*
* <p>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<Path, String>): Map<Path, Set<String>> {
val directories: Set<Path> = TreeSet(sourceRoots.keys)
val dirWants: MutableMap<Path, MutableSet<String>> = 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<Label>): ImmutableSet<Path> {
val directories = hashSetOf<Path>()
for (sourceFile in sourceFiles) {
if (sourceFile.name.endsWith(".xml")) {
@SuppressWarnings("PathAsIterable")
val pathParts = sourceFile.getNamePath().toList()
val resPos = pathParts.indexOf(Path.of("res"))
if (resPos >= 0) {
directories.add(sourceFile.getBuildPackagePath().resolve(sourceFile.getNamePath().subpath(0, resPos + 1)))
}
}
}
return ImmutableSet.copyOf(directories)
}
}
@Throws(BuildException::class)
fun createProject(graph: BuildGraphData): ProjectProto.Project {
val javaSourceRoots =calculateJavaRootSources(context, graph.getJavaSourceFiles(), graph.packages())
val rootToNonJavaSource = nonJavaSourceFolders(graph.getSourceFilesByRuleKindAndType({ t -> !RuleKinds.isJava(t) }, *SourceType.all()))
// Note: according to:
// https://developer.android.com/guide/topics/resources/providing-resources
// "Never save resource files directly inside the res/ directory. It causes a compiler error."
// This implies that we can safely take the grandparent of each resource file to find the
// top level res dir:
val resList = graph.getAndroidResourceFiles()
val androidResDirs =
resList
.map(Path::getParent)
.distinct()
.map(Path::getParent)
.distinct()
.toSet()
val androidResPackages = setOf<String>()
context.output(PrintOutput.log("%-10d Android resource directories", androidResDirs.size))
val workspaceModule : ProjectProto.Module.Builder =
ProjectProto.Module.newBuilder()
.setName(BlazeProjectDataStorage.WORKSPACE_MODULE_NAME)
.setType(ProjectProto.ModuleType.MODULE_TYPE_DEFAULT)
.addAllAndroidResourceDirectories(androidResDirs.map { it.toString() })
.addAllAndroidSourcePackages(androidResPackages)
.addAllAndroidCustomPackages(graph.getAllCustomPackages())
val excludesByRootDirectory =projectDefinition.getExcludesByRootDirectory()
val testSourceGlobMatcher = TestSourceGlobMatcher.create(projectDefinition)
for (dir in projectDefinition.projectIncludes()) {
val contentEntry =
ProjectProto.ContentEntry.newBuilder()
.setRoot(
ProjectProto.ProjectPath.newBuilder()
.setPath(dir.toString())
.setBase(Base.WORKSPACE))
val sourceRootsWithPrefixes = javaSourceRoots.get(dir).orEmpty()
for (entry in sourceRootsWithPrefixes.entries) {
val path = dir.resolve(entry.key)
contentEntry.addSources(
ProjectProto.SourceFolder.newBuilder()
.setProjectPath(
ProjectProto.ProjectPath.newBuilder()
.setBase(Base.WORKSPACE)
.setPath(path.toString()))
.setPackagePrefix(entry.value)
.setIsTest(testSourceGlobMatcher.matches(path))
.build())
}
for (nonJavaDirPath in rootToNonJavaSource.get(dir).orEmpty()) {
if (javaSourceRoots.get(dir).orEmpty().keys
.none {p -> p.toString().isEmpty() || nonJavaDirPath.startsWith(p)}) {
val path = dir.resolve(nonJavaDirPath)
// TODO(b/305743519): make java source properties like package prefix specific to java
// source folders only.
contentEntry.addSources(
ProjectProto.SourceFolder.newBuilder()
.setProjectPath(
ProjectProto.ProjectPath.newBuilder()
.setBase(Base.WORKSPACE)
.setPath(path.toString()))
.setPackagePrefix("")
.setIsTest(testSourceGlobMatcher.matches(path))
.build())
}
}
for (exclude in excludesByRootDirectory.get(dir)) {
contentEntry.addExcludes(exclude.toString())
}
workspaceModule.addContentEntries(contentEntry)
}
val activeLanguages = graph.getActiveLanguages()
return ProjectProto.Project.newBuilder()
.addModules(workspaceModule)
.addAllActiveLanguages(activeLanguages.map{it -> it.protoValue}.toList())
.build()
}
/**
* Heuristic for computing android source java packages (used in generating R classes). Examines
* packages of source files owned by Android targets (at most one file per target). Inefficient
* for large projects with many android targets. To be replaced by a more robust implementation.
*/
@VisibleForTesting
fun computeAndroidSourcePackages(
androidSourceFiles: List<Path>,
rootToPrefix: ImmutableMap<Path, ImmutableMap<Path, String>>,
): ImmutableSet<String> {
val androidSourcePackages: ImmutableSet.Builder<String> = ImmutableSet.builder()
// Map entries are sorted by path length to ensure that, if the map contains keys k1 and k2,
// where k1 is a prefix of k2, then k2 is checked before k1. We check by string length to ensure
// the empty path is checked last.
val sortedRootToPrefix: Map<Path, List<Map.Entry<Path, String>>> =
rootToPrefix
.mapValues{
entry ->
val sourceDirs: Map<Path, String> = entry.value
val sortedEntries: List<Map.Entry<Path, String>> =
sourceDirs.entries.sortedWith(Collections.reverseOrder(
comparingInt{e -> e.key.toString().length}))
sortedEntries
}
for (androidSourceFile in androidSourceFiles) {
var found = false
for (root in sortedRootToPrefix.entries) {
if (androidSourceFile.startsWith(root.key)) {
val inRoot =
androidSourceFile.toString().substring(root.key.toString().length + 1)
val sourceDirs = root.value
for (prefixes in sourceDirs) {
if (inRoot.startsWith(prefixes.key.toString())) {
found = true
val inSource = inRoot.substring(prefixes.key.toString().length)
val ix = inSource.lastIndexOf('/')
var suffix = if (ix != -1) inSource.substring(0, ix) else ""
if (suffix.startsWith("/")) {
suffix = suffix.substring(1)
}
var pkg = prefixes.value
if (!suffix.isEmpty()) {
if (pkg.isNotEmpty()) {
pkg += "."
}
pkg += suffix.replace('/', '.')
}
androidSourcePackages.add(pkg)
break
}
}
if (found) {
break
}
}
}
if (!found) {
context.output(
PrintOutput.log(
String.format("Android source %s not found in any root", androidSourceFile)))
}
}
return androidSourcePackages.build()
}
}