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

produces: * *

     *   java/src => ""
     *   java/src/com/google/sample => com.example
     * 
*/ 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. * *

i.e. * *

     *   src/ => ""
     *   src/com/ => com
     *   src/com/google/ => com.google
     *   src/com/google/lib => com.google.lib
     *   src/com/google/else => smth.else
     * 
* *

results in * *

     *   src/ => ""
     *   src/com/google/else => smth.else
     * 
*/ 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. * *

For example, in the following structure: * *

     *   src/ => ""
     *   src/com/ => com
     *   src/com/google/ => com.google; smth
     *   src/com/google/lib => com.google.lib
     *   src/com/google/else => smth.else
     * 
* *

`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