diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 900b540..cd5d702 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,10 +3,19 @@ kotlin = "2.0.0" gradlePublishPlugin = "1.2.1" +junit-jupiter = "5.8.0" + +kotest = "5.9.1" + [libraries] plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +test-junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } +test-junit-jupiter-launcher = { module = "org.junit.jupiter:junit-jupiter-engine" } +test-kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } + + [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } gradle-publish = { id = "com.gradle.plugin-publish", version.ref = "gradlePublishPlugin" } diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index c304c34..51d2277 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -7,6 +7,15 @@ plugins { dependencies { implementation(gradleApi()) implementation(libs.plugin.kotlin) + + testImplementation(gradleTestKit()) + testImplementation(libs.test.junit.jupiter) + testImplementation(libs.test.kotest.assertions) + testRuntimeOnly(libs.test.junit.jupiter.launcher) +} + +tasks.named("test") { + useJUnitPlatform() } version = "0.5.1" diff --git a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibEntry.kt b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibEntry.kt index cdaf364..ebd0050 100644 --- a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibEntry.kt +++ b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibEntry.kt @@ -1,47 +1,21 @@ package io.github.ttypic.swiftklib.gradle +import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import java.io.File import javax.inject.Inject -abstract class SwiftKlibEntry @Inject constructor(val name: String) { +abstract class SwiftKlibEntry @Inject constructor( + val name: String, + objects: ObjectFactory, +) { - abstract val pathProperty: Property - abstract val packageNameProperty: Property - abstract val minIosProperty: Property - abstract val minMacosProperty: Property - abstract val minTvosProperty: Property - abstract val minWatchosProperty: Property + val path: Property = objects.property(File::class.java) + val packageName: Property = objects.property(String::class.java) + val minIos: Property = objects.property(Int::class.java) + val minMacos: Property = objects.property(Int::class.java) + val minTvos: Property = objects.property(Int::class.java) + val minWatchos: Property = objects.property(Int::class.java) - var path: File - get() = pathProperty.get() - set(value) { - pathProperty.set(value) - } - - fun packageName(name: String) = packageNameProperty.set(name) - - var minIos: Int - get() = minIosProperty.get() - set(value) { - minIosProperty.set(value) - } - - var minMacos: Int - get() = minMacosProperty.get() - set(value) { - minMacosProperty.set(value) - } - - var minTvos: Int - get() = minTvosProperty.get() - set(value) { - minTvosProperty.set(value) - } - - var minWatchos: Int - get() = minWatchosProperty.get() - set(value) { - minWatchosProperty.set(value) - } + fun packageName(name: String) = packageName.set(name) } diff --git a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibPlugin.kt b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibPlugin.kt index 61f00f2..2c55678 100644 --- a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibPlugin.kt +++ b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/SwiftKlibPlugin.kt @@ -10,6 +10,7 @@ import org.jetbrains.kotlin.gradle.tasks.CInteropProcess const val EXTENSION_NAME = "swiftklib" +@Suppress("unused") class SwiftKlibPlugin : Plugin { override fun apply(target: Project) = with(target) { val objects: ObjectFactory = project.objects @@ -24,22 +25,24 @@ class SwiftKlibPlugin : Plugin { swiftKlibEntries.all { entry -> val name: String = entry.name - val targetToTaskName = CompileTarget.values().associateWith { + val targetToTaskName = CompileTarget.entries.associateWith { getTaskName(name, it) } + val buildDir = project.layout.buildDirectory.asFile.get().absolutePath targetToTaskName.entries.forEach { (target, taskName) -> tasks.register( taskName, CompileSwiftTask::class.java, name, target, - entry.pathProperty, - entry.packageNameProperty, - entry.minIosProperty, - entry.minMacosProperty, - entry.minTvosProperty, - entry.minWatchosProperty, + buildDir, + entry.path, + entry.packageName, + entry.minIos, + entry.minMacos, + entry.minTvos, + entry.minWatchos, ) } } diff --git a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileSwiftTask.kt b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileSwiftTask.kt index 77d0bfa..fd8bba6 100644 --- a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileSwiftTask.kt +++ b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileSwiftTask.kt @@ -13,15 +13,17 @@ import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations import java.io.ByteArrayOutputStream import java.io.File import java.math.BigInteger import java.security.MessageDigest import javax.inject.Inject -open class CompileSwiftTask @Inject constructor( +abstract class CompileSwiftTask @Inject constructor( @Input val cinteropName: String, @Input val compileTarget: CompileTarget, + @Input val buildDirectory: String, @InputDirectory val pathProperty: Property, @Input val packageNameProperty: Property, @Optional @Input val minIosProperty: Property, @@ -33,7 +35,7 @@ open class CompileSwiftTask @Inject constructor( @get:Internal internal val targetDir: File get() { - return project.buildDir.resolve("${EXTENSION_NAME}/$cinteropName/$compileTarget") + return File(buildDirectory, "${EXTENSION_NAME}/$cinteropName/$compileTarget") } @get:OutputDirectory @@ -44,18 +46,23 @@ open class CompileSwiftTask @Inject constructor( val defFile get() = File(targetDir, "$cinteropName.def") + @get:Inject + abstract val execOperations: ExecOperations + @TaskAction fun produce() { val packageName: String = packageNameProperty.get() prepareBuildDirectory() createPackageSwift() - val (libPath, headerPath) = buildSwift() + val xcodeMajorVersion = readXcodeMajorVersion() + val (libPath, headerPath) = buildSwift(xcodeMajorVersion) createDefFile( libPath = libPath, headerPath = headerPath, packageName = packageName, + xcodeVersion = xcodeMajorVersion, ) } @@ -64,10 +71,6 @@ open class CompileSwiftTask @Inject constructor( private val minTvos get() = minTvosProperty.getOrElse(13) private val minWatchos get() = minWatchosProperty.getOrElse(8) - private val xcodeVersion: Int by lazy { - readXcodeMajorVersion() - } - /** * Creates build directory or cleans up if it already exists * and copies Swift source files to it @@ -89,22 +92,28 @@ open class CompileSwiftTask @Inject constructor( */ private fun createPackageSwift() { File(swiftBuildDir, "Package.swift") - .create(createPackageSwiftContents(cinteropName)) + .writeText(createPackageSwiftContents(cinteropName)) } - private fun buildSwift(): SwiftBuildResult { + private fun buildSwift(xcodeVersion: Int): SwiftBuildResult { val sourceFilePathReplacements = mapOf( buildDir().absolutePath to pathProperty.get().absolutePath ) - project.exec { + val extraArgs = if (xcodeVersion >= 15 && compileTarget in SDKLESS_TARGETS) { + additionalSysrootArgs() + } else { + emptyList() + } + val args = generateBuildArgs() + extraArgs + + logger.info("-- Running swift build --") + logger.info("Working directory: $swiftBuildDir") + logger.info("xcrun ${args.joinToString(" ")}") + + execOperations.exec { it.executable = "xcrun" it.workingDir = swiftBuildDir - val extraArgs = if (xcodeVersion >= 15 && compileTarget in SDKLESS_TARGETS) { - additionalSysrootArgs() - } else { - emptyList() - } - it.args = generateBuildArgs() + extraArgs + it.args = args it.standardOutput = StringReplacingOutputStream( delegate = System.out, replacements = sourceFilePathReplacements @@ -115,47 +124,48 @@ open class CompileSwiftTask @Inject constructor( ) } + val releaseBuildPath = File(swiftBuildDir, ".build/${compileTarget.arch()}-apple-macosx/release") + return SwiftBuildResult( - libPath = File( - swiftBuildDir, - ".build/${compileTarget.arch()}-apple-macosx/release/lib${cinteropName}.a" - ), - headerPath = File( - swiftBuildDir, - ".build/${compileTarget.arch()}-apple-macosx/release/$cinteropName.build/$cinteropName-Swift.h" - ) + libPath = File(releaseBuildPath, "lib${cinteropName}.a"), + headerPath = File(releaseBuildPath, "$cinteropName.build/$cinteropName-Swift.h") ) } - private fun generateBuildArgs(): List = listOf( - "swift", - "build", - "--arch", - compileTarget.arch(), - "-c", - "release", - "-Xswiftc", - "-sdk", - "-Xswiftc", - readSdkPath(), - "-Xswiftc", - "-target", - "-Xswiftc", - "${compileTarget.archPrefix()}-apple-${operatingSystem(compileTarget)}.0${compileTarget.simulatorSuffix()}", - ) + private fun generateBuildArgs(): List { + val sdkPath = readSdkPath() + val baseArgs = "swift build --arch ${compileTarget.arch()} -c release".split(" ") + + val xcrunArgs = listOf( + "-sdk", + sdkPath, + "-target", + compileTarget.asSwiftcTarget(compileTarget.operatingSystem()), + ).asSwiftcArgs() + + return baseArgs + xcrunArgs + } /** Workaround for bug in toolchain where the sdk path (via `swiftc -sdk` flag) is not propagated to clang. */ - private fun additionalSysrootArgs(): List = listOf( - "-Xcc", - "-isysroot", - "-Xcc", - readSdkPath(), - ) + private fun additionalSysrootArgs(): List = + listOf( + "-isysroot", + readSdkPath(), + ).asCcArgs() + + private fun List.asSwiftcArgs() = asBuildToolArgs("swiftc") + private fun List.asCcArgs() = asBuildToolArgs("cc") + + private fun List.asBuildToolArgs(tool: String): List { + return this.flatMap { + listOf("-X$tool", it) + } + } private fun readSdkPath(): String { val stdout = ByteArrayOutputStream() - project.exec { + execOperations.exec { it.executable = "xcrun" it.args = listOf( "--sdk", @@ -171,7 +181,7 @@ open class CompileSwiftTask @Inject constructor( private fun readXcodeMajorVersion(): Int { val stdout = ByteArrayOutputStream() - project.exec { + execOperations.exec { it.executable = "xcodebuild" it.args = listOf("-version") it.standardOutput = stdout @@ -187,7 +197,7 @@ open class CompileSwiftTask @Inject constructor( private fun readXcodePath(): String { val stdout = ByteArrayOutputStream() - project.exec { + execOperations.exec { it.executable = "xcode-select" it.args = listOf("--print-path") it.standardOutput = stdout @@ -202,29 +212,48 @@ open class CompileSwiftTask @Inject constructor( * Note: adds lib-file md5 hash to library in order to automatically * invalidate connected cinterop task */ - private fun createDefFile(libPath: File, headerPath: File, packageName: String) { + private fun createDefFile(libPath: File, headerPath: File, packageName: String, xcodeVersion: Int) { val xcodePath = readXcodePath() val linkerPlatformVersion = if (xcodeVersion >= 15) compileTarget.linkerPlatformVersionName() else compileTarget.linkerMinOsVersionName() + val modulePath = headerPath.parentFile.absolutePath + + val basicLinkerOpts = listOf( + "-L/usr/lib/swift", + "-$linkerPlatformVersion", + "${minOs(compileTarget)}.0", + "${minOs(compileTarget)}.0", + "-L${xcodePath}/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/${compileTarget.os()}" + ) + + val linkerOpts = basicLinkerOpts.joinToString(" ") + val content = """ package = $packageName language = Objective-C - headers = ${headerPath.absolutePath} + modules = $cinteropName # md5 ${libPath.md5()} staticLibraries = ${libPath.name} libraryPaths = ${libPath.parentFile.absolutePath} - linkerOpts = -L/usr/lib/swift -$linkerPlatformVersion ${minOs(compileTarget)}.0 ${minOs(compileTarget)}.0 -L${xcodePath}/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/${compileTarget.os()} + compilerOpts = -fmodules -I$modulePath + linkerOpts = $linkerOpts """.trimIndent() - defFile.create(content) + + logger.info("--- Generated cinterop def file for $cinteropName ---") + logger.info("--- cinterop def ---") + logger.info(content) + logger.info("---/ cinterop def /---") + + defFile.writeText(content) } - private fun operatingSystem(compileTarget: CompileTarget): String = - when (compileTarget) { + private fun CompileTarget.operatingSystem(): String = + when (this) { CompileTarget.iosX64, CompileTarget.iosArm64, CompileTarget.iosSimulatorArm64 -> "ios$minIos" CompileTarget.watchosX64, CompileTarget.watchosArm64, CompileTarget.watchosSimulatorArm64 -> "watchos$minWatchos" CompileTarget.tvosX64, CompileTarget.tvosArm64, CompileTarget.tvosSimulatorArm64 -> "tvos$minTvos" @@ -246,7 +275,9 @@ private data class SwiftBuildResult( ) val SDKLESS_TARGETS = listOf( + CompileTarget.iosX64, CompileTarget.iosArm64, + CompileTarget.iosSimulatorArm64, CompileTarget.watchosArm64, CompileTarget.watchosX64, CompileTarget.watchosSimulatorArm64, @@ -255,12 +286,6 @@ val SDKLESS_TARGETS = listOf( CompileTarget.tvosSimulatorArm64, ) -private fun File.create(content: String) { - bufferedWriter().use { - it.write(content) - } -} - private fun File.md5() = BigInteger(1, MessageDigest.getInstance("MD5").digest(readBytes())) .toString(16) .padStart(32, '0') diff --git a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileTargetExt.kt b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileTargetExt.kt index 8ca3e64..bb7e81c 100644 --- a/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileTargetExt.kt +++ b/plugin/src/main/kotlin/io/github/ttypic/swiftklib/gradle/task/CompileTargetExt.kt @@ -72,3 +72,7 @@ internal fun CompileTarget.linkerMinOsVersionName() = when(this) { CompileTarget.macosX64 -> "macosx_version_min" CompileTarget.macosArm64 -> "macosx_version_min" } + +internal fun CompileTarget.asSwiftcTarget(operatingSystem: String): String { + return "${archPrefix()}-apple-${operatingSystem}.0${simulatorSuffix()}" +} diff --git a/plugin/src/test/kotlin/io/github/ttypic/swiftklib/gradle/CinteropModulesTest.kt b/plugin/src/test/kotlin/io/github/ttypic/swiftklib/gradle/CinteropModulesTest.kt new file mode 100644 index 0000000..6cc15c5 --- /dev/null +++ b/plugin/src/test/kotlin/io/github/ttypic/swiftklib/gradle/CinteropModulesTest.kt @@ -0,0 +1,133 @@ +package io.github.ttypic.swiftklib.gradle + +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class CinteropModulesTest { + @TempDir + lateinit var testProjectDir: File + private lateinit var settingsFile: File + private lateinit var buildFile: File + private lateinit var swiftLocation: File + private lateinit var swiftCodeFile: File + private lateinit var kotlinLocation: File + private lateinit var kotlinCodeFile: File + private lateinit var gradlePropertiesFile: File + + @BeforeEach + fun setup() { + settingsFile = File(testProjectDir, "settings.gradle.kts") + buildFile = File(testProjectDir, "build.gradle.kts") + swiftLocation = File(testProjectDir, "swift") + swiftCodeFile = File(swiftLocation, "test.swift") + kotlinLocation = File(testProjectDir, "src/commonMain/kotlin/test") + kotlinCodeFile = File(kotlinLocation, "Test.kt") + gradlePropertiesFile = File(testProjectDir, "gradle.properties") + } + + @Test + fun `build with imported UIKit framework is successful`() { + testBuild( + swiftCode = """ + import UIKit + + @objc public class TestView: UIView {} + """.trimIndent(), + kotlinCode = """ + import test.TestView + + val view = TestView() + """.trimIndent(), + ) { + task(":build") + .shouldNotBeNull() + .outcome.shouldBe(TaskOutcome.SUCCESS) + } + } + + private fun testBuild( + @Language("swift") + swiftCode: String, + @Language("kotlin") + kotlinCode: String = "", + swiftklibName: String = "test", + swiftklibPackage: String = "test", + asserter: BuildResult.() -> Unit, + ) { + gradlePropertiesFile.writeText( + """ + kotlin.mpp.enableCInteropCommonization=true + """.trimIndent() + ) + @Language("kotlin") + val settingsKts = """ + pluginManagement { + includeBuild("..") + } + + dependencyResolutionManagement { + repositories { + mavenCentral() + } + } + """.trimIndent() + settingsFile.writeText(settingsKts) + + @Language("kotlin") + val buildKts = """ + plugins { + embeddedKotlin("multiplatform") + id("io.github.ttypic.swiftklib") + } + + kotlin { + compilerOptions { + optIn.addAll( + "kotlinx.cinterop.ExperimentalForeignApi", + ) + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { + it.compilations { + val main by getting { + cinterops.create("$swiftklibName") + } + } + } + } + + swiftklib { + create("$swiftklibName") { + path = file("${swiftLocation.absolutePath}") + packageName("$swiftklibPackage") + } + } + """.trimIndent() + buildFile.writeText(buildKts) + + swiftLocation.mkdirs() + kotlinLocation.mkdirs() + + swiftCodeFile.writeText(swiftCode) + kotlinCodeFile.writeText(kotlinCode) + + GradleRunner.create() + .withProjectDir(testProjectDir) + .withArguments("build") + .withPluginClasspath() + .build() + .asserter() + } +}