Skip to content

feat(spm): add multi-module Package.swift auto-generation#284

Open
hanrw wants to merge 1 commit intotouchlab:mainfrom
tddworks:feature/spm-multi-module-auto-generation
Open

feat(spm): add multi-module Package.swift auto-generation#284
hanrw wants to merge 1 commit intotouchlab:mainfrom
tddworks:feature/spm-multi-module-auto-generation

Conversation

@hanrw
Copy link
Contributor

@hanrw hanrw commented Dec 18, 2025

Summary

Add a new root-level plugin co.touchlab.kmmbridge.spm that automatically generates Package.swift for multi-module KMP projects.

New Features

  • spmDevBuildAll: Builds all XCFrameworks locally and generates Package.swift with local paths for development
  • kmmBridgePublishAll: Publishes all modules and generates Package.swift with URLs for distribution
  • generatePackageSwift: Generates Package.swift from published metadata

Changes

  • Add KmmBridgeSpmPlugin for root-level SPM management
  • Add KmmBridgeSpmExtension for configuration options
  • Add SpmModuleMetadata for JSON metadata exchange between modules
  • Add writeSpmMetadata task to each module for metadata generation
  • Disable module-level spmDevBuild when root SPM plugin is applied
  • Add comprehensive documentation in docs/SPM_MULTI_MODULE.md

Benefits

  • No manual Package.swift editing required
  • No need for useCustomPackageFile or perModuleVariablesBlock flags
  • Unified workflow for both local development and CI publishing
  • Automatic platform version resolution (takes maximum)
  • Automatic Swift tools version resolution

Add a new root-level plugin `co.touchlab.kmmbridge.spm` that automatically
generates Package.swift for multi-module KMP projects.

## New Features

- **spmDevBuildAll**: Builds all XCFrameworks locally and generates
  Package.swift with local paths for development
- **kmmBridgePublishAll**: Publishes all modules and generates
  Package.swift with URLs for distribution
- **generatePackageSwift**: Generates Package.swift from published metadata

## Changes

- Add `KmmBridgeSpmPlugin` for root-level SPM management
- Add `KmmBridgeSpmExtension` for configuration options
- Add `SpmModuleMetadata` for JSON metadata exchange between modules
- Add `writeSpmMetadata` task to each module for metadata generation
- Disable module-level `spmDevBuild` when root SPM plugin is applied
- Add comprehensive documentation in docs/SPM_MULTI_MODULE.md

## Benefits

- No manual Package.swift editing required
- No need for `useCustomPackageFile` or `perModuleVariablesBlock` flags
- Unified workflow for both local development and CI publishing
- Automatic platform version resolution (takes maximum)
- Automatic Swift tools version resolution
@samhill303 samhill303 requested a review from faogustavo January 13, 2026 20:39
Copy link
Contributor

@faogustavo faogustavo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Things seem to be working locally. I'll clean up my tests and push a branch with this new implementation to github.com/touchlab/KMMBridgeSPMQuickStart

if (useCustomPackageFile) return // No local dev when using a custom package file

// Skip if root SPM plugin is applied (use spmDevBuildAll instead)
val rootHasSpmPlugin = project.rootProject.extensions.findByName("kmmBridgeSpm") != null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Use the constant to prevent breaking if the name changes

Suggested change
val rootHasSpmPlugin = project.rootProject.extensions.findByName("kmmBridgeSpm") != null
val rootHasSpmPlugin = project.rootProject.extensions.findByName(KmmBridgeSpmPlugin.EXTENSION_NAME) != null


// Depend on all module kmmBridgePublish tasks
kmmBridgeModules.forEach { module ->
val publishTask = module.tasks.findByName("kmmBridgePublish")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is out of the scope, as it's already implemented this way, but if possible, try to create an object (or something similar) to store the task names. uploadXCFramework, kmmBridgePublish, writeSpmMetadata, etc).

Not a blocker :)

@faogustavo
Copy link
Contributor

Adding copilot just for a second pair of eyes 😅

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds comprehensive multi-module SPM support to KMMBridge, enabling automatic Package.swift generation for projects with multiple Kotlin Multiplatform modules. The feature eliminates the need for manual Package.swift editing or using custom package file markers.

Changes:

  • Added a new root-level Gradle plugin co.touchlab.kmmbridge.spm that automatically discovers KMMBridge modules and generates unified Package.swift
  • Introduced metadata exchange system using JSON files to communicate module information between subprojects and the root plugin
  • Extended existing SPM dependency manager to write module metadata and disable conflicting module-level tasks when root plugin is active

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmPlugin.kt Core plugin implementation with task registration, module discovery, and Package.swift generation logic
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/KmmBridgeSpmExtension.kt Configuration DSL for the root-level SPM plugin with properties for package name, version, output directory, and module filters
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadata.kt Data model and JSON serialization for module metadata exchange
kmmbridge/src/main/kotlin/co/touchlab/kmmbridge/dependencymanager/SpmDependencyManager.kt Added writeSpmMetadata task and logic to skip module-level spmDevBuild when root plugin is present
kmmbridge/build.gradle.kts Registered new plugin with Gradle plugin portal configuration
kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/SpmModuleMetadataTest.kt Unit tests for metadata serialization, deserialization, and file I/O
kmmbridge/src/test/kotlin/co/touchlab/kmmbridge/spm/PackageSwiftGeneratorTest.kt Unit tests for Package.swift generation, version resolution, and platform aggregation logic
docs/SPM_MULTI_MODULE.md Comprehensive documentation covering usage, configuration, workflows, architecture, and troubleshooting

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

val zipFile = project.zipFilePath()
val urlFile = project.urlFile
val metadataFile = File(project.layout.buildDirectory.asFile.get(), SpmModuleMetadata.METADATA_FILE_NAME)
val platformsMap = parsePlatformsMap(project)
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The platformsMap is evaluated eagerly during task configuration (outside of doLast), which may cause issues with Gradle's configuration cache and prevent lazy evaluation. Consider moving the parsePlatformsMap call inside the doLast block to ensure it's evaluated at execution time.

Copilot uses AI. Check for mistakes.
Comment on lines +87 to +93
// Depend on all module upload tasks
kmmBridgeModules.forEach { module ->
val uploadTask = module.tasks.findByName("uploadXCFramework")
if (uploadTask != null) {
dependsOn(uploadTask)
}
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generatePackageSwift task depends on uploadXCFramework, but the metadata it reads is written by the writeSpmMetadata task. While the dependency chain is technically correct (since writeSpmMetadata depends on uploadXCFramework), it would be clearer and more robust to have generatePackageSwift depend directly on writeSpmMetadata tasks from each module. This makes the dependency relationship more explicit and prevents issues if the task structure changes.

Copilot uses AI. Check for mistakes.
Comment on lines +229 to +231
// Default platforms (we could enhance this to read from config)
val platforms = mapOf("iOS" to "15", "macOS" to "15")

Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default platforms are hardcoded to iOS 15 and macOS 15, which may not reflect the actual platforms configured in the module. This could result in incorrect platform specifications in the generated Package.swift when using spmDevBuildAll for local development. Consider reading the actual platform configuration from each module's KMMBridge extension instead of using hardcoded defaults.

Suggested change
// Default platforms (we could enhance this to read from config)
val platforms = mapOf("iOS" to "15", "macOS" to "15")
// Platforms: try to read from the KMMBridge extension, fall back to sensible defaults
val platforms: Map<String, String> = run {
// Prefer an explicit SPM platforms configuration if provided by the extension
val platformsMethod = kmmBridgeExt.javaClass.methods.find { it.name == "getSpmPlatforms" }
?: kmmBridgeExt.javaClass.methods.find { it.name == "getPlatforms" }
val rawPlatforms = platformsMethod?.invoke(kmmBridgeExt)
val extracted: Map<*, *>? = when (rawPlatforms) {
is org.gradle.api.provider.Property<*> -> rawPlatforms.orNull as? Map<*, *>
is Map<*, *> -> rawPlatforms
else -> null
}
val typed = extracted
?.entries
?.mapNotNull { (k, v) ->
val key = k as? String
val value = v as? String
if (key != null && value != null) key to value else null
}
?.toMap()
// Fall back to previous hardcoded defaults if nothing is configured
typed?.takeIf { it.isNotEmpty() } ?: mapOf("iOS" to "15", "macOS" to "15")
}

Copilot uses AI. Check for mistakes.
Comment on lines +318 to +339
private fun parsePlatformsMap(project: Project): Map<String, String> {
val targetPlatforms = TargetPlatformDsl()
.apply(_targetPlatforms)
.targetPlatforms

val platformMap = mutableMapOf<String, String>()

targetPlatforms.forEach { platform ->
project.kotlin.targets
.withType<KotlinNativeTarget>()
.asSequence()
.filter { it.konanTarget.family.isAppleFamily }
.filter { appleTarget -> platform.targets.firstOrNull { it.konanTarget == appleTarget.konanTarget } != null }
.mapNotNull { it.konanTarget.family.swiftPackagePlatformName }
.distinct()
.forEach { platformName ->
platformMap[platformName] = platform.version.name
}
}

return platformMap
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If multiple TargetPlatform entries specify the same Swift package platform name (e.g., both iOS and tvOS can map to different versions), the last one wins due to simple map assignment. This could result in incorrect platform version metadata. Consider tracking the maximum version across all occurrences of the same platform, similar to how resolvePlatforms works in KmmBridgeSpmPlugin.

Suggested change
private fun parsePlatformsMap(project: Project): Map<String, String> {
val targetPlatforms = TargetPlatformDsl()
.apply(_targetPlatforms)
.targetPlatforms
val platformMap = mutableMapOf<String, String>()
targetPlatforms.forEach { platform ->
project.kotlin.targets
.withType<KotlinNativeTarget>()
.asSequence()
.filter { it.konanTarget.family.isAppleFamily }
.filter { appleTarget -> platform.targets.firstOrNull { it.konanTarget == appleTarget.konanTarget } != null }
.mapNotNull { it.konanTarget.family.swiftPackagePlatformName }
.distinct()
.forEach { platformName ->
platformMap[platformName] = platform.version.name
}
}
return platformMap
}
* Parse platforms into a map for metadata JSON.
* Returns a map like {"iOS": "15", "macOS": "15"}
*/
private fun parsePlatformsMap(project: Project): Map<String, String> {
val targetPlatforms = TargetPlatformDsl()
.apply(_targetPlatforms)
.targetPlatforms
val platformMap = mutableMapOf<String, String>()
targetPlatforms.forEach { platform ->
project.kotlin.targets
.withType<KotlinNativeTarget>()
.asSequence()
.filter { it.konanTarget.family.isAppleFamily }
.filter { appleTarget -> platform.targets.firstOrNull { it.konanTarget == appleTarget.konanTarget } != null }
.mapNotNull { it.konanTarget.family.swiftPackagePlatformName }
.distinct()
.forEach { platformName ->
val newVersion = platform.version.name
val existingVersion = platformMap[platformName]
if (existingVersion == null || isNewerSwiftPlatformVersion(newVersion, existingVersion)) {
platformMap[platformName] = newVersion
}
}
}
return platformMap
}
/**
* Compare Swift platform version strings and determine if [candidate] is newer than [current].
*
* Tries to compare numerically when both versions are simple integer strings (e.g., "13", "15").
* Falls back to lexicographical comparison otherwise.
*/
private fun isNewerSwiftPlatformVersion(candidate: String, current: String): Boolean {
val candidateInt = candidate.toIntOrNull()
val currentInt = current.toIntOrNull()
return if (candidateInt != null && currentInt != null) {
candidateInt > currentInt
} else {
candidate > current
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +382 to +392
".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])"
}

val targetsString = modules
.sortedBy { it.frameworkName }
.joinToString(",\n ") { module ->
""".binaryTarget(
name: "${module.frameworkName}",
url: "${module.url}",
checksum: "${module.checksum}"
)"""
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String interpolation is used directly without escaping when generating Swift code. If framework names, URLs, or checksums contain special characters like quotes or backslashes, the generated Package.swift could be malformed. Consider adding string escaping for these values to ensure robustness against edge cases.

Copilot uses AI. Check for mistakes.
Comment on lines +261 to +270
".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])"
}

val targetsString = modules
.sortedBy { it.frameworkName }
.joinToString(",\n ") { module ->
""".binaryTarget(
name: "${module.frameworkName}",
path: "${module.localPath}"
)"""
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String interpolation is used directly without escaping when generating Swift code. If framework names or local paths contain special characters like quotes or backslashes, the generated Package.swift could be malformed. Consider adding string escaping for these values to ensure robustness against edge cases.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +106
private class PackageSwiftGenerator {
private val versionComparator = Comparator<String> { v1, v2 ->
val parts1 = v1.split(".").mapNotNull { it.toIntOrNull() }
val parts2 = v2.split(".").mapNotNull { it.toIntOrNull() }
val maxLen = maxOf(parts1.size, parts2.size)
for (i in 0 until maxLen) {
val p1 = parts1.getOrElse(i) { 0 }
val p2 = parts2.getOrElse(i) { 0 }
if (p1 != p2) return@Comparator p1.compareTo(p2)
}
0
}

fun resolveSwiftToolsVersion(modules: List<SpmModuleMetadata>, defaultVersion: String): String {
val versions = modules.map { it.swiftToolsVersion }.filter { it.isNotBlank() }
return if (versions.isNotEmpty()) {
versions.maxWithOrNull(versionComparator) ?: defaultVersion
} else {
defaultVersion
}
}

fun resolvePlatforms(modules: List<SpmModuleMetadata>): Map<String, String> {
val platformVersions = mutableMapOf<String, MutableList<String>>()

modules.forEach { module ->
module.platforms.forEach { (platform, version) ->
platformVersions.getOrPut(platform) { mutableListOf() }.add(version)
}
}

return platformVersions.mapValues { (_, versions) ->
versions.maxWithOrNull(versionComparator) ?: versions.first()
}
}

fun generatePackageSwift(
packageName: String,
swiftToolsVersion: String,
modules: List<SpmModuleMetadata>
): String {
val platforms = resolvePlatforms(modules)
val platformsString = platforms.entries
.sortedBy { it.key }
.joinToString(",\n ") { (platform, version) ->
".$platform(.v$version)"
}

val productsString = modules
.sortedBy { it.frameworkName }
.joinToString(",\n ") { module ->
".library(name: \"${module.frameworkName}\", targets: [\"${module.frameworkName}\"])"
}

val targetsString = modules
.sortedBy { it.frameworkName }
.joinToString(",\n ") { module ->
""".binaryTarget(
name: "${module.frameworkName}",
url: "${module.url}",
checksum: "${module.checksum}"
)"""
}

return """// swift-tools-version:$swiftToolsVersion
// Generated by KMMBridge - DO NOT EDIT MANUALLY
// https://github.com/touchlab/KMMBridge
import PackageDescription

let package = Package(
name: "$packageName",
platforms: [
$platformsString
],
products: [
$productsString
],
targets: [
$targetsString
]
)
"""
}
}
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PackageSwiftGenerator helper class in the test duplicates the version comparison and package generation logic from KmmBridgeSpmPlugin. This creates maintenance burden as changes to the production code won't automatically update the test helper. Consider extracting the common logic into a shared utility class that both the plugin and tests can use, or test the actual plugin methods directly.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants