Refactor metalava html parsing

Metalava would previously parse html files that it discovered via
crawling the sourcepath argument. Refactor things such that if html
files are passed as explicit sources, those get parsed as well.

Note that metalava is still capable of crawling -sourcepath, if no
sources are passed explicitly. If so, it will now also pick up html
files during that crawl.

There's a bit of ugliness around how metalava discovers the package name
those html files are in: in reads random java files in the directory
containing the html file. This should probably be cleaned up by
migrating from package.html to package-info.java.

Bug: 153703940
Test: DocAnalayzerTest + SymlinkTest
Merged-In: Ic17dade2d834098a4fb4def89172757a86ae7f35
Change-Id: Ic17dade2d834098a4fb4def89172757a86ae7f35
(cherry picked from commit 01c25630d4244deaa1dcbd43683d5d041fa932a0)
diff --git a/src/main/java/com/android/tools/metalava/Driver.kt b/src/main/java/com/android/tools/metalava/Driver.kt
index 2c5586f..47dac44 100644
--- a/src/main/java/com/android/tools/metalava/Driver.kt
+++ b/src/main/java/com/android/tools/metalava/Driver.kt
@@ -67,6 +67,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 = """
@@ -904,7 +906,7 @@
     val rootDir = sourceRoots.firstOrNull() ?: sourcePath.firstOrNull() ?: File("").canonicalFile
 
     val units = Extractor.createUnitsForFiles(environment.ideaProject, sources)
-    val packageDocs = gatherHiddenPackagesFromJavaDocs(sourcePath)
+    val packageDocs = gatherPackageJavadoc(sources, sourceRoots)
 
     val codebase = PsiBasedCodebase(rootDir, description)
     codebase.initialize(environment, units, packageDocs)
@@ -1118,9 +1120,12 @@
                 addSourceFiles(list, child)
             }
         }
-    } else {
-        if (file.isFile && (file.path.endsWith(DOT_JAVA) || file.path.endsWith(DOT_KT))) {
-            list.add(file)
+    } 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)
         }
     }
 }
@@ -1137,94 +1142,44 @@
     return sources.sortedWith(compareBy { it.name })
 }
 
-private fun addHiddenPackages(
-    packageToDoc: MutableMap<String, String>,
-    packageToOverview: MutableMap<String, String>,
-    hiddenPackages: MutableSet<String>,
-    file: File,
-    pkg: String
-) {
-    if (FileReadSandbox.isDirectory(file)) {
-        if (skippableDirectory(file)) {
-            return
-        }
-        // Ignore symbolic links during traversal
-        if (java.nio.file.Files.isSymbolicLink(file.toPath())) {
-            reporter.report(
-                Issues.IGNORING_SYMLINK, file,
-                "Ignoring symlink during package.html discovery directory traversal"
-            )
-            return
-        }
-        val files = file.listFiles()
-        if (files != null) {
-            for (child in files) {
-                var subPkg =
-                    if (FileReadSandbox.isDirectory(child))
-                        if (pkg.isEmpty())
-                            child.name
-                        else pkg + "." + child.name
-                    else pkg
-
-                if (subPkg.endsWith("src.main.java")) {
-                    // It looks like the source path was incorrectly configured; make corrections here
-                    // to ensure that we map the package.html files to the real packages.
-                    subPkg = ""
-                }
-
-                addHiddenPackages(packageToDoc, packageToOverview, hiddenPackages, child, subPkg)
-            }
-        }
-    } else if (FileReadSandbox.isFile(file)) {
+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; packageToDoc
+            PACKAGE_HTML -> {
+                javadoc = true; packageComments
             }
-            "overview.html" -> {
-                packageToOverview
+            OVERVIEW_HTML -> {
+                overviewHtml
             }
-            else -> return
+            else -> continue
         }
         var contents = Files.asCharSource(file, UTF_8).read()
         if (javadoc) {
             contents = packageHtmlToJavadoc(contents)
         }
 
-        var realPkg = pkg
-        // Sanity check the package; it's computed from the directory name
-        // relative to the source path, but if the real source path isn't
-        // passed in (and is instead some directory containing the source path)
-        // then we compute the wrong package here. Instead, look for an adjacent
-        // java class and pick the package from it
-        for (sibling in file.parentFile?.listFiles() ?: emptyArray()) {
-            if (sibling.path.endsWith(DOT_JAVA)) {
-                val javaPkg = ClassName(sibling.readText()).packageName
-                if (javaPkg != null) {
-                    realPkg = javaPkg
-                    break
-                }
-            }
+        // 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[realPkg] = contents
+        map[pkg] = contents
         if (contents.contains("@hide")) {
-            hiddenPackages.add(realPkg)
+            hiddenPackages.add(pkg)
         }
     }
-}
-
-private fun gatherHiddenPackagesFromJavaDocs(sourcePath: List<File>): PackageDocs {
-    val packageComments = HashMap<String, String>(100)
-    val overviewHtml = HashMap<String, String>(10)
-    val hiddenPackages = HashSet<String>(100)
-    for (file in sourcePath) {
-        if (file.path.isBlank()) {
-            // Ignoring empty paths, which means "no source path search". Use "." for current directory.
-            continue
-        }
-        addHiddenPackages(packageComments, overviewHtml, hiddenPackages, file, "")
-    }
 
     return PackageDocs(packageComments, overviewHtml, hiddenPackages)
 }
diff --git a/src/test/java/com/android/tools/metalava/DriverTest.kt b/src/test/java/com/android/tools/metalava/DriverTest.kt
index 7542c0f..9f45870 100644
--- a/src/test/java/com/android/tools/metalava/DriverTest.kt
+++ b/src/test/java/com/android/tools/metalava/DriverTest.kt
@@ -1380,8 +1380,9 @@
                     val actualContents = readFile(actual, stripBlankLines, trim)
                     assertEquals(expected.contents, actualContents)
                 } else {
+                    val existing = stubsDir.walkTopDown().filter { it.isFile }.map { it.path }.joinToString("\n  ")
                     throw FileNotFoundException(
-                        "Could not find a generated stub for ${expected.targetRelativePath}"
+                        "Could not find a generated stub for ${expected.targetRelativePath}. Found these files: \n  $existing"
                     )
                 }
             }
diff --git a/src/test/java/com/android/tools/metalava/SymlinkTest.kt b/src/test/java/com/android/tools/metalava/SymlinkTest.kt
index c2a222b..16187ff 100644
--- a/src/test/java/com/android/tools/metalava/SymlinkTest.kt
+++ b/src/test/java/com/android/tools/metalava/SymlinkTest.kt
@@ -16,7 +16,6 @@
 
 package com.android.tools.metalava
 
-import com.android.tools.lint.checks.infrastructure.TestFiles.source
 import org.junit.Test
 import java.io.File
 
@@ -38,9 +37,20 @@
         val before = System.getProperty("user.dir")
         try {
             check(
-                expectedIssues = "TESTROOT/src/test/pkg/sub1/sub2/sub3: info: Ignoring symlink during package.html discovery directory traversal [IgnoringSymlink]",
-                sourceFiles = arrayOf(
-                    java(
+                expectedIssues = "TESTROOT/src/test/pkg/sub1/sub2/sub3: info: Ignoring symlink during source file discovery directory traversal [IgnoringSymlink]",
+                projectSetup = { dir ->
+                    // Add a symlink from deep in the source tree back out to the
+                    // root, which makes a cycle
+                    val file = File(dir, "src/test/pkg/sub1/sub2")
+                    file.mkdirs()
+                    val symlink = File(file, "sub3").toPath()
+                    java.nio.file.Files.createSymbolicLink(symlink, dir.toPath())
+
+                    val git = File(file, ".git").toPath()
+                    java.nio.file.Files.createSymbolicLink(git, dir.toPath())
+
+                    // Write implicit source files to be discovered by our crawl.
+                    File(dir, "src/test/pkg/Foo.java").writeText(
                         """
                         package test.pkg;
                         import android.annotation.Nullable;
@@ -52,10 +62,8 @@
                             @Nullable public Double method2(@NonNull Double factor1, @NonNull Double factor2) { }
                             @Nullable public Double method3(@NonNull Double factor1, @NonNull Double factor2) { }
                         }
-                        """
-                    ),
-                    source(
-                        "src/test/pkg/sub1/package.html",
+                        """)
+                    File(dir, "src/test/pkg/sub1/package.html").writeText(
                         """
                         <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
                         <!-- not a body tag: <body> -->
@@ -67,21 +75,7 @@
                         Another line.<br>
                         </BODY>
                         </html>
-                        """
-                    ).indented(),
-                    nonNullSource,
-                    nullableSource
-                ),
-                projectSetup = { dir ->
-                    // Add a symlink from deep in the source tree back out to the
-                    // root, which makes a cycle
-                    val file = File(dir, "src/test/pkg/sub1/sub2")
-                    file.mkdirs()
-                    val symlink = File(file, "sub3").toPath()
-                    java.nio.file.Files.createSymbolicLink(symlink, dir.toPath())
-
-                    val git = File(file, ".git").toPath()
-                    java.nio.file.Files.createSymbolicLink(git, dir.toPath())
+                        """)
                 },
                 // Empty source path: don't pick up random directory stuff
                 extraArguments = arrayOf(
@@ -113,4 +107,4 @@
             System.setProperty("user.dir", before)
         }
     }
-}
\ No newline at end of file
+}