diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc196d0d..1c5717d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Support manifest header relocation via configurable `attributesToRelocate` property. - Allow disabling default ProGuard rules in R8 minimization with `R8Spec.useDefaultRules`. ([#2252](https://github.com/GradleUp/shadow/pull/2252)) - Allow passing classpath files to R8 minimization with `R8Spec.classpath`. ([#2255](https://github.com/GradleUp/shadow/pull/2255)) +- Support shadowed sources JAR. ([#2265](https://github.com/GradleUp/shadow/pull/2265)) ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 319faa8ea..43b9d0105 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,8 +105,9 @@ When adding new features or public APIs: - Prefer exact and complete matching using `isEqualTo` for string assertions whenever possible instead of partial matching (e.g., `contains`). -- Prefer complete assertions like `containsOnly` for collections whenever possible instead of partial assertions like - `containsAtLeast` or `containsNone`. +- Prefer complete assertions like `containsOnly` (or `containsExactly` when order matters) for collections whenever + possible instead of partial assertions like `containsAtLeast` or `containsNone`. Prefer `containsExactly(...)` over + `isEqualTo(listOf(...))` when verifying exact collection elements in order. - Raw multiline strings in tests should be constructed using `.trimMargin()`. - Prefer `=` property assignment over `.set(...)` in Gradle build scripts (both in documentation snippets and functional tests) unless `.set(...)` is explicitly required. diff --git a/api/shadow.api b/api/shadow.api index 5ce64b4d7..4428364a4 100644 --- a/api/shadow.api +++ b/api/shadow.api @@ -58,6 +58,7 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugi public static final field COMPONENT_NAME Ljava/lang/String; public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin$Companion; public static final field SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME Ljava/lang/String; + public static final field SHADOW_SOURCES_ELEMENTS_CONFIGURATION_NAME Ljava/lang/String; public fun (Lorg/gradle/api/component/SoftwareComponentFactory;)V public synthetic fun apply (Ljava/lang/Object;)V public fun apply (Lorg/gradle/api/Project;)V @@ -69,6 +70,7 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugi public final class com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin$Companion { public final synthetic fun getShadowRuntimeElements (Lorg/gradle/api/artifacts/ConfigurationContainer;)Lorg/gradle/api/NamedDomainObjectProvider; + public final synthetic fun getShadowSourcesElements (Lorg/gradle/api/artifacts/ConfigurationContainer;)Lorg/gradle/api/NamedDomainObjectProvider; } public abstract class com/github/jengelman/gradle/plugins/shadow/ShadowKmpPlugin : org/gradle/api/Plugin { @@ -259,6 +261,7 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar public fun getAddMultiReleaseAttribute ()Lorg/gradle/api/provider/Property; public fun getApiJars ()Lorg/gradle/api/file/ConfigurableFileCollection; protected abstract fun getArchiveOperations ()Lorg/gradle/api/file/ArchiveOperations; + public fun getArchiveSourcesFile ()Lorg/gradle/api/file/RegularFileProperty; public fun getConfigurations ()Lorg/gradle/api/provider/SetProperty; public fun getDependencyFilter ()Lorg/gradle/api/provider/Property; public fun getDuplicatesStrategy ()Lorg/gradle/api/file/DuplicatesStrategy; @@ -267,7 +270,9 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar public fun getExcludes ()Ljava/util/Set; protected abstract fun getExecOperations ()Lorg/gradle/process/ExecOperations; public fun getFailOnDuplicateEntries ()Lorg/gradle/api/provider/Property; + public fun getGenerateSourcesJar ()Lorg/gradle/api/provider/Property; public fun getIncludedDependencies ()Lorg/gradle/api/file/ConfigurableFileCollection; + public fun getIncludedSourcesJars ()Lorg/gradle/api/file/ConfigurableFileCollection; public fun getIncludes ()Ljava/util/Set; public fun getJavaLauncher ()Lorg/gradle/api/provider/Property; public fun getMainClass ()Lorg/gradle/api/provider/Property; @@ -279,6 +284,7 @@ public abstract class com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar public fun getRelocationPrefix ()Lorg/gradle/api/provider/Property; public fun getRelocators ()Lorg/gradle/api/provider/SetProperty; public fun getSourceSetsClassesDirs ()Lorg/gradle/api/file/ConfigurableFileCollection; + public fun getSourceSetsSourceDirs ()Lorg/gradle/api/file/ConfigurableFileCollection; public fun getToMinimize ()Lorg/gradle/api/file/ConfigurableFileCollection; public fun getTransformers ()Lorg/gradle/api/provider/SetProperty; public fun mergeGroovyExtensionModules ()V diff --git a/build.gradle.kts b/build.gradle.kts index ae2dcc51a..2bae18d78 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -146,6 +146,7 @@ dependencies { testPluginRuntimeOnly(testKit.map { it.output }) testPluginRuntimeOnly(libs.foojayResolver) testPluginRuntimeOnly(libs.pluginPublish) + testPluginRuntimeOnly(libs.dokka) lintChecks(libs.androidx.gradlePluginLints) } diff --git a/docs/configuration/minimizing/README.md b/docs/configuration/minimizing/README.md index 09f8f1351..904f0e777 100644 --- a/docs/configuration/minimizing/README.md +++ b/docs/configuration/minimizing/README.md @@ -131,6 +131,18 @@ rules published in dependency JARs, for example under `META-INF/proguard`. > Alternatively, if you use [R8 Repackaging][r8-repackaging] (e.g. `-repackageclasses`), R8 applies embedded rules > natively without needing rule rewriting. +> [!NOTE] +> **Shadowed Sources JAR and R8** +> +> R8 operates directly on compiled JVM bytecode rather than source code. When minimizing with R8 +(`minimize { r8 { ... } }`), +> Shadow cannot determine which source files correspond to classes removed by R8. Therefore, the shadowed sources JAR +will +> contain all relocated source files without responding to R8 shrinking results. +> +> If you need unused source files to be filtered out of the shadowed sources JAR, use the default dependency analyzer +> minimization (`minimize()`) instead. + === ":material-language-kotlin: build.gradle.kts" ```kotlin diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index f75096494..ddd1cbc44 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -137,8 +137,12 @@ in their build logic), Shadow will automatically configure the following behavio - `META-INF/*.RSA` - `META-INF/versions/**/module-info.class` - `module-info.class` +- Configures the [`ShadowJar`][ShadowJar] task to generate a companion **Shadowed Sources JAR** containing both + project sources and shadowed dependency sources with relocated packages when `java.withSourcesJar()` is enabled (or + when [`generateSourcesJar`][ShadowJar.generateSourcesJar] is set to `true`). - Creates and registers the `shadow` component in the project (used for integrating with - [`maven-publish`][maven-publish]). + [`maven-publish`][maven-publish]), including the `shadowSourcesElements` variant when `java.withSourcesJar()` is + enabled. ## ShadowJar Command Line options @@ -155,6 +159,8 @@ Here are the options that can be passed to the `shadowJar`: --no-enable-kotlin-module-remapping Disables option --enable-kotlin-module-remapping. --fail-on-duplicate-entries Fails build if the ZIP entries in the shadowed JAR are duplicate. --no-fail-on-duplicate-entries Disables option --fail-on-duplicate-entries. +--generate-sources-jar Generates a companion shadowed sources JAR containing project and dependency sources. +--no-generate-sources-jar Disables option --generate-sources-jar. --main-class Main class attribute to add to manifest. --minimize-jar Minimizes the jar by removing unused classes. --no-minimize-jar Disables option --minimize-jar. @@ -175,5 +181,6 @@ Refer to [listing command line options][listing-command-line-options]. [JavaPlugin]: https://docs.gradle.org/current/userguide/java_plugin.html [maven-publish]: https://docs.gradle.org/current/userguide/publishing_maven.html [ShadowJar]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/index.html +[ShadowJar.generateSourcesJar]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/generate-sources-jar.html [gradle-plugin-portal]: https://plugins.gradle.org/plugin/com.gradleup.shadow [listing-command-line-options]: https://docs.gradle.org/current/userguide/custom_tasks.html#sec:listing_task_options diff --git a/docs/kotlin-plugins/README.md b/docs/kotlin-plugins/README.md index e97c39ceb..c1bac80a3 100644 --- a/docs/kotlin-plugins/README.md +++ b/docs/kotlin-plugins/README.md @@ -139,6 +139,9 @@ automatically configure additional tasks for bundling the shadowed JAR for its ` } ``` +For details on publishing shadowed artifacts and sources JAR in KMP projects, see +[Publishing with Kotlin Multiplatform (KMP)][publishing-with-kmp]. + ## Kotlin Module Metadata Remapping Kotlin module metadata (`.kotlin_module`) files contain information about package parts and facades. When relocating @@ -169,4 +172,5 @@ To explicitly apply this remapping (recommended for future compatibility), add [KotlinModuleMetadataTransformer]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.transformers/-kotlin-module-metadata-transformer/index.html [dependency-on-the-standard-library]: https://kotlinlang.org/docs/gradle-configure-project.html#dependency-on-the-standard-library [publishing-libraries]: ../publishing/README.md +[publishing-with-kmp]: ../publishing/README.md#publishing-with-kotlin-multiplatform-kmp [running-applications]: ../application-plugin/README.md diff --git a/docs/publishing/README.md b/docs/publishing/README.md index 2ec088dfd..e064137d6 100644 --- a/docs/publishing/README.md +++ b/docs/publishing/README.md @@ -295,41 +295,98 @@ You may want to publish the shadowed JAR instead of the original JAR. This can b ``` Because the default `archiveClassifier` of [`Jar`][Jar] is `""` (empty), setting the `archiveClassifier` of -[`ShadowJar`][ShadowJar] to `""` (empty) will make collisions between the outputs of these two tasks in some cases. If -you don't need the standard JAR, you can disable the `jar` task like: +[`ShadowJar`][ShadowJar] to `""` (empty) will make collisions between the outputs of standard tasks and `shadowJar`: + +- The binary shadowed JAR is output to `-.jar`, conflicting with the `jar` task. +- When `generateSourcesJar` is enabled (such as when `java.withSourcesJar()` is used), the companion shadowed sources + JAR is output to `--sources.jar`, conflicting with the standard `sourcesJar` task. + +If you want to replace standard JARs with the shadowed ones, disable the standard `jar` and `sourcesJar` tasks: === ":material-language-kotlin: build.gradle.kts" ```kotlin + plugins { + java + id("com.gradleup.shadow") + } + + java { + withSourcesJar() + } + tasks.jar { enabled = false } + + tasks.named("sourcesJar") { + enabled = false + } ``` === ":simple-apachegroovy: build.gradle" ```groovy + plugins { + id 'java' + id 'com.gradleup.shadow' + } + + java { + withSourcesJar() + } + tasks.named('jar', Jar) { enabled = false } + + tasks.named('sourcesJar', Jar) { + enabled = false + } ``` -Or set a different `archiveClassifier` for the standard [`Jar`][Jar] like: +Or set different `archiveClassifier` values for the standard tasks: === ":material-language-kotlin: build.gradle.kts" ```kotlin + plugins { + java + id("com.gradleup.shadow") + } + + java { + withSourcesJar() + } + tasks.jar { archiveClassifier = "ignored" } + + tasks.named("sourcesJar") { + archiveClassifier = "ignored-sources" + } ``` === ":simple-apachegroovy: build.gradle" ```groovy + plugins { + id 'java' + id 'com.gradleup.shadow' + } + + java { + withSourcesJar() + } + tasks.named('jar', Jar) { archiveClassifier = 'ignored' } + + tasks.named('sourcesJar', Jar) { + archiveClassifier = 'ignored-sources' + } ``` ## Publishing the Shadowed Gradle Plugins @@ -515,10 +572,429 @@ customizable properties listed in [Configuring Output Name][configuring-output-n We modified `archiveClassifier`, `archiveExtension` and `archiveBaseName` in this example, the published artifact will be named `my-artifact-2.0-my-classifier.my-ext` instead of `1.0-all.jar`. +## Shadowed Sources JAR + +When publishing a shadowed library, consumers and IDEs need a corresponding sources JAR to navigate source code and +inspect implementations. A standard sources JAR only contains your project's original un-relocated sources, which +causes broken navigation when consumers reference relocated packages. + +Shadow automatically generates a **Shadowed Sources JAR** containing: + +- Source files from your project's source sets (`Java`, `Kotlin`, `Groovy`, `Scala`). +- Source files resolved and merged from all bundled dependencies' `-sources.jar` archives. +- Relocated package declarations, imports, and symbol references that match your [`relocate`][ShadowJar.relocate] rules. +- Normalized package directory layout matching the declared `package` in each source file. +- Automatic filtering: dependencies excluded in `dependencies { exclude(...) }` or unused classes removed via + `minimize()` are automatically excluded from the shadowed sources JAR as well. + +### Publishing with `withSourcesJar()` + +When Gradle's standard `java.withSourcesJar()` is enabled, the Shadow plugin automatically registers the +`shadowSourcesElements` variant and publishes the shadowed sources JAR alongside the shadowed binary JAR: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + plugins { + java + `maven-publish` + id("com.gradleup.shadow") + } + + java { + withSourcesJar() + } + + publishing { + publications { + create("shadow") { + from(components["shadow"]) + } + } + repositories { + maven("https://repo.myorg.com") + } + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + plugins { + id 'java' + id 'maven-publish' + id 'com.gradleup.shadow' + } + + java { + withSourcesJar() + } + + publishing { + publications { + shadow(MavenPublication) { + from components.shadow + } + } + repositories { + maven { url = 'https://repo.myorg.com' } + } + } + ``` + +The published Maven publication will include both `--all.jar` and +`--all-sources.jar`. + +### Local File Names vs. Published Classifiers + +The Shadow plugin distinguishes between the **local output file** on disk and the **published artifact classifier** in +Maven repositories and Gradle Module Metadata: + +| Configuration | Local Output File (`archiveSourcesFile` in `build/libs`) | Published Classifier | Published File (Maven Repository) | Use Case | +|:----------------------------------------|:---------------------------------------------------------|:---------------------|:--------------------------------------------|:---------------------------------------------------| +| `archiveClassifier = "all"` *(default)* | `--all-sources.jar` | `all-sources` | `--all-sources.jar` | **Coexistence** (coexists with standard `sources`) | +| `archiveClassifier = "shaded"` | `--shaded-sources.jar` | `shaded-sources` | `--shaded-sources.jar` | **Coexistence** (custom classifier) | +| `archiveClassifier = ""` | `--sources.jar` | `sources` | `--sources.jar` | **Replacement** (replaces standard `sources`) | + +#### Coexistence Scenario + +When publishing alongside standard Java artifacts (e.g. publishing `from(components["java"])` with +`shadow.addShadowVariantIntoJavaComponent = true`), the standard sources variant uses classifier `sources`. To prevent +coordinate collisions within the same publication, the shadowed sources variant dynamically derives its classifier as +`-sources` (such as `all-sources` or `shaded-sources`). + +#### Replacement Scenario + +When configuring `shadowJar` to replace the standard JAR (`archiveClassifier = ""`), the companion shadowed sources JAR +automatically uses the standard `sources` classifier. + +To publish shadowed artifacts as the primary publication: + +1. **Publish from `components["shadow"]` (Recommended)**: Publish the `shadow` component directly in your Maven + publication, and disable standard archive tasks to prevent destination file collisions in `build/libs`: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + plugins { + java + `maven-publish` + id("com.gradleup.shadow") + } + + java { + withSourcesJar() + } + + tasks.jar { + enabled = false + } + + tasks.named("sourcesJar") { + enabled = false + } + + tasks.shadowJar { + archiveClassifier = "" + } + + publishing { + publications { + create("shadow") { + from(components["shadow"]) + } + } + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + plugins { + id 'java' + id 'maven-publish' + id 'com.gradleup.shadow' + } + + java { + withSourcesJar() + } + + tasks.named('jar', Jar) { + enabled = false + } + + tasks.named('sourcesJar', Jar) { + enabled = false + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + archiveClassifier = '' + } + + publishing { + publications { + shadow(MavenPublication) { + from components.shadow + } + } + } + ``` + +2. **Publish from `components["java"]`**: If publishing `from(components["java"])`, disabling the `jar` or `sourcesJar` + tasks does not remove standard variants from the `java` software component. You must also explicitly skip the + standard publication variants: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + plugins { + java + `maven-publish` + id("com.gradleup.shadow") + } + + java { + withSourcesJar() + } + + components.named("java") { + withVariantsFromConfiguration(configurations["runtimeElements"]) { skip() } + withVariantsFromConfiguration(configurations["sourcesElements"]) { skip() } + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + plugins { + id 'java' + id 'maven-publish' + id 'com.gradleup.shadow' + } + + java { + withSourcesJar() + } + + components.named('java', org.gradle.api.component.AdhocComponentWithVariants) { + withVariantsFromConfiguration(configurations.runtimeElements) { skip() } + withVariantsFromConfiguration(configurations.sourcesElements) { skip() } + } + ``` + +> [!NOTE] +> Generating the companion shadowed sources JAR is controlled by [`generateSourcesJar`][ShadowJar.generateSourcesJar]. +> In Java projects, it defaults to `true` when `java.withSourcesJar()` is enabled, and `false` otherwise to avoid +> unnecessary build overhead for application builds. If `withSourcesJar()` is omitted, publishing from +> `components["shadow"]` will only publish the shadowed binary JAR, preserving backward compatibility for existing +builds. +> You can also explicitly toggle generation via `generateSourcesJar = true` (or `--generate-sources-jar`). + +### Customizing the Sources Archive File + +The companion shadowed sources JAR output location is configured via +[`ShadowJar.archiveSourcesFile`][ShadowJar.archiveSourcesFile], +which defaults to the same destination and base name as `archiveFile` with `-sources.jar` suffix: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + tasks.shadowJar { + archiveSourcesFile = layout.buildDirectory.file("custom-libs/my-sources.jar") + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + archiveSourcesFile = layout.buildDirectory.file('custom-libs/my-sources.jar') + } + ``` + +You can also customize the source inputs included in the companion sources JAR using +[`sourceSetsSourceDirs`][ShadowJar.sourceSetsSourceDirs] and +[`includedSourcesJars`][ShadowJar.includedSourcesJars]: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + tasks.shadowJar { + // Add custom source directories + sourceSetsSourceDirs.from("src/extra/java") + + // Add additional dependency sources JARs + includedSourcesJars.from("libs/external-lib-sources.jar") + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + // Add custom source directories + sourceSetsSourceDirs.from 'src/extra/java' + + // Add additional dependency sources JARs + includedSourcesJars.from 'libs/external-lib-sources.jar' + } + ``` + +### Publishing with Kotlin Multiplatform (KMP) + +In Kotlin Multiplatform (KMP) projects, publications are managed by the Kotlin Gradle Plugin (KGP) per target (such as +the `jvm` publication). You can attach the shadowed sources JAR artifact to the `jvm` Maven publication: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.gradleup.shadow") + `maven-publish` + } + + kotlin { + jvm() + } + + publishing { + publications { + withType().configureEach { + if (name == "jvm") { + artifact(tasks.named("shadowJar").flatMap { it.archiveSourcesFile }) { + classifier = "sources" + } + } + } + } + repositories { + maven("https://repo.myorg.com") + } + } + + tasks.named("shadowJar") { + generateSourcesJar = true + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + plugins { + id 'org.jetbrains.kotlin.multiplatform' + id 'com.gradleup.shadow' + id 'maven-publish' + } + + kotlin { + jvm() + } + + publishing { + publications { + withType(MavenPublication).configureEach { + if (name == 'jvm') { + artifact(tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar).flatMap { it.archiveSourcesFile }) { + classifier = 'sources' + } + } + } + } + repositories { + maven { url = 'https://repo.myorg.com' } + } + } + + tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + generateSourcesJar = true + } + ``` + +## Generating Javadoc or Dokka from Shadowed Sources + +When creating fat / shadowed libraries, you may want to generate a complete Javadoc or Dokka JAR covering both your +project sources and shadowed dependency sources with relocated packages. + +Because `shadowJar` outputs the shadowed sources archive at `archiveSourcesFile` (where relocated packages and source +contents have already been transformed), you can configure the `javadoc` task (or Dokka task) to consume the shadowed +sources and classes directly from `shadowJar`. The generated documentation will reflect the relocated package names +(e.g. `shadow.com.Example` instead of `com.Example`). + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + tasks.javadoc { + classpath = files(tasks.shadowJar.flatMap { it.archiveFile }) + source = zipTree(tasks.shadowJar.flatMap { it.archiveSourcesFile }) + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + tasks.named('javadoc', Javadoc) { + classpath = files(tasks.named('shadowJar').flatMap { it.archiveFile }) + source = zipTree(tasks.named('shadowJar').flatMap { it.archiveSourcesFile }) + } + ``` + +If using [Dokka][dokka] for Kotlin projects, you can extract the shadowed sources and configure `sourceRoots`: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + plugins { + kotlin("jvm") + id("org.jetbrains.dokka") + id("com.gradleup.shadow") + } + + val extractShadowedSources = tasks.register("extractShadowedSources") { + from(zipTree(tasks.shadowJar.flatMap { it.archiveSourcesFile })) + into(layout.buildDirectory.dir("extracted-shadowed-sources")) + } + + dokka { + dokkaSourceSets.configureEach { + classpath.setFrom(tasks.shadowJar.flatMap { it.archiveFile }) + sourceRoots.setFrom(extractShadowedSources.map { it.destinationDir }) + } + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + plugins { + id 'org.jetbrains.kotlin.jvm' + id 'org.jetbrains.dokka' + id 'com.gradleup.shadow' + } + + tasks.register('extractShadowedSources', Sync) { + from zipTree(tasks.named('shadowJar').flatMap { it.archiveSourcesFile }) + into layout.buildDirectory.dir('extracted-shadowed-sources') + } + + dokka { + dokkaSourceSets.configureEach { + classpath.from tasks.named('shadowJar').flatMap { it.archiveFile } + sourceRoots.from extractShadowedSources.map { it.destinationDir } + } + } + ``` [Jar]: https://docs.gradle.org/current/dsl/org.gradle.api.tasks.bundling.Jar.html [MavenPublication.artifact]: https://docs.gradle.org/current/dsl/org.gradle.api.publish.maven.MavenPublication.html#org.gradle.api.publish.maven.MavenPublication:artifact(java.lang.Object) [ShadowJar]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/index.html +[ShadowJar.archiveSourcesFile]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/archive-sources-file.html +[ShadowJar.generateSourcesJar]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/generate-sources-jar.html +[ShadowJar.includedSourcesJars]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/included-sources-jars.html +[ShadowJar.relocate]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/relocate.html +[ShadowJar.sourceSetsSourceDirs]: ../api/shadow/com.github.jengelman.gradle.plugins.shadow.tasks/-shadow-jar/source-sets-source-dirs.html [maven-publish]: https://docs.gradle.org/current/userguide/publishing_maven.html [gradle-plugin-publish-docs]: https://docs.gradle.org/current/userguide/publishing_gradle_plugins.html#shadow_dependencies [configuring-output-name]: ../configuration/README.md#configuring-output-name +[dokka]: https://kotlinlang.org/docs/dokka-introduction.html diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d6b8d4dfd..108d94120 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ minGradle = "9.4.0" kotlin = "2.4.20" moshi = "1.15.2" pluginPublish = "2.2.0" +dokka = "2.2.0" [libraries] apache-ant = "org.apache.ant:ant:1.10.18" @@ -22,6 +23,7 @@ foojayResolver = "org.gradle.toolchains.foojay-resolver-convention:org.gradle.to develocity = "com.gradle:develocity-gradle-plugin:4.5.1" kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } pluginPublish = { module = "com.gradle.plugin-publish:com.gradle.plugin-publish.gradle.plugin", version.ref = "pluginPublish" } +dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } androidx-gradlePluginLints = "androidx.lint:lint-gradle:1.0.0" # Dummy to get renovate updates, the version is used in rootProject build.gradle with spotless. @@ -34,7 +36,7 @@ assertk = "com.willowtreeapps.assertk:assertk:0.28.1" [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } android-lint = "com.android.lint:9.4.0" -jetbrains-dokka = "org.jetbrains.dokka:2.2.0" +jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } mavenPublish = "com.vanniktech.maven.publish:0.37.0" pluginPublish = { id = "com.gradle.plugin-publish", version.ref = "pluginPublish" } spotless = "com.diffplug.spotless:8.10.2" diff --git a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/SnippetExecutable.kt b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/SnippetExecutable.kt index 244bc6851..377314c0f 100644 --- a/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/SnippetExecutable.kt +++ b/src/documentTest/kotlin/com/github/jengelman/gradle/plugins/shadow/SnippetExecutable.kt @@ -4,6 +4,7 @@ import com.github.jengelman.gradle.plugins.shadow.testkit.assertNoDeprecationWar import com.github.jengelman.gradle.plugins.shadow.testkit.commonGradleArgs import com.github.jengelman.gradle.plugins.shadow.testkit.enableNoImplicitLookupInParentProjects import com.github.jengelman.gradle.plugins.shadow.testkit.gradleRunner +import com.github.jengelman.gradle.plugins.shadow.testkit.isDokkaIssue4600 import java.nio.file.Path import java.util.jar.JarOutputStream import kotlin.io.path.createDirectory @@ -51,6 +52,25 @@ sealed interface SnippetExecutable { """ .trimMargin() ) + + // TODO: https://github.com/Kotlin/dokka/issues/4488 + projectRoot + .resolve("gradle.properties") + .writeText( + """ + |# Dokka 2.2.0 DGPv2 is the default, but the plugin still looks up these properties dynamically. + |# Setting them here avoids cross-project property lookups that break isolated projects. + |org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled + |org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true + |org.jetbrains.dokka.experimental.gradle.pluginMode.nowarn=true + |org.jetbrains.dokka.experimental.tryK2=true + |org.jetbrains.dokka.experimental.tryK2.noWarn=true + |org.jetbrains.dokka.experimental.tryK2.nowarn=true + |org.jetbrains.dokka.internal.enableWorkaroundKT80551=true + """ + .trimMargin() + ) + val pluginsBlock = """ |plugins { @@ -104,6 +124,9 @@ sealed interface SnippetExecutable { .assertNoDeprecationWarnings() } catch (t: Throwable) { val buildOutput = (t as? UnexpectedBuildFailure)?.buildResult?.output ?: gradleBuildOutput + + if (buildOutput?.isDokkaIssue4600 == true) return + throw AssertionError( buildString { append("The error line in the doc is near $sourceLocation") diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt index cf98e3770..59bf7036c 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt @@ -61,6 +61,9 @@ abstract class BasePluginTest { lateinit var artifactBJar: Path private set + lateinit var artifactGJar: Path + private set + val projectScript: Path get() = path("build.gradle") @@ -73,15 +76,22 @@ abstract class BasePluginTest { open val outputShadowedJar: JarPath get() = jarPath("build/libs/my-1.0-all.jar") + val outputShadowedSourcesJar: JarPath + get() = jarPath("build/libs/my-1.0-all-sources.jar") + val outputServerShadowedJar: JarPath get() = jarPath("server/build/libs/server-1.0-all.jar") + val outputServerShadowedSourcesJar: JarPath + get() = jarPath("server/build/libs/server-1.0-all-sources.jar") + @BeforeAll fun beforeAll() { localRepo = createDefaultLocalMavenRepository(junitJar).apply { publish() } artifactAJar = path("my/a/1.0/a-1.0.jar", parent = localRepo.root) artifactBJar = path("my/b/1.0/b-1.0.jar", parent = localRepo.root) + artifactGJar = path("my/g/1.0/g-1.0.jar", parent = localRepo.root) } @BeforeEach @@ -265,6 +275,9 @@ abstract class BasePluginTest { .writeText( """ |${getDefaultProjectBuildScript("java")} + |java { + | withSourcesJar() + |} |dependencies { | implementation 'junit:junit:3.8.2' |} @@ -286,6 +299,9 @@ abstract class BasePluginTest { .writeText( """ |${getDefaultProjectBuildScript("java")} + |java { + | withSourcesJar() + |} |dependencies { | implementation project(':client') |} @@ -416,10 +432,16 @@ abstract class BasePluginTest { } } - fun createEmptyClassBytes(internalName: String): ByteArray { + fun createEmptyClassBytes( + internalName: String, + sourceFile: String? = "${internalName.substringAfterLast('/')}.java", + ): ByteArray { return ClassWriter(0) .apply { visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, internalName, null, "java/lang/Object", null) + if (sourceFile != null) { + visitSource(sourceFile, null) + } visitEnd() } .toByteArray() diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/FilteringTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/FilteringTest.kt index 25c137088..8630d1507 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/FilteringTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/FilteringTest.kt @@ -191,6 +191,16 @@ class FilteringTest : BasePluginTest() { loadClass("server.Server") } } + assertThat(outputServerShadowedSourcesJar).useAll { + containsOnly( + "client/", + "server/", + "client/Client.java", + "server/Server.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } } @Test @@ -242,7 +252,7 @@ class FilteringTest : BasePluginTest() { projectScript.appendText( """ |dependencies { - | implementation 'my:g:1.0' + | implementation 'my:l:1.0' |} |$shadowJarTask { | dependencies { @@ -256,7 +266,35 @@ class FilteringTest : BasePluginTest() { runWithSuccess(shadowJarPath) assertThat(outputShadowedJar).useAll { - containsOnly("g.properties", *entriesInAB, "META-INF/", "META-INF/MANIFEST.MF") + containsOnly("l.properties", *entriesInAB, "META-INF/", "META-INF/MANIFEST.MF") + } + } + + @Test + fun excludeDependencyFromSourcesJar() { + projectScript.appendText( + """ + |dependencies { + | implementation 'my:g:1.0' + | implementation 'my:h:1.0' + |} + |$shadowJarTask { + | generateSourcesJar = true + | dependencies { + | exclude(dependency('my:h:1.0')) + | } + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedJar).useAll { + containsOnly(*entriesInAB, "g/", "g/G.class", "META-INF/", "META-INF/MANIFEST.MF") + } + assertThat(outputShadowedSourcesJar).useAll { + containsOnly("g/", "g/G.java", "META-INF/", "META-INF/MANIFEST.MF") } } @@ -280,5 +318,8 @@ class FilteringTest : BasePluginTest() { loadClass("junit.framework.Test") } } + assertThat(outputServerShadowedSourcesJar).useAll { + containsOnly("server/", "server/Server.java", "META-INF/", "META-INF/MANIFEST.MF") + } } } diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginsTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginsTest.kt index c9cfc9b5f..09f6b3db7 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginsTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/JavaPluginsTest.kt @@ -3,6 +3,7 @@ package com.github.jengelman.gradle.plugins.shadow import assertk.all import assertk.assertThat import assertk.assertions.contains +import assertk.assertions.containsAtLeast import assertk.assertions.containsMatch import assertk.assertions.doesNotContain import assertk.assertions.isEqualTo @@ -29,6 +30,8 @@ import kotlin.io.path.deleteExisting import kotlin.io.path.invariantSeparatorsPathString import kotlin.io.path.name import kotlin.io.path.outputStream +import kotlin.io.path.relativeTo +import kotlin.io.path.walk import kotlin.io.path.writeText import kotlin.reflect.full.declaredFunctions import kotlin.reflect.jvm.javaMethod @@ -126,6 +129,8 @@ class JavaPluginsTest : BasePluginTest() { | --no-enable-kotlin-module-remapping Disables option --enable-kotlin-module-remapping. | --fail-on-duplicate-entries Fails build if the ZIP entries in the shadowed JAR are duplicate. | --no-fail-on-duplicate-entries Disables option --fail-on-duplicate-entries. + | --generate-sources-jar Generates a companion shadowed sources JAR containing project and dependency sources. + | --no-generate-sources-jar Disables option --generate-sources-jar. | --main-class Main class attribute to add to manifest. | --minimize-jar Minimizes the jar by removing unused classes. | --no-minimize-jar Disables option --minimize-jar. @@ -153,6 +158,16 @@ class JavaPluginsTest : BasePluginTest() { "META-INF/MANIFEST.MF", ) } + assertThat(outputServerShadowedSourcesJar).useAll { + containsOnly( + "client/", + "server/", + "client/Client.java", + "server/Server.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } } @Test @@ -1282,6 +1297,114 @@ class JavaPluginsTest : BasePluginTest() { } } + @Test + fun generateJavadocFromShadowedSourcesJar() { + path("src/main/java/my/Main.java") + .writeText( + """ + |package my; + |/** Main class doc */ + |public class Main { + | public static void main(String[] args) {} + |} + """ + .trimMargin() + ) + projectScript.appendText( + """ + |dependencies { + | implementation 'my:g:1.0' + |} + |$shadowJarTask { + | generateSourcesJar = true + | relocate 'g', 'shadow.g' + |} + |tasks.named('javadoc', Javadoc) { + | classpath = files($shadowJarTask.flatMap { it.archiveFile }) + | source = zipTree($shadowJarTask.flatMap { it.archiveSourcesFile }).matching { include('**/*.java') } + |} + """ + .trimMargin() + ) + + runWithSuccess("javadoc") + + val javadocDir = projectRoot.resolve("build/docs/javadoc") + val javadocFiles = + javadocDir.walk().map { it.relativeTo(javadocDir).invariantSeparatorsPathString } + assertThat(javadocFiles) + .containsAtLeast( + "index.html", + "my/Main.html", + "shadow/g/G.html", + ) + } + + @Test + fun sourcesJarPreservesResourceRelativePath() { + writeClass() + path("src/main/resources/config/sub/app.properties").writeText("key=value") + + projectScript.appendText( + """ + |$shadowJarTask { + | generateSourcesJar = true + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "config/", + "config/sub/", + "my/Main.java", + "config/sub/app.properties", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + } + + @Test + fun sourcesJarHandlesOverlappingSourceDirectoryPrefixes() { + writeClass() + path("src/main/res/a.properties").writeText("a=1") + path("src/main/resources/b.properties").writeText("b=2") + + projectScript.appendText( + """ + |sourceSets { + | main { + | resources { + | srcDir 'src/main/res' + | } + | } + |} + |$shadowJarTask { + | generateSourcesJar = true + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/Main.java", + "a.properties", + "b.properties", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + } + private fun dependencies(configuration: String, vararg flags: String): String { return runWithSuccess("dependencies", "--configuration", configuration, *flags).output } diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/KotlinPluginsTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/KotlinPluginsTest.kt index bb37e5bfc..60a70f343 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/KotlinPluginsTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/KotlinPluginsTest.kt @@ -2,6 +2,7 @@ package com.github.jengelman.gradle.plugins.shadow import assertk.assertThat import assertk.assertions.contains +import assertk.assertions.containsAtLeast import assertk.assertions.isEqualTo import com.github.jengelman.gradle.plugins.shadow.internal.mainClassAttributeKey import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar.Companion.SHADOW_JAR_TASK_NAME @@ -9,9 +10,13 @@ import com.github.jengelman.gradle.plugins.shadow.testkit.classLoader import com.github.jengelman.gradle.plugins.shadow.testkit.containsAtLeast import com.github.jengelman.gradle.plugins.shadow.testkit.containsOnly import com.github.jengelman.gradle.plugins.shadow.testkit.getMainAttr +import com.github.jengelman.gradle.plugins.shadow.testkit.isDokkaIssue4600 import com.github.jengelman.gradle.plugins.shadow.testkit.loadClass import com.github.jengelman.gradle.plugins.shadow.util.JvmLang import kotlin.io.path.appendText +import kotlin.io.path.invariantSeparatorsPathString +import kotlin.io.path.relativeTo +import kotlin.io.path.walk import kotlin.io.path.writeText import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -287,6 +292,65 @@ class KotlinPluginsTest : BasePluginTest() { ) } + @Test + fun generateDokkaFromShadowedSourcesJar() { + path("src/main/kotlin/my/Main.kt") + .writeText( + """ + |package my + |/** Main class doc */ + |class Main + """ + .trimMargin() + ) + projectScript.writeText( + """ + |plugins { + | id 'org.jetbrains.kotlin.jvm' + | id 'com.gradleup.shadow' + | id 'org.jetbrains.dokka' + |} + |dependencies { + | implementation 'my:g:1.0' + |} + |$shadowJarTask { + | generateSourcesJar = true + | relocate 'g', 'shadow.g' + |} + |def extractShadowedSources = tasks.register('extractShadowedSources', Sync) { + | from zipTree($shadowJarTask.flatMap { it.archiveSourcesFile }) + | into layout.buildDirectory.dir('extracted-shadowed-sources') + |} + |dokka { + | dokkaSourceSets.configureEach { + | classpath.from($shadowJarTask.flatMap { it.archiveFile }) + | sourceRoots.from(extractShadowedSources.map { it.destinationDir }) + | } + |} + """ + .trimMargin() + ) + + try { + runWithSuccess("dokkaGenerateHtml") + } catch (t: Throwable) { + if (t.stackTraceToString().isDokkaIssue4600) { + // Do nothing. + } else { + throw t + } + } + + val dokkaDir = projectRoot.resolve("build/dokka/html") + val dokkaFiles = dokkaDir.walk().map { it.relativeTo(dokkaDir).invariantSeparatorsPathString } + assertThat(dokkaFiles) + .containsAtLeast( + "index.html", + "my/my/-main/index.html", + "my/shadow.g/-g/index.html", + ) + } + private fun compileOnlyStdlib(exclude: Boolean): String { return if (exclude) { // Disable the stdlib dependency added via `implementation`. diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt index 4f91912b3..75eafc051 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/MinimizeTest.kt @@ -132,6 +132,63 @@ class MinimizeTest : BasePluginTest() { } } + @Test + fun minimizeSourcesJar() { + path("src/main/java/my/Main.java") + .writeText( + """ + |package my; + |import h.H; + |import k.CustomUtils; + |public class Main { + | H h; + | CustomUtils u; + |} + """ + .trimMargin() + ) + projectScript.appendText( + """ + |dependencies { + | implementation 'my:h:1.0' + | implementation 'my:k:1.0' + |} + |$shadowJarTask { + | generateSourcesJar = true + | minimize() + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedJar).useAll { + containsOnly( + "my/", + "h/", + "k/", + "my/Main.class", + "h/H.class", + "k/CustomUtils.class", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "h/", + "k/", + "my/Main.java", + "h/H.java", + "k/Utils.kt", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + } + /** * 'Client', 'Server' and 'junit' are independent. 'junit' is excluded from the minimize step. The * minimize step shall remove 'Client' but not 'junit'. diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/PublishingTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/PublishingTest.kt index 7af0a92f6..7d2b3c30e 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/PublishingTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/PublishingTest.kt @@ -17,6 +17,7 @@ import com.github.jengelman.gradle.plugins.shadow.testkit.containsNone import com.github.jengelman.gradle.plugins.shadow.testkit.containsOnly import com.github.jengelman.gradle.plugins.shadow.testkit.getMainAttr import com.github.jengelman.gradle.plugins.shadow.util.GradleModuleMetadata +import com.github.jengelman.gradle.plugins.shadow.util.JvmLang import com.github.jengelman.gradle.plugins.shadow.util.coordinate import com.github.jengelman.gradle.plugins.shadow.util.prependText import com.squareup.moshi.JsonAdapter @@ -29,12 +30,14 @@ import kotlin.io.path.inputStream import kotlin.io.path.listDirectoryEntries import kotlin.io.path.name import kotlin.io.path.readText +import kotlin.io.path.writeText import org.apache.maven.model.Dependency import org.apache.maven.model.Model import org.apache.maven.model.io.xpp3.MavenXpp3Reader import org.gradle.api.JavaVersion import org.gradle.api.attributes.Bundling import org.gradle.api.attributes.Category +import org.gradle.api.attributes.DocsType import org.gradle.api.attributes.LibraryElements import org.gradle.api.attributes.Usage import org.gradle.api.attributes.java.TargetJvmVersion @@ -282,7 +285,219 @@ class PublishingTest : BasePluginTest() { ) assertShadowJarCommon(repoJarPath("$artifactRoot/maven-1.0.jar")) assertPomCommon(repoPath("$artifactRoot/maven-1.0.pom")) - assertShadowVariantCommon(gmmAdapter.fromJson(repoPath("$artifactRoot/maven-1.0.module"))) + val gmm = gmmAdapter.fromJson(repoPath("$artifactRoot/maven-1.0.module")) + assertShadowVariantCommon(gmm) + } + + @Test + fun publishShadowJarWithSourcesWhenWithSourcesJarEnabled() { + projectScript.appendText( + publishConfiguration( + projectBlock = + """ + |java { + | withSourcesJar() + |} + """ + .trimMargin(), + shadowBlock = + """ + |archiveClassifier = '' + """ + .trimMargin(), + publicationsBlock = + """ + |shadow(MavenPublication) { + | from components.shadow + |} + """ + .trimMargin(), + ) + ) + + publish() + + val artifactRoot = "my/maven/1.0" + assertThat(repoPath(artifactRoot).entries) + .containsOnly( + "maven-1.0.jar", + "maven-1.0.module", + "maven-1.0.pom", + "maven-1.0.jar.md5", + "maven-1.0.module.md5", + "maven-1.0.pom.md5", + "maven-1.0.jar.sha1", + "maven-1.0.module.sha1", + "maven-1.0.pom.sha1", + "maven-1.0.jar.sha256", + "maven-1.0.module.sha256", + "maven-1.0.pom.sha256", + "maven-1.0.jar.sha512", + "maven-1.0.module.sha512", + "maven-1.0.pom.sha512", + "maven-1.0-sources.jar", + "maven-1.0-sources.jar.md5", + "maven-1.0-sources.jar.sha1", + "maven-1.0-sources.jar.sha256", + "maven-1.0-sources.jar.sha512", + ) + assertShadowJarCommon(repoJarPath("$artifactRoot/maven-1.0.jar")) + assertPomCommon(repoPath("$artifactRoot/maven-1.0.pom")) + val gmm = gmmAdapter.fromJson(repoPath("$artifactRoot/maven-1.0.module")) + assertShadowVariantCommon(gmm) + assertShadowSourcesVariantCommon(gmm) + } + + @Test + fun publishWithSourcesJarAndCustomClassifier() { + projectScript.appendText( + publishConfiguration( + projectBlock = + """ + |java { + | withSourcesJar() + |} + """ + .trimMargin(), + shadowBlock = + """ + |archiveClassifier = 'shaded' + |archiveSourcesFile = layout.buildDirectory.file('custom.jar') + """ + .trimMargin(), + publicationsBlock = + """ + |shadow(MavenPublication) { + | from components.shadow + |} + """ + .trimMargin(), + ) + ) + + publish() + + val artifactRoot = "my/maven/1.0" + assertThat(repoPath(artifactRoot).entries.filter { it.endsWith(".jar") }) + .containsOnly( + "maven-1.0-shaded.jar", + "maven-1.0-shaded-sources.jar", + ) + val gmm = gmmAdapter.fromJson(repoPath("$artifactRoot/maven-1.0.module")) + assertThat(gmm.shadowSourcesElementsVariant.fileNames.single()) + .isEqualTo("maven-1.0-shaded-sources.jar") + } + + @Test + fun publishWithSourcesJarAndCustomClassifierAfterPublishingBlock() { + projectScript.appendText( + """ + |apply plugin: 'maven-publish' + |java { + | withSourcesJar() + |} + |publishing { + | repositories { + | maven { url = '${remoteRepoPath.toUri()}' } + | } + | publications { + | shadow(MavenPublication) { + | from components.shadow + | } + | } + |} + |tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + | archiveClassifier = 'shaded' + | archiveSourcesFile = layout.buildDirectory.file('custom.jar') + |} + """ + .trimMargin() + ) + + publish() + + val artifactRoot = "my/maven/1.0" + assertThat(repoPath(artifactRoot).entries.filter { it.endsWith(".jar") }) + .containsOnly( + "maven-1.0-shaded.jar", + "maven-1.0-shaded-sources.jar", + ) + val gmm = gmmAdapter.fromJson(repoPath("$artifactRoot/maven-1.0.module")) + assertThat(gmm.shadowSourcesElementsVariant.fileNames.single()) + .isEqualTo("maven-1.0-shaded-sources.jar") + } + + @Test + fun publishJavaComponentWithShadowAndSourcesVariants() { + projectScript.appendText( + publishConfiguration( + projectBlock = + """ + |java { + | withSourcesJar() + |} + """ + .trimMargin(), + publicationsBlock = + """ + |shadow(MavenPublication) { + | from components.java + |} + """ + .trimMargin(), + ) + ) + + publish() + + val artifactRoot = "my/maven/1.0" + assertThat(repoPath(artifactRoot).entries.filter { it.endsWith(".jar") }) + .containsOnly( + "maven-1.0.jar", + "maven-1.0-sources.jar", + "maven-1.0-all.jar", + "maven-1.0-all-sources.jar", + ) + } + + @Test + fun dontPublishSourcesWhenGenerateSourcesJarDisabled() { + projectScript.appendText( + publishConfiguration( + projectBlock = + """ + |java { + | withSourcesJar() + |} + """ + .trimMargin(), + shadowBlock = + """ + |archiveClassifier = '' + |generateSourcesJar = false + """ + .trimMargin(), + publicationsBlock = + """ + |shadow(MavenPublication) { + | from components.shadow + |} + """ + .trimMargin(), + ) + ) + + val result = publish(infoArgument) + + assertThat(result.output) + .contains("Skipping adding shadowSourcesElements variant to shadow component.") + val artifactRoot = "my/maven/1.0" + assertThat(repoPath(artifactRoot).entries.filter { it.contains("sources") }).isEmpty() + assertShadowJarCommon(repoJarPath("$artifactRoot/maven-1.0.jar")) + assertPomCommon(repoPath("$artifactRoot/maven-1.0.pom")) + val gmm = gmmAdapter.fromJson(repoPath("$artifactRoot/maven-1.0.module")) + assertShadowVariantCommon(gmm) + assertThat(gmm.variantNames).containsOnly(SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME) } @Test @@ -644,6 +859,102 @@ class PublishingTest : BasePluginTest() { } } + @Test + fun publishKmpWithShadowedSources() { + path("gradle.properties").writeText("kotlin.stdlib.default.dependency=false") + projectScript.writeText( + """ + |plugins { + | id 'org.jetbrains.kotlin.multiplatform' + | id 'com.gradleup.shadow' + | id 'maven-publish' + |} + |group = 'my' + |version = '1.0' + |kotlin { + | jvm() + | sourceSets { + | commonMain { + | dependencies { + | implementation 'my:g:1.0' + | compileOnly 'org.jetbrains.kotlin:kotlin-stdlib' + | } + | } + | jvmMain { + | dependencies { + | implementation 'my:h:1.0' + | } + | } + | } + |} + |$shadowJarTask { + | archiveClassifier = '' + | generateSourcesJar = true + |} + |publishing { + | repositories { + | maven { url = '${remoteRepoPath.toUri()}' } + | } + | publications { + | shadow(MavenPublication) { + | artifactId = 'my-all' + | artifact($shadowJarTask) + | artifact($shadowJarTask.flatMap { it.archiveSourcesFile }) { + | classifier = 'sources' + | } + | } + | } + |} + """ + .trimMargin() + ) + writeClass(sourceSet = "commonMain", jvmLang = JvmLang.Kotlin, className = "CommonMain") + writeClass(sourceSet = "jvmMain", jvmLang = JvmLang.Kotlin, className = "JvmMain") + + publish() + + val artifactRoot = "my/my-all/1.0" + assertThat(repoPath(artifactRoot).entries.filter { it.endsWith(".jar") }) + .containsOnly( + "my-all-1.0.jar", + "my-all-1.0-sources.jar", + ) + + assertThat(repoJarPath("$artifactRoot/my-all-1.0.jar")).useAll { + containsOnly( + "my/", + "g/", + "h/", + "my/CommonMain.class", + "my/JvmMain.class", + "g/G.class", + "h/H.class", + "h/UnusedH.class", + "META-INF/my_maven.kotlin_module", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + + assertThat(repoJarPath("$artifactRoot/my-all-1.0-sources.jar")).useAll { + containsOnly( + "my/", + "g/", + "h/", + "my/CommonMain.kt", + "my/JvmMain.kt", + "g/G.java", + "h/H.java", + "h/UnusedH.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + + assertPomCommon(repoPath("$artifactRoot/my-all-1.0.pom"), emptyArray()) + assertThat(repoPath(artifactRoot).entries.filter { it.endsWith(".module") }).isEmpty() + } + private fun repoPath(relative: String): Path { return remoteRepoPath.resolve(relative).also { check(it.exists()) { "Path not found: $it" } } } @@ -704,10 +1015,12 @@ class PublishingTest : BasePluginTest() { private fun assertPomCommon(pomPath: Path, coordinates: Array = arrayOf("my:b:1.0")) { assertThat(pomReader.read(pomPath)).all { transform { it.dependencies.map(Dependency::coordinate) }.containsOnly(*coordinates) - // All scopes should be runtime. - transform { it.dependencies.map(Dependency::getScope).distinct() } - .single() - .isEqualTo("runtime") + if (coordinates.isNotEmpty()) { + // All scopes should be runtime. + transform { it.dependencies.map(Dependency::getScope).distinct() } + .single() + .isEqualTo("runtime") + } } } @@ -724,6 +1037,17 @@ class PublishingTest : BasePluginTest() { } } + private fun assertShadowSourcesVariantCommon( + gmm: GradleModuleMetadata, + variantAttrs: Array> = shadowSourcesVariantAttrs, + body: Assert.() -> Unit = {}, + ) { + assertThat(gmm.shadowSourcesElementsVariant).all { + transform { it.attributes }.containsOnly(*variantAttrs) + body() + } + } + private fun assertShadowJarCommon(jarPath: JarPath) { assertThat(jarPath).useAll { containsAtLeast(*entriesInA) @@ -754,6 +1078,14 @@ class PublishingTest : BasePluginTest() { Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_RUNTIME, ) + val shadowSourcesVariantAttrs = + arrayOf( + Category.CATEGORY_ATTRIBUTE.name to Category.DOCUMENTATION, + Bundling.BUNDLING_ATTRIBUTE.name to Bundling.SHADOWED, + DocsType.DOCS_TYPE_ATTRIBUTE.name to DocsType.SOURCES, + Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_RUNTIME, + ) + fun MavenXpp3Reader.read(path: Path): Model = path.inputStream().use { read(it) } fun JsonAdapter.fromJson(path: Path): T = checkNotNull(fromJson(path.readText())) diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt index 8ec589ce7..a53eeaecf 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt @@ -3,6 +3,7 @@ package com.github.jengelman.gradle.plugins.shadow import assertk.assertThat import assertk.assertions.contains import assertk.assertions.isEqualTo +import assertk.assertions.isFalse import assertk.assertions.isNotEmpty import assertk.assertions.isNotEqualTo import assertk.fail @@ -10,11 +11,14 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar.Companion.CONS import com.github.jengelman.gradle.plugins.shadow.testkit.classLoader import com.github.jengelman.gradle.plugins.shadow.testkit.containsOnly import com.github.jengelman.gradle.plugins.shadow.testkit.getBytes +import com.github.jengelman.gradle.plugins.shadow.testkit.getContent import com.github.jengelman.gradle.plugins.shadow.testkit.isAssignableFrom import com.github.jengelman.gradle.plugins.shadow.testkit.loadClass import com.github.jengelman.gradle.plugins.shadow.testkit.requireResourceAsPath import com.github.jengelman.gradle.plugins.shadow.testkit.runMain +import com.github.jengelman.gradle.plugins.shadow.util.JarBuilder import kotlin.io.path.appendText +import kotlin.io.path.exists import kotlin.io.path.readBytes import kotlin.io.path.writeText import kotlin.time.Duration.Companion.seconds @@ -738,6 +742,279 @@ class RelocationTest : BasePluginTest() { } } + @Test + fun generateNoShadowedSourcesJarByDefault() { + writeClass() + projectScript.appendText( + """ + |dependencies { + | implementation 'my:g:1.0' + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(projectRoot.resolve("build/libs/my-1.0-all-sources.jar").exists()).isFalse() + } + + @Test + fun generateShadowedSourcesJarWithRelocation() { + path("src/main/java/my/Main.java") + .writeText( + """ + |package my; + |import g.G; + |public class Main { + | G g; + |} + """ + .trimMargin() + ) + projectScript.appendText( + """ + |dependencies { + | implementation 'my:g:1.0' + |} + |$shadowJarTask { + | generateSourcesJar = true + | relocate('g', 'shadow.g') + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/Main.java", + "shadow/", + "shadow/g/", + "shadow/g/G.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + getContent("my/Main.java") + .isEqualTo( + """ + |package my; + |import shadow.g.G; + |public class Main { + | G g; + |} + """ + .trimMargin() + ) + getContent("shadow/g/G.java") + .isEqualTo( + """ + |package shadow.g; + |public class G {} + """ + .trimMargin() + ) + } + } + + @Test + fun generateShadowedSourcesJarWhenNoIncludedSourcesJars() { + writeClass() + projectScript.appendText( + """ + |dependencies { + | implementation 'my:b:1.0' + |} + |$shadowJarTask { + | generateSourcesJar = true + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/Main.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + } + + @Test + fun generateEmptyShadowedSourcesJarWhenNoSources() { + projectScript.appendText( + """ + |dependencies { + | implementation 'my:b:1.0' + |} + |$shadowJarTask { + | generateSourcesJar = true + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly("META-INF/", "META-INF/MANIFEST.MF") + } + } + + @Test + fun generateShadowedSourcesJarWithCustomSourceSetsSourceDirs() { + path("src/main/java/my/Main.java") + .writeText( + """ + |package my; + |public class Main {} + """ + .trimMargin() + ) + path("src/extra/java/extra/Extra.java") + .writeText( + """ + |package extra; + |public class Extra {} + """ + .trimMargin() + ) + projectScript.appendText( + """ + |$shadowJarTask { + | generateSourcesJar = true + | sourceSetsSourceDirs.from('src/extra/java') + | relocate('extra', 'shadow.extra') + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/Main.java", + "shadow/", + "shadow/extra/", + "shadow/extra/Extra.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + getContent("shadow/extra/Extra.java") + .isEqualTo( + """ + |package shadow.extra; + |public class Extra {} + """ + .trimMargin() + ) + } + } + + @Test + fun relocateShadowedSourcesJarRespectsSourceDirectorySetFilters() { + path("src/main/java/my/Main.java") + .writeText( + """ + |package my; + |public class Main {} + """ + .trimMargin() + ) + path("src/main/java/my/Excluded.java") + .writeText( + """ + |package my; + |public class Excluded {} + """ + .trimMargin() + ) + projectScript.appendText( + """ + |sourceSets { + | main { + | java { + | exclude '**/Excluded.java' + | } + | } + |} + |$shadowJarTask { + | generateSourcesJar = true + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/Main.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + } + } + + @Test + fun generateShadowedSourcesJarWithCustomIncludedSourcesJars() { + writeClass() + val customSourcesJar = path("libs/external-sources.jar") + customSourcesJar.parent.toFile().mkdirs() + JarBuilder(customSourcesJar) + .insert( + "ext/Ext.java", + """ + package ext; + public class Ext {} + """ + .trimIndent(), + ) + .write() + + projectScript.appendText( + """ + |$shadowJarTask { + | generateSourcesJar = true + | includedSourcesJars.from('libs/external-sources.jar') + | relocate('ext', 'shadow.ext') + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/Main.java", + "shadow/", + "shadow/ext/", + "shadow/ext/Ext.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + getContent("shadow/ext/Ext.java") + .isEqualTo( + """ + |package shadow.ext; + |public class Ext {} + """ + .trimMargin() + ) + } + } + private companion object { @JvmStatic fun preserveLastModifiedProvider() = diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/GradleModuleMetadata.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/GradleModuleMetadata.kt index fef15ab37..4e888c1c2 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/GradleModuleMetadata.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/GradleModuleMetadata.kt @@ -1,6 +1,7 @@ package com.github.jengelman.gradle.plugins.shadow.util import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin.Companion.SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME +import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin.Companion.SHADOW_SOURCES_ELEMENTS_CONFIGURATION_NAME import org.gradle.api.plugins.JavaPlugin.API_ELEMENTS_CONFIGURATION_NAME import org.gradle.api.plugins.JavaPlugin.RUNTIME_ELEMENTS_CONFIGURATION_NAME @@ -18,6 +19,9 @@ data class GradleModuleMetadata(private val variants: List) { val shadowRuntimeElementsVariant: Variant get() = variants.single { it.name == SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME } + val shadowSourcesElementsVariant: Variant + get() = variants.single { it.name == SHADOW_SOURCES_ELEMENTS_CONFIGURATION_NAME } + val variantNames: List get() = variants.map { it.name } diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/LocalMavenRepository.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/LocalMavenRepository.kt index 64644a589..8b9bbfc42 100644 --- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/LocalMavenRepository.kt +++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/LocalMavenRepository.kt @@ -1,5 +1,6 @@ package com.github.jengelman.gradle.plugins.shadow.util +import com.github.jengelman.gradle.plugins.shadow.BasePluginTest.Companion.createEmptyClassBytes import java.nio.file.Path import kotlin.io.path.createDirectories import kotlin.io.path.createTempDirectory @@ -39,7 +40,76 @@ fun createDefaultLocalMavenRepository(junitJar: Path): AppendableMavenRepository } val g = jarModule("my", "g", "1.0") { - buildJar { insert("g.properties", "g") } + buildJar { insert("g/G.class", createEmptyClassBytes("g/G")) } + buildSourcesJar { + insert( + "g/G.java", + """ + |package g; + |public class G {} + """ + .trimMargin(), + ) + } + } + val h = + jarModule("my", "h", "1.0") { + buildJar { + insert("h/H.class", createEmptyClassBytes("h/H")) + insert("h/UnusedH.class", createEmptyClassBytes("h/UnusedH")) + } + buildSourcesJar { + insert( + "h/H.java", + """ + |package h; + |public class H {} + """ + .trimMargin(), + ) + insert( + "h/UnusedH.java", + """ + |package h; + |public class UnusedH {} + """ + .trimMargin(), + ) + } + } + val k = + jarModule("my", "k", "1.0") { + buildJar { + insert("k/CustomUtils.class", createEmptyClassBytes("k/CustomUtils", "Utils.kt")) + insert( + "k/CustomUnusedUtils.class", + createEmptyClassBytes("k/CustomUnusedUtils", "UnusedUtils.kt"), + ) + } + buildSourcesJar { + insert( + "k/Utils.kt", + """ + |@file:JvmName("CustomUtils") + |package k + |fun util() {} + """ + .trimMargin(), + ) + insert( + "k/UnusedUtils.kt", + """ + |@file:JvmName("CustomUnusedUtils") + |package k + |fun unusedUtil() {} + """ + .trimMargin(), + ) + } + } + val l = + jarModule("my", "l", "1.0") { + buildJar { insert("l.properties", "l") } addDependency(pomModule("my", "pom-dep", "1.0")) } bomModule("my", "bom", "1.0") { @@ -50,6 +120,9 @@ fun createDefaultLocalMavenRepository(junitJar: Path): AppendableMavenRepository addDependency(e) addDependency(f) addDependency(g) + addDependency(h) + addDependency(k) + addDependency(l) } } } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowApplicationPlugin.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowApplicationPlugin.kt index 2bb9ef578..4da9fe347 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowApplicationPlugin.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowApplicationPlugin.kt @@ -46,7 +46,7 @@ public abstract class ShadowApplicationPlugin : Plugin { task.description = "Runs this project as a JVM application using the shadow jar" task.group = ApplicationPlugin.APPLICATION_GROUP - task.classpath = files(tasks.shadowJar) + task.classpath = files(tasks.shadowJar.flatMap { it.archiveFile }) with(applicationExtension) { task.mainModule.convention(mainModule) @@ -63,7 +63,7 @@ public abstract class ShadowApplicationPlugin : Plugin { task.description = "Creates OS specific scripts to run the project as a JVM application using the shadow jar" - task.classpath = files(tasks.shadowJar) + task.classpath = files(tasks.shadowJar.flatMap { it.archiveFile }) @Suppress("InternalGradleApiUsage") // TODO: replace usages of conventionMapping. with(applicationExtension) { @@ -118,7 +118,7 @@ public abstract class ShadowApplicationPlugin : Plugin { dist.contents { distSpec -> distSpec.from(file("src/dist")) distSpec.into("lib") { lib -> - lib.from(tasks.shadowJar) + lib.from(tasks.shadowJar.flatMap { it.archiveFile }) // Reflects the value of the `Class-Path` attribute in the JAR manifest. lib.from(configurations.shadow) } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin.kt index ff017e8ca..c268a82cc 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowJavaPlugin.kt @@ -15,6 +15,7 @@ import org.gradle.api.artifacts.ConfigurationContainer import org.gradle.api.artifacts.ConsumableConfiguration import org.gradle.api.attributes.Bundling import org.gradle.api.attributes.Category +import org.gradle.api.attributes.DocsType import org.gradle.api.attributes.LibraryElements import org.gradle.api.attributes.Usage import org.gradle.api.attributes.java.TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE @@ -23,6 +24,7 @@ import org.gradle.api.component.ConfigurationVariantDetails import org.gradle.api.component.SoftwareComponentFactory import org.gradle.api.logging.Logger import org.gradle.api.plugins.JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME +import org.gradle.api.plugins.JavaPlugin.SOURCES_ELEMENTS_CONFIGURATION_NAME import org.gradle.api.tasks.bundling.Jar public abstract class ShadowJavaPlugin @@ -38,9 +40,24 @@ constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Pl } protected open fun Project.configureShadowJar() { + val mainSourceSet = sourceSets.named("main") val taskProvider = registerShadowJarCommon(tasks.named("jar", Jar::class.java)) { task -> - task.from(sourceSets.named("main").map { it.output }) + task.from(mainSourceSet.map { it.output }) + task.generateSourcesJar.convention( + // If `withSourcesJar` is present in `java` block. + provider { configurations.findByName(SOURCES_ELEMENTS_CONFIGURATION_NAME) != null } + ) + task.sourceSetsSourceDirs.convention( + // Avoid snapshotting source inputs when sources JAR generation is disabled. + task.generateSourcesJar.flatMap { generate -> + if (generate) { + mainSourceSet.map { it.allSource } + } else { + provider { emptySet() } + } + } + ) task.configurations.convention(provider { listOf(runtimeConfiguration) }) } artifacts.add(configurations.shadow.name, taskProvider) @@ -53,14 +70,9 @@ constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Pl compileClasspath.extendsFrom(shadowConfig) } val shadowRuntimeElements = - configurations.consumable(SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME) { shadowRuntimeElements - -> - shadowRuntimeElements.extendsFrom(shadowConfig) - shadowRuntimeElements.attributes { attrs -> - attrs.attribute( - Usage.USAGE_ATTRIBUTE, - objects.named(Usage::class.java, Usage.JAVA_RUNTIME), - ) + registerConsumableConfiguration(SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME) { + extendsFrom(shadowConfig) + attributes { attrs -> attrs.attribute( Category.CATEGORY_ATTRIBUTE, objects.named(Category::class.java, Category.LIBRARY), @@ -69,13 +81,37 @@ constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Pl LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements::class.java, LibraryElements.JAR), ) - attrs.attributeProvider( - Bundling.BUNDLING_ATTRIBUTE, - shadow.bundlingAttribute.map { attr -> objects.named(Bundling::class.java, attr) }, - ) } - shadowRuntimeElements.outgoing.artifact(tasks.shadowJar) + outgoing.artifact(tasks.shadowJar) } + registerConsumableConfiguration(SHADOW_SOURCES_ELEMENTS_CONFIGURATION_NAME) { + attributes { attrs -> + attrs.attribute( + Category.CATEGORY_ATTRIBUTE, + objects.named(Category::class.java, Category.DOCUMENTATION), + ) + attrs.attribute( + DocsType.DOCS_TYPE_ATTRIBUTE, + objects.named(DocsType::class.java, DocsType.SOURCES), + ) + } + val shadowJarTask = tasks.shadowJar + outgoing.artifact(shadowJarTask.flatMap { it.archiveSourcesFile }) { artifact -> + with(artifact) { + builtBy(shadowJarTask) + name = shadowJarTask.flatMap { it.archiveBaseName }.orNull.orEmpty() + extension = shadowJarTask.flatMap { it.archiveExtension }.orNull ?: "jar" + type = "jar" + classifier = + shadowJarTask + .flatMap { it.archiveClassifier } + .orNull + .let { shadowClassifier -> + if (shadowClassifier.isNullOrEmpty()) "sources" else "$shadowClassifier-sources" + } + } + } + } // See more details in #2086. afterEvaluate { @@ -112,20 +148,39 @@ constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Pl } protected open fun Project.configureComponents() { + val addIntoJavaComponent = shadow.addShadowVariantIntoJavaComponent val shadowRuntimeElements = configurations.shadowRuntimeElements - val shadowComponent = softwareComponentFactory.adhoc(COMPONENT_NAME) - components.add(shadowComponent) - shadowComponent.addVariants( - outgoingConfiguration = shadowRuntimeElements, - logger = logger, - ) { - mapToMavenScope("runtime") + val shadowSourcesElements = configurations.shadowSourcesElements + val generateSourcesJar = { tasks.shadowJar.flatMap { it.generateSourcesJar }.get() } + + softwareComponentFactory.adhoc(COMPONENT_NAME).let { component -> + components.add(component) + component.addVariants( + outgoingConfiguration = shadowRuntimeElements, + logger = logger, + shouldAdd = { true }, + ) { + mapToMavenScope("runtime") + } + component.addVariants( + outgoingConfiguration = shadowSourcesElements, + logger = logger, + shouldAdd = generateSourcesJar, + ) } + components.named("java", AdhocComponentWithVariants::class.java) { component -> component.addVariants( outgoingConfiguration = shadowRuntimeElements, logger = logger, - shouldAdd = shadow.addShadowVariantIntoJavaComponent::get, + shouldAdd = addIntoJavaComponent::get, + ) { + mapToOptional() + } + component.addVariants( + outgoingConfiguration = shadowSourcesElements, + logger = logger, + shouldAdd = { addIntoJavaComponent.get() && generateSourcesJar() }, ) { mapToOptional() } @@ -135,8 +190,8 @@ constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Pl private fun AdhocComponentWithVariants.addVariants( outgoingConfiguration: NamedDomainObjectProvider, logger: Logger, - shouldAdd: () -> Boolean = { true }, - action: ConfigurationVariantDetails.() -> Unit, + shouldAdd: () -> Boolean, + action: ConfigurationVariantDetails.() -> Unit = {}, ) { addVariantsFromConfiguration(outgoingConfiguration) { variant -> if (shouldAdd()) { @@ -149,16 +204,40 @@ constructor(private val softwareComponentFactory: SoftwareComponentFactory) : Pl } } + private fun Project.registerConsumableConfiguration( + name: String, + action: ConsumableConfiguration.() -> Unit, + ) = + configurations.consumable(name) { configuration -> + configuration.attributes { attrs -> + attrs.attribute( + Usage.USAGE_ATTRIBUTE, + objects.named(Usage::class.java, Usage.JAVA_RUNTIME), + ) + attrs.attributeProvider( + Bundling.BUNDLING_ATTRIBUTE, + shadow.bundlingAttribute.map { attr -> objects.named(Bundling::class.java, attr) }, + ) + } + configuration.action() + } + @Deprecated("This method will be removed in Shadow 10.") protected open fun Project.configureJavaGradlePlugin() {} public companion object { public const val COMPONENT_NAME: String = SHADOW public const val SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME: String = "shadowRuntimeElements" + public const val SHADOW_SOURCES_ELEMENTS_CONFIGURATION_NAME: String = "shadowSourcesElements" @get:JvmSynthetic public inline val ConfigurationContainer.shadowRuntimeElements: NamedDomainObjectProvider get() = named(SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME, ConsumableConfiguration::class.java) + + @get:JvmSynthetic + public inline val ConfigurationContainer.shadowSourcesElements: + NamedDomainObjectProvider + get() = named(SHADOW_SOURCES_ELEMENTS_CONFIGURATION_NAME, ConsumableConfiguration::class.java) } } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowKmpPlugin.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowKmpPlugin.kt index c8bb3a257..df0903bef 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowKmpPlugin.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowKmpPlugin.kt @@ -36,6 +36,18 @@ public abstract class ShadowKmpPlugin : Plugin { val kotlinJvmMain = target.compilations.named("main") registerShadowJarCommon(tasks.named(target.artifactsTaskName, Jar::class.java)) { task -> task.from(kotlinJvmMain.map { it.output.allOutputs }) + task.sourceSetsSourceDirs.convention( + // Avoid snapshotting source inputs when sources JAR generation is disabled. + task.generateSourcesJar.flatMap { generate -> + if (generate) { + kotlinJvmMain.map { + it.allKotlinSourceSets.map { ss -> ss.kotlin } + } + } else { + provider { emptySet() } + } + } + ) task.configurations.convention( kotlinJvmMain .flatMap { configurations.named(it.runtimeDependencyConfigurationName) } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilter.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilter.kt index c743b14b8..03ac02909 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilter.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilter.kt @@ -2,9 +2,19 @@ package com.github.jengelman.gradle.plugins.shadow.internal import com.github.jengelman.gradle.plugins.shadow.tasks.DependencyFilter import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.ResolvedDependency +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.component.ProjectComponentIdentifier +import org.gradle.api.artifacts.result.ResolvedArtifactResult +import org.gradle.api.artifacts.result.ResolvedDependencyResult +import org.gradle.api.attributes.Category +import org.gradle.api.attributes.DocsType +import org.gradle.api.file.FileCollection +import org.gradle.jvm.JvmLibrary +import org.gradle.language.base.artifact.SourcesArtifact -internal class DefaultDependencyFilter(project: Project) : +internal class DefaultDependencyFilter(@Transient private val project: Project) : DependencyFilter.AbstractDependencyFilter(project) { override fun resolve( dependencies: Set, @@ -19,4 +29,79 @@ internal class DefaultDependencyFilter(project: Project) : } } } + + fun resolveSourcesJars(configurations: Collection): FileCollection { + return configurations + .map { resolveSourcesJars(it) } + .reduceOrNull { acc, fileCollection -> acc + fileCollection } ?: project.files() + } + + private fun resolveSourcesJars(configuration: Configuration): FileCollection { + val includes = mutableSetOf() + val excludes = mutableSetOf() + resolve( + dependencies = configuration.resolvedConfiguration.firstLevelModuleDependencies, + includedDependencies = includes, + excludedDependencies = excludes, + ) + + val includedDependenciesResults = + configuration.incoming.resolutionResult.allDependencies + .filterIsInstance() + .filter { dep -> + includes.any { inc -> + inc.moduleGroup == dep.selected.moduleVersion?.group && + inc.moduleName == dep.selected.moduleVersion?.name && + inc.moduleVersion == dep.selected.moduleVersion?.version + } + } + + val externalComponentIds = + includedDependenciesResults + .map { it.selected.id } + .filterIsInstance() + .toSet() + + val externalSourcesFiles = + project.dependencies + .createArtifactResolutionQuery() + .forComponents(externalComponentIds) + .withArtifacts(JvmLibrary::class.java, SourcesArtifact::class.java) + .execute() + .resolvedComponents + .flatMap { it.getArtifacts(SourcesArtifact::class.java) } + .filterIsInstance() + .map { it.file } + + val projectComponentIds = + includedDependenciesResults + .map { it.selected.id } + .filterIsInstance() + .toSet() + + val projectSourcesFiles = + try { + configuration.incoming + .artifactView { view -> + view.withVariantReselection() + view.attributes { attrs -> + attrs.attribute( + Category.CATEGORY_ATTRIBUTE, + project.objects.named(Category::class.java, Category.DOCUMENTATION), + ) + attrs.attribute( + DocsType.DOCS_TYPE_ATTRIBUTE, + project.objects.named(DocsType::class.java, DocsType.SOURCES), + ) + } + view.componentFilter { id -> id in projectComponentIds } + view.lenient(true) + } + .files + } catch (_: Exception) { + project.files() + } + + return project.files(externalSourcesFiles) + projectSourcesFiles + } } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapper.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapper.kt new file mode 100644 index 000000000..59b19e68d --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapper.kt @@ -0,0 +1,141 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import com.github.jengelman.gradle.plugins.shadow.relocation.RelocatePathContext +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator +import com.github.jengelman.gradle.plugins.shadow.relocation.SimpleRelocator +import com.github.jengelman.gradle.plugins.shadow.relocation.relocatePath +import java.util.regex.Pattern + +private val RX_ENDS_WITH_DOT_SLASH_SPACE: Pattern = Pattern.compile("[./ ]$") + +private val RX_ENDS_WITH_JAVA_KEYWORD: Pattern = + Pattern.compile( + "\\b(import|package|public|protected|private|static|final|synchronized|abstract|volatile|extends|implements|throws) $" + + "|" + + "\\{@link( \\*)* $" + + "|" + + "([{}(=;,]|\\*/) $" + ) + +/** + * Remaps source content by applying relocators in a single pass with first-match-wins precedence, + * avoiding cascade replacements where earlier relocations get re-relocated by subsequent rules. + */ +internal fun Iterable.remapSource(sourceContent: String): String { + val relocatorList = this.toList() + if (relocatorList.isEmpty() || sourceContent.isEmpty()) return sourceContent + + val simpleRelocators = + relocatorList.filterIsInstance().filter { + !it.rawString && it.pattern.isNotEmpty() + } + + if (simpleRelocators.isEmpty()) { + var content = sourceContent + for (relocator in relocatorList) { + content = relocator.applyToSourceContent(content) + } + return content + } + + val patterns = + simpleRelocators + .flatMap { listOf(it.pattern, it.pathPattern) } + .filter { it.isNotEmpty() } + .distinct() + .sortedByDescending { it.length } + + if (patterns.isEmpty()) return sourceContent + + val patternRegex = Regex("\\b(" + patterns.joinToString("|") { Regex.escape(it) } + ")\\b") + + val result = StringBuilder((sourceContent.length * 1.1).toInt()) + var lastIndex = 0 + + for (match in patternRegex.findAll(sourceContent)) { + val matchStart = match.range.first + val matchEnd = match.range.last + 1 + val matchedText = match.value + + result.append(sourceContent, lastIndex, matchStart) + lastIndex = matchEnd + + val previousSnippet = sourceContent.substring(0, matchStart) + val previousSnippetOneLine = previousSnippet.replace("\\s+".toRegex(), " ") + val afterDotSlashSpace = RX_ENDS_WITH_DOT_SLASH_SPACE.matcher(previousSnippetOneLine).find() + val afterJavaKeyWord = RX_ENDS_WITH_JAVA_KEYWORD.matcher(previousSnippetOneLine).find() + val contextValid = !afterDotSlashSpace || afterJavaKeyWord + + var replaced = false + if (contextValid) { + val suffixSnippet = sourceContent.substring(matchEnd) + for (relocator in relocatorList) { + if ( + relocator is SimpleRelocator && !relocator.rawString && relocator.pattern.isNotEmpty() + ) { + val isDotMatch = matchedText == relocator.pattern + val isPathMatch = matchedText == relocator.pathPattern + if (isDotMatch || isPathMatch) { + val sourceIncludes = + SimpleRelocator.getSourceSubpatterns(relocator.includes, relocator.pattern) + val sourceExcludes = + SimpleRelocator.getSourceSubpatterns(relocator.excludes, relocator.pattern) + val hasIncludes = relocator.includes.isNotEmpty() + if (hasIncludes && sourceIncludes.isEmpty()) { + continue + } + val isIncluded = + !hasIncludes || + sourceIncludes.any { SimpleRelocator.matchesSubpattern(suffixSnippet, it) } + val isExcluded = sourceExcludes.any { + SimpleRelocator.matchesSubpattern(suffixSnippet, it) + } + if (isIncluded && !isExcluded) { + result.append( + if (isDotMatch) relocator.shadedPattern else relocator.shadedPathPattern + ) + replaced = true + break + } + } + } + } + } + + if (!replaced) { + result.append(matchedText) + } + } + + result.append(sourceContent, lastIndex, sourceContent.length) + return result.toString() +} + +/** + * Relocates a source file path by stripping its extension before matching against class/path + * relocators, ensuring class-level include/exclude patterns work symmetrically with binary classes. + */ +internal fun Iterable.relocateSourcePath(path: String): String { + if (isSourceFile(path)) { + val extension = path.substringAfterLast('.', "") + val pathWithoutExt = path.removeSuffix(".$extension") + val className = pathWithoutExt.replace('/', '.') + + for (relocator in this) { + if (relocator.canRelocateClass(className) || relocator.canRelocatePath(pathWithoutExt)) { + val relocatedWithoutExt = relocator.relocatePath(RelocatePathContext(pathWithoutExt)) + return "$relocatedWithoutExt.$extension" + } + } + return path + } + + return relocatePath(path) +} + +internal fun isSourceFile(path: String): Boolean { + return path.endsWith(".java") || + path.endsWith(".kt") || + path.endsWith(".groovy") || + path.endsWith(".scala") +} diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourcesJar.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourcesJar.kt new file mode 100644 index 000000000..ef06e4525 --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourcesJar.kt @@ -0,0 +1,243 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import com.github.jengelman.gradle.plugins.shadow.relocation.Relocator +import com.github.jengelman.gradle.plugins.shadow.relocation.relocatePath +import java.io.File +import java.nio.charset.Charset +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.bundling.ZipEntryCompression +import org.vafer.jdeb.shaded.objectweb.asm.ClassReader +import org.vafer.jdeb.shaded.objectweb.asm.ClassVisitor +import org.vafer.jdeb.shaded.objectweb.asm.Opcodes + +internal fun generateSourcesJar( + sourcesJarFile: File, + sourceSetsSourceDirs: FileCollection, + includedSourcesJars: Iterable, + classesDirs: Iterable = emptyList(), + dependencies: Iterable = emptyList(), + relocators: Iterable, + unusedClasses: Set = emptySet(), + entryCompression: ZipEntryCompression, + isZip64: Boolean, + metadataCharset: String?, + preserveFileTimestamps: Boolean, +) { + val sourcesJars = includedSourcesJars.filter { it.exists() && it.isFile }.sortedBy { it.path } + + val visitedFiles = mutableSetOf() + val charset = metadataCharset?.let(Charset::forName) ?: Charsets.UTF_8 + val sourceToClasses = + if (unusedClasses.isNotEmpty()) { + buildSourceToClassesMap(classesDirs = classesDirs, dependencies = dependencies) + } else { + emptyMap() + } + + try { + sourcesJarFile + .createZipOutputStream( + entryCompression = entryCompression, + isZip64 = isZip64, + encoding = metadataCharset, + ) + .use { zos -> + val manifestEntry = "META-INF/MANIFEST.MF" + visitedFiles.add(manifestEntry) + zos.writeEntry( + name = manifestEntry, + preserveLastModified = preserveFileTimestamps, + unixMode = UnixMode.file(), + ) { + write("Manifest-Version: 1.0\n\n".toByteArray(charset)) + } + + val filesWithRelPaths = mutableListOf>() + sourceSetsSourceDirs.asFileTree.visit { details -> + if (!details.isDirectory) { + filesWithRelPaths.add(details.file to details.relativePath.pathString) + } + } + + for ((file, relPath) in filesWithRelPaths.sortedBy { it.second }) { + val isSource = isSourceFile(relPath) + if (isSource) { + if (isUnused(relPath, unusedClasses, sourceToClasses)) continue + val relocatedPath = relocators.relocateSourcePath(relPath) + if (visitedFiles.add(relocatedPath)) { + val text = file.readText(charset) + val transformedText = relocators.remapSource(text) + val bytes = transformedText.toByteArray(charset) + zos.writeEntry( + name = relocatedPath, + preserveLastModified = preserveFileTimestamps, + lastModified = file.lastModified(), + unixMode = UnixMode.file(), + ) { + write(bytes) + } + } + } else { + val relocatedPath = relocators.relocatePath(relPath) + if (visitedFiles.add(relocatedPath)) { + val bytes = file.readBytes() + zos.writeEntry( + name = relocatedPath, + preserveLastModified = preserveFileTimestamps, + lastModified = file.lastModified(), + unixMode = UnixMode.file(), + ) { + write(bytes) + } + } + } + } + + sourcesJars.forEach { jarFile -> + jarFile.useZip { + entries() + .toList() + .filterNot { it.isDirectory } + .sortedBy { it.name } + .forEach { entry -> + val name = entry.name + if ( + name == "META-INF/MANIFEST.MF" || + name.endsWith(".class") || + name.startsWith("META-INF/INDEX.LIST") || + (name.startsWith("META-INF/") && + (name.endsWith(".SF") || name.endsWith(".DSA") || name.endsWith(".RSA"))) + ) { + return@forEach + } + val isSource = isSourceFile(name) + if (isSource) { + if (isUnused(name, unusedClasses, sourceToClasses)) return@forEach + val relocatedPath = relocators.relocateSourcePath(name) + if (visitedFiles.add(relocatedPath)) { + val text = getInputStream(entry).bufferedReader(charset).readText() + val transformedText = relocators.remapSource(text) + val bytes = transformedText.toByteArray(charset) + zos.writeEntry( + name = relocatedPath, + preserveLastModified = preserveFileTimestamps, + lastModified = entry.time, + unixMode = UnixMode.file(), + ) { + write(bytes) + } + } + } else { + val relocatedPath = relocators.relocatePath(name) + if (visitedFiles.add(relocatedPath)) { + val bytes = getInputStream(entry).readBytes() + zos.writeEntry( + name = relocatedPath, + preserveLastModified = preserveFileTimestamps, + lastModified = entry.time, + unixMode = UnixMode.file(), + ) { + write(bytes) + } + } + } + } + } + } + + val entries = zos.entries.map { it.name } + val added = entries.toMutableSet() + entries.forEach { name -> + name.parentDirectoryEntries().forEach { entryName -> + if (!added.add(entryName)) return@forEach + zos.writeEntry( + name = entryName, + preserveLastModified = preserveFileTimestamps, + unixMode = UnixMode.directory(), + ) + } + } + } + } catch (e: Exception) { + sourcesJarFile.delete() + gradleError("Could not create shadowed sources JAR '$sourcesJarFile'.", e) + } +} + +private fun buildSourceToClassesMap( + classesDirs: Iterable, + dependencies: Iterable, +): Map> { + val sourceToClasses = mutableMapOf>() + + fun processClassBytes(bytes: ByteArray) { + try { + var internalName: String? = null + var sourceFile: String? = null + ClassReader(bytes) + .accept( + object : ClassVisitor(Opcodes.ASM9) { + override fun visit( + version: Int, + access: Int, + name: String, + signature: String?, + superName: String?, + interfaces: Array?, + ) { + internalName = name + super.visit(version, access, name, signature, superName, interfaces) + } + + override fun visitSource(source: String?, debug: String?) { + sourceFile = source + super.visitSource(source, debug) + } + }, + ClassReader.SKIP_CODE or ClassReader.SKIP_FRAMES, + ) + + val name = internalName ?: return + val source = sourceFile ?: return + val pkg = name.substringBeforeLast('/', "") + val canonicalSourcePath = if (pkg.isEmpty()) source else "$pkg/$source" + val className = name.replace('/', '.') + sourceToClasses.getOrPut(canonicalSourcePath) { mutableSetOf() }.add(className) + } catch (_: Exception) { + // Ignore invalid class files + } + } + + for (dir in classesDirs.filter(File::isDirectory)) { + dir + .walkTopDown() + .filter { it.isFile && it.name.endsWith(".class") } + .forEach { file -> processClassBytes(file.readBytes()) } + } + + for (file in + dependencies.filter { it.isFile && (it.extension == "jar" || it.extension == "zip") }) { + try { + file.useZip { + entries() + .toList() + .filter { !it.isDirectory && it.name.endsWith(".class") } + .forEach { entry -> processClassBytes(getInputStream(entry).readBytes()) } + } + } catch (_: Exception) { + // Ignore invalid archives + } + } + + return sourceToClasses +} + +internal fun isUnused( + canonicalPath: String, + unusedClasses: Set, + sourceToClasses: Map>, +): Boolean { + if (unusedClasses.isEmpty()) return false + val classes = sourceToClasses[canonicalPath] ?: return false + return classes.isNotEmpty() && classes.all { it in unusedClasses } +} diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt index 85384f069..0a844d5c1 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocator.kt @@ -66,32 +66,16 @@ constructor( if (!excludes.isNullOrEmpty()) { this.excludes.addAll(excludes) } - - if (!rawString) { - // Create exclude pattern sets for sources. - for (exclude in this.excludes) { - // Excludes should be subpackages of the global pattern. - if (exclude.startsWith(this.pattern)) { - sourcePackageExcludes.add( - exclude.substring(this.pattern.length).replaceFirst("[.][*]$".toRegex(), "") - ) - } - // Excludes should be subpackages of the global pattern. - if (exclude.startsWith(pathPattern)) { - sourcePathExcludes.add( - exclude.substring(pathPattern.length).replaceFirst("/[*]$".toRegex(), "") - ) - } - } - } } public open fun include(pattern: String) { includes.addAll(normalizePatterns(listOf(pattern))) + includes.add(pattern) } public open fun exclude(pattern: String) { excludes.addAll(normalizePatterns(listOf(pattern))) + excludes.add(pattern) } override fun canRelocatePath(path: String): Boolean { @@ -127,15 +111,27 @@ constructor( return if (rawString) clazz else clazz.replaceFirst(pattern.toRegex(), shadedPattern) } - /** - * We don't call this function now, so we don't have to expose [sourcePackageExcludes] and - * [sourcePathExcludes] as inputs. - */ override fun applyToSourceContent(sourceContent: String): String { - if (rawString) return sourceContent + if (rawString || pattern.isEmpty()) return sourceContent + val sourceIncludes = getSourceSubpatterns(includes, pattern) + val sourceExcludes = getSourceSubpatterns(excludes, pattern) val content = - shadeSourceWithExcludes(sourceContent, pattern, shadedPattern, sourcePackageExcludes) - return shadeSourceWithExcludes(content, pathPattern, shadedPathPattern, sourcePathExcludes) + shadeSourceWithFilters( + sourceContent = sourceContent, + patternFrom = pattern, + patternTo = shadedPattern, + includedPatterns = sourceIncludes, + hasIncludes = includes.isNotEmpty(), + excludedPatterns = sourceExcludes, + ) + return shadeSourceWithFilters( + sourceContent = content, + patternFrom = pathPattern, + patternTo = shadedPathPattern, + includedPatterns = sourceIncludes, + hasIncludes = includes.isNotEmpty(), + excludedPatterns = sourceExcludes, + ) } override fun equals(other: Any?): Boolean { @@ -147,8 +143,6 @@ constructor( pathPattern == other.pathPattern && shadedPattern == other.shadedPattern && shadedPathPattern == other.shadedPathPattern && - sourcePackageExcludes == other.sourcePackageExcludes && - sourcePathExcludes == other.sourcePathExcludes && includes == other.includes && excludes == other.excludes } @@ -161,8 +155,6 @@ constructor( pathPattern, shadedPattern, shadedPathPattern, - sourcePackageExcludes, - sourcePathExcludes, includes, excludes, ) @@ -175,8 +167,6 @@ constructor( append("pathPattern='$pathPattern'").append(", ") append("shadedPattern='$shadedPattern'").append(", ") append("shadedPathPattern='$shadedPathPattern'").append(", ") - append("sourcePackageExcludes=$sourcePackageExcludes").append(", ") - append("sourcePathExcludes=$sourcePathExcludes").append(", ") append("includes=$includes").append(", ") append("excludes=$excludes") append(")") @@ -190,9 +180,9 @@ constructor( return excludes.any { SelectorUtils.matchPath(it, path, "/", true) } } - private companion object { + internal companion object { /** Match dot, slash or space at end of string */ - val RX_ENDS_WITH_DOT_SLASH_SPACE: Pattern = Pattern.compile("[./ ]$") + private val RX_ENDS_WITH_DOT_SLASH_SPACE: Pattern = Pattern.compile("[./ ]$") /** * Match @@ -203,7 +193,7 @@ constructor( * * at end of string */ - val RX_ENDS_WITH_JAVA_KEYWORD: Pattern = + private val RX_ENDS_WITH_JAVA_KEYWORD: Pattern = Pattern.compile( "\\b(import|package|public|protected|private|static|final|synchronized|abstract|volatile|extends|implements|throws) $" + "|" + @@ -212,7 +202,7 @@ constructor( "([{}(=;,]|\\*/) $" ) - fun normalizePatterns(patterns: Collection?) = buildSet { + private fun normalizePatterns(patterns: Collection?) = buildSet { patterns ?: return@buildSet for (pattern in patterns) { // Regex patterns don't need to be normalized and stay as is. @@ -243,31 +233,71 @@ constructor( } } - fun shadeSourceWithExcludes( + fun getSourceSubpatterns(patterns: Set, patternPrefix: String): Set { + if (patternPrefix.isEmpty()) return emptySet() + val result = mutableSetOf() + val dotPrefix = patternPrefix.replace('/', '.') + val slashPrefix = patternPrefix.replace('.', '/') + val trailingWildcardRegex = "[./][*]+$".toRegex() + + for (pat in patterns) { + val dotPat = pat.replace('/', '.') + if (dotPat.startsWith(dotPrefix)) { + val sub = dotPat.substring(dotPrefix.length).replaceFirst(trailingWildcardRegex, "") + if (sub.isEmpty()) { + result.add("") + } else { + result.add(sub) + result.add(sub.replace('.', '/')) + } + } + val slashPat = pat.replace('.', '/') + if (slashPat.startsWith(slashPrefix)) { + val sub = slashPat.substring(slashPrefix.length).replaceFirst(trailingWildcardRegex, "") + if (sub.isEmpty()) { + result.add("") + } else { + result.add(sub) + result.add(sub.replace('/', '.')) + } + } + } + return result + } + + fun matchesSubpattern(snippet: String, subpattern: String): Boolean { + if (!snippet.startsWith(subpattern)) return false + if (subpattern.isEmpty() || snippet.length == subpattern.length) return true + if (subpattern.endsWith('.') || subpattern.endsWith('/')) return true + val nextChar = snippet[subpattern.length] + return !nextChar.isLetterOrDigit() && nextChar != '_' + } + + private fun shadeSourceWithFilters( sourceContent: String, patternFrom: String, patternTo: String, + includedPatterns: Set, + hasIncludes: Boolean, excludedPatterns: Set, ): String { - // Usually shading makes package names a bit longer, so make buffer 10% bigger than original - // source. + if (hasIncludes && includedPatterns.isEmpty()) { + return sourceContent + } + val shadedSourceContent = StringBuilder(sourceContent.length * 11 / 10) - // Make sure that search pattern starts at word boundary and that we look for literal ".", not - // regex jokers. val snippets = sourceContent .split(("\\b" + patternFrom.replace(".", "[.]") + "\\b").toRegex()) .filter(CharSequence::isNotEmpty) + snippets.forEachIndexed { i, snippet -> val isFirstSnippet = i == 0 val previousSnippet = if (isFirstSnippet) "" else snippets[i - 1] - var doExclude = false - for (excludedPattern in excludedPatterns) { - if (snippet.startsWith(excludedPattern)) { - doExclude = true - break - } - } + + val isIncluded = !hasIncludes || includedPatterns.any { matchesSubpattern(snippet, it) } + val isExcluded = excludedPatterns.any { matchesSubpattern(snippet, it) } + if (isFirstSnippet) { shadedSourceContent.append(snippet) } else { @@ -275,8 +305,9 @@ constructor( val afterDotSlashSpace = RX_ENDS_WITH_DOT_SLASH_SPACE.matcher(previousSnippetOneLine).find() val afterJavaKeyWord = RX_ENDS_WITH_JAVA_KEYWORD.matcher(previousSnippetOneLine).find() - val shouldExclude = doExclude || afterDotSlashSpace && !afterJavaKeyWord - shadedSourceContent.append(if (shouldExclude) patternFrom else patternTo).append(snippet) + val shouldRelocate = + isIncluded && !isExcluded && (!afterDotSlashSpace || afterJavaKeyWord) + shadedSourceContent.append(if (shouldRelocate) patternTo else patternFrom).append(snippet) } } return shadedSourceContent.toString() diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt index e33047b13..643b33b18 100644 --- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowJar.kt @@ -11,6 +11,7 @@ import com.github.jengelman.gradle.plugins.shadow.internal.classPathAttributeKey import com.github.jengelman.gradle.plugins.shadow.internal.createZipOutputStream import com.github.jengelman.gradle.plugins.shadow.internal.fileCollection import com.github.jengelman.gradle.plugins.shadow.internal.findUnusedClasses +import com.github.jengelman.gradle.plugins.shadow.internal.generateSourcesJar import com.github.jengelman.gradle.plugins.shadow.internal.getApiJars import com.github.jengelman.gradle.plugins.shadow.internal.gradleError import com.github.jengelman.gradle.plugins.shadow.internal.javaPluginExtension @@ -51,6 +52,7 @@ import org.gradle.api.file.DuplicatesStrategy.FAIL import org.gradle.api.file.DuplicatesStrategy.INCLUDE import org.gradle.api.file.DuplicatesStrategy.INHERIT import org.gradle.api.file.DuplicatesStrategy.WARN +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.provider.SetProperty import org.gradle.api.tasks.CacheableTask @@ -60,6 +62,7 @@ import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Nested import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -198,6 +201,76 @@ public abstract class ShadowJar : Jar() { dependencyFilter.zip(configurations) { df, cs -> df.resolve(cs) } } + /** + * If `true`, generates a companion shadowed sources JAR containing project and dependency + * sources. + * + * In projects applying the `shadow` plugin for Java, this convention defaults to `true` when + * `java.withSourcesJar()` is enabled, and `false` otherwise. + */ + @get:Input + @get:Option( + option = "generate-sources-jar", + description = + "Generates a companion shadowed sources JAR containing project and dependency sources.", + ) + public open val generateSourcesJar: Property = objectFactory.property(false) + + /** + * Source JARs resolved from bundled dependencies to be merged into the companion shadowed sources + * JAR. + */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + public open val includedSourcesJars: ConfigurableFileCollection = objectFactory.fileCollection { + // Avoid resolving sources JARs during task input snapshotting when sources JAR generation is + // disabled. + generateSourcesJar.flatMap { + if (it) { + dependencyFilter.zip(configurations) { df, cs -> + (df as? DefaultDependencyFilter)?.resolveSourcesJars(cs) ?: project.files() + } + } else { + project.provider { emptySet() } + } + } + } + + /** + * Source directories from project source sets to be included in the companion shadowed sources + * JAR. + * + * In projects applying the `shadow` plugin for Java or Kotlin Multiplatform, this defaults to the + * relevant source sets' source directories. + */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + public open val sourceSetsSourceDirs: ConfigurableFileCollection = objectFactory.fileCollection() + + /** + * The destination location of the companion shadowed sources JAR. + * + * Defaults to + * `/--sources.`. + */ + @get:Optional + @get:OutputFile + public open val archiveSourcesFile: RegularFileProperty = + objectFactory + .fileProperty() + .convention( + destinationDirectory.file( + archiveFileName.map { name -> + val idx = name.lastIndexOf('.') + if (idx != -1) { + "${name.substring(0, idx)}-sources${name.substring(idx)}" + } else { + "$name-sources" + } + } + ) + ) + /** * Enables auto relocation of packages in the dependencies. * @@ -540,6 +613,7 @@ public abstract class ShadowJar : Jar() { addIncludedDependencies() injectManifestAttributes() super.copy() + generateShadowedSourcesJar() runR8Minimization() } @@ -559,6 +633,7 @@ public abstract class ShadowJar : Jar() { if (unusedClasses.isNotEmpty()) { logger.info("Found {} unused classes to drop for minimization.", unusedClasses.size) } + this.unusedClasses = unusedClasses val actualTransformers = transformers.get().let { set -> if ( @@ -742,6 +817,25 @@ public abstract class ShadowJar : Jar() { ) } + private var unusedClasses: Set = emptySet() + + private fun generateShadowedSourcesJar() { + if (!generateSourcesJar.get() || !archiveSourcesFile.isPresent) return + generateSourcesJar( + sourcesJarFile = archiveSourcesFile.get().asFile, + sourceSetsSourceDirs = sourceSetsSourceDirs, + includedSourcesJars = includedSourcesJars.files, + classesDirs = sourceSetsClassesDirs.files, + dependencies = includedDependencies.files, + relocators = relocators.get() + packageRelocators, + unusedClasses = unusedClasses, + entryCompression = entryCompression, + isZip64 = isZip64, + metadataCharset = metadataCharset, + preserveFileTimestamps = isPreserveFileTimestamps, + ) + } + public companion object { public const val SHADOW_JAR_TASK_NAME: String = "shadowJar" diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowPropertiesTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowPropertiesTest.kt index 91c70e5ab..225005831 100644 --- a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowPropertiesTest.kt +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/ShadowPropertiesTest.kt @@ -4,6 +4,7 @@ import assertk.all import assertk.assertThat import assertk.assertions.containsNone import assertk.assertions.containsOnly +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isNotNull @@ -162,9 +163,28 @@ class ShadowPropertiesTest { assertThat(relocationPrefix.get()).isEqualTo(ShadowBasePlugin.SHADOW) assertThat(configurations.get()).containsOnly(runtimeConfiguration) + assertThat(generateSourcesJar.get()).isFalse() + assertThat(archiveSourcesFile.get().asFile).all { + isEqualTo(destinationDirectory.file("my-project-1.0.0-all-sources.jar").get().asFile) + isEqualTo(projectDir.resolve("build/libs/my-project-1.0.0-all-sources.jar")) + } + assertThat(sourceSetsSourceDirs.files).isEmpty() + assertThat(includedSourcesJars.files).isEmpty() } } + @Test + fun applyJavaPluginWithSourcesJar() = + with(project) { + plugins.apply(JavaPlugin::class.java) + javaPluginExtension.withSourcesJar() + val shadowJarTask = tasks.shadowJar.get() + assertThat(shadowJarTask.generateSourcesJar.get()).isTrue() + val mainSourceSet = javaPluginExtension.sourceSets.getByName("main") + assertThat(shadowJarTask.sourceSetsSourceDirs.files) + .containsOnly(*mainSourceSet.allSource.files.toTypedArray()) + } + @Test fun applyApplicationPlugin() = with(project) { diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapperTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapperTest.kt new file mode 100644 index 000000000..5c1fa8728 --- /dev/null +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapperTest.kt @@ -0,0 +1,107 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.github.jengelman.gradle.plugins.shadow.relocation.SimpleRelocator +import org.junit.jupiter.api.Test + +class SourceRemapperTest { + + @Test + fun chainedRelocatorsDoNotCascade() { + val r1 = SimpleRelocator("a.foo", "b.foo") + val r2 = SimpleRelocator("b.foo", "c.foo") + val relocators = listOf(r1, r2) + + val input = + """ + |package a.foo; + |import b.foo.Bar; + |public class Main { + | a.foo.Baz baz; + | b.foo.Bar bar; + |} + """ + .trimMargin() + + val expected = + """ + |package b.foo; + |import c.foo.Bar; + |public class Main { + | b.foo.Baz baz; + | c.foo.Bar bar; + |} + """ + .trimMargin() + + assertThat(relocators.remapSource(input)).isEqualTo(expected) + } + + @Test + fun relocateSourcePathWithClassInclude() { + val relocator = + SimpleRelocator( + "pkg", + "hidden.pkg", + includes = listOf("pkg.A", "pkg.sub.*"), + ) + val relocators = listOf(relocator) + + // Included class files + assertThat(relocators.relocateSourcePath("pkg/A.java")).isEqualTo("hidden/pkg/A.java") + assertThat(relocators.relocateSourcePath("pkg/A.kt")).isEqualTo("hidden/pkg/A.kt") + assertThat(relocators.relocateSourcePath("pkg/sub/Nested.java")) + .isEqualTo("hidden/pkg/sub/Nested.java") + + // Excluded / un-included class file + assertThat(relocators.relocateSourcePath("pkg/B.java")).isEqualTo("pkg/B.java") + assertThat(relocators.relocateSourcePath("other/Other.java")).isEqualTo("other/Other.java") + } + + @Test + fun relocateSourcePathWithClassExclude() { + val relocator = + SimpleRelocator( + "pkg", + "hidden.pkg", + excludes = listOf("pkg.B"), + ) + val relocators = listOf(relocator) + + assertThat(relocators.relocateSourcePath("pkg/A.java")).isEqualTo("hidden/pkg/A.java") + assertThat(relocators.relocateSourcePath("pkg/B.java")).isEqualTo("pkg/B.java") + } + + @Test + fun remapSourceWithIncludesAndExcludes() { + val relocator = + SimpleRelocator( + "com.example", + "shaded.example", + includes = listOf("com.example.used.*"), + excludes = listOf("com.example.used.Excluded"), + ) + val relocators = listOf(relocator) + + val input = + """ + |package com.example.used; + |import com.example.used.Foo; + |import com.example.used.Excluded; + |import com.example.unused.Bar; + """ + .trimMargin() + + val expected = + """ + |package shaded.example.used; + |import shaded.example.used.Foo; + |import com.example.used.Excluded; + |import com.example.unused.Bar; + """ + .trimMargin() + + assertThat(relocators.remapSource(input)).isEqualTo(expected) + } +} diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourcesJarTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourcesJarTest.kt new file mode 100644 index 000000000..f67a6a7fc --- /dev/null +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourcesJarTest.kt @@ -0,0 +1,159 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import assertk.assertFailure +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.containsOnly +import assertk.assertions.hasMessage +import assertk.assertions.isFalse +import assertk.assertions.isInstanceOf +import assertk.assertions.isTrue +import com.github.jengelman.gradle.plugins.shadow.util.testObjectFactory +import java.io.File +import java.util.zip.ZipFile +import org.gradle.api.GradleException +import org.gradle.api.tasks.bundling.ZipEntryCompression +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +class SourcesJarTest { + + @Test + fun isUnusedMatching() { + val unusedSet = + setOf( + "com.example.UnusedJava", + "com.example.UnusedJava\$Inner", + "com.example.UnusedKtClass", + "com.example.DefaultFacadeKt", + "com.example.CustomFacade", + ) + val sourceToClasses = + mapOf( + "com/example/UnusedJava.java" to + setOf("com.example.UnusedJava", "com.example.UnusedJava\$Inner"), + "com/example/PartiallyUsedJava.java" to + setOf("com.example.UnusedJava", "com.example.UsedHelper"), + "com/example/UsedJava.java" to setOf("com.example.UsedJava"), + "com/example/UnusedKtClass.kt" to setOf("com.example.UnusedKtClass"), + "com/example/DefaultFacade.kt" to setOf("com.example.DefaultFacadeKt"), + "com/example/Utils.kt" to setOf("com.example.CustomFacade"), + "com/example/MixedUtils.kt" to setOf("com.example.CustomFacade", "com.example.UsedClass"), + "Main.java" to setOf("Main"), + ) + + // All classes unused in file -> unused + assertThat(isUnused("com/example/UnusedJava.java", unusedSet, sourceToClasses)).isTrue() + assertThat(isUnused("com/example/UnusedKtClass.kt", unusedSet, sourceToClasses)).isTrue() + assertThat(isUnused("com/example/DefaultFacade.kt", unusedSet, sourceToClasses)).isTrue() + assertThat(isUnused("com/example/Utils.kt", unusedSet, sourceToClasses)).isTrue() + assertThat(isUnused("Main.java", setOf("Main"), sourceToClasses)).isTrue() + + // At least one class is used in file -> NOT unused (kept!) + assertThat(isUnused("com/example/PartiallyUsedJava.java", unusedSet, sourceToClasses)).isFalse() + assertThat(isUnused("com/example/MixedUtils.kt", unusedSet, sourceToClasses)).isFalse() + assertThat(isUnused("com/example/UsedJava.java", unusedSet, sourceToClasses)).isFalse() + + // Unknown source file or empty unused set -> kept + assertThat(isUnused("com/example/Unknown.java", unusedSet, sourceToClasses)).isFalse() + assertThat(isUnused("com/example/UnusedJava.java", emptySet(), sourceToClasses)).isFalse() + assertThat(isUnused("Main.java", setOf("Other"), sourceToClasses)).isFalse() + } + + @Test + fun deterministicOrdering(@TempDir tempDir: File) { + val srcDir = tempDir.resolve("src").apply { mkdirs() } + srcDir.resolve("z/sub/Z.java").apply { + parentFile.mkdirs() + writeText("package z.sub;\nclass Z {}") + } + srcDir.resolve("a/A.java").apply { + parentFile.mkdirs() + writeText("package a;\nclass A {}") + } + srcDir.resolve("m/M.java").apply { + parentFile.mkdirs() + writeText("package m;\nclass M {}") + } + + val outputJar = tempDir.resolve("output-sources.jar") + generateSourcesJar( + sourcesJarFile = outputJar, + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + includedSourcesJars = emptyList(), + relocators = emptyList(), + unusedClasses = emptySet(), + entryCompression = ZipEntryCompression.DEFLATED, + isZip64 = false, + metadataCharset = null, + preserveFileTimestamps = true, + ) + + val entries = ZipFile(outputJar).use { zip -> zip.entries().toList().map { it.name } } + assertThat(entries) + .containsExactly( + "META-INF/MANIFEST.MF", + "a/A.java", + "m/M.java", + "z/sub/Z.java", + "META-INF/", + "a/", + "m/", + "z/", + "z/sub/", + ) + } + + @Test + fun respectsExcludedDirectory(@TempDir tempDir: File) { + val srcDir = tempDir.resolve("src").apply { mkdirs() } + srcDir.resolve("Excluded.java").writeText("public class Excluded {}") + + val fileTree = + testObjectFactory.fileCollection().from(srcDir).asFileTree.matching { + it.exclude("**/Excluded.java") + } + + val outputJar = tempDir.resolve("output-sources.jar") + generateSourcesJar( + sourcesJarFile = outputJar, + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(fileTree), + includedSourcesJars = emptyList(), + relocators = emptyList(), + unusedClasses = emptySet(), + entryCompression = ZipEntryCompression.DEFLATED, + isZip64 = false, + metadataCharset = null, + preserveFileTimestamps = true, + ) + + val entries = ZipFile(outputJar).use { zip -> zip.entries().toList().map { it.name } } + assertThat(entries).containsOnly("META-INF/", "META-INF/MANIFEST.MF") + } + + @Test + fun throwsGradleExceptionOnFailure(@TempDir tempDir: File) { + val invalidFile = tempDir.resolve("not-a-file").apply { mkdirs() } + val srcDir = + tempDir.resolve("src").apply { + mkdirs() + resolve("Main.java").writeText("public class Main {}") + } + + assertFailure { + generateSourcesJar( + sourcesJarFile = invalidFile, + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + includedSourcesJars = emptyList(), + relocators = emptyList(), + unusedClasses = emptySet(), + entryCompression = ZipEntryCompression.DEFLATED, + isZip64 = false, + metadataCharset = null, + preserveFileTimestamps = true, + ) + } + .isInstanceOf() + .hasMessage("Could not create shadowed sources JAR '$invalidFile'.") + } +} diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt index 4a88226aa..030cfdb26 100644 --- a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/relocation/SimpleRelocatorTest.kt @@ -357,15 +357,54 @@ class SimpleRelocatorTest { assertThat(relocator.applyToSourceContent(sourceFile)).isEqualTo(sourceFile) } + @Test + fun relocateSourceFileWithPrefixCollision() { + val relocator = + SimpleRelocator( + "org.example", + "relocated.org.example", + includes = listOf("org.example.In"), + ) + val source = + """ + |import org.example.In; + |import org.example.Input; + |import org.example.In.Nested; + | + |public class Test { + | org.example.In a; + | org.example.Input b; + |} + """ + .trimMargin() + val expected = + """ + |import relocated.org.example.In; + |import org.example.Input; + |import relocated.org.example.In.Nested; + | + |public class Test { + | relocated.org.example.In a; + | org.example.Input b; + |} + """ + .trimMargin() + assertThat(relocator.applyToSourceContent(source)).isEqualTo(expected) + } + @Test fun relocateSourceWithExcludes() { - // Main relocator with in-/excludes + // Main relocator with excludes val relocator = SimpleRelocator( "org.apache.maven", "com.acme.maven", - listOf("foo.bar", "zot.baz"), - listOf("irrelevant.exclude", "org.apache.maven.exclude1", "org.apache.maven.sub.exclude2"), + excludes = + listOf( + "irrelevant.exclude", + "org.apache.maven.exclude1", + "org.apache.maven.sub.exclude2", + ), ) // Make sure not to replace variables 'io' and 'ioInput', package 'java.io' val ioRelocator = SimpleRelocator("io", "shaded.io") @@ -383,6 +422,70 @@ class SimpleRelocatorTest { .isEqualTo(relocatedFile) } + @Test + fun relocateSourceWithIncludes() { + val relocator = + SimpleRelocator( + "org.apache.maven", + "com.acme.maven", + includes = listOf("org.apache.maven.hello.*", "org.apache.maven.In"), + ) + val input = + """ + |package org.apache.maven.hello; + |import org.apache.maven.hello.World; + |import org.apache.maven.other.Other; + |import org.apache.maven.In; + |import org.apache.maven.NotIn; + """ + .trimMargin() + val expected = + """ + |package com.acme.maven.hello; + |import com.acme.maven.hello.World; + |import org.apache.maven.other.Other; + |import com.acme.maven.In; + |import org.apache.maven.NotIn; + """ + .trimMargin() + assertThat(relocator.applyToSourceContent(input)).isEqualTo(expected) + } + + @Test + fun relocateSourceWithDslExcludeAndInclude() { + val relocatorExclude = SimpleRelocator("org.apache.maven", "com.acme.maven") + relocatorExclude.exclude("org.apache.maven.exclude1.*") + val inputExclude = + """ + |import org.apache.maven.hello.World; + |import org.apache.maven.exclude1.Ex1; + """ + .trimMargin() + val expectedExclude = + """ + |import com.acme.maven.hello.World; + |import org.apache.maven.exclude1.Ex1; + """ + .trimMargin() + assertThat(relocatorExclude.applyToSourceContent(inputExclude)).isEqualTo(expectedExclude) + + val relocatorInclude = SimpleRelocator("org.apache.maven", "com.acme.maven") + relocatorInclude.include("org.apache.maven.hello.*") + val inputInclude = + """ + |import org.apache.maven.hello.World; + |import org.apache.maven.other.Other; + """ + .trimMargin() + val expectedInclude = + """ + |import com.acme.maven.hello.World; + |import org.apache.maven.other.Other; + """ + .trimMargin() + assertThat(relocatorInclude.applyToSourceContent(inputInclude)).isEqualTo(expectedInclude) + } + private companion object { val sourceFile = """ diff --git a/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/GradleRunner.kt b/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/GradleRunner.kt index d07a6c32e..97c0f2336 100644 --- a/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/GradleRunner.kt +++ b/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/GradleRunner.kt @@ -49,6 +49,13 @@ val commonGradleArgs = isolatedProjectsFlag, ) +// TODO: https://github.com/Kotlin/dokka/issues/4600 +val String.isDokkaIssue4600: Boolean + get() = let { output -> + output.contains("The Configuration.setVisible(boolean) method has been deprecated") && + output.contains("org.jetbrains.dokka.gradle") + } + fun gradleRunner( projectDir: Path, arguments: Iterable, diff --git a/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/JarPath.kt b/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/JarPath.kt index 6ba359b9b..cb7d8b8e8 100644 --- a/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/JarPath.kt +++ b/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/JarPath.kt @@ -122,6 +122,6 @@ fun Assert.runMain( os.toString().invariantEolString } -private fun Assert.toEntries() = transform { actual -> +fun Assert.toEntries() = transform { actual -> actual.entries().toList().map { it.name } }