1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.tools.metalava.model.source
18 
19 import com.android.tools.metalava.model.source.utils.DOT_JAVA
20 import com.android.tools.metalava.model.source.utils.DOT_KT
21 import com.android.tools.metalava.model.source.utils.OVERVIEW_HTML
22 import com.android.tools.metalava.model.source.utils.PACKAGE_HTML
23 import com.android.tools.metalava.model.source.utils.findPackage
24 import com.android.tools.metalava.reporter.Issues
25 import com.android.tools.metalava.reporter.Reporter
26 import java.io.File
27 import java.nio.file.Files
28 
29 /**
30  * An abstraction of source files and root directories.
31  *
32  * Those are always paired together or computed from one another.
33  *
34  * @param sources the list of source files
35  * @param sourcePath a possibly empty list of root directories within which source files may be
36  *   found.
37  */
38 class SourceSet(val sources: List<File>, val sourcePath: List<File>) {
39 
40     val absoluteSources: List<File>
41         get() {
<lambda>null42             return sources.map { it.absoluteFile }
43         }
44 
45     val absoluteSourcePaths: List<File>
46         get() {
<lambda>null47             return sourcePath.filter { it.path.isNotBlank() }.map { it.absoluteFile }
48         }
49 
50     /** Creates a copy of [SourceSet], but with elements mapped with [File.getAbsoluteFile] */
absoluteCopynull51     fun absoluteCopy(): SourceSet {
52         return SourceSet(absoluteSources, absoluteSourcePaths)
53     }
54 
55     /**
56      * Creates a new instance of [SourceSet], adding in source roots implied by the source files in
57      * the current [SourceSet]
58      */
extractRootsnull59     fun extractRoots(reporter: Reporter): SourceSet {
60         val sourceRoots = extractRoots(reporter, sources, sourcePath.toMutableList())
61         return SourceSet(sources, sourceRoots)
62     }
63 
64     companion object {
emptynull65         fun empty(): SourceSet = SourceSet(emptyList(), emptyList())
66 
67         /** * Creates [SourceSet] from the given [sourcePath] */
68         fun createFromSourcePath(
69             reporter: Reporter,
70             sourcePath: List<File>,
71             fileTester: (File) -> Boolean = ::isSupportedSource,
72         ): SourceSet {
73             val sources = gatherSources(reporter, sourcePath, fileTester)
74             return SourceSet(sources, sourcePath)
75         }
76 
skippableDirectorynull77         private fun skippableDirectory(file: File): Boolean =
78             file.path.endsWith(".git") && file.name == ".git"
79 
80         private fun isSupportedSource(file: File): Boolean =
81             file.name.endsWith(DOT_JAVA) ||
82                 file.name.endsWith(DOT_KT) ||
83                 file.name.equals(PACKAGE_HTML) ||
84                 file.name.equals(OVERVIEW_HTML)
85 
86         private fun addSourceFiles(
87             reporter: Reporter,
88             list: MutableList<File>,
89             file: File,
90             fileTester: (File) -> Boolean = ::isSupportedSource,
91         ) {
92             if (file.isDirectory) {
93                 if (skippableDirectory(file)) {
94                     return
95                 }
96                 if (Files.isSymbolicLink(file.toPath())) {
97                     reporter.report(
98                         Issues.IGNORING_SYMLINK,
99                         file,
100                         "Ignoring symlink during source file discovery directory traversal"
101                     )
102                     return
103                 }
104                 val files = file.listFiles()
105                 if (files != null) {
106                     for (child in files) {
107                         addSourceFiles(reporter, list, child)
108                     }
109                 }
110             } else if (file.isFile) {
111                 if (fileTester.invoke(file)) {
112                     list.add(file)
113                 }
114             }
115         }
116 
gatherSourcesnull117         private fun gatherSources(
118             reporter: Reporter,
119             sourcePath: List<File>,
120             fileTester: (File) -> Boolean = ::isSupportedSource,
121         ): List<File> {
122             val sources = mutableListOf<File>()
123             for (file in sourcePath) {
124                 if (file.path.isBlank()) {
125                     // --source-path "" means don't search source path; use "." for pwd
126                     continue
127                 }
128                 addSourceFiles(reporter, sources, file.absoluteFile, fileTester)
129             }
130             return sources.sortedWith(compareBy { it.name })
131         }
132 
extractRootsnull133         private fun extractRoots(
134             reporter: Reporter,
135             sources: List<File>,
136             sourceRoots: MutableList<File> = mutableListOf()
137         ): List<File> {
138             // Cache for each directory since computing root for a source file is expensive
139             val dirToRootCache = mutableMapOf<String, File>()
140             for (file in sources) {
141                 val parent = file.parentFile ?: continue
142                 val found = dirToRootCache[parent.path]
143                 if (found != null) {
144                     continue
145                 }
146 
147                 val root = findRoot(reporter, file) ?: continue
148                 dirToRootCache[parent.path] = root
149 
150                 if (!sourceRoots.contains(root)) {
151                     sourceRoots.add(root)
152                 }
153             }
154             return sourceRoots
155         }
156 
157         /**
158          * If given a full path to a Java or Kotlin source file, produces the path to the source
159          * root if possible.
160          */
findRootnull161         private fun findRoot(reporter: Reporter, file: File): File? {
162             val path = file.path
163             if (path.endsWith(DOT_JAVA) || path.endsWith(DOT_KT)) {
164                 val pkg = findPackage(file) ?: return null
165                 val parent = file.parentFile ?: return null
166                 val endIndex = parent.path.length - pkg.length
167                 val before = path[endIndex - 1]
168                 if (before == '/' || before == '\\') {
169                     return File(path.substring(0, endIndex))
170                 } else {
171                     reporter.report(
172                         Issues.IO_ERROR,
173                         file,
174                         "Unable to determine the package name. " +
175                             "This usually means that a source file was where the directory does not seem to match the package " +
176                             "declaration; we expected the path $path to end with /${pkg.replace('.', '/') + '/' + file.name}"
177                     )
178                 }
179             }
180             return null
181         }
182     }
183 }
184