Repository: GradleUp/shadow
Pull request #1218 (2025-01-29 11:44:56)

Simplify `ServiceFileTransformer` and remove `ServiceStream`
Sync logic to

- https://github.com/apache/maven-shade-plugin/blob/49f6e13de5748700ef2185795f89f66be61554ef/src/main/java/org/apache/maven/plugins/shade/resource/ServicesResourceTransformer.java
- https://github.com/apache/maven-shade-plugin/blob/49f6e13de5748700ef2185795f89f66be61554ef/src/test/java/org/apache/maven/plugins/shade/resource/ServiceResourceTransformerTest.java

---

- [x] [CHANGELOG](https://github.com/GradleUp/shadow/blob/main/src/docs/changes/README.md)'s "Unreleased" section has been updated, if applicable.



Patch:
diff --git a/api/shadow.api b/api/shadow.api
index 631634e6..8d93b146 100644
--- a/api/shadow.api
+++ b/api/shadow.api
@@ -547,6 +547,7 @@ public class com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFile
 	public fun exclude ([Ljava/lang/String;)Lorg/gradle/api/tasks/util/PatternFilterable;
 	public fun getExcludes ()Ljava/util/Set;
 	public fun getIncludes ()Ljava/util/Set;
+	public fun getPath ()Ljava/lang/String;
 	public fun hasTransformedResource ()Z
 	public fun include (Lgroovy/lang/Closure;)Lorg/gradle/api/tasks/util/PatternFilterable;
 	public fun include (Ljava/lang/Iterable;)Lorg/gradle/api/tasks/util/PatternFilterable;
@@ -555,16 +556,10 @@ public class com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFile
 	public fun modifyOutputStream (Lorg/apache/tools/zip/ZipOutputStream;Z)V
 	public fun setExcludes (Ljava/lang/Iterable;)Lorg/gradle/api/tasks/util/PatternFilterable;
 	public fun setIncludes (Ljava/lang/Iterable;)Lorg/gradle/api/tasks/util/PatternFilterable;
-	public fun setPath (Ljava/lang/String;)Lorg/gradle/api/tasks/util/PatternFilterable;
+	public fun setPath (Ljava/lang/String;)V
 	public fun transform (Lcom/github/jengelman/gradle/plugins/shadow/transformers/TransformerContext;)V
 }
 
-public class com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformer$ServiceStream : java/io/ByteArrayOutputStream {
-	public fun <init> ()V
-	public fun append (Ljava/io/InputStream;)V
-	public fun toInputStream ()Ljava/io/InputStream;
-}
-
 public abstract interface class com/github/jengelman/gradle/plugins/shadow/transformers/Transformer : org/gradle/api/Named {
 	public static final field Companion Lcom/github/jengelman/gradle/plugins/shadow/transformers/Transformer$Companion;
 	public abstract fun canTransformResource (Lorg/gradle/api/file/FileTreeElement;)Z
diff --git a/src/docs/changes/README.md b/src/docs/changes/README.md
index b606b823..ac1d26d4 100644
--- a/src/docs/changes/README.md
+++ b/src/docs/changes/README.md
@@ -11,6 +11,10 @@
 
 - **BREAKING CHANGE:** Mark `RelocatorRemapper` as `internal`. ([#1227](https://github.com/GradleUp/shadow/pull/1227))
 
+**Removed**
+
+- **BREAKING CHANGE:** `ServiceFileTransformer.ServiceStream` has been removed. ([#1218](https://github.com/GradleUp/shadow/pull/1218))
+
 
 ## [v9.0.0-beta7] (2025-02-02)
 
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 4f1edc07..9eeb95fc 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
@@ -196,7 +196,7 @@ public abstract class ShadowJar :
   override fun mergeServiceFiles(rootPath: String): ShadowJar {
     return runCatching {
       transform(ServiceFileTransformer::class.java) {
-        it.setPath(rootPath)
+        it.path = rootPath
       }
     }.getOrDefault(this)
   }
diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformer.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformer.kt
index f8144571..cf384f2a 100644
--- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformer.kt
+++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformer.kt
@@ -2,11 +2,7 @@ package com.github.jengelman.gradle.plugins.shadow.transformers
 
 import com.github.jengelman.gradle.plugins.shadow.relocation.RelocateClassContext
 import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowCopyAction
-import java.io.ByteArrayInputStream
-import java.io.ByteArrayOutputStream
-import java.io.File
-import java.io.IOException
-import java.io.InputStream
+import com.github.jengelman.gradle.plugins.shadow.transformers.GroovyExtensionModuleTransformer.Companion.PATH_LEGACY_GROOVY_EXTENSION_MODULE_DESCRIPTOR
 import org.apache.tools.zip.ZipEntry
 import org.apache.tools.zip.ZipOutputStream
 import org.gradle.api.file.FileTreeElement
@@ -32,11 +28,18 @@ import org.gradle.api.tasks.util.PatternSet
 public open class ServiceFileTransformer(
   private val patternSet: PatternSet = PatternSet()
     .include(SERVICES_PATTERN)
-    .exclude(GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATTERN),
+    .exclude(PATH_LEGACY_GROOVY_EXTENSION_MODULE_DESCRIPTOR),
 ) : Transformer,
   PatternFilterable by patternSet {
   @get:Internal
-  internal val serviceEntries = mutableMapOf<String, ServiceStream>()
+  internal val serviceEntries = mutableMapOf<String, MutableSet<String>>()
+
+  @get:Internal // No need to mark this as an input as `getIncludes` is already marked as `@Input`.
+  public open var path: String = SERVICES_PATH
+    set(value) {
+      field = value
+      patternSet.setIncludes(listOf("$value/**"))
+    }
 
   override fun canTransformResource(element: FileTreeElement): Boolean {
     val target = if (element is ShadowCopyAction.ArchiveFileTreeElement) element.asFileTreeElement() else element
@@ -44,37 +47,37 @@ public open class ServiceFileTransformer(
   }
 
   override fun transform(context: TransformerContext) {
-    val lines = context.inputStream.bufferedReader().readLines().toMutableList()
-    var targetPath = context.path
-    context.relocators.forEach { rel ->
-      if (rel.canRelocateClass(File(targetPath).name)) {
-        val classContext = RelocateClassContext(className = targetPath, stats = context.stats)
-        targetPath = rel.relocateClass(classContext)
+    var resource = context.path.substringAfter("$path/")
+    context.relocators.forEach { relocator ->
+      if (relocator.canRelocateClass(resource)) {
+        val classContext = RelocateClassContext(className = resource, stats = context.stats)
+        resource = relocator.relocateClass(classContext)
+        return@forEach
       }
-      lines.forEachIndexed { i, line ->
-        if (rel.canRelocateClass(line)) {
+    }
+    resource = "$path/$resource"
+    val out = serviceEntries.getOrPut(resource) { mutableSetOf() }
+
+    context.inputStream.bufferedReader().use { it.readLines() }.forEach {
+      var line = it
+      context.relocators.forEach { relocator ->
+        if (relocator.canRelocateClass(line)) {
           val lineContext = RelocateClassContext(className = line, stats = context.stats)
-          lines[i] = rel.relocateClass(lineContext)
+          line = relocator.relocateClass(lineContext)
         }
       }
-    }
-    lines.forEach { line ->
-      serviceEntries[targetPath] = serviceEntries.getOrDefault(targetPath, ServiceStream()).apply {
-        append(ByteArrayInputStream(line.toByteArray()))
-      }
+      out.add(line)
     }
   }
 
   override fun hasTransformedResource(): Boolean = serviceEntries.isNotEmpty()
 
   override fun modifyOutputStream(os: ZipOutputStream, preserveFileTimestamps: Boolean) {
-    serviceEntries.forEach { (path, stream) ->
+    serviceEntries.forEach { (path, data) ->
       val entry = ZipEntry(path)
       entry.time = TransformerContext.getEntryTimestamp(preserveFileTimestamps, entry.time)
       os.putNextEntry(entry)
-      stream.toInputStream().use {
-        it.copyTo(os)
-      }
+      os.write(data.joinToString("\n").toByteArray())
       os.closeEntry()
     }
   }
@@ -85,28 +88,8 @@ public open class ServiceFileTransformer(
   @Input // TODO: https://github.com/GradleUp/shadow/issues/1202
   override fun getExcludes(): MutableSet<String> = patternSet.excludes
 
-  public open fun setPath(path: String): PatternFilterable = apply {
-    patternSet.setIncludes(listOf("$path/**"))
-  }
-
-  public open class ServiceStream : ByteArrayOutputStream(1024) {
-    @Throws(IOException::class)
-    public open fun append(inputStream: InputStream) {
-      if (count > 0 && buf[count - 1] != '\n'.code.toByte() && buf[count - 1] != '\r'.code.toByte()) {
-        val newline = "\n".toByteArray()
-        write(newline, 0, newline.size)
-      }
-      inputStream.use {
-        it.copyTo(this)
-      }
-    }
-
-    public open fun toInputStream(): InputStream = ByteArrayInputStream(buf, 0, count)
-  }
-
   private companion object {
-    private const val SERVICES_PATTERN = "META-INF/services/**"
-    private const val GROOVY_EXTENSION_MODULE_DESCRIPTOR_PATTERN =
-      "META-INF/services/org.codehaus.groovy.runtime.ExtensionModule"
+    private const val SERVICES_PATH = "META-INF/services"
+    private const val SERVICES_PATTERN = "$SERVICES_PATH/**"
   }
 }


Test patch:
diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/JarPath.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/JarPath.kt
index ec66a913..025bcc64 100644
--- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/JarPath.kt
+++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/util/JarPath.kt
@@ -29,7 +29,7 @@ fun ZipFile.getContent(entryName: String): String {
 }
 
 fun ZipFile.getStream(entryName: String): InputStream {
-  val entry = getEntry(entryName) ?: error("Entry not found: $entryName")
+  val entry = getEntry(entryName) ?: error("Entry $entryName not found in all entries: ${entries().toList()}")
   return getInputStream(entry)
 }
 
diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/BaseTransformerTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/BaseTransformerTest.kt
index 6e94fcf9..3a963a53 100644
--- a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/BaseTransformerTest.kt
+++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/BaseTransformerTest.kt
@@ -23,7 +23,7 @@ abstract class BaseTransformerTest<T : Transformer> {
     get() = TransformerContext(MANIFEST_NAME, requireResourceAsStream(MANIFEST_NAME))
 
   @BeforeEach
-  fun setup() {
+  open fun setup() {
     @Suppress("UNCHECKED_CAST")
     val clazz = (this::class.java.genericSuperclass as ParameterizedType).actualTypeArguments.first() as Class<T>
     transformer = clazz.create(testObjectFactory)
diff --git a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformerTest.kt b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformerTest.kt
index c2841d39..8450aeb0 100644
--- a/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformerTest.kt
+++ b/src/test/kotlin/com/github/jengelman/gradle/plugins/shadow/transformers/ServiceFileTransformerTest.kt
@@ -4,12 +4,40 @@ 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.util.SimpleRelocator
+import java.io.InputStream
+import java.io.OutputStream
+import java.nio.file.Path
+import java.util.zip.ZipFile
+import kotlin.io.path.createTempFile
+import kotlin.io.path.deleteExisting
+import kotlin.io.path.outputStream
+import org.apache.tools.zip.ZipOutputStream
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
 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 [org.apache.maven.plugins.shade.resource.ServiceResourceTransformerTest.java](https://github.com/apache/maven-shade-plugin/blob/master/src/test/java/org/apache/maven/plugins/shade/resource/ServiceResourceTransformerTest.java).
+ */
 class ServiceFileTransformerTest : BaseTransformerTest<ServiceFileTransformer>() {
+  private lateinit var tempJar: Path
+
+  @BeforeEach
+  override fun setup() {
+    super.setup()
+    tempJar = createTempFile("shade.", ".jar")
+  }
+
+  @AfterEach
+  fun cleanup() {
+    tempJar.deleteExisting()
+  }
+
   @ParameterizedTest
   @MethodSource("resourceProvider")
   fun canTransformResource(path: String, exclude: Boolean, expected: Boolean) {
@@ -28,7 +56,7 @@ class ServiceFileTransformerTest : BaseTransformerTest<ServiceFileTransformer>()
     }
 
     assertThat(transformer.hasTransformedResource()).isTrue()
-    val entry = transformer.serviceEntries.getValue(path).toInputStream().bufferedReader().use { it.readText() }
+    val entry = transformer.serviceEntries.getValue(path).joinToString("\n")
     assertThat(entry).isEqualTo(output)
   }
 
@@ -38,9 +66,105 @@ class ServiceFileTransformerTest : BaseTransformerTest<ServiceFileTransformer>()
     assertThat(transformer.canTransformResource(element)).isFalse()
   }
 
+  @Test
+  fun canTransformAlternateResource() {
+    transformer.path = "foo/bar"
+    assertThat(transformer.canTransformResource("foo/bar/moo/goo/Zoo")).isTrue()
+    assertThat(transformer.canTransformResource("META-INF/services/Zoo")).isFalse()
+  }
+
+  @Test
+  fun relocatedClasses() {
+    val relocator = SimpleRelocator("org.foo", "borg.foo", excludes = listOf("org.foo.exclude.*"))
+    val content = "org.foo.Service\norg.foo.exclude.OtherService\n"
+    val contentResource = "META-INF/services/org.foo.something.another"
+    val contentResourceShaded = "META-INF/services/borg.foo.something.another"
+
+    val transformer = ServiceFileTransformer()
+    transformer.transform(context(contentResource, content, relocator))
+
+    tempJar.outputStream().zipOutputStream().use { zos ->
+      transformer.modifyOutputStream(zos, false)
+    }
+
+    val transformedContent = ZipFile(tempJar.toFile()).use { it.getContent(contentResourceShaded) }
+    assertThat(transformedContent).isEqualTo("borg.foo.Service\norg.foo.exclude.OtherService")
+  }
+
+  @Test
+  fun mergeRelocatedFiles() {
+    val relocator = SimpleRelocator("org.foo", "borg.foo", excludes = listOf("org.foo.exclude.*"))
+    val content = "org.foo.Service\norg.foo.exclude.OtherService\n"
+    val contentResource = "META-INF/services/org.foo.something.another"
+    val contentResourceShaded = "META-INF/services/borg.foo.something.another"
+
+    val transformer = ServiceFileTransformer()
+    transformer.transform(context(contentResource, content, relocator))
+    transformer.transform(context(contentResourceShaded, content, relocator))
+
+    tempJar.outputStream().zipOutputStream().use { zos ->
+      transformer.modifyOutputStream(zos, false)
+    }
+
+    val transformedContent = ZipFile(tempJar.toFile()).use { it.getContent(contentResourceShaded) }
+    assertThat(transformedContent).isEqualTo("borg.foo.Service\norg.foo.exclude.OtherService")
+  }
+
+  @Test
+  fun concatenationAppliedMultipleTimes() {
+    val relocator = SimpleRelocator("org.eclipse", "org.eclipse1234")
+    val content = "org.eclipse.osgi.launch.EquinoxFactory\n"
+    val contentResource = "META-INF/services/org.osgi.framework.launch.FrameworkFactory"
+
+    val transformer = ServiceFileTransformer()
+    transformer.transform(context(contentResource, content, relocator))
+
+    tempJar.outputStream().zipOutputStream().use { zos ->
+      transformer.modifyOutputStream(zos, false)
+    }
+
+    val transformedContent = ZipFile(tempJar.toFile()).use { it.getContent(contentResource) }
+    assertThat(transformedContent).isEqualTo("org.eclipse1234.osgi.launch.EquinoxFactory")
+  }
+
+  @Test
+  fun concatenation() {
+    val relocator = SimpleRelocator("org.foo", "borg.foo")
+    var content = "org.foo.Service\n"
+    var contentResource = "META-INF/services/org.something.another"
+
+    val transformer = ServiceFileTransformer()
+    transformer.transform(context(contentResource, content, relocator))
+
+    content = "org.blah.Service\n"
+    contentResource = "META-INF/services/org.something.another"
+
+    transformer.transform(context(contentResource, content, relocator))
+
+    tempJar.outputStream().zipOutputStream().use { zos ->
+      transformer.modifyOutputStream(zos, false)
+    }
+
+    val transformedContent = ZipFile(tempJar.toFile()).use { it.getContent(contentResource) }
+    assertThat(transformedContent).isEqualTo("borg.foo.Service\norg.blah.Service")
+  }
+
   private companion object {
-    fun context(path: String, input: String): TransformerContext {
-      return TransformerContext(path, input.byteInputStream(), stats = sharedStats)
+    fun context(path: String, input: String, vararg relocators: Relocator): TransformerContext {
+      return TransformerContext(path, input.byteInputStream(), relocators = relocators.toSet(), stats = sharedStats)
+    }
+
+    fun OutputStream.zipOutputStream(): ZipOutputStream {
+      return if (this is ZipOutputStream) this else ZipOutputStream(this)
+    }
+
+    fun ZipFile.getContent(entryName: String): String {
+      return getStream(entryName).bufferedReader().use { it.readText() }
+    }
+
+    fun ZipFile.getStream(entryName: String): InputStream {
+      val entry = getEntry(entryName) ?: error("Entry $entryName not found in all entries: ${entries().toList()}")
+      return getInputStream(entry)
     }
 
     @JvmStatic