1 /*
<lambda>null2  * Copyright 2025 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 androidx.appfunctions.compiler.testings
18 
19 import androidx.room.compiler.processing.util.DiagnosticMessage
20 import androidx.room.compiler.processing.util.Resource
21 import androidx.room.compiler.processing.util.Source
22 import androidx.room.compiler.processing.util.compiler.TestCompilationArguments
23 import androidx.room.compiler.processing.util.compiler.TestCompilationResult
24 import androidx.room.compiler.processing.util.compiler.compile
25 import com.google.common.truth.Truth.assertWithMessage
26 import com.google.devtools.ksp.processing.SymbolProcessorProvider
27 import java.io.File
28 import java.nio.file.Files
29 import java.nio.file.Path
30 import javax.tools.Diagnostic
31 import kotlin.io.path.Path
32 import kotlin.io.path.createDirectories
33 import kotlin.io.path.createFile
34 import kotlin.io.path.deleteIfExists
35 import kotlin.io.path.writeText
36 
37 /** A helper to test compilation. */
38 class CompilationTestHelper(
39     /** The root directory containing the source test files. */
40     private val testFileSrcDir: File,
41     /** The root directory containing the source golden files. */
42     private val goldenFileSrcDir: File,
43     /** A list of proxy source files to be compiled with the test sources. */
44     private val proxySourceFileNames: List<String>,
45     /** A list of [com.google.devtools.ksp.processing.SymbolProcessorProvider] under test. */
46     private val symbolProcessorProviders: List<SymbolProcessorProvider>,
47 ) {
48 
49     init {
50         check(testFileSrcDir.exists()) {
51             "Test file source directory [${testFileSrcDir.path}] does not exist"
52         }
53         check(testFileSrcDir.isDirectory) { "[$testFileSrcDir] is not a directory." }
54 
55         check(goldenFileSrcDir.exists()) {
56             "Golden file source directory [${goldenFileSrcDir.path}] does not exist"
57         }
58         check(goldenFileSrcDir.isDirectory) { "[$goldenFileSrcDir] is not a directory." }
59     }
60 
61     private val outputDir: Path by lazy {
62         requireNotNull(System.getProperty("test_output_dir")) {
63                 "test_output_dir not set for diff test."
64             }
65             .let { Path(it) }
66     }
67 
68     /** Compiles all [sourceFileNames] with additional [processorOptions]. */
69     fun compileAll(
70         sourceFileNames: List<String>,
71         processorOptions: Map<String, String> = emptyMap<String, String>(),
72     ): CompilationReport {
73         val sources =
74             sourceFileNames.map { sourceFileName ->
75                 val sourceFile = getTestSourceFile(sourceFileName)
76                 Source.Companion.kotlin(
77                     ensureKotlinFileNameFormat(sourceFileName),
78                     sourceFile.readText()
79                 )
80             } +
81                 proxySourceFileNames.map { proxySourceFileName ->
82                     val proxySourceFile = getTestSourceFile(proxySourceFileName)
83                     Source.Companion.kotlin(
84                         ensureKotlinFileNameFormat(proxySourceFile.name),
85                         proxySourceFile.readText()
86                     )
87                 }
88 
89         val workingDir =
90             Files.createTempDirectory("compile").toFile().also { file -> file.deleteOnExit() }
91         val result =
92             compile(
93                 workingDir,
94                 TestCompilationArguments(
95                     sources = sources,
96                     symbolProcessorProviders = symbolProcessorProviders,
97                     processorOptions = processorOptions,
98                 )
99             )
100 
101         return CompilationReport.create(result, outputDir)
102     }
103 
104     /**
105      * Asserts that the compilation succeeds and contains a generated file (either source or
106      * resource) with the given name, whose content matches the golden file.
107      */
108     private fun assertSuccessWithGeneratedContent(
109         report: CompilationReport,
110         expectGeneratedFileName: String,
111         goldenFileName: String,
112         generatedFileContent: String?
113     ) {
114         assertWithMessage(
115                 """
116             Compile failed with error:
117             ${report.printDiagnostics(Diagnostic.Kind.ERROR)}
118 
119             Generated content:
120             $generatedFileContent
121             """
122                     .trimIndent()
123             )
124             .that(report.isSuccess)
125             .isTrue()
126 
127         val goldenFile = getGoldenFile(goldenFileName)
128         val updateGoldenFiles = System.getProperty("update_golden_files")?.toBoolean() == true
129         assertWithMessage(
130                 "Generated file [$expectGeneratedFileName] does not exist or had multiple matches"
131             )
132             .that(generatedFileContent)
133             .isNotNull()
134         if (updateGoldenFiles) {
135             println("Updating golden file: ${goldenFile.path}")
136             goldenFile.writeText(checkNotNull(generatedFileContent))
137         } else {
138             assertWithMessage(
139                     """
140                 Content of generated file [$expectGeneratedFileName] does not match
141                 the content of golden file [${goldenFile.path}].
142 
143                 To update the golden file,
144                 run:
145                   ./gradlew appfunctions:appfunctions-compiler:test -Dupdate_golden_files=true
146                 """
147                         .trimIndent()
148                 )
149                 .that(generatedFileContent)
150                 .isEqualTo(goldenFile.readText())
151         }
152     }
153 
154     /**
155      * Asserts that the compilation succeeds and contains [expectGeneratedSourceFileName] in
156      * generated sources that is identical to the content of [goldenFileName].
157      */
158     fun assertSuccessWithSourceContent(
159         report: CompilationReport,
160         expectGeneratedSourceFileName: String,
161         goldenFileName: String,
162     ) {
163         assertSuccessWithGeneratedContent(
164             report,
165             expectGeneratedSourceFileName,
166             goldenFileName,
167             report.generatedSourceFiles
168                 .singleOrNull { sourceFile ->
169                     sourceFile.source.relativePath.contains(expectGeneratedSourceFileName)
170                 }
171                 ?.source
172                 ?.contents
173         )
174     }
175 
176     /**
177      * Asserts that the compilation succeeds and contains [expectGeneratedResourceFileName] in
178      * generated resources that is identical to the content of [goldenFileName].
179      */
180     fun assertSuccessWithResourceContent(
181         report: CompilationReport,
182         expectGeneratedResourceFileName: String,
183         goldenFileName: String,
184     ) {
185         assertSuccessWithGeneratedContent(
186             report,
187             expectGeneratedResourceFileName,
188             goldenFileName,
189             report.generatedResourceFiles
190                 .singleOrNull { resourceFile ->
191                     resourceFile.resource.relativePath.contains(expectGeneratedResourceFileName)
192                 }
193                 ?.resource
194                 ?.getContents()
195         )
196     }
197 
198     fun assertErrorWithMessage(report: CompilationReport, expectedErrorMessage: String) {
199         assertWithMessage("Compile succeed").that(report.isSuccess).isFalse()
200 
201         val errorDiagnostics = report.diagnostics[Diagnostic.Kind.ERROR] ?: emptyList()
202         var foundError = false
203         for (errorDiagnostic in errorDiagnostics) {
204             if (errorDiagnostic.msg.contains(expectedErrorMessage)) {
205                 foundError = true
206                 break
207             }
208         }
209         assertWithMessage(
210                 """
211                 Unable to find the expected error message [$expectedErrorMessage] from the
212                 diagnostics results:
213 
214                 ${report.printDiagnostics(Diagnostic.Kind.ERROR)}
215             """
216                     .trimIndent()
217             )
218             .that(foundError)
219             .isTrue()
220     }
221 
222     private fun ensureKotlinFileNameFormat(sourceFileName: String): String {
223         val nameParts = sourceFileName.split(".")
224         require(nameParts.last().lowercase() == "kt") {
225             "Source file $sourceFileName is not a Kotlin file"
226         }
227         val fileNameWithoutExtension =
228             nameParts.joinToString(separator = ".", limit = nameParts.size - 1)
229         return "${fileNameWithoutExtension}.kt"
230     }
231 
232     private fun getTestSourceFile(fileName: String): File {
233         return File(
234                 testFileSrcDir,
235                 /** child= */
236                 fileName
237             )
238             .also { file -> check(file.exists()) { "Source file [${file.path}] does not exist" } }
239     }
240 
241     private fun getGoldenFile(fileName: String): File {
242         return File(
243                 goldenFileSrcDir,
244                 /** child= */
245                 fileName
246             )
247             .also { file -> check(file.exists()) { "Golden file [${file.path}] does not exist" } }
248     }
249 
250     private fun String.sanitizeFilePath(): String {
251         return this.replace("$", "\\$")
252     }
253 
254     /** The compilation report. */
255     data class CompilationReport(
256         /** Indicates whether the compilation succeed or not. */
257         val isSuccess: Boolean,
258         /** A list of generated source files. */
259         val generatedSourceFiles: List<GeneratedSourceFile>,
260         /** A map of diagnostics results. */
261         val diagnostics: Map<Diagnostic.Kind, List<DiagnosticMessage>>,
262         /** A list of generated source files. */
263         val generatedResourceFiles: List<GeneratedResourceFile>,
264     ) {
265         /** Print the diagnostics result of type [kind]. */
266         fun printDiagnostics(kind: Diagnostic.Kind): String {
267             val errorDiagnostics = diagnostics[kind] ?: return "No ${kind.name} diagnostic message"
268             return buildString {
269                 append("${kind.name} diagnostic messages:\n\n")
270                 for (diagnostic in errorDiagnostics) {
271                     append("$diagnostic\n\n")
272                 }
273             }
274         }
275 
276         companion object {
277             internal fun create(result: TestCompilationResult, outputDir: Path): CompilationReport {
278                 return CompilationReport(
279                     isSuccess = result.success,
280                     generatedSourceFiles =
281                         result.generatedSources.map { source ->
282                             GeneratedSourceFile.create(source, outputDir)
283                         },
284                     diagnostics = result.diagnostics,
285                     generatedResourceFiles =
286                         result.generatedResources.map { resource ->
287                             GeneratedResourceFile.create(resource, outputDir)
288                         }
289                 )
290             }
291         }
292     }
293 
294     /** A wrapper class contains [source] with its file path. */
295     data class GeneratedSourceFile(val source: Source, val sourceFilePath: Path) {
296         companion object {
297             internal fun create(source: Source, outputDir: Path): GeneratedSourceFile {
298                 val filePath =
299                     outputDir.resolve(source.relativePath).apply {
300                         parent?.createDirectories()
301                         deleteIfExists()
302                         createFile()
303                         writeText(source.contents)
304                     }
305                 return GeneratedSourceFile(source, filePath)
306             }
307         }
308     }
309 
310     /** A wrapper class contains [Resource] with its file path. */
311     data class GeneratedResourceFile(val resource: Resource, val resourceFilePath: Path) {
312         companion object {
313             internal fun create(resource: Resource, outputDir: Path): GeneratedResourceFile {
314                 val filePath =
315                     outputDir.resolve(resource.relativePath).apply {
316                         parent?.createDirectories()
317                         deleteIfExists()
318                         createFile()
319                         writeText(resource.getContents())
320                     }
321                 return GeneratedResourceFile(resource, filePath)
322             }
323         }
324     }
325 
326     companion object {
327         private fun Resource.getContents(): String =
328             openInputStream().bufferedReader().use { it.readText() }
329     }
330 }
331