diff --git a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/DocCodeSnippetTest.kt b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/DocCodeSnippetTest.kt index d9b271dc2..34a5c50af 100644 --- a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/DocCodeSnippetTest.kt +++ b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/DocCodeSnippetTest.kt @@ -1,33 +1,37 @@ package com.github.jengelman.gradle.plugins.shadow -import com.github.jengelman.gradle.plugins.shadow.snippet.CodeSnippetExtractor import com.github.jengelman.gradle.plugins.shadow.snippet.DslLang +import com.github.jengelman.gradle.plugins.shadow.snippet.SnippetExecutable +import com.github.jengelman.gradle.plugins.shadow.snippet.extractCodeSnippets import java.nio.file.Path -import kotlin.io.path.createDirectory -import org.junit.jupiter.api.DynamicTest -import org.junit.jupiter.api.TestFactory +import org.junit.jupiter.api.Named.named import org.junit.jupiter.api.io.TempDir +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.Arguments.arguments +import org.junit.jupiter.params.provider.MethodSource class DocCodeSnippetTest { - @TestFactory - fun provideDynamicTests(@TempDir root: Path): List { - val langExecutables = DslLang.entries.map { executor -> CodeSnippetExtractor.extract(executor) } + @ParameterizedTest(name = "{0}") + @MethodSource("snippets") + fun test(executable: SnippetExecutable, @TempDir tempDir: Path) { + executable.execute(tempDir) + } - check(langExecutables.sumOf { it.size } > 0) { "No code snippets found." } - check(langExecutables.size == DslLang.entries.size) { - "We must provide build script snippets for all languages." - } - check(langExecutables.map { it.size }.distinct().size == 1) { - "All languages must have the same number of code snippets." - } + private companion object { + @JvmStatic + fun snippets(): List { + val langExecutables = DslLang.entries.map(DslLang::extractCodeSnippets) - return langExecutables.flatten().map { - val dirName = it.displayName.replace(nonAlphanumeric, "_") - it.tempDir = root.resolve(dirName).createDirectory() - DynamicTest.dynamicTest(it.displayName, it) + check(langExecutables.sumOf { it.size } > 0) { "No code snippets found." } + check(langExecutables.map { it.size }.distinct().size == 1) { + "All languages must have the same number of code snippets." + } + + return langExecutables.flatten().map { executable -> + arguments(named(executable.displayName, executable)) + } } } } - -private val nonAlphanumeric = "[^a-zA-Z0-9]".toRegex() diff --git a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/CodeSnippetExtractor.kt b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/CodeSnippetExtractor.kt index 859117a26..5c6a95894 100644 --- a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/CodeSnippetExtractor.kt +++ b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/CodeSnippetExtractor.kt @@ -1,7 +1,6 @@ package com.github.jengelman.gradle.plugins.shadow.snippet import com.github.jengelman.gradle.plugins.shadow.DocumentTestBuildConfig.DOCS_DIR -import java.nio.file.Path import java.util.regex.Pattern import kotlin.io.path.Path import kotlin.io.path.name @@ -9,44 +8,38 @@ import kotlin.io.path.readText import kotlin.io.path.relativeTo import kotlin.io.path.walk -object CodeSnippetExtractor { - private val docRoot = Path(DOCS_DIR) - - private val markdownPaths = - docRoot.walk().filter { it.name.endsWith(".md", ignoreCase = true) }.toList() - - fun extract(lang: DslLang): List { - return markdownPaths.flatMap { path -> createExecutables(lang, path) } - } - - private fun createExecutables(lang: DslLang, markdownPath: Path): List { - val relativeDocPath = markdownPath.relativeTo(docRoot).toString() - return createSnippets(markdownPath.readText(), lang).map { (lineNumber, snippet) -> - SnippetExecutable.create(lang, snippet, "$relativeDocPath:$lineNumber") { cause -> - RuntimeException( - "The error line in the doc is near ${markdownPath.toUri()}:$lineNumber\n\n${cause.message}", - cause, - ) +private val docRoot = Path(DOCS_DIR) + +fun DslLang.extractCodeSnippets(): List { + val lang = this + return docRoot + .walk() + .filter { it.name.endsWith(".md", ignoreCase = true) } + .flatMap { path -> + val source = path.readText() + val matcher = Pattern.compile("(?ims) {4}```${lang}\n(.*?)\n {4}```").matcher(source) + + buildList { + while (matcher.find()) { + val lineNumber = source.lineNumberAt(matcher.start()) + add( + SnippetExecutable.create( + lang = lang, + snippet = matcher.group(1), + testName = "${path.relativeTo(docRoot)}:$lineNumber", + sourceLocation = "${path.toUri()}:$lineNumber", + ) + ) + } } } - } - - private fun createSnippets(source: String, lang: DslLang) = buildMap { - val pattern = Pattern.compile("(?ims) {4}```${lang}\n(.*?)\n {4}```") - val matcher = pattern.matcher(source) - - while (matcher.find()) { - val line = source.lineNumberAt(matcher.start()) - val code = matcher.group(1) - put(line, code) - } - } + .toList() +} - private fun String.lineNumberAt(index: Int): Int { - var line = 1 - for (i in 0 until index.coerceAtMost(length)) { - if (this[i] == '\n') line++ - } - return line +private fun String.lineNumberAt(index: Int): Int { + var line = 1 + for (i in 0 until index.coerceAtMost(length)) { + if (this[i] == '\n') line++ } + return line } diff --git a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/GroovyBuildExecutable.kt b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/GroovyBuildExecutable.kt index c89775987..36b6bf7ec 100644 --- a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/GroovyBuildExecutable.kt +++ b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/GroovyBuildExecutable.kt @@ -3,22 +3,10 @@ package com.github.jengelman.gradle.plugins.shadow.snippet class GroovyBuildExecutable( override val snippet: String, override val displayName: String, - override val exceptionTransformer: (Throwable) -> Throwable, -) : SnippetExecutable() { - - override val lang: DslLang = DslLang.Groovy - + override val sourceLocation: String, +) : SnippetExecutable { override val buildScriptName: String = "build.gradle" - override val pluginsBlock: String = - """ - |plugins { - | id 'java' - | id 'com.gradleup.shadow' - |} - """ - .trimMargin() - override val assembleDependsOn: String = """ |tasks.named('assemble') { diff --git a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/KotlinBuildExecutable.kt b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/KotlinBuildExecutable.kt index 4cf702695..bce103673 100644 --- a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/KotlinBuildExecutable.kt +++ b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/KotlinBuildExecutable.kt @@ -3,22 +3,10 @@ package com.github.jengelman.gradle.plugins.shadow.snippet class KotlinBuildExecutable( override val snippet: String, override val displayName: String, - override val exceptionTransformer: (Throwable) -> Throwable, -) : SnippetExecutable() { - - override val lang: DslLang = DslLang.Kotlin - + override val sourceLocation: String, +) : SnippetExecutable { override val buildScriptName: String = "build.gradle.kts" - override val pluginsBlock: String = - """ - |plugins { - | java - | id("com.gradleup.shadow") - |} - """ - .trimMargin() - override val assembleDependsOn: String = """ |tasks.named("assemble") { diff --git a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/SnippetExecutable.kt b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/SnippetExecutable.kt index 7423b312d..c3abae4c1 100644 --- a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/SnippetExecutable.kt +++ b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/snippet/SnippetExecutable.kt @@ -7,39 +7,26 @@ import com.github.jengelman.gradle.plugins.shadow.testkit.gradleRunner import java.nio.file.Path import java.util.jar.JarOutputStream import kotlin.io.path.createDirectory -import kotlin.io.path.createFile import kotlin.io.path.outputStream import kotlin.io.path.writeText import org.gradle.testkit.runner.UnexpectedBuildFailure -import org.junit.jupiter.api.function.Executable - -sealed class SnippetExecutable : Executable { - abstract val lang: DslLang - abstract val buildScriptName: String - abstract val pluginsBlock: String - abstract val assembleDependsOn: String - - abstract val snippet: String +sealed interface SnippetExecutable { + val buildScriptName: String + val assembleDependsOn: String + val snippet: String /** Unique name for the test, formatted as `publishing/README.md:10`. */ - abstract val displayName: String - abstract val exceptionTransformer: (Throwable) -> Throwable + val displayName: String + val sourceLocation: String - lateinit var tempDir: Path - - override fun execute() { + fun execute(projectRoot: Path) { + var generatedMainScript: String? = null + var gradleBuildOutput: String? = null try { - execute(tempDir, snippet) - } catch (t: Throwable) { - throw exceptionTransformer(t) - } - } - - private fun execute(projectRoot: Path, snippet: String) { - projectRoot - .resolve("settings.gradle") - .writeText( - """ + projectRoot + .resolve("settings.gradle") + .writeText( + """ |gradle.beforeProject { p -> | // Snippet version placeholders resolve to '+', so avoid frequent remote version checks. | p.buildscript.configurations.configureEach { @@ -63,69 +50,85 @@ sealed class SnippetExecutable : Executable { |$enableNoImplicitLookupInParentProjects |enableFeaturePreview 'STABLE_CONFIGURATION_CACHE' |enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS' + """ + .trimMargin() + ) + val pluginsBlock = + """ + |plugins { + | id("java") + | id("com.gradleup.shadow") + |} """ .trimMargin() - ) - - val apiScript = buildString { - appendLine(pluginsBlock) - append(assembleDependsOn) - } - projectRoot.addSubProject("api", apiScript) - - val (imports, withoutImports) = importsExtractor(snippet) - val mainScript = buildString { - appendLine(imports) - // All buildscript {} blocks must appear before any plugins {} blocks in the script. - if (withoutImports.contains("buildscript {")) { - appendLine(withoutImports) - } else { - if (!withoutImports.contains("plugins {")) { - appendLine(pluginsBlock) + val apiScript = buildString { + appendLine(pluginsBlock) + append(assembleDependsOn) + } + projectRoot.addSubProject("api", apiScript) + + val (imports, withoutImports) = extractImports() + val mainScript = buildString { + appendLine(imports) + // All buildscript {} blocks must appear before any plugins {} blocks in the script. + if (withoutImports.contains("buildscript {")) { + appendLine(withoutImports) + } else { + if (!withoutImports.contains("plugins {")) { + appendLine(pluginsBlock) + } + appendLine(withoutImports) } - appendLine(withoutImports) } - } - .trimIndent() - projectRoot.addSubProject("main", mainScript + assembleDependsOn) - projectRoot.resolve("main/foo.jar").createFile().also { - // Dummy JAR file to ensure the project can be built. - JarOutputStream(it.outputStream()).use {} - } - projectRoot.resolve("main/bar.jar").createFile().also { - // Dummy JAR file to ensure the project can be built. - JarOutputStream(it.outputStream()).use {} - } + .trimIndent() + generatedMainScript = mainScript + projectRoot.addSubProject("main", mainScript + assembleDependsOn) + listOf("foo.jar", "bar.jar").forEach { name -> + // Dummy JAR file to ensure the project can be built. + JarOutputStream(projectRoot.resolve("main/$name").outputStream()).use {} + } - // Script-defined classes (e.g., inline custom ResourceTransformer) are not supported by - // CC/IP because transient script classloaders cannot be serialized. - val runnerArgs = - if (withoutImports.contains("class ")) { - commonGradleArgs.filterNot { - it == "--configuration-cache" || it.contains("isolated-projects") + // Script-defined classes (e.g., inline custom ResourceTransformer) are not supported by + // CC/IP because transient script classloaders cannot be serialized. + val runnerArgs = + if (withoutImports.contains("class ")) { + commonGradleArgs.filterNot { + it == "--configuration-cache" || it.contains("isolated-projects") + } + } else { + commonGradleArgs.toList() } - } else { - commonGradleArgs.toList() - } - try { gradleRunner(projectDir = projectRoot, arguments = runnerArgs + "build") .build() + .also { gradleBuildOutput = it.output } .assertNoDeprecationWarnings() } catch (t: Throwable) { - val buildOutput = (t as? UnexpectedBuildFailure)?.buildResult?.output - val message = buildString { - appendLine("--- Snippet ---") - appendLine() - appendLine(mainScript) - if (!buildOutput.isNullOrBlank()) { - appendLine() - appendLine("--- Gradle Build Output ---") - appendLine() - appendLine(buildOutput.trim()) - } - } - throw RuntimeException(message, t) + val buildOutput = (t as? UnexpectedBuildFailure)?.buildResult?.output ?: gradleBuildOutput + throw AssertionError( + buildString { + append("The error line in the doc is near $sourceLocation") + if (generatedMainScript != null) { + appendLine() + appendLine() + appendLine("--- Snippet ---") + appendLine() + append(generatedMainScript) + } + if (!buildOutput.isNullOrBlank()) { + appendLine() + appendLine() + appendLine("--- Gradle Build Output ---") + appendLine() + append(buildOutput.trim()) + } else if (!t.message.isNullOrBlank()) { + appendLine() + appendLine() + append(t.message) + } + }, + t, + ) } } @@ -133,7 +136,7 @@ sealed class SnippetExecutable : Executable { resolve(project).createDirectory().resolve(buildScriptName).writeText(buildScriptText) } - private fun importsExtractor(snippet: String): Pair { + private fun extractImports(): Pair { val imports = StringBuilder() val withoutImports = StringBuilder() @@ -153,11 +156,11 @@ sealed class SnippetExecutable : Executable { lang: DslLang, snippet: String, testName: String, - exceptionTransformer: (Throwable) -> Throwable, + sourceLocation: String, ): SnippetExecutable = when (lang) { - DslLang.Groovy -> GroovyBuildExecutable(snippet, testName, exceptionTransformer) - DslLang.Kotlin -> KotlinBuildExecutable(snippet, testName, exceptionTransformer) + DslLang.Groovy -> GroovyBuildExecutable(snippet, testName, sourceLocation) + DslLang.Kotlin -> KotlinBuildExecutable(snippet, testName, sourceLocation) } } }