diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc6c858b..08189bc5d 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 99fbf72fd..aba6dbafd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -147,6 +147,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..fc798d18b 100644 --- a/docs/configuration/minimizing/README.md +++ b/docs/configuration/minimizing/README.md @@ -131,6 +131,17 @@ 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 d8ebb7e0c..32ef360b8 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -127,8 +127,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 @@ -145,6 +149,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. @@ -165,5 +171,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..c2e128382 100644 --- a/docs/publishing/README.md +++ b/docs/publishing/README.md @@ -295,41 +295,104 @@ 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 tasks (note that `sourcesJar` is only +created and needs handling when `java.withSourcesJar()` is enabled; if `withSourcesJar()` is not used, you only need to +configure the `jar` task): === ":material-language-kotlin: build.gradle.kts" ```kotlin + plugins { + java + id("com.gradleup.shadow") + } + + java { + withSourcesJar() + } + tasks.jar { enabled = false } + + // Only needed when java.withSourcesJar() is enabled. + 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 } + + // Only needed when java.withSourcesJar() is enabled. + 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" } + + // Only needed when java.withSourcesJar() is enabled. + 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' } + + // Only needed when java.withSourcesJar() is enabled. + tasks.named('sourcesJar', Jar) { + archiveClassifier = 'ignored-sources' + } ``` ## Publishing the Shadowed Gradle Plugins @@ -512,13 +575,421 @@ 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`. +Since 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. +- Relocated paths for source files laid out in their package directories. Other source files keep their original + relative paths, such as Kotlin Multiplatform (KMP) dependency sources under `commonMain/` or `jvmMain/`. +- Automatic filtering: dependencies excluded in `dependencies { exclude(...) }` are excluded from the shadowed sources + JAR as well. When minimizing with the default dependency analyzer (`minimize()`), source files of removed classes are + also excluded, as long as they are laid out in their package directories. Minimizing with R8 does not filter the + shadowed sources JAR. + +### 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: + +> [!TIP] +> In Kotlin/JVM projects applying `id("org.jetbrains.kotlin.jvm")`, configure `java { withSourcesJar() }` in the same +> way. The Kotlin JVM plugin applies Gradle's Java plugin internally. + +=== ":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, publish the `shadow` component directly in your Maven +publication, and disable standard archive tasks to prevent destination file collisions in `build/libs` (disabling +`sourcesJar` is only needed when `java.withSourcesJar()` is enabled): + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + plugins { + java + `maven-publish` + id("com.gradleup.shadow") + } + + java { + withSourcesJar() + } + + tasks.jar { + enabled = false + } + + // Only needed when java.withSourcesJar() is enabled. + 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 + } + + // Only needed when java.withSourcesJar() is enabled. + 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 + } + } + } + ``` + +> [!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. In Kotlin Multiplatform (KMP) projects, it defaults to the JVM target's sources publishing setting +> (`true` by default, or `false` when `withSourcesJar(publish = false)` is configured). +> You can also explicitly toggle generation via `generateSourcesJar = true` (or `--generate-sources-jar`). + +> [!NOTE] +> The shadowed sources JAR is generated by the `shadowJar` task itself, so it can reuse the same relocation, +> dependency filtering, and minimization configuration. As a result, when `generateSourcesJar` is enabled, source files +> become inputs of `shadowJar`, and any change to them (even a comment that doesn't affect the compiled classes) +> reruns `shadowJar` and rebuilds the shadowed JAR as well. + +> [!WARNING] +> The shadowed sources JAR has a few limitations compared to the shadowed JAR: +> +> - `include(...)` and `exclude(...)` patterns configured on `shadowJar` are not applied to the shadowed sources JAR, +> so sources of excluded classes are still included. +> - When multiple source files share the same path, only the first one is kept, regardless of `duplicatesStrategy` and +> `failOnDuplicateEntries`. Project sources are added first, then dependency sources. For example, in KMP projects, +> `commonMain/kotlin/foo/Foo.kt` and `jvmMain/kotlin/foo/Foo.kt` (such as `expect` and `actual` declarations) share +> the path `foo/Foo.kt`, and KMP dependencies may share paths like `commonMain/Annotations.kt`. + +### 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), which publish the original, non-shadowed artifacts. Shadow doesn't publish the shadowed JAR or +the shadowed sources JAR automatically in KMP projects, so create a dedicated publication for them. Don't attach the +shadowed sources JAR to KGP's `jvm` publication, as it would not match the non-shadowed JAR published there: + +=== ":material-language-kotlin: build.gradle.kts" + + ```kotlin + plugins { + id("org.jetbrains.kotlin.multiplatform") + id("com.gradleup.shadow") + `maven-publish` + } + + kotlin { + jvm() + } + + val shadowJar = tasks.named("shadowJar") { + archiveClassifier = "" + } + + publishing { + publications { + create("shadow") { + artifactId = "my-library-all" + artifact(shadowJar) + artifact(shadowJar.flatMap { it.archiveSourcesFile }) { + classifier = "sources" + } + } + } + repositories { + maven("https://repo.myorg.com") + } + } + ``` + +=== ":simple-apachegroovy: build.gradle" + + ```groovy + plugins { + id 'org.jetbrains.kotlin.multiplatform' + id 'com.gradleup.shadow' + id 'maven-publish' + } + + kotlin { + jvm() + } + + def shadowJar = tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar) { + archiveClassifier = '' + } + + publishing { + publications { + shadow(MavenPublication) { + artifactId = 'my-library-all' + artifact(shadowJar) + artifact(shadowJar.flatMap { it.archiveSourcesFile }) { + classifier = 'sources' + } + } + } + repositories { + maven { url = 'https://repo.myorg.com' } + } + } + ``` + +In KMP projects, `generateSourcesJar` is enabled by default via the JVM target's sources publishing setting +(`target.isSourcesPublishable`). If you want to disable companion sources JAR generation, configure +`withSourcesJar(publish = false)` on the target or set `generateSourcesJar = false` on the `shadowJar` task. + +## 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 31e2c32f6..7f7ae2f3d 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.1" +dokka = "2.3.0-Beta" [libraries] apache-ant = "org.apache.ant:ant:1.10.18" @@ -23,6 +24,7 @@ foojayResolver = "org.gradle.toolchains.foojay-resolver-convention:org.gradle.to develocity = "com.gradle:develocity-gradle-plugin:4.6.0" 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. @@ -35,7 +37,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.1" -jetbrains-dokka = "org.jetbrains.dokka:2.3.0-Beta" +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..eebcb53de 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 @@ -104,6 +105,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..a451b51a5 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 @@ -1,7 +1,6 @@ package com.github.jengelman.gradle.plugins.shadow import assertk.Assert -import assertk.all import assertk.assertions.isEqualTo import assertk.assertions.isNotNull import com.github.jengelman.gradle.plugins.shadow.ShadowApplicationPlugin.Companion.SHADOW_INSTALL_TASK_NAME @@ -14,12 +13,12 @@ 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.requireResourceAsPath +import com.github.jengelman.gradle.plugins.shadow.testkit.useAll as testkitUseAll import com.github.jengelman.gradle.plugins.shadow.transformers.ResourceTransformer import com.github.jengelman.gradle.plugins.shadow.util.AppendableMavenRepository import com.github.jengelman.gradle.plugins.shadow.util.JarBuilder import com.github.jengelman.gradle.plugins.shadow.util.JvmLang import com.github.jengelman.gradle.plugins.shadow.util.createDefaultLocalMavenRepository -import java.io.Closeable import java.nio.file.Path import java.util.Properties import java.util.jar.JarEntry @@ -73,9 +72,15 @@ 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() } @@ -150,6 +155,16 @@ abstract class BasePluginTest { .trimMargin() } + fun compileOnlyStdlib(exclude: Boolean): String { + return if (exclude) { + // Disable the stdlib dependency added via `implementation`. + path("gradle.properties").writeText("kotlin.stdlib.default.dependency=false") + "compileOnly 'org.jetbrains.kotlin:kotlin-stdlib'" + } else { + "" + } + } + fun jarPath(relative: String, parent: Path = projectRoot): JarPath { return JarPath(parent.resolve(relative)) } @@ -244,7 +259,22 @@ abstract class BasePluginTest { return "$basePath.class" } - fun writeClientAndServerModules(clientShadowed: Boolean = false, serverShadowBlock: String = "") { + fun writeClientAndServerModules( + clientShadowed: Boolean = false, + serverShadowBlock: String = "", + withSourcesJar: Boolean = false, + ) { + val javaBlock = + if (withSourcesJar) { + """ + |java { + | withSourcesJar() + |} + """ + .trimMargin() + } else { + "" + } settingsScript.appendText( """ |include 'client', 'server' @@ -265,6 +295,7 @@ abstract class BasePluginTest { .writeText( """ |${getDefaultProjectBuildScript("java")} + |$javaBlock |dependencies { | implementation 'junit:junit:3.8.2' |} @@ -286,6 +317,7 @@ abstract class BasePluginTest { .writeText( """ |${getDefaultProjectBuildScript("java")} + |$javaBlock |dependencies { | implementation project(':client') |} @@ -405,6 +437,7 @@ abstract class BasePluginTest { "tasks.named('$SHADOW_JAR_TASK_NAME', ${ShadowJar::class.java.name})" const val runShadowTask = "tasks.named('$SHADOW_RUN_TASK_NAME', JavaExec)" const val jarTask = "tasks.named('jar', Jar)" + const val sourcesJarTask = "tasks.named('sourcesJar', Jar)" const val infoArgument = "--info" @@ -416,10 +449,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() @@ -442,11 +481,7 @@ abstract class BasePluginTest { .trimMargin() } - fun Assert.useAll(body: Assert.() -> Unit) = all { - body() - // Close the resource after all assertions are done. - given { it.use(block = {}) } - } + fun Assert.useAll(body: Assert.() -> Unit) = testkitUseAll(body) fun Assert.taskOutcomeEquals(taskPath: String, expectedOutcome: TaskOutcome) { return transform { it.task(taskPath)?.outcome }.isNotNull().isEqualTo(expectedOutcome) 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..9e8105574 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 @@ -137,7 +137,8 @@ class FilteringTest : BasePluginTest() { | exclude($clientProject) |} """ - .trimMargin() + .trimMargin(), + withSourcesJar = true, ) runWithSuccess(serverShadowJarPath) @@ -154,7 +155,8 @@ class FilteringTest : BasePluginTest() { | exclude(project(':client')) |} """ - .trimMargin() + .trimMargin(), + withSourcesJar = true, ) path("client/build.gradle").appendText("version = '1.0.0+1'") @@ -172,7 +174,8 @@ class FilteringTest : BasePluginTest() { | exclude { it.moduleGroup == 'junit' } |} """ - .trimMargin() + .trimMargin(), + withSourcesJar = true, ) runWithSuccess(serverShadowJarPath) @@ -191,6 +194,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 +255,7 @@ class FilteringTest : BasePluginTest() { projectScript.appendText( """ |dependencies { - | implementation 'my:g:1.0' + | implementation 'my:l:1.0' |} |$shadowJarTask { | dependencies { @@ -256,7 +269,81 @@ 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") + } + } + + @Test + fun sourcesJarRespectsSourceDirectorySetFilters() { + 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", + ) } } @@ -280,5 +367,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..8df3bf5d2 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,7 +3,9 @@ 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.containsOnly import assertk.assertions.doesNotContain import assertk.assertions.isEqualTo import assertk.assertions.isNotEmpty @@ -24,11 +26,15 @@ import com.github.jengelman.gradle.plugins.shadow.testkit.getMainAttr import com.github.jengelman.gradle.plugins.shadow.testkit.getStream import com.github.jengelman.gradle.plugins.shadow.testkit.runMain import com.github.jengelman.gradle.plugins.shadow.util.prependText +import java.nio.file.Path import kotlin.io.path.appendText import kotlin.io.path.deleteExisting import kotlin.io.path.invariantSeparatorsPathString +import kotlin.io.path.listDirectoryEntries 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 +132,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. @@ -138,7 +146,7 @@ class JavaPluginsTest : BasePluginTest() { @Test fun includeProjectDependencies() { - writeClientAndServerModules() + writeClientAndServerModules(withSourcesJar = true) runWithSuccess(serverShadowJarPath) @@ -153,6 +161,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 +1300,248 @@ 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", + ) + } + } + + @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 generateShadowedSourcesJarWithCustomSources() { + writeClass() + path("src/extra/java/extra/Extra.java") + .writeText( + """ + |package extra; + |public class Extra {} + """ + .trimMargin() + ) + val customSourcesJar = + buildJar("libs/external-sources.jar") { + insert( + "ext/Ext.java", + """ + |package ext; + |public class Ext {} + """ + .trimMargin(), + ) + } + + projectScript.appendText( + """ + |$shadowJarTask { + | generateSourcesJar = true + | sourceSetsSourceDirs.from 'src/extra/java' + | includedSourcesJars.from '${customSourcesJar.invariantSeparatorsPathString}' + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/Main.java", + "extra/", + "extra/Extra.java", + "ext/", + "ext/Ext.java", + "META-INF/", + "META-INF/MANIFEST.MF", + ) + getContent("extra/Extra.java") + .isEqualTo( + """ + |package extra; + |public class Extra {} + """ + .trimMargin() + ) + getContent("ext/Ext.java") + .isEqualTo( + """ + |package ext; + |public class Ext {} + """ + .trimMargin() + ) + } + } + + @Test + fun shadowJarOutputsOnlyContainShadowedJarByDefault() { + val syncShadowJar = "syncShadowJar" + projectScript.appendText( + """ + |tasks.register('$syncShadowJar', Sync) { + | // `singleFile` fails if the sources JAR is registered as an output when it's disabled. + | from $shadowJarTask.map { it.outputs.files.singleFile } + | into layout.buildDirectory.dir('synced') + |} + """ + .trimMargin() + ) + + runWithSuccess(syncShadowJar) + + assertThat(path("build/synced").listDirectoryEntries().map(Path::name)) + .containsOnly("my-1.0-all.jar") + } + 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..0872184c7 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,8 @@ package com.github.jengelman.gradle.plugins.shadow import assertk.assertThat import assertk.assertions.contains +import assertk.assertions.containsAtLeast +import assertk.assertions.doesNotExist 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 +11,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,13 +293,121 @@ class KotlinPluginsTest : BasePluginTest() { ) } - private fun compileOnlyStdlib(exclude: Boolean): String { - return if (exclude) { - // Disable the stdlib dependency added via `implementation`. - path("gradle.properties").writeText("kotlin.stdlib.default.dependency=false") - "compileOnly 'org.jetbrains.kotlin:kotlin-stdlib'" - } else { - "" + @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", + ) + } + + @Test + fun generateSourcesJarByDefaultInKmp() { + val stdlib = compileOnlyStdlib(true) + writeClass(sourceSet = "jvmMain", jvmLang = JvmLang.Kotlin, className = "JvmMain") + projectScript.appendText( + """ + |kotlin { + | jvm() + | sourceSets { + | jvmMain { + | dependencies { + | $stdlib + | } + | } + | } + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(outputShadowedSourcesJar).useAll { + containsOnly( + "my/", + "my/JvmMain.kt", + "META-INF/", + "META-INF/MANIFEST.MF", + ) } } + + @Test + fun disableSourcesJarInKmpWithSourcesJarFalse() { + val stdlib = compileOnlyStdlib(true) + writeClass(sourceSet = "jvmMain", jvmLang = JvmLang.Kotlin, className = "JvmMain") + projectScript.appendText( + """ + |kotlin { + | jvm { + | withSourcesJar(false) + | } + | sourceSets { + | jvmMain { + | dependencies { + | $stdlib + | } + | } + | } + |} + """ + .trimMargin() + ) + + runWithSuccess(shadowJarPath) + + assertThat(projectRoot.resolve("build/libs/my-1.0-all-sources.jar")).doesNotExist() + } } 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..451971e0e 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 @@ -71,17 +74,15 @@ class PublishingTest : BasePluginTest() { val assertions = { variantAttrs: Array> -> publish() - assertPomCommon(repoPath("my/maven-all/1.0/maven-all-1.0.pom")) - val gmm = gmmAdapter.fromJson(repoPath("my/maven-all/1.0/maven-all-1.0.module")) - assertShadowVariantCommon(gmm, variantAttrs = variantAttrs) + assertPomCommon("my/maven-all/1.0/maven-all-1.0.pom") + assertShadowVariantCommon( + "my/maven-all/1.0/maven-all-1.0.module", + variantAttrs = variantAttrs, + ) } assertions(shadowVariantAttrs) - val attrsWithoutTargetJvm = - shadowVariantAttrs - .filterNot { (name, _) -> name == TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name } - .toTypedArray() val targetJvmAttr17 = TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name to "17" val targetJvmAttr11 = TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name to "11" val targetJvmAttr8 = TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name to "8" @@ -102,7 +103,7 @@ class PublishingTest : BasePluginTest() { |""" .trimMargin() ) - assertions(attrsWithoutTargetJvm + targetJvmAttr17) + assertions(shadowVariantAttrsWithoutTargetJvm + targetJvmAttr17) projectScript.appendText( """ @@ -112,7 +113,7 @@ class PublishingTest : BasePluginTest() { |""" .trimMargin() ) - assertions(attrsWithoutTargetJvm + targetJvmAttr11) + assertions(shadowVariantAttrsWithoutTargetJvm + targetJvmAttr11) projectScript.appendText( """ @@ -123,7 +124,7 @@ class PublishingTest : BasePluginTest() { .trimMargin() ) // sourceCompatibility doesn't affect the target JVM version. - assertions(attrsWithoutTargetJvm + targetJvmAttr11) + assertions(shadowVariantAttrsWithoutTargetJvm + targetJvmAttr11) projectScript.appendText( """ @@ -134,7 +135,7 @@ class PublishingTest : BasePluginTest() { .trimMargin() ) // options.release flag is honored. - assertions(attrsWithoutTargetJvm + targetJvmAttr8) + assertions(shadowVariantAttrsWithoutTargetJvm + targetJvmAttr8) } @Test // #1665 @@ -164,11 +165,8 @@ class PublishingTest : BasePluginTest() { "Cannot set the target JVM version to Int.MAX_VALUE when `java.autoTargetJvmDisabled` is enabled or in other cases." ) assertShadowVariantCommon( - gmm = gmmAdapter.fromJson(repoPath("my/maven-all/1.0/maven-all-1.0.module")), - variantAttrs = - shadowVariantAttrs - .filterNot { (name, _) -> name == TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name } - .toTypedArray(), + "my/maven-all/1.0/maven-all-1.0.module", + variantAttrs = shadowVariantAttrsWithoutTargetJvm, ) } @@ -199,11 +197,8 @@ class PublishingTest : BasePluginTest() { "Skipping setting org.gradle.jvm.version attribute for shadowRuntimeElements configuration." ) assertShadowVariantCommon( - gmm = gmmAdapter.fromJson(repoPath("my/maven-all/1.0/maven-all-1.0.module")), - variantAttrs = - shadowVariantAttrs - .filterNot { (name, _) -> name == TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name } - .toTypedArray(), + "my/maven-all/1.0/maven-all-1.0.module", + variantAttrs = shadowVariantAttrsWithoutTargetJvm, ) } @@ -230,7 +225,7 @@ class PublishingTest : BasePluginTest() { publish() assertShadowVariantCommon( - gmm = gmmAdapter.fromJson(repoPath("my/maven-all/1.0/maven-all-1.0.module")), + "my/maven-all/1.0/maven-all-1.0.module", variantAttrs = commonVariantAttrs + arrayOf( @@ -244,6 +239,13 @@ class PublishingTest : BasePluginTest() { fun publishShadowJarInsteadOfJar() { projectScript.appendText( publishConfiguration( + projectBlock = + """ + |$jarTask { + | enabled = false + |} + """ + .trimMargin(), shadowBlock = """ |archiveClassifier = '' @@ -264,25 +266,200 @@ class PublishingTest : BasePluginTest() { 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", - ) - assertShadowJarCommon(repoJarPath("$artifactRoot/maven-1.0.jar")) - assertPomCommon(repoPath("$artifactRoot/maven-1.0.pom")) - assertShadowVariantCommon(gmmAdapter.fromJson(repoPath("$artifactRoot/maven-1.0.module"))) + *withChecksums( + "maven-1.0.jar", + "maven-1.0.module", + "maven-1.0.pom", + ) + ) + assertShadowJarCommon("$artifactRoot/maven-1.0.jar") + assertPomCommon("$artifactRoot/maven-1.0.pom") + assertShadowVariantCommon("$artifactRoot/maven-1.0.module") + } + + @Test + fun publishShadowJarWithSourcesWhenWithSourcesJarEnabled() { + projectScript.appendText( + publishConfiguration( + projectBlock = + """ + |java { + | withSourcesJar() + |} + |$jarTask { + | enabled = false + |} + |$sourcesJarTask { + | enabled = false + |} + """ + .trimMargin(), + shadowBlock = + """ + |archiveClassifier = '' + """ + .trimMargin(), + publicationsBlock = + """ + |shadow(MavenPublication) { + | from components.shadow + |} + """ + .trimMargin(), + ) + ) + + publish() + + val artifactRoot = "my/maven/1.0" + assertThat(repoPath(artifactRoot).entries) + .containsOnly( + *withChecksums( + "maven-1.0.jar", + "maven-1.0.module", + "maven-1.0.pom", + "maven-1.0-sources.jar", + ) + ) + assertShadowJarCommon("$artifactRoot/maven-1.0.jar") + assertPomCommon("$artifactRoot/maven-1.0.pom") + repoGmm("$artifactRoot/maven-1.0.module").let { gmm -> + 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) + .containsOnly( + *withChecksums( + "maven-1.0-shaded.jar", + "maven-1.0-shaded-sources.jar", + "maven-1.0.module", + "maven-1.0.pom", + ) + ) + assertThat( + repoGmm("$artifactRoot/maven-1.0.module").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) + .containsOnly( + *withChecksums( + "maven-1.0.jar", + "maven-1.0-sources.jar", + "maven-1.0-all.jar", + "maven-1.0-all-sources.jar", + "maven-1.0.module", + "maven-1.0.pom", + ) + ) + } + + @Test + fun dontPublishSourcesWhenGenerateSourcesJarDisabled() { + projectScript.appendText( + publishConfiguration( + projectBlock = + """ + |java { + | withSourcesJar() + |} + |$jarTask { + | enabled = false + |} + """ + .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) + .containsOnly( + *withChecksums( + "maven-1.0.jar", + "maven-1.0.module", + "maven-1.0.pom", + ) + ) + assertShadowJarCommon("$artifactRoot/maven-1.0.jar") + assertPomCommon("$artifactRoot/maven-1.0.pom") + repoGmm("$artifactRoot/maven-1.0.module").let { gmm -> + assertShadowVariantCommon(gmm) + assertThat(gmm.variantNames).containsOnly(SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME) + } } @Test @@ -351,18 +528,20 @@ class PublishingTest : BasePluginTest() { publish() val artifactRoot = "my/plugin/my-gradle-plugin/1.0" - assertThat(repoPath(artifactRoot).entries.filter { it.endsWith(".jar") }) + assertThat(repoPath(artifactRoot).entries) .containsOnly( - "my-gradle-plugin-1.0.jar", - "my-gradle-plugin-1.0-javadoc.jar", - "my-gradle-plugin-1.0-sources.jar", + *withChecksums( + "my-gradle-plugin-1.0.jar", + "my-gradle-plugin-1.0-javadoc.jar", + "my-gradle-plugin-1.0-sources.jar", + "my-gradle-plugin-1.0.module", + "my-gradle-plugin-1.0.pom", + ) ) - assertShadowJarCommon(repoJarPath("$artifactRoot/my-gradle-plugin-1.0.jar")) - assertPomCommon(repoPath("$artifactRoot/my-gradle-plugin-1.0.pom")) - assertShadowVariantCommon( - gmmAdapter.fromJson(repoPath("$artifactRoot/my-gradle-plugin-1.0.module")) - ) + assertShadowJarCommon("$artifactRoot/my-gradle-plugin-1.0.jar") + assertPomCommon("$artifactRoot/my-gradle-plugin-1.0.pom") + assertShadowVariantCommon("$artifactRoot/my-gradle-plugin-1.0.module") } @Test // #614, #860, #945 @@ -398,26 +577,16 @@ class PublishingTest : BasePluginTest() { val artifactRoot = "my-group/my-artifact/2.0" assertThat(repoPath(artifactRoot).entries) .containsOnly( - "my-artifact-2.0-my-classifier.my-ext.sha512", - "my-artifact-2.0-my-classifier.my-ext", - "my-artifact-2.0.pom.sha256", - "my-artifact-2.0.module", - "my-artifact-2.0.pom", - "my-artifact-2.0.module.sha256", - "my-artifact-2.0.module.sha1", - "my-artifact-2.0.module.md5", - "my-artifact-2.0.pom.sha512", - "my-artifact-2.0-my-classifier.my-ext.sha256", - "my-artifact-2.0.module.sha512", - "my-artifact-2.0-my-classifier.my-ext.sha1", - "my-artifact-2.0-my-classifier.my-ext.md5", - "my-artifact-2.0.pom.md5", - "my-artifact-2.0.pom.sha1", - ) - - assertShadowJarCommon(repoJarPath("$artifactRoot/my-artifact-2.0-my-classifier.my-ext")) - assertPomCommon(repoPath("$artifactRoot/my-artifact-2.0.pom")) - assertShadowVariantCommon(gmmAdapter.fromJson(repoPath("$artifactRoot/my-artifact-2.0.module"))) + *withChecksums( + "my-artifact-2.0-my-classifier.my-ext", + "my-artifact-2.0.module", + "my-artifact-2.0.pom", + ) + ) + + assertShadowJarCommon("$artifactRoot/my-artifact-2.0-my-classifier.my-ext") + assertPomCommon("$artifactRoot/my-artifact-2.0.pom") + assertShadowVariantCommon("$artifactRoot/my-artifact-2.0.module") } @Test @@ -449,46 +618,20 @@ class PublishingTest : BasePluginTest() { assertThat(repoPath("my/maven/1.0").entries) .containsOnly( - // Entries of maven-1.0.jar - "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", - // Entries of maven-1.0-all.jar - "maven-1.0-all.jar", - "maven-1.0-all.jar.md5", - "maven-1.0-all.jar.sha1", - "maven-1.0-all.jar.sha256", - "maven-1.0-all.jar.sha512", + *withChecksums( + "maven-1.0.jar", + "maven-1.0.module", + "maven-1.0.pom", + "maven-1.0-all.jar", + ) ) assertThat(repoPath("my/maven-all/1.0").entries) .containsOnly( - "maven-all-1.0-all.jar", - "maven-all-1.0.module", - "maven-all-1.0.pom", - "maven-all-1.0-all.jar.md5", - "maven-all-1.0.module.md5", - "maven-all-1.0.pom.md5", - "maven-all-1.0-all.jar.sha1", - "maven-all-1.0.module.sha1", - "maven-all-1.0.pom.sha1", - "maven-all-1.0-all.jar.sha256", - "maven-all-1.0.module.sha256", - "maven-all-1.0.pom.sha256", - "maven-all-1.0-all.jar.sha512", - "maven-all-1.0.module.sha512", - "maven-all-1.0.pom.sha512", + *withChecksums( + "maven-all-1.0-all.jar", + "maven-all-1.0.module", + "maven-all-1.0.pom", + ) ) assertThat(repoJarPath("my/maven/1.0/maven-1.0.jar")).useAll { @@ -498,8 +641,8 @@ class PublishingTest : BasePluginTest() { containsOnly(*entriesInAB, "META-INF/", "META-INF/MANIFEST.MF") } - assertPomCommon(repoPath("my/maven/1.0/maven-1.0.pom"), arrayOf("my:a:1.0", "my:b:1.0")) - gmmAdapter.fromJson(repoPath("my/maven/1.0/maven-1.0.module")).let { gmm -> + assertPomCommon("my/maven/1.0/maven-1.0.pom", arrayOf("my:a:1.0", "my:b:1.0")) + repoGmm("my/maven/1.0/maven-1.0.module").let { gmm -> // apiElements, runtimeElements, shadowRuntimeElements assertThat(gmm.variantNames) .containsOnly( @@ -507,29 +650,12 @@ class PublishingTest : BasePluginTest() { RUNTIME_ELEMENTS_CONFIGURATION_NAME, SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME, ) - assertThat(gmm.apiElementsVariant).all { - transform { it.attributes } - .containsOnly( - *commonVariantAttrs, - Bundling.BUNDLING_ATTRIBUTE.name to Bundling.EXTERNAL, - Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_API, - ) - transform { it.coordinates }.isEmpty() - } - assertThat(gmm.runtimeElementsVariant).all { - transform { it.attributes } - .containsOnly( - *commonVariantAttrs, - Bundling.BUNDLING_ATTRIBUTE.name to Bundling.EXTERNAL, - Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_RUNTIME, - ) - transform { it.coordinates }.containsOnly("my:a:1.0", "my:b:1.0") - } + assertJavaVariantsCommon(gmm, arrayOf("my:a:1.0", "my:b:1.0")) assertShadowVariantCommon(gmm) } - assertPomCommon(repoPath("my/maven-all/1.0/maven-all-1.0.pom")) - gmmAdapter.fromJson(repoPath("my/maven-all/1.0/maven-all-1.0.module")).let { gmm -> + assertPomCommon("my/maven-all/1.0/maven-all-1.0.pom") + repoGmm("my/maven-all/1.0/maven-all-1.0.module").let { gmm -> assertThat(gmm.variantNames).containsOnly(SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME) assertShadowVariantCommon(gmm) } @@ -571,59 +697,23 @@ class PublishingTest : BasePluginTest() { "Skipping adding shadowRuntimeElements variant to java component." } ) - val assertVariantsCommon = { gmm: GradleModuleMetadata -> - assertThat(gmm.apiElementsVariant).all { - transform { it.attributes } - .containsOnly( - *commonVariantAttrs, - Bundling.BUNDLING_ATTRIBUTE.name to Bundling.EXTERNAL, - Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_API, - ) - transform { it.coordinates }.isEmpty() - } - assertThat(gmm.runtimeElementsVariant).all { - transform { it.attributes } - .containsOnly( - *commonVariantAttrs, - Bundling.BUNDLING_ATTRIBUTE.name to Bundling.EXTERNAL, - Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_RUNTIME, - ) - transform { it.coordinates }.containsOnly("my:a:1.0") - } - } val entriesCommon = - arrayOf( + withChecksums( "maven-1.0.jar", - "maven-1.0.jar.md5", - "maven-1.0.jar.sha1", - "maven-1.0.jar.sha256", - "maven-1.0.jar.sha512", "maven-1.0.module", - "maven-1.0.module.md5", - "maven-1.0.module.sha1", - "maven-1.0.module.sha256", - "maven-1.0.module.sha512", "maven-1.0.pom", - "maven-1.0.pom.md5", - "maven-1.0.pom.sha1", - "maven-1.0.pom.sha256", - "maven-1.0.pom.sha512", ) val artifactEntries = repoPath("my/maven/1.0/").entries - val gmm = gmmAdapter.fromJson(repoPath("my/maven/1.0/maven-1.0.module")) + val gmm = repoGmm("my/maven/1.0/maven-1.0.module") val pomDependencies = - pomReader.read(repoPath("my/maven/1.0/maven-1.0.pom")).dependencies.map { + repoPom("my/maven/1.0/maven-1.0.pom").dependencies.map { it.coordinate to it.scope } if (addShadowVariant) { assertThat(artifactEntries) .containsOnly( - "maven-1.0-all.jar", - "maven-1.0-all.jar.md5", - "maven-1.0-all.jar.sha1", - "maven-1.0-all.jar.sha256", - "maven-1.0-all.jar.sha512", + *withChecksums("maven-1.0-all.jar"), *entriesCommon, ) assertThat(gmm.variantNames) @@ -632,18 +722,115 @@ class PublishingTest : BasePluginTest() { RUNTIME_ELEMENTS_CONFIGURATION_NAME, SHADOW_RUNTIME_ELEMENTS_CONFIGURATION_NAME, ) - assertVariantsCommon(gmm) + assertJavaVariantsCommon(gmm) assertShadowVariantCommon(gmm) assertThat(pomDependencies).containsOnly("my:a:1.0" to "runtime", "my:b:1.0" to "compile") } else { assertThat(artifactEntries).containsOnly(*entriesCommon) assertThat(gmm.variantNames) .containsOnly(API_ELEMENTS_CONFIGURATION_NAME, RUNTIME_ELEMENTS_CONFIGURATION_NAME) - assertVariantsCommon(gmm) + assertJavaVariantsCommon(gmm) assertThat(pomDependencies).containsOnly("my:a:1.0" to "runtime") } } + @Test + fun publishKmpWithShadowedSources() { + val stdlib = compileOnlyStdlib(true) + 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' + | $stdlib + | } + | } + | jvmMain { + | dependencies { + | implementation 'my:h:1.0' + | } + | } + | } + |} + |$shadowJarTask { + | archiveClassifier = '' + |} + |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) + .containsOnly( + *withChecksums( + "my-all-1.0.jar", + "my-all-1.0-sources.jar", + "my-all-1.0.pom", + ) + ) + + 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("$artifactRoot/my-all-1.0.pom", emptyArray()) + } + private fun repoPath(relative: String): Path { return remoteRepoPath.resolve(relative).also { check(it.exists()) { "Path not found: $it" } } } @@ -652,7 +839,16 @@ class PublishingTest : BasePluginTest() { return JarPath(remoteRepoPath.resolve(relative)) } - private fun publish(vararg arguments: String): BuildResult = runWithSuccess("publish", *arguments) + private fun repoGmm(relative: String): GradleModuleMetadata { + return gmmAdapter.fromJson(repoPath(relative)) + } + + private fun repoPom(relative: String): Model { + return pomReader.read(repoPath(relative)) + } + + private fun publish(vararg arguments: String): BuildResult = + runWithSuccess("build", "publish", *arguments) private fun publishConfiguration( projectBlock: String = "", @@ -676,10 +872,11 @@ class PublishingTest : BasePluginTest() { |dependencies { | $dependenciesBlock |} + |${publishingBlock(projectBlock = projectBlock, publicationsBlock = publicationsBlock)} + |// Place shadow jar block after publishing block to cover more lazy cases. |$shadowJarTask { | $shadowBlock |} - |${publishingBlock(projectBlock = projectBlock, publicationsBlock = publicationsBlock)} | """ .trimMargin() @@ -701,31 +898,64 @@ class PublishingTest : BasePluginTest() { .trimMargin() } - private fun assertPomCommon(pomPath: Path, coordinates: Array = arrayOf("my:b:1.0")) { - assertThat(pomReader.read(pomPath)).all { + private fun assertPomCommon(relative: String, coordinates: Array = arrayOf("my:b:1.0")) { + assertThat(repoPom(relative)).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") + } } } - private fun assertShadowVariantCommon( + private fun assertJavaVariantsCommon( gmm: GradleModuleMetadata, + runtimeCoordinates: Array = arrayOf("my:a:1.0"), + ) { + assertThat(gmm.apiElementsVariant).all { + transform { it.attributes }.containsOnly(*apiVariantAttrs) + transform { it.coordinates }.isEmpty() + } + assertThat(gmm.runtimeElementsVariant).all { + transform { it.attributes }.containsOnly(*runtimeVariantAttrs) + transform { it.coordinates }.containsOnly(*runtimeCoordinates) + } + } + + private fun assertShadowVariantCommon( + gmm: Any, variantAttrs: Array> = shadowVariantAttrs, coordinates: Array = arrayOf("my:b:1.0"), body: Assert.() -> Unit = {}, ) { - assertThat(gmm.shadowRuntimeElementsVariant).all { + val realGmm = + when (gmm) { + is String -> repoGmm(gmm) + is GradleModuleMetadata -> gmm + else -> error("Unsupported type $gmm") + } + assertThat(realGmm.shadowRuntimeElementsVariant).all { transform { it.attributes }.containsOnly(*variantAttrs) transform { it.coordinates }.containsOnly(*coordinates) body() } } - private fun assertShadowJarCommon(jarPath: JarPath) { - assertThat(jarPath).useAll { + private fun assertShadowSourcesVariantCommon( + gmm: GradleModuleMetadata, + variantAttrs: Array> = shadowSourcesVariantAttrs, + body: Assert.() -> Unit = {}, + ) { + assertThat(gmm.shadowSourcesElementsVariant).all { + transform { it.attributes }.containsOnly(*variantAttrs) + body() + } + } + + private fun assertShadowJarCommon(relative: String) { + assertThat(repoJarPath(relative)).useAll { containsAtLeast(*entriesInA) containsNone(*entriesInB) getMainAttr(classPathAttributeKey).isEqualTo("b-1.0.jar") @@ -747,6 +977,20 @@ class PublishingTest : BasePluginTest() { TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name to JavaVersion.current().majorVersion, ) + val apiVariantAttrs = + commonVariantAttrs + + arrayOf( + Bundling.BUNDLING_ATTRIBUTE.name to Bundling.EXTERNAL, + Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_API, + ) + + val runtimeVariantAttrs = + commonVariantAttrs + + arrayOf( + Bundling.BUNDLING_ATTRIBUTE.name to Bundling.EXTERNAL, + Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_RUNTIME, + ) + val shadowVariantAttrs = commonVariantAttrs + arrayOf( @@ -754,11 +998,29 @@ class PublishingTest : BasePluginTest() { Usage.USAGE_ATTRIBUTE.name to Usage.JAVA_RUNTIME, ) + val shadowVariantAttrsWithoutTargetJvm = + shadowVariantAttrs + .filterNot { (name, _) -> name == TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE.name } + .toTypedArray() + + 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())) val Path.entries: List get() = listDirectoryEntries().map { it.name } + + fun withChecksums(vararg baseNames: String): Array = + baseNames + .flatMap { listOf(it, "$it.md5", "$it.sha1", "$it.sha256", "$it.sha512") } + .toTypedArray() } } 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..0520e7766 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 @@ -10,6 +10,7 @@ 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 @@ -738,6 +739,66 @@ class RelocationTest : BasePluginTest() { } } + @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() + ) + } + } + 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..bc66f8fd0 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 @@ -5,16 +5,21 @@ import com.github.jengelman.gradle.plugins.shadow.ShadowBasePlugin.Companion.sha import com.github.jengelman.gradle.plugins.shadow.internal.javaPluginExtension import com.github.jengelman.gradle.plugins.shadow.internal.runtimeConfiguration import com.github.jengelman.gradle.plugins.shadow.internal.sourceSets +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar.Companion.registerShadowJarCommon import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar.Companion.shadowJar +import java.io.File +import java.util.Date import javax.inject.Inject import org.gradle.api.NamedDomainObjectProvider import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.ConfigurationContainer import org.gradle.api.artifacts.ConsumableConfiguration +import org.gradle.api.artifacts.PublishArtifact 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 +28,9 @@ 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.TaskDependency +import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.bundling.Jar public abstract class ShadowJavaPlugin @@ -38,9 +46,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 +76,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 +87,30 @@ 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 + val artifact = ShadowSourcesPublishArtifact(shadowJarTask) + outgoing.artifacts( + shadowJarTask.flatMap { task -> + task.generateSourcesJar.map { generate -> + if (generate) listOf(artifact) else emptyList() + } + } + ) + } // See more details in #2086. afterEvaluate { @@ -112,20 +147,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 +189,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 +203,64 @@ 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) + } +} + +// TODO: https://github.com/gradle/gradle/issues/31474 +private class ShadowSourcesPublishArtifact(private val shadowJarTask: TaskProvider) : + PublishArtifact { + override fun getName(): String = shadowJarTask.flatMap { it.archiveBaseName }.orNull.orEmpty() + + override fun getExtension(): String = + shadowJarTask.flatMap { it.archiveExtension }.orNull ?: "jar" + + override fun getType(): String = "jar" + + override fun getClassifier(): String { + val shadowClassifier = shadowJarTask.flatMap { it.archiveClassifier }.orNull + return if (shadowClassifier.isNullOrEmpty()) "sources" else "$shadowClassifier-sources" + } + + override fun getFile(): File = shadowJarTask.flatMap { it.archiveSourcesFile }.get().asFile + + override fun getDate(): Date? = null + + override fun getBuildDependencies(): TaskDependency = TaskDependency { + setOf(@Suppress("EagerGradleConfiguration") shadowJarTask.get()) } } 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..45adcc4dc 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,19 @@ 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.generateSourcesJar.convention(provider { target.isSourcesPublishable }) + 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..4e516bfbc 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 @@ -1,11 +1,22 @@ package com.github.jengelman.gradle.plugins.shadow.internal import com.github.jengelman.gradle.plugins.shadow.tasks.DependencyFilter +import javax.inject.Inject import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration import org.gradle.api.artifacts.ResolvedDependency +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.api.model.ObjectFactory -internal class DefaultDependencyFilter(project: Project) : - DependencyFilter.AbstractDependencyFilter(project) { +internal abstract class DefaultDependencyFilter +@Inject +constructor( + project: Project, + private val objectFactory: ObjectFactory, +) : DependencyFilter.AbstractDependencyFilter(project) { override fun resolve( dependencies: Set, includedDependencies: MutableSet, @@ -19,4 +30,52 @@ internal class DefaultDependencyFilter(project: Project) : } } } + + fun resolveSourcesJars(configurations: Collection): FileCollection { + return configurations + .map { resolveSourcesJars(it) } + .reduceOrNull { acc, fileCollection -> acc + fileCollection } + ?: objectFactory.fileCollection() + } + + private fun resolveSourcesJars(configuration: Configuration): FileCollection { + val includes = mutableSetOf() + val excludes = mutableSetOf() + resolve( + dependencies = configuration.resolvedConfiguration.firstLevelModuleDependencies, + includedDependencies = includes, + excludedDependencies = excludes, + ) + + val includedComponentIds = + 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 + } + } + .map { it.selected.id } + .toSet() + + return configuration.incoming + .artifactView { view -> + view.withVariantReselection() + view.attributes { attrs -> + attrs.attribute( + Category.CATEGORY_ATTRIBUTE, + objectFactory.named(Category::class.java, Category.DOCUMENTATION), + ) + attrs.attribute( + DocsType.DOCS_TYPE_ATTRIBUTE, + objectFactory.named(DocsType::class.java, DocsType.SOURCES), + ) + } + view.componentFilter { id -> id in includedComponentIds } + view.lenient(true) + } + .files + } } diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/ShadowedSourcesJar.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/ShadowedSourcesJar.kt new file mode 100644 index 000000000..595cd23eb --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/ShadowedSourcesJar.kt @@ -0,0 +1,206 @@ +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.io.InputStream +import org.apache.tools.zip.ZipOutputStream +import org.gradle.api.file.FileCollection +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, + zipOutStream: ZipOutputStream, + sourceSetsSourceDirs: FileCollection, + includedSourcesJars: Iterable, + classesDirs: Iterable, + dependencies: Iterable, + relocators: Iterable, + unusedClasses: Set, + preserveFileTimestamps: Boolean, +) = + try { + zipOutStream.use { zos -> + val sourcesJars = includedSourcesJars.filter { it.exists() && it.isFile }.sortedBy { it.path } + + val visitedFiles = mutableSetOf() + val sourceToClasses = + if (unusedClasses.isNotEmpty()) { + buildSourceToClassesMap(classesDirs = classesDirs, dependencies = dependencies) + } else { + emptyMap() + } + + 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()) + } + + val filesWithRelPaths = mutableListOf>() + sourceSetsSourceDirs.asFileTree.visit { details -> + if (!details.isDirectory) { + filesWithRelPaths.add(details.file to details.relativePath.pathString) + } + } + + // Relocates and writes a project or dependency source file, or a resource. + fun addEntry(name: String, lastModified: Long, readBytes: () -> ByteArray) { + if (name.isModuleInfo) return + val isSource = name.isSourceFile() + if (isSource && isUnused(name, unusedClasses, sourceToClasses)) return + val relocatedPath = + if (isSource) relocators.relocateSourcePath(name) else relocators.relocatePath(name) + if (!visitedFiles.add(relocatedPath)) return + val bytes = + if (isSource) { + relocators + .remapSourceFile(readBytes().decodeToString(), name, relocatedPath) + .toByteArray() + } else { + readBytes() + } + zos.writeEntry( + name = relocatedPath, + preserveLastModified = preserveFileTimestamps, + lastModified = lastModified, + unixMode = UnixMode.file(), + ) { + write(bytes) + } + } + + filesWithRelPaths + .sortedBy { it.second } + .forEach { (file, relPath) -> + addEntry(relPath, file.lastModified()) { file.readBytes() } + } + + sourcesJars.forEach { jarFile -> + jarFile.useZip { + entries() + .toList() + .filterNot { entry -> + val name = entry.name + entry.isDirectory || + 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"))) + } + .sortedBy { it.name } + .forEach { entry -> + addEntry(entry.name, entry.time) { getInputStream(entry).use { it.readBytes() } } + } + } + } + + 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) + } + +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 } +} + +private fun buildSourceToClassesMap( + classesDirs: Iterable, + dependencies: Iterable, +): Map> { + val sourceToClasses = mutableMapOf>() + + fun InputStream.recordSourceMapping() = use { + try { + var internalName: String? = null + var sourceFile: String? = null + ClassReader(this) + .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@use + val source = sourceFile ?: return@use + 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. + } + } + + classesDirs + .filter(File::isDirectory) + .flatMap(File::walk) + .filter { it.isFile && it.name.endsWith(".class") } + .forEach { file -> file.inputStream().recordSourceMapping() } + + dependencies + .filter(File::isFile) + .filter { + it.extension.equals("jar", ignoreCase = true) || it.extension.equals("zip", ignoreCase = true) + } + .forEach { file -> + try { + file.useZip { + entries() + .toList() + .filter { it.name.endsWith(".class") } + .forEach { entry -> getInputStream(entry).recordSourceMapping() } + } + } catch (_: Exception) { + // Ignore invalid archives. + } + } + + return sourceToClasses +} + +// Aligned with the default exclusion of `module-info.class` from the shadowed JAR. +private val String.isModuleInfo: Boolean + get() = substringAfterLast('/') == "module-info.java" 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..be0a792ac --- /dev/null +++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapper.kt @@ -0,0 +1,70 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import com.github.jengelman.gradle.plugins.shadow.relocation.RelocateClassContext +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.relocatePath + +internal fun Iterable.remapSource(sourceContent: String): String { + var content = sourceContent + for (relocator in this) { + content = relocator.applyToSourceContent(content) + } + return content +} + +/** + * Remaps the content of the source file at [path], which is relocated to [relocatedPath]. + * + * If the file is laid out in its package directory, its `package` declaration is set to match + * [relocatedPath], which is decided per class like class files, as the declaration alone can't + * match class-level includes and excludes. + */ +internal fun Iterable.remapSourceFile( + sourceContent: String, + path: String, + relocatedPath: String, +): String { + val content = remapSource(sourceContent) + val originalPackage = packageRegex.find(sourceContent)?.groupValues?.get(1) ?: return content + val relocatedPackage = relocatedPath.packageOfPath + if (originalPackage != path.packageOfPath || relocatedPackage.isEmpty()) return content + val packageRange = packageRegex.find(content)?.groups?.get(1)?.range ?: return content + return content.replaceRange(packageRange, relocatedPackage) +} + +/** + * 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 (path.isSourceFile()) { + val extension = path.substringAfterLast('.', "") + val pathWithoutExt = path.removeSuffix(".$extension") + val className = pathWithoutExt.replace('/', '.') + + for (relocator in this) { + val relocatedWithoutExt = + when { + relocator.canRelocatePath(pathWithoutExt) -> + relocator.relocatePath(RelocatePathContext(pathWithoutExt)) + relocator.canRelocateClass(className) -> + relocator.relocateClass(RelocateClassContext(className)).replace('.', '/') + else -> continue + } + return "$relocatedWithoutExt.$extension" + } + return path + } + + return relocatePath(path) +} + +internal fun String.isSourceFile(): Boolean { + return endsWith(".java") || endsWith(".kt") || endsWith(".groovy") || endsWith(".scala") +} + +private val packageRegex = Regex("""^\s*package\s+([\w.]+)""", RegexOption.MULTILINE) + +private val String.packageOfPath: String + get() = substringBeforeLast('/', "").replace('/', '.') 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..5dee1b994 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 @@ -1,7 +1,6 @@ package com.github.jengelman.gradle.plugins.shadow.relocation import java.util.Objects -import java.util.regex.Pattern import org.codehaus.plexus.util.SelectorUtils import org.gradle.api.tasks.Input @@ -28,8 +27,6 @@ constructor( @get:Input internal val pathPattern: String @get:Input internal val shadedPattern: String @get:Input internal val shadedPathPattern: String - private val sourcePackageExcludes = mutableSetOf() - private val sourcePathExcludes = mutableSetOf() @get:Input public val includes: MutableSet = mutableSetOf() @get:Input public val excludes: MutableSet = mutableSetOf() @@ -66,24 +63,6 @@ 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) { @@ -95,7 +74,7 @@ constructor( } override fun canRelocatePath(path: String): Boolean { - if (rawString) return Pattern.compile(pathPattern).matcher(path).find() + if (rawString) return pathPattern.toPattern().matcher(path).find() // If string is too short - no need to perform expensive string operations. if (path.length < pathPattern.length) return false var adjustedPath = path.removeSuffix(".class") @@ -127,15 +106,31 @@ 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 + // Fast path to skip building subpatterns and regexes for unrelated sources. + if (pattern !in sourceContent && pathPattern !in sourceContent) return sourceContent + val sourceIncludes = extractSourceSubpatterns(includes, pattern) + val sourceExcludes = extractSourceSubpatterns(excludes, pattern) + // Relocate package and class references in dot notation (e.g. "org.foo.Bar"). val content = - shadeSourceWithExcludes(sourceContent, pattern, shadedPattern, sourcePackageExcludes) - return shadeSourceWithExcludes(content, pathPattern, shadedPathPattern, sourcePathExcludes) + relocateSourcePattern( + sourceContent = sourceContent, + patternFrom = pattern, + patternTo = shadedPattern, + hasIncludes = includes.isNotEmpty(), + includedPatterns = sourceIncludes, + excludedPatterns = sourceExcludes, + ) + // Relocate resource and classpath references in slash notation (e.g. "org/foo/Bar"). + return relocateSourcePattern( + sourceContent = content, + patternFrom = pathPattern, + patternTo = shadedPathPattern, + hasIncludes = includes.isNotEmpty(), + includedPatterns = sourceIncludes, + excludedPatterns = sourceExcludes, + ) } override fun equals(other: Any?): Boolean { @@ -147,8 +142,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 +154,6 @@ constructor( pathPattern, shadedPattern, shadedPathPattern, - sourcePackageExcludes, - sourcePathExcludes, includes, excludes, ) @@ -175,8 +166,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(")") @@ -191,27 +180,6 @@ constructor( } private companion object { - /** Match dot, slash or space at end of string */ - val RX_ENDS_WITH_DOT_SLASH_SPACE: Pattern = Pattern.compile("[./ ]$") - - /** - * Match - * - certain Java keywords + space - * - beginning of Javadoc link + optional line breaks and continuations with '*' - * - (opening curly brace / opening parenthesis / comma / equals / semicolon) + space - * - (closing curly brace / closing multi-line comment) + space - * - * at end of string - */ - val RX_ENDS_WITH_JAVA_KEYWORD: Pattern = - Pattern.compile( - "\\b(import|package|public|protected|private|static|final|synchronized|abstract|volatile|extends|implements|throws) $" + - "|" + - "\\{@link( \\*)* $" + - "|" + - "([{}(=;,]|\\*/) $" - ) - fun normalizePatterns(patterns: Collection?) = buildSet { patterns ?: return@buildSet for (pattern in patterns) { @@ -243,43 +211,139 @@ constructor( } } - fun shadeSourceWithExcludes( + fun relocateSourcePattern( sourceContent: String, patternFrom: String, patternTo: String, + hasIncludes: Boolean, + includedPatterns: Set, excludedPatterns: Set, ): String { - // Usually shading makes package names a bit longer, so make buffer 10% bigger than original - // source. - 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 + if (hasIncludes && includedPatterns.isEmpty()) return sourceContent + + val regex = Regex("\\b" + Regex.escape(patternFrom) + "\\b") + val matches = regex.findAll(sourceContent).toList() + if (matches.isEmpty()) return sourceContent + + val result = StringBuilder((sourceContent.length * 1.1).toInt()) + var lastIndex = 0 + + for (match in matches) { + val matchStart = match.range.first + val matchEnd = match.range.last + 1 + + result.append(sourceContent, lastIndex, matchStart) + lastIndex = matchEnd + + val isIncluded = + !hasIncludes || includedPatterns.any { matchesSubpattern(sourceContent, matchEnd, it) } + val isExcluded = excludedPatterns.any { matchesSubpattern(sourceContent, matchEnd, it) } + val contextValid = isValidSourceContext(patternFrom, sourceContent, matchStart, matchEnd) + + if (isIncluded && !isExcluded && contextValid) { + result.append(patternTo) + } else { + result.append(patternFrom) + } + } + + result.append(sourceContent, lastIndex, sourceContent.length) + return result.toString() + } + + /** + * Extracts the parts of [patterns] after [patternPrefix] in both dot and slash notations, as + * source contents may reference classes (e.g. "org.foo.Bar") or paths (e.g. "org/foo/Bar"). + */ + fun extractSourceSubpatterns(patterns: Set, patternPrefix: String): Set { + if (patternPrefix.isEmpty()) return emptySet() + val result = mutableSetOf() + val dotPrefix = patternPrefix.replace('/', '.') + val trailingWildcardRegex = "[./][*]+$".toRegex() + + for (pat in patterns) { + val dotPat = pat.replace('/', '.') + if (!dotPat.startsWith(dotPrefix)) continue + val sub = dotPat.substring(dotPrefix.length).replaceFirst(trailingWildcardRegex, "") + result.add(sub) + result.add(sub.replace('.', '/')) + } + return result + } + + fun matchesSubpattern(content: CharSequence, offset: Int, subpattern: String): Boolean { + val subLen = subpattern.length + if (offset + subLen > content.length) return false + for (i in 0 until subLen) { + if (content[offset + i] != subpattern[i]) return false + } + if (subLen == 0 || offset + subLen == content.length) return true + if (subpattern.endsWith('.') || subpattern.endsWith('/')) return true + val nextChar = content[offset + subLen] + return !nextChar.isLetterOrDigit() && nextChar != '_' + } + + fun isValidSourceContext( + pattern: String, + sourceContent: CharSequence, + matchStart: Int, + matchEnd: Int, + ): Boolean { + var prevIndex = matchStart - 1 + while (prevIndex >= 0 && sourceContent[prevIndex].isWhitespace()) { + prevIndex-- + } + + if (prevIndex >= 0) { + val prevChar = sourceContent[prevIndex] + if (prevChar == '.') { + val beforeDot = if (prevIndex > 0) sourceContent[prevIndex - 1] else null + // A dot is only a package separator if it's not a Kotlin range '..' or varargs/spread + // '...' + if (beforeDot != '.') return false + } + if (prevChar == '/') { + val beforeSlash = if (prevIndex > 0) sourceContent[prevIndex - 1] else null + // Only reject if pattern does not contain '.' and the slash is an actual path delimiter + // (not a leading slash in a string literal like `getResource("/org/foo/x")`, which is + // relocated in class files as well, nor closing a block comment '*/' or a single-line + // comment '//') + val isLeadingSlash = beforeSlash == null || beforeSlash == '"' || beforeSlash == '\'' + if ( + !pattern.contains('.') && !isLeadingSlash && beforeSlash != '*' && beforeSlash != '/' + ) { + return false } } - if (isFirstSnippet) { - shadedSourceContent.append(snippet) - } else { - 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 shouldExclude = doExclude || afterDotSlashSpace && !afterJavaKeyWord - shadedSourceContent.append(if (shouldExclude) patternFrom else patternTo).append(snippet) + } + + // In all JVM languages, qualified names containing '.' or '/' cannot be local identifiers. + if (pattern.contains('.') || pattern.contains('/')) return true + + // For unqualified single-word patterns (e.g. "io", "foo"), check if followed by '.' or '/' + var nextIndex = matchEnd + while (nextIndex < sourceContent.length && sourceContent[nextIndex].isWhitespace()) { + nextIndex++ + } + if (nextIndex < sourceContent.length) { + val nextChar = sourceContent[nextIndex] + if (nextChar == '.' || nextChar == '/') return true + } + + // Check if preceded by 'package', 'import', or '{@link' + if (prevIndex >= 0) { + var tokenStart = prevIndex + while (tokenStart > 0 && sourceContent[tokenStart - 1].isJavaIdentifierPart()) { + tokenStart-- } + val prevToken = sourceContent.subSequence(tokenStart, prevIndex + 1).toString() + if (prevToken == "package" || prevToken == "import") return true + val lookbackStart = (matchStart - 32).coerceAtLeast(0) + val lookbackSnippet = sourceContent.substring(lookbackStart, matchStart) + if (lookbackSnippet.contains("{@link")) return true } - return shadedSourceContent.toString() + + return false } } } 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..71b7d0b48 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 @@ -190,7 +193,7 @@ public abstract class ShadowJar : Jar() { @get:Internal // The resolved result is tracked by includedDependencies. public open val dependencyFilter: Property = - objectFactory.property(DefaultDependencyFilter(project)) + objectFactory.property(objectFactory.newInstance(DefaultDependencyFilter::class.java)) /** Final dependencies to be shadowed. */ @get:Classpath @@ -198,6 +201,88 @@ 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. In projects applying the `shadow` + * plugin for Kotlin Multiplatform, this convention defaults to `true` when sources publishing is + * enabled on the JVM target, 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.` when + * [generateSourcesJar] is enabled, and has no value otherwise. + */ + @get:Optional + @get:OutputFile + public open val archiveSourcesFile: RegularFileProperty = + objectFactory + .fileProperty() + .convention( + generateSourcesJar.flatMap { generate -> + if (generate) { + destinationDirectory.file( + archiveFileName.map { name -> + val idx = name.lastIndexOf('.') + if (idx != -1) { + "${name.substring(0, idx)}-sources${name.substring(idx)}" + } else { + "$name-sources" + } + } + ) + } else { + // Leave it absent so it isn't registered as a task output when sources JAR generation + // is disabled, keeping `outputs.files` to the shadowed JAR only. + // TODO: replace with `providers.absent()` once the min Gradle version is 9.9. + project.provider { null } + } + } + ) + /** * Enables auto relocation of packages in the dependencies. * @@ -540,25 +625,12 @@ public abstract class ShadowJar : Jar() { addIncludedDependencies() injectManifestAttributes() super.copy() + generateShadowedSourcesJar() runR8Minimization() } @Suppress("InternalGradleApiUsage") // For creating ShadowCopyAction. override fun createCopyAction(): org.gradle.api.internal.file.copy.CopyAction { - val unusedClasses = - if (_minimizeJar.get() && minimizeSpec.tool.get() == MinimizeTool.DEPENDENCY_ANALYZER) { - findUnusedClasses( - sourceSetsClassesDirs = sourceSetsClassesDirs, - classJars = apiJars, - toMinimize = toMinimize, - dependencies = includedDependencies, - ) - } else { - emptySet() - } - if (unusedClasses.isNotEmpty()) { - logger.info("Found {} unused classes to drop for minimization.", unusedClasses.size) - } val actualTransformers = transformers.get().let { set -> if ( @@ -631,6 +703,23 @@ public abstract class ShadowJar : Jar() { } } + // We can't cache these classes due to CC serialization. + private val unusedClasses: Set + get() { + return if ( + _minimizeJar.get() && minimizeSpec.tool.get() == MinimizeTool.DEPENDENCY_ANALYZER + ) { + findUnusedClasses( + sourceSetsClassesDirs = sourceSetsClassesDirs, + classJars = apiJars, + toMinimize = toMinimize, + dependencies = includedDependencies, + ) + } else { + emptySet() + } + } + private fun addIncludedDependencies() { val isAar: File.() -> Boolean = { try { @@ -742,6 +831,27 @@ public abstract class ShadowJar : Jar() { ) } + private fun generateShadowedSourcesJar() { + if (!generateSourcesJar.get() || !archiveSourcesFile.isPresent) return + val sourcesJarFile = archiveSourcesFile.get().asFile.apply { parentFile?.mkdirs() } + generateSourcesJar( + sourcesJarFile = sourcesJarFile, + zipOutStream = + sourcesJarFile.createZipOutputStream( + entryCompression = entryCompression, + isZip64 = isZip64, + encoding = metadataCharset, + ), + sourceSetsSourceDirs = sourceSetsSourceDirs, + includedSourcesJars = includedSourcesJars.files, + classesDirs = sourceSetsClassesDirs.files, + dependencies = includedDependencies.files, + relocators = relocators.get() + packageRelocators, + unusedClasses = unusedClasses, + 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..06688cac3 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 @@ -15,6 +16,7 @@ import com.github.jengelman.gradle.plugins.shadow.ShadowApplicationPlugin.Compan import com.github.jengelman.gradle.plugins.shadow.ShadowApplicationPlugin.Companion.shadowDistZip import com.github.jengelman.gradle.plugins.shadow.ShadowApplicationPlugin.Companion.startShadowScripts import com.github.jengelman.gradle.plugins.shadow.ShadowBasePlugin.Companion.shadow +import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin.Companion.shadowSourcesElements import com.github.jengelman.gradle.plugins.shadow.internal.applicationExtension import com.github.jengelman.gradle.plugins.shadow.internal.javaPluginExtension import com.github.jengelman.gradle.plugins.shadow.internal.javaToolchainService @@ -35,7 +37,6 @@ import org.gradle.api.plugins.JavaPlugin.COMPILE_ONLY_API_CONFIGURATION_NAME import org.gradle.api.plugins.JavaPlugin.COMPILE_ONLY_CONFIGURATION_NAME import org.gradle.api.tasks.TaskContainer import org.gradle.api.tasks.TaskProvider -import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.Jar import org.gradle.language.base.plugins.LifecycleBasePlugin import org.gradle.language.base.plugins.LifecycleBasePlugin.ASSEMBLE_TASK_NAME @@ -162,7 +163,75 @@ class ShadowPropertiesTest { assertThat(relocationPrefix.get()).isEqualTo(ShadowBasePlugin.SHADOW) assertThat(configurations.get()).containsOnly(runtimeConfiguration) + assertThat(generateSourcesJar.get()).isFalse() + assertThat(archiveSourcesFile.orNull).isNull() + assertThat(outputs.files.singleFile).isEqualTo(archiveFile.get().asFile) + assertThat(sourceSetsSourceDirs.files).isEmpty() + assertThat(includedSourcesJars.files).isEmpty() } + + assertThat(configurations.shadowSourcesElements.get().artifacts).isEmpty() + } + + @Test + fun applyJavaPluginWithSourcesJar() = + with(project) { + plugins.apply(JavaPlugin::class.java) + javaPluginExtension.withSourcesJar() + val shadowJarTask = tasks.shadowJar.get() + with(shadowJarTask) { + assertThat(generateSourcesJar.get()).isTrue() + 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(outputs.files.files) + .containsOnly( + archiveFile.get().asFile, + archiveSourcesFile.get().asFile, + ) + val mainSourceSet = javaPluginExtension.sourceSets.getByName("main") + assertThat(sourceSetsSourceDirs.files) + .containsOnly(*mainSourceSet.allSource.files.toTypedArray()) + } + + with(project.configurations.shadowSourcesElements.get().artifacts.single()) { + assertThat(classifier).isEqualTo("all-sources") + assertThat(name).isEqualTo("my-project") + assertThat(extension).isEqualTo("jar") + assertThat(type).isEqualTo("jar") + assertThat(file).isEqualTo(shadowJarTask.archiveSourcesFile.get().asFile) + assertThat(date).isNull() + assertThat(buildDependencies.getDependencies(null)).containsOnly(shadowJarTask) + + // Test dynamic updates on ShadowSourcesPublishArtifact + shadowJarTask.archiveClassifier.set("custom") + assertThat(classifier).isEqualTo("custom-sources") + + shadowJarTask.archiveClassifier.set("") + assertThat(classifier).isEqualTo("sources") + + shadowJarTask.archiveBaseName.set("renamed") + shadowJarTask.archiveExtension.set("zip") + assertThat(name).isEqualTo("renamed") + assertThat(extension).isEqualTo("zip") + } + } + + @Test + fun shadowSourcesElementsArtifactTogglesWithGenerateSourcesJar() = + with(project) { + plugins.apply(JavaPlugin::class.java) + val shadowJarTask = tasks.shadowJar.get() + val shadowSourcesElements = configurations.shadowSourcesElements.get() + + assertThat(shadowSourcesElements.artifacts).isEmpty() + + shadowJarTask.generateSourcesJar.set(true) + assertThat(shadowSourcesElements.artifacts.size).isEqualTo(1) + + shadowJarTask.generateSourcesJar.set(false) + assertThat(shadowSourcesElements.artifacts).isEmpty() } @Test @@ -226,7 +295,7 @@ class ShadowPropertiesTest { } listOf(shadowDistZip, shadowDistTar).forEach { - with(it as AbstractArchiveTask) { + with(it) { assertThat(description).isEqualTo("Bundles the project as a distribution.") assertThat(group).isEqualTo("distribution") assertThat(archiveAppendix.orNull).isNull() diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilterTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilterTest.kt index a08203436..bdc68815a 100644 --- a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilterTest.kt +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/DefaultDependencyFilterTest.kt @@ -15,7 +15,7 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource class DefaultDependencyFilterTest { - private val filter = DefaultDependencyFilter(project) + private val filter = project.objects.newInstance(DefaultDependencyFilter::class.java) @ParameterizedTest @MethodSource("dependencyNotationProvider") diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/ShadowedSourcesJarTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/ShadowedSourcesJarTest.kt new file mode 100644 index 000000000..5b4cb5011 --- /dev/null +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/ShadowedSourcesJarTest.kt @@ -0,0 +1,305 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue +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.testkit.JarPath +import com.github.jengelman.gradle.plugins.shadow.testkit.containsExactly +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.useAll +import com.github.jengelman.gradle.plugins.shadow.util.testObjectFactory +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createParentDirectories +import kotlin.io.path.writeBytes +import kotlin.io.path.writeText +import org.apache.tools.zip.ZipOutputStream +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.bundling.ZipEntryCompression +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +class ShadowedSourcesJarTest { + @TempDir lateinit var tempDir: Path + + @Test + fun metadataCharsetDoesNotChangeSourceEncoding() { + val srcDir = tempDir.resolve("src").createDirectories() + val sourceBytes = "class Main { val message = \"你好\" }\n".toByteArray(StandardCharsets.UTF_8) + srcDir.resolve("Main.kt").writeBytes(sourceBytes) + + val outputJar = tempDir.resolve("output-sources.jar") + generateSourcesJar( + sourcesJarFile = outputJar.toFile(), + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + metadataCharset = Charsets.US_ASCII.toString(), + ) + + assertThat(JarPath(outputJar)).useAll { + getBytes("Main.kt").isEqualTo(sourceBytes) + } + } + + @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() { + val srcDir = tempDir.resolve("src").createDirectories() + srcDir.resolve("z/sub/Z.java").apply { + createParentDirectories() + writeText("package z.sub;\nclass Z {}") + } + srcDir.resolve("a/A.java").apply { + createParentDirectories() + writeText("package a;\nclass A {}") + } + srcDir.resolve("m/M.java").apply { + createParentDirectories() + writeText("package m;\nclass M {}") + } + + val outputJar = tempDir.resolve("output-sources.jar") + generateSourcesJar( + sourcesJarFile = outputJar.toFile(), + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + ) + + assertThat(JarPath(outputJar)).useAll { + 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() { + val srcDir = tempDir.resolve("src").createDirectories() + 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.toFile(), + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(fileTree), + ) + + assertThat(JarPath(outputJar)).useAll { + containsOnly("META-INF/", "META-INF/MANIFEST.MF") + } + } + + @Test + fun packageDeclarationFollowsClassIncludesAndExcludes() { + val srcDir = tempDir.resolve("src").createDirectories() + srcDir.resolve("org/foo/Bar.java").apply { + createParentDirectories() + writeText("package org.foo;\npublic class Bar {}") + } + srcDir.resolve("org/foo/Baz.java").apply { + createParentDirectories() + writeText("package org.foo;\nimport org.foo.Bar;\npublic class Baz {}") + } + // Not laid out in its package directory, so its package declaration is left as-is. + srcDir.resolve("Flat.kt").writeText("package org.foo\nclass Flat") + + val excludeJar = tempDir.resolve("exclude-sources.jar") + generateSourcesJar( + sourcesJarFile = excludeJar.toFile(), + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + relocators = + listOf(SimpleRelocator("org.foo", "shaded.org.foo", excludes = listOf("org.foo.Bar"))), + ) + assertThat(JarPath(excludeJar)).useAll { + getContent("org/foo/Bar.java").isEqualTo("package org.foo;\npublic class Bar {}") + getContent("shaded/org/foo/Baz.java") + .isEqualTo("package shaded.org.foo;\nimport org.foo.Bar;\npublic class Baz {}") + getContent("Flat.kt").isEqualTo("package shaded.org.foo\nclass Flat") + } + + val includeJar = tempDir.resolve("include-sources.jar") + generateSourcesJar( + sourcesJarFile = includeJar.toFile(), + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + relocators = + listOf(SimpleRelocator("org.foo", "shaded.org.foo", includes = listOf("org.foo.Bar"))), + ) + assertThat(JarPath(includeJar)).useAll { + getContent("shaded/org/foo/Bar.java") + .isEqualTo("package shaded.org.foo;\npublic class Bar {}") + getContent("org/foo/Baz.java") + .isEqualTo("package org.foo;\nimport shaded.org.foo.Bar;\npublic class Baz {}") + getContent("Flat.kt").isEqualTo("package org.foo\nclass Flat") + } + } + + @Test + fun excludesModuleInfo() { + val srcDir = tempDir.resolve("src").createDirectories() + srcDir.resolve("module-info.java").writeText("module my.module {}") + srcDir.resolve("com/example/Main.java").apply { + createParentDirectories() + writeText("package com.example;\npublic class Main {}") + } + + val depSourcesJar = tempDir.resolve("dep-sources.jar") + ZipOutputStream(depSourcesJar.toFile()).use { zos -> + zos.writeEntry("module-info.java") { write("module dep {}".toByteArray()) } + zos.writeEntry("jvmMain/module-info.java") { write("module dep.jvm {}".toByteArray()) } + zos.writeEntry("dep/Dep.java") { + write("package dep;\npublic class Dep {}".toByteArray()) + } + } + + val outputJar = tempDir.resolve("output-sources.jar") + generateSourcesJar( + sourcesJarFile = outputJar.toFile(), + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + includedSourcesJars = listOf(depSourcesJar.toFile()), + ) + + assertThat(JarPath(outputJar)).useAll { + containsOnly( + "META-INF/MANIFEST.MF", + "com/example/Main.java", + "dep/Dep.java", + "META-INF/", + "com/", + "com/example/", + "dep/", + ) + } + } + + @Test + fun mergesAndRelocatesIncludedSourcesJar() { + val srcDir = tempDir.resolve("src").createDirectories() + srcDir.resolve("com/example/Main.java").apply { + createParentDirectories() + writeText("package com.example;\npublic class Main {}") + } + + val depSourcesJar = tempDir.resolve("dep-sources.jar") + depSourcesJar + .toFile() + .createZipOutputStream( + entryCompression = ZipEntryCompression.STORED, + isZip64 = false, + encoding = null, + ) + .use { zos -> + zos.writeEntry("dep/Dep.java") { + write("package dep;\npublic class Dep {}".toByteArray()) + } + zos.writeEntry("dep/resource.txt") { + write("dep resource".toByteArray()) + } + } + + val outputJar = tempDir.resolve("output-sources.jar") + generateSourcesJar( + sourcesJarFile = outputJar.toFile(), + sourceSetsSourceDirs = testObjectFactory.fileCollection().from(srcDir), + includedSourcesJars = listOf(depSourcesJar.toFile()), + relocators = listOf(SimpleRelocator("dep", "shaded.dep")), + ) + + assertThat(JarPath(outputJar)).useAll { + containsExactly( + "META-INF/MANIFEST.MF", + "com/example/Main.java", + "shaded/dep/Dep.java", + "shaded/dep/resource.txt", + "META-INF/", + "com/", + "com/example/", + "shaded/", + "shaded/dep/", + ) + getContent("shaded/dep/Dep.java").isEqualTo("package shaded.dep;\npublic class Dep {}") + getContent("shaded/dep/resource.txt").isEqualTo("dep resource") + } + } +} + +private fun generateSourcesJar( + sourcesJarFile: File, + sourceSetsSourceDirs: FileCollection, + includedSourcesJars: Iterable = emptyList(), + relocators: Iterable = emptyList(), + metadataCharset: String? = null, +) = + generateSourcesJar( + sourcesJarFile = sourcesJarFile, + zipOutStream = + sourcesJarFile.createZipOutputStream( + entryCompression = ZipEntryCompression.STORED, + isZip64 = false, + encoding = metadataCharset, + ), + sourceSetsSourceDirs = sourceSetsSourceDirs, + includedSourcesJars = includedSourcesJars, + classesDirs = emptyList(), + dependencies = emptyList(), + relocators = relocators, + unusedClasses = emptySet(), + preserveFileTimestamps = true, + ) 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..7cdc1aca9 --- /dev/null +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/internal/SourceRemapperTest.kt @@ -0,0 +1,251 @@ +package com.github.jengelman.gradle.plugins.shadow.internal + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.github.jengelman.gradle.plugins.shadow.relocation.RelocateClassContext +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 org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class SourceRemapperTest { + + @ParameterizedTest(name = "{0}") + @MethodSource("remapSourceProvider") + fun remapSource( + name: String, + relocators: List, + input: String, + expected: String, + ) { + assertThat(relocators.remapSource(input)).isEqualTo(expected) + } + + @Test + fun pathWithClassInclude() { + 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 pathWithClassExclude() { + 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 pathWithClassOnlyRelocator() { + val classOnlyRelocator = + object : Relocator by DefaultRelocator { + override fun canRelocateClass(className: String) = className.startsWith("custom.pkg.") + + override fun relocateClass(context: RelocateClassContext) = + context.className.replaceFirst("custom.pkg.", "shaded.pkg.") + } + val relocators = listOf(classOnlyRelocator) + + assertThat(relocators.relocateSourcePath("custom/pkg/MyClass.java")) + .isEqualTo("shaded/pkg/MyClass.java") + assertThat(relocators.relocateSourcePath("custom/pkg/sub/OtherClass.kt")) + .isEqualTo("shaded/pkg/sub/OtherClass.kt") + assertThat(relocators.relocateSourcePath("unrelated/pkg/Unrelated.java")) + .isEqualTo("unrelated/pkg/Unrelated.java") + } + + @Test + fun pathWithNonSourceFileDelegatesToRelocatePath() { + val relocators = listOf(SimpleRelocator("pkg", "hidden.pkg")) + + // Resource files and class files are handled via relocatePath + assertThat(relocators.relocateSourcePath("pkg/config.properties")) + .isEqualTo("hidden/pkg/config.properties") + assertThat(relocators.relocateSourcePath("pkg/A.class")).isEqualTo("hidden/pkg/A.class") + assertThat(relocators.relocateSourcePath("other/file.txt")).isEqualTo("other/file.txt") + } + + @Test + fun pathWithMultipleRelocators() { + val r1 = SimpleRelocator("pkg.one", "shaded.one") + val r2 = SimpleRelocator("pkg.two", "shaded.two") + val relocators = listOf(r1, r2) + + assertThat(relocators.relocateSourcePath("pkg/one/Foo.groovy")) + .isEqualTo("shaded/one/Foo.groovy") + assertThat(relocators.relocateSourcePath("pkg/two/Bar.scala")).isEqualTo("shaded/two/Bar.scala") + assertThat(relocators.relocateSourcePath("pkg/three/Baz.java")).isEqualTo("pkg/three/Baz.java") + } + + @Test + fun pathWithEmptyRelocators() { + assertThat(emptyList().relocateSourcePath("com/example/Foo.java")) + .isEqualTo("com/example/Foo.java") + } + + private companion object { + @JvmStatic + fun remapSourceProvider(): List = + listOf( + Arguments.of( + "custom relocator alongside SimpleRelocator", + listOf( + SimpleRelocator("com.example", "shaded.example"), + object : Relocator by DefaultRelocator { + override fun applyToSourceContent(sourceContent: String) = + sourceContent.replace("CUSTOM_NAME", "RELOCATED_NAME") + }, + ), + """ + |package com.example; + |class Main { + | String value = CUSTOM_NAME; + |} + """ + .trimMargin(), + """ + |package shaded.example; + |class Main { + | String value = RELOCATED_NAME; + |} + """ + .trimMargin(), + ), + Arguments.of( + "multiple relocators sequentially", + listOf( + SimpleRelocator("a.foo", "b.foo"), + SimpleRelocator("b.foo", "c.foo"), + ), + """ + |package a.foo; + |import b.foo.Bar; + |public class Main { + | a.foo.Baz baz; + | b.foo.Bar bar; + |} + """ + .trimMargin(), + """ + |package c.foo; + |import c.foo.Bar; + |public class Main { + | c.foo.Baz baz; + | c.foo.Bar bar; + |} + """ + .trimMargin(), + ), + Arguments.of( + "prefix collisions and variables protection", + listOf( + SimpleRelocator("com.example", "shaded.example"), + SimpleRelocator("io", "shaded.io"), + ), + """ + |package io; + |import io.netty.channel.Channel; + |import other.com.example.Foo; + |import java.io.IOException; + | + |class Main { + | String io, val; + | String path = "dir/com/example/File.txt"; + | String relocatedPath = "com/example/File.txt"; + | /* comment */ com.example.Type t; + | // line comment com.example.Type t2; + |} + """ + .trimMargin(), + """ + |package shaded.io; + |import shaded.io.netty.channel.Channel; + |import other.com.example.Foo; + |import java.io.IOException; + | + |class Main { + | String io, val; + | String path = "dir/com/example/File.txt"; + | String relocatedPath = "shaded/example/File.txt"; + | /* comment */ shaded.example.Type t; + | // line comment shaded.example.Type t2; + |} + """ + .trimMargin(), + ), + Arguments.of( + "content start offset 0 and single-word Javadoc link", + listOf( + SimpleRelocator("com.example", "shaded.example"), + SimpleRelocator("io", "shaded.io"), + ), + """ + |com.example.Factory.create() + |io.netty.channel.Channel.open() + |/** + | * See {@link io} or {@link com.example.Type} + | */ + """ + .trimMargin(), + """ + |shaded.example.Factory.create() + |shaded.io.netty.channel.Channel.open() + |/** + | * See {@link shaded.io} or {@link shaded.example.Type} + | */ + """ + .trimMargin(), + ), + Arguments.of( + "empty relocators", + emptyList(), + """ + |package com.example; + |class Foo {} + """ + .trimMargin(), + """ + |package com.example; + |class Foo {} + """ + .trimMargin(), + ), + ) + } +} + +private object DefaultRelocator : Relocator { + override fun canRelocatePath(path: String) = false + + override fun relocatePath(context: RelocatePathContext) = context.path + + override fun canRelocateClass(className: String) = false + + override fun relocateClass(context: RelocateClassContext) = context.className + + override fun applyToSourceContent(sourceContent: String): String = sourceContent +} 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..3cfb4ca28 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 @@ -5,6 +5,9 @@ import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource /** * Modified from @@ -359,13 +362,17 @@ class SimpleRelocatorTest { @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,7 +390,231 @@ class SimpleRelocatorTest { .isEqualTo(relocatedFile) } + @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) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("sourceRelocationProvider") + fun relocateSourceContent( + name: String, + relocator: SimpleRelocator, + input: String, + expected: String, + ) { + assertThat(relocator.applyToSourceContent(input)).isEqualTo(expected) + } + private companion object { + @JvmStatic + fun sourceRelocationProvider(): List = + listOf( + Arguments.of( + "resource paths with leading slash", + SimpleRelocator("org.foo", "shaded.org.foo"), + """ + |String a = "org/foo/a.properties"; + |String b = "/org/foo/b.properties"; + |getClass().getResource("/org/foo/c.properties"); + |String d = '/org/foo/d.properties'; + |String e = "com/example/org/foo/e.properties"; + |String f = "../org/foo/f.properties"; + |String g = "my-lib/org/foo/g.properties"; + """ + .trimMargin(), + """ + |String a = "shaded/org/foo/a.properties"; + |String b = "/shaded/org/foo/b.properties"; + |getClass().getResource("/shaded/org/foo/c.properties"); + |String d = '/shaded/org/foo/d.properties'; + |String e = "com/example/org/foo/e.properties"; + |String f = "../org/foo/f.properties"; + |String g = "my-lib/org/foo/g.properties"; + """ + .trimMargin(), + ), + Arguments.of( + "prefix collision with included class", + SimpleRelocator( + "org.example", + "relocated.org.example", + includes = listOf("org.example.In"), + ), + """ + |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(), + """ + |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(), + ), + Arguments.of( + "includes only", + SimpleRelocator( + "org.apache.maven", + "com.acme.maven", + includes = listOf("org.apache.maven.hello.*", "org.apache.maven.In"), + ), + """ + |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(), + """ + |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(), + ), + Arguments.of( + "qualified names in expressions and type annotations", + SimpleRelocator("com.example", "shaded.example"), + """ + |class Main { + | void method() { + | return com.example.Factory.create(); + | } + | com.example.Type value = new com.example.Type(); + | val typed: com.example.Type = com.example.Factory.create() + |} + """ + .trimMargin(), + """ + |class Main { + | void method() { + | return shaded.example.Factory.create(); + | } + | shaded.example.Type value = new shaded.example.Type(); + | val typed: shaded.example.Type = shaded.example.Factory.create() + |} + """ + .trimMargin(), + ), + Arguments.of( + "includes and excludes combined", + SimpleRelocator( + "com.example", + "shaded.example", + includes = listOf("com.example.used.*"), + excludes = listOf("com.example.used.Excluded"), + ), + """ + |package com.example.used; + |import com.example.used.Foo; + |import com.example.used.Excluded; + |import com.example.unused.Bar; + """ + .trimMargin(), + """ + |package shaded.example.used; + |import shaded.example.used.Foo; + |import com.example.used.Excluded; + |import com.example.unused.Bar; + """ + .trimMargin(), + ), + Arguments.of( + "multi-language constructs in Kotlin, Groovy, Scala, and Java", + SimpleRelocator("com.example", "shaded.example"), + """ + |// Kotlin constructs + |val delegate by com.example.Delegate() + |val isType = obj is com.example.Type + |val asType = obj as com.example.Type + |val range = 0..com.example.Constants.MAX + |fun compute(factory: () -> com.example.Type = { com.example.Factory.create() }) + | + |// Groovy constructs + |def dynamicVar = com.example.Factory.create() + |def coerced = obj as com.example.Type + | + |// Scala constructs + |case _: com.example.Type => true + |class MyService with com.example.Trait + | + |// Java expressions and operators + |boolean flag = condition ? com.example.Factory.create() : null; + |int divided = total / com.example.Constants.SCALE; + |int bitwise = flags & com.example.Constants.MASK; + """ + .trimMargin(), + """ + |// Kotlin constructs + |val delegate by shaded.example.Delegate() + |val isType = obj is shaded.example.Type + |val asType = obj as shaded.example.Type + |val range = 0..shaded.example.Constants.MAX + |fun compute(factory: () -> shaded.example.Type = { shaded.example.Factory.create() }) + | + |// Groovy constructs + |def dynamicVar = shaded.example.Factory.create() + |def coerced = obj as shaded.example.Type + | + |// Scala constructs + |case _: shaded.example.Type => true + |class MyService with shaded.example.Trait + | + |// Java expressions and operators + |boolean flag = condition ? shaded.example.Factory.create() : null; + |int divided = total / shaded.example.Constants.SCALE; + |int bitwise = flags & shaded.example.Constants.MASK; + """ + .trimMargin(), + ), + ) + val sourceFile = """ |package org.apache.maven.hello; diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyActionTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyActionTest.kt index f0cf101e1..b0246233f 100644 --- a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyActionTest.kt +++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyActionTest.kt @@ -189,7 +189,7 @@ class ShadowCopyActionTest { zipFile = zipFile, zipOutStream = zipFile.createZipOutputStream( - entryCompression = ZipEntryCompression.DEFLATED, + entryCompression = ZipEntryCompression.STORED, isZip64 = isZip64, encoding = null, ), diff --git a/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/AssertK.kt b/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/AssertK.kt new file mode 100644 index 000000000..84e62a193 --- /dev/null +++ b/src/testKit/kotlin/com/github/jengelman/gradle/plugins/shadow/testkit/AssertK.kt @@ -0,0 +1,17 @@ +@file:Suppress("PackageDirectoryMismatch") + +package assertk.assertions + +import assertk.Assert +import assertk.assertions.support.expected +import assertk.assertions.support.show +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path + +// TODO: https://github.com/assertk-org/assertk/pull/542 +fun Assert.doesNotExist(vararg options: LinkOption) = given { actual -> + if (!Files.notExists(actual, *options)) { + expected("${show(actual)} does not exist, but it exists") + } +} 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..f50e9e629 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 @@ -1,6 +1,7 @@ package com.github.jengelman.gradle.plugins.shadow.testkit import assertk.Assert +import assertk.all import assertk.assertions.containsAtLeast import assertk.assertions.containsExactly import assertk.assertions.containsNone @@ -122,6 +123,12 @@ fun Assert.runMain( os.toString().invariantEolString } +fun Assert.useAll(body: Assert.() -> Unit) = all { + body() + // Close the resource after all assertions are done. + given { it.use(block = {}) } +} + private fun Assert.toEntries() = transform { actual -> actual.entries().toList().map { it.name } }