From 299f0b4ef00b998c58dffe877769e75ee735915b Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 28 Sep 2026 23:15:21 +0200 Subject: [PATCH 01/57] build: bump Kotlin to 2.4.20 and Gradle to 9.8.0 - kotlin-native-utils 2.2.10 -> 2.4.20; examples on KGP 2.4.20. - Gradle wrapper 8.11.1 -> 9.8.0 (KGP 2.5 needs >= 8.14.4), plugin-publish 1.3.0 -> 2.2.1. - Since 2.4 the cinterop generator locates jni.h itself via java.home, then JAVA_HOME. The java on PATH is often a JBR without include/, so pass the JDK already resolved for the stub as JAVA_HOME. - Gradle 9 validatePlugins fails on tasks without a caching declaration: mark the toolchain-dependent tasks @DisableCachingByDefault and give stubCFile a path sensitivity. - JVM example reads resolvedLibraryDirectory instead of reconstructing the jniLibs path, which went stale when host libraries moved to lib/. Co-Authored-By: Claude Opus 5.5 --- build.gradle.kts | 4 +- examples/jvm/build.gradle.kts | 12 +-- examples/native/build.gradle.kts | 2 +- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 47623 bytes gradle/wrapper/gradle-wrapper.properties | 4 +- gradlew | 18 ++-- gradlew.bat | 86 +++++++++++------- .../io/github/lemcoder/RunKonanClangTask.kt | 2 + .../io/github/lemcoder/interop/CMakeTasks.kt | 3 + .../lemcoder/jvm/GenerateJvmInteropTask.kt | 6 ++ .../github/lemcoder/jvm/LinkJvmInteropTask.kt | 4 +- 11 files changed, 85 insertions(+), 56 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 936890a..5b86184 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ repositories { } plugins { - id("com.gradle.plugin-publish") version "1.3.0" + id("com.gradle.plugin-publish") version "2.2.1" `kotlin-dsl` } @@ -42,7 +42,7 @@ dependencies { // KonanTarget & HostManager: the plugin speaks Kotlin/Native's own target type, so a build can pass // `kotlinNativeTarget.konanTarget` straight in. Not compileOnly β€” it appears in decorated DSL // signatures, which Gradle resolves in projects that have no Kotlin plugin on the classpath. - implementation("org.jetbrains.kotlin:kotlin-native-utils:2.2.10") + implementation("org.jetbrains.kotlin:kotlin-native-utils:2.4.20") // Used only to auto-wire generated sources/jniLibs into Android projects via the AGP variant API. // compileOnly: the consuming Android project supplies AGP at runtime. compileOnly("com.android.tools.build:gradle-api:9.2.1") diff --git a/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts index f1f9ed3..e6cded3 100644 --- a/examples/jvm/build.gradle.kts +++ b/examples/jvm/build.gradle.kts @@ -1,10 +1,9 @@ -import io.github.lemcoder.abiDir import io.github.lemcoder.hostKonanTarget import org.jetbrains.kotlin.konan.target.KonanTarget import io.github.lemcoder.interop.jvmInterops plugins { - kotlin("jvm") version "2.3.10" + kotlin("jvm") version "2.4.20" id("io.github.lemcoder.konanplugin") application } @@ -19,6 +18,8 @@ konanConfig { libName.set("mymath") } +lateinit var mymathLibDir: Provider + kotlin { jvmToolchain(17) @@ -29,6 +30,7 @@ kotlin { create("mymath") { defFile(project.file("src/main/nativeInterop/mymath.def")) includeDirs.from(file("native")) + mymathLibDir = resolvedLibraryDirectory } } } @@ -38,8 +40,6 @@ application { mainClass.set("example.MainKt") } tasks.named("run") { dependsOn("linkJvmInteropMymath") // loadLibrary() resolves the stub from java.library.path. - jvmArgs( - "-Djava.library.path=" + - layout.buildDirectory.dir("jvmInterop/mymath/jniLibs/${host.abiDir}").get().asFile.absolutePath - ) + val libDir = mymathLibDir + jvmArgumentProviders.add { listOf("-Djava.library.path=${libDir.get().asFile.absolutePath}") } } diff --git a/examples/native/build.gradle.kts b/examples/native/build.gradle.kts index e596843..a3ce359 100644 --- a/examples/native/build.gradle.kts +++ b/examples/native/build.gradle.kts @@ -5,7 +5,7 @@ import org.jetbrains.kotlin.konan.target.KonanTarget plugins { - kotlin("multiplatform") version "2.3.10" + kotlin("multiplatform") version "2.4.20" id("io.github.lemcoder.konanplugin") } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..5097068a8d375f5bf0d15693fb5b3615c972a801 100644 GIT binary patch delta 39621 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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e2847c8..9b7cb4a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index f5feea6..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright Β© 2015-2021 the original authors. +# Copyright Β© 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -173,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -206,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a21..3185a43 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,39 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt b/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt index 6a5a7f7..38cdc5b 100644 --- a/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt +++ b/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt @@ -23,6 +23,7 @@ import org.gradle.api.tasks.PathSensitivity.NAME_ONLY import org.gradle.api.tasks.PathSensitivity.RELATIVE import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault import javax.inject.Inject /** @@ -30,6 +31,7 @@ import javax.inject.Inject * [`run_konan`](https://github.com/JetBrains/kotlin/blob/v1.9.0/kotlin-native/HACKING.md#running-clang-the-same-way-kotlinnative-compiler-does) * utility. */ +@DisableCachingByDefault(because = "Output depends on the local Kotlin/Native toolchain, which is not a tracked input") abstract class RunKonanClangTask @Inject constructor( private val exec: ExecOperations, private val fs: FileSystemOperations, diff --git a/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt b/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt index 7e3582d..c8c2ba1 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt +++ b/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt @@ -17,10 +17,12 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault import java.io.File import javax.inject.Inject /** Configures the external CMake build that compiles the generated JNI stub. */ +@DisableCachingByDefault(because = "CMake configure writes machine-specific absolute paths") abstract class CMakeConfigureTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @@ -68,6 +70,7 @@ abstract class CMakeConfigureTask @Inject constructor( } /** Runs the external CMake build for the generated JNI stub. */ +@DisableCachingByDefault(because = "External build; CMake does its own incremental tracking") abstract class CMakeBuildTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { diff --git a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt index dd9c9d6..a7fd404 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt +++ b/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt @@ -27,6 +27,7 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault import javax.inject.Inject /** @@ -35,6 +36,7 @@ import javax.inject.Inject * The same def feeds the native targets' cinterop, so the two legs cannot drift: only `package` is * overridden here, since the JVM bindings live in their own package. */ +@DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") abstract class GenerateJvmInteropTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @@ -127,6 +129,10 @@ abstract class GenerateJvmInteropTask @Inject constructor( ) args(includes); args(extraOpts) environment("LIBCLANG_DISABLE_CRASH_RECOVERY", "1") + // Since 2.4 the generator looks for jni.h itself, under its own java.home and then + // JAVA_HOME. The `java` on PATH is often a JBR with include/ stripped, so point it at the + // JDK whose headers were resolved for the stub. + jniIncludeDirs.get().firstOrNull()?.let { environment("JAVA_HOME", File(it).parent) } } logger.lifecycle(result.output) result.assertNormalExitValue() diff --git a/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt index 53926fa..e61bd3d 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt +++ b/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt @@ -23,15 +23,17 @@ import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault /** Compiles the generated `.c` and statically links the target's `.a` into a shared JNI library. */ +@DisableCachingByDefault(because = "Native link output depends on the local toolchain; cheap to rebuild") abstract class LinkJvmInteropTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @get:Input @get:Optional abstract val konanPath: Property @get:Input abstract val target: Property @get:Input abstract val stubBaseName: Property - @get:InputFile abstract val stubCFile: RegularFileProperty + @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) abstract val stubCFile: RegularFileProperty /** Include roots for the stub compile: the def's directory plus whatever the interop declared. */ @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val includeDirs: ConfigurableFileCollection From e24e730780e9b906b92b88c7a1aca5dad6ae2b6d Mon Sep 17 00:00:00 2001 From: mikolaj Date: Wed, 30 Sep 2026 17:19:31 +0200 Subject: [PATCH 02/57] chore: add Kotlin sources at v2.4.20 as a sparse submodule third_party/kotlin pins JetBrains/kotlin at the v2.4.20 release, checked out sparsely to kotlin-native/Interop: the cinterop indexer (NativeIndex), StubIr and the stub generator, for reading and testing against the same version as the distribution the plugin runs. A plain `git submodule update --init` would check out the whole repository, since sparse-checkout is not recorded; scripts/init-kotlin-submodule.sh does the sparse, blob-less fetch instead. Co-Authored-By: Claude Opus 5.5 --- .gitmodules | 4 ++++ scripts/init-kotlin-submodule.sh | 20 ++++++++++++++++++++ third_party/kotlin | 1 + 3 files changed, 25 insertions(+) create mode 100644 .gitmodules create mode 100755 scripts/init-kotlin-submodule.sh create mode 160000 third_party/kotlin diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..025a482 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "third_party/kotlin"] + path = third_party/kotlin + url = https://github.com/JetBrains/kotlin.git + shallow = true diff --git a/scripts/init-kotlin-submodule.sh b/scripts/init-kotlin-submodule.sh new file mode 100755 index 0000000..1b4d42b --- /dev/null +++ b/scripts/init-kotlin-submodule.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Checks out third_party/kotlin with only kotlin-native/Interop, at the commit this repo pins. +# A plain `git submodule update --init` would check out the whole Kotlin repository: sparse-checkout +# is local config and is not recorded in .gitmodules. +set -euo pipefail +cd "$(dirname "$0")/.." + +path=third_party/kotlin +url=$(git config -f .gitmodules "submodule.$path.url") +sha=$(git ls-tree HEAD "$path" | awk '{print $3}') + +git submodule init "$path" +if [ ! -e "$path/.git" ]; then + git init -q "$path" + git -C "$path" remote add origin "$url" +fi +git -C "$path" fetch -q --depth 1 --filter=blob:none origin "$sha" +git -C "$path" sparse-checkout set kotlin-native/Interop +git -C "$path" checkout -q --detach "$sha" +git submodule absorbgitdirs "$path" diff --git a/third_party/kotlin b/third_party/kotlin new file mode 160000 index 0000000..890ac1d --- /dev/null +++ b/third_party/kotlin @@ -0,0 +1 @@ +Subproject commit 890ac1d94fdb80eb85f0eeb5be5e4352df987b2f From 12bfd8d961b6e80cc0d930bf5a06cf548df328b0 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sat, 3 Oct 2026 10:26:25 +0200 Subject: [PATCH 03/57] docs: draft the interop architecture Components of the planned rewrite (DSL, target model, toolchain, library provider, binding spec, generators, glue builder, packaging), what the Kotlin/Native distribution ships, and the open design questions. Sections A-D are outlines still to be designed. Co-Authored-By: Claude Opus 5.5 --- doc/interop-architecture.md | 192 ++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 doc/interop-architecture.md diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md new file mode 100644 index 0000000..607120a --- /dev/null +++ b/doc/interop-architecture.md @@ -0,0 +1,192 @@ +# KonanPlugin: Interop Architecture + +Status: draft Β· 2026-09-28 + +## Context and goals + +The plugin moves from "cinterop with a JVM hack" to a set of swappable parts: a DSL, binding +generators, toolchains and packaging, each behind an interface. Having dependencies inside an +implementation is fine; the goal is that no one implementation shapes the whole design. + +Today the plugin does three things, all tied to the Kotlin/Native distribution: it compiles C with +konan's clang, generates JNI bindings by running cinterop's undocumented JVM flavor, and rewrites +that output so strings and buffers cross as Kotlin types. The 2.4 upgrade broke the second of these +(cinterop started looking for `jni.h` on its own), which shows how fragile that path is. + +The plugin must serve two kinds of project: + +| Path | Example | Who builds the native code | What the plugin must do | +| --- | --- | --- | --- | +| **Seam** | A few C/C++ files next to Kotlin Multiplatform code | The plugin, for every target | Compile, link, generate bindings, package | +| **Systems integration** | OpenGL headers, JUCE, ggml | An existing build (CMake, the vendor) or a prebuilt binary | Generate bindings against headers, bring in the binaries, package | + +The seam path is what the plugin does today. The systems path cannot rely on konan's clang: a +project like JUCE has its own build system and platform SDKs, so the plugin must accept binaries +built elsewhere. + +Non-goals: + +- Replacing CMake, Bazel or a vendor's build. The plugin calls them or takes their output. +- Being a general C/C++ build system. The plugin's own compile path stays for small sources. + +## What the Kotlin/Native distribution ships + +The distribution is two things: a JVM compiler in one 82 MB jar, and a per-host set of cross +toolchains downloaded on demand. The plugin uses the toolchains to compile C and one internal entry +point of the jar to generate JNI bindings; everything else is Kotlin-specific. Measured on +`kotlin-native-prebuilt-macos-aarch64-2.4.20` (1.1 GB) plus `~/.konan/dependencies` (3.8 GB). + +| Part | Where | What it is | Used by the plugin today | +| --- | --- | --- | --- | +| Launchers | `bin/` | `konanc`, `cinterop`, `klib`, `run_konan`, `generate-platform`: shell scripts that start the jar | `run_konan clang` for compiling C | +| Compiler jar | `konan/lib/kotlin-native-compiler-embeddable.jar` (82 MB) | Compiler, cinterop (`interop.gen.jvm.MainKt`), header indexer (`interop.indexer`), `ClangArgs` (per-target clang flags) | cinterop's JVM flavor, run as an undocumented `main` | +| JNI helpers | `konan/nativelib/` | `libclangstubs`, `libllvmstubs`, `libkotlinxcinteropjvmcallbacks`: JNI glue so the jar can call libclang and LLVM | On `java.library.path` of the generator | +| Target config | `konan/konan.properties` | Per host and target: which LLVM, sysroot, toolchain, libffi to use and download | Indirectly, through `run_konan` | +| Platform defs | `konan/platformDef//` | 176 `.def` files for macOS alone (AppKit, Metal, POSIX…) | No | +| Platform klibs | `klib/platform//` | Prebuilt cinterop output of those defs, 21 targets | No | +| Stdlib | `klib/common/stdlib`, `sources/` | Kotlin/Native stdlib | No | +| LLVM | `dependencies/llvm-21-…-essentials` (497 MB) | `clang`, `clang++`, `lld`, `llvm-ar`, `libclang` | clang, llvm-ar | +| Android toolchain | `dependencies/target-toolchain-…-android_ndk` (1.5 GB) + sysroot (546 MB) | A trimmed Android NDK | Links Android JNI libraries; compiler-rt lookup | +| Linux toolchain | `dependencies/x86_64-unknown-linux-gnu-gcc-8.3.0-glibc-2.19` (268 MB) | GCC sysroot with glibc 2.19 | Cross-compiling to Linux | +| Apple toolchain | Not downloaded | `konan.properties` points at the local Xcode | Through `run_konan` | +| libffi | `dependencies/libffi-3.3-…` | For Kotlin/Native callbacks | No | + +What this means for the design: + +- **The valuable part is the cross toolchain, not the compiler.** One clang plus a sysroot per + target, already downloaded by any Kotlin Multiplatform build, is what makes the seam path work with + zero setup. It stays as one toolchain implementation. +- **The jar's cinterop and indexer are internal APIs.** They are fine behind a generator interface, + but nothing outside that implementation should know they exist. +- **Apple targets always need Xcode.** Konan only borrows it, so an Xcode-based toolchain is not a + loss of capability. + +## System components + +The system is eight components that meet at two contracts: **native artifacts per target** (headers +plus static and shared libraries, however they came to exist) and **generated bindings** (Kotlin +sources plus any native glue). Everything upstream of the first contract decides how binaries are +made; everything downstream decides how Kotlin reaches them. That split is what lets a prebuilt JUCE +and a three-file C seam flow through the same pipeline. + +```mermaid +flowchart TD + DSL["1 Β· DSL
declares libraries, bindings, toolchains, packaging"] + TC["3 Β· Toolchain
Konan Β· Xcode Β· Android NDK Β· system clang/GCC"] + LP["4 Β· Library provider
Sources Β· External build Β· Prebuilt"] + SPEC["5 Β· Binding spec
headers, includes, defines, filters, package"] + NA[["Native artifacts per target
headers Β· .a Β· .so / .dylib / .dll"]] + GEN["6 Β· Binding generator
cinterop Β· JNI Β· FFM"] + GB[["Generated bindings
Kotlin sources + native glue"]] + GLUE["7 Β· Glue builder
links the JNI stub"] + PKG["8 Β· Packaging and wiring
static or shared per consumer β†’ KMP native Β· JVM Β· Android"] + TM["2 Β· Target model β€” one target type, mapped for every component"] + + DSL --> TC + DSL --> LP + DSL --> SPEC + TC -- builds Sources --> LP + LP --> NA + SPEC --> GEN + NA --> GEN + GEN --> GB + GB -- glue, JNI only --> GLUE + NA --> GLUE + TC -- links glue --> GLUE + GLUE --> PKG + NA --> PKG + GB --> PKG + PKG ~~~ TM +``` + +| # | Component | Responsibility | Consumes β†’ produces | Implementations | +| --- | --- | --- | --- | --- | +| 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | One, modelled on `cinterops {}` and `externalNativeBuild {}` (section A) | +| 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | Maps to `KonanTarget`, Android ABI, JVM `os.name`/`os.arch`, clang triple, CMake toolchain | +| 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots), Xcode, Android NDK, system clang/GCC/MSVC; Zig later | +| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | **Sources** (built with a toolchain), **External build** (CMake, any command), **Prebuilt** (paths or a Maven artifact per target) | +| 5 | **Binding spec** | What to bind: headers, include paths, defines, filters, package name | Headers + DSL (or a `.def`) β†’ one spec shared by all generators | One; `.def` read and written as an interchange format | +| 6 | **Binding generator** | Turns a spec into bindings for one kind of consumer | Spec + native artifacts β†’ generated bindings | **cinterop** (Kotlin/Native, delegates to KGP), **JNI** (cinterop JVM flavor now, clang AST later), **FFM** (jextract) | +| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | One, reusing the toolchain; or hands the glue to an external build (today's `KONAN_JNI_STUB_DIR` contract) | +| 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, jar resources, klib link options, publications | KMP native, JVM, Android; each in a `plugins.withId` block | + +How the two paths use them: + +- **Seam**: DSL β†’ Sources provider with the Konan toolchain β†’ any generator β†’ packaging. This is + today's plugin, with zero setup on a machine that already builds Kotlin Multiplatform. +- **Systems integration**: DSL β†’ External or Prebuilt provider β†’ generator against the vendor's + headers β†’ packaging. No toolchain is needed unless a generator emits glue (JNI), and then only to + link a small stub. + +Rules that fall out of this: + +- **Providers never know about generators, and generators never know which provider ran.** Both see + only native artifacts per target. +- **Linkage is a provider fact and a packaging choice.** A provider declares what it can produce + (static, shared, or both); packaging picks per consumer, for example static into a Kotlin/Native + binary and shared on the JVM. +- **KGP and AGP types live only in component 8 and in the cinterop generator.** Everything else can + be applied to a project without either plugin, which keeps the TestKit fixture that catches + decoration failures meaningful. + +## A. DSL + +Not designed yet; next round. It must decide: + +- Where declarations live: top-level `nativeLibraries {}` and `bindings {}` containers, or per + compilation like `compilations["main"].cinterops {}`, or both with one referencing the other. +- How much to borrow from AGP's `externalNativeBuild { cmake { path; version } }` and + `defaultConfig.externalNativeBuild { cmake { arguments; abiFilters } }`, where the build is declared + once and tuned per variant. +- How a binding names its library (by reference, so provider and generator stay decoupled) and picks + generators per consumer. +- Prior art to read first: the current KGP `cinterops {}` DSL, AGP `externalNativeBuild` and + `ndk {}`, and Gradle's own `cpp-library` plugin. + +## B. Generator model + +Not designed yet. It must decide: + +- The interface shape: roughly `generate(spec, artifacts, target, outputDir): GeneratedBindings`, + where the result reports Kotlin source roots, glue sources, the library name to load and runtime + needs (JDK 22+ for FFM). +- Whether the cinterop generator produces bindings at all, or only configures KGP's own cinterop + task, since KGP already owns that pipeline. +- Where the string and buffer marshalling lives: inside the JNI generator, or as a transform any + generator can reuse. + +## C. File tree layout + +Not designed yet. Two trees need a layout: + +- **Plugin source**: one package per component (`dsl`, `target`, `toolchain`, `provider`, `spec`, + `generator/{cinterop,jni,ffm}`, `glue`, `packaging/{kmp,jvm,android}`), so the rule "KGP and AGP + types only in packaging and the cinterop generator" can be checked by package. +- **Consumer build output**: a fixed scheme such as `build/native///` for artifacts + and `build/generated/bindings///` for bindings, keeping the existing rule that + only ABI-named directories sit under `jniLibs`. + +## D. Toolchains, prebuilt binaries and packaging + +Not designed yet. It must decide: + +- How a toolchain is chosen per target: a default per host and target (Konan where available), + overridable per library. +- How prebuilt binaries arrive: local paths per target, a Maven artifact with a classifier per + target, or an archive to unpack; and how their headers travel with them. +- Packaging per consumer: static into Kotlin/Native binaries via the klib's link options, shared on + the JVM (resources extracted at load, or `java.library.path`), shared under `jniLibs/` on + Android, and what a published library carries for its own consumers. + +## Open questions + +- [ ] Should the cinterop generator run KGP's own cinterop task, or should the plugin run cinterop + itself so all three generators behave the same way? +- [ ] Lowest JDK for the Gradle daemon and for consumers at runtime? It decides whether FFM is a + default or an opt-in, and whether any header parsing can run in-process. +- [ ] Does the `.def` stay a first-class input, or become an import format next to a header-first + DSL? +- [ ] Is a published multi-target library (bindings plus binaries per target) in scope for the first + release, or only in-build consumption? +- [ ] Migration: does the current `konanConfig {}` and `jvmInterops {}` DSL keep working behind the + new model, or is this a major version with a break? From 6344446c9fe5fb846dd45c4423c94225e4aa73e7 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sat, 3 Oct 2026 10:26:25 +0200 Subject: [PATCH 04/57] feat: export cinterop's NativeIndex out of the Kotlin/Native distribution cinterop builds a C-level model of the headers a .def names (NativeIndex) before it generates any Kotlin. Reuse that model instead of parsing cinterop's generated JVM bindings: - An exporter (src/indexExporter) indexes the headers with cinterop's own buildNativeIndex and writes the result as JSON. Structs, enums and typedefs go into tables referenced by id, since the model is a graph: a struct can point to itself. - ExportNativeIndexTask runs it with javaexec, on a classpath of only the distribution's compiler jar and the exporter jar (nested in the plugin jar). Not in the daemon, where libclang may already be loaded by KGP's cinterop, and not in a Gradle worker, which would put Gradle's Kotlin stdlib and the plugin's classes next to the compiler jar's own. - NativeIndexReader reads the file back into upstream's model classes, vendored unmodified from JetBrains/kotlin into src/main with a three-symbol shim for what they borrow from the libclang-bound half of the indexer. - kotlinNativeVersion in gradle.properties is the one version for kotlin-native-utils, the vendored model, the exporter's compile-only compiler jar and the distribution it runs in. updateNativeIndexModel re-vendors the model; verifyNativeIndexModel (part of check) fails while the stamps disagree. The wiring moves to a buildSrc convention plugin. Round-trip tests export a sample header covering unions, bit-fields, anonymous members, enums, typedef chains, function pointers, flexible arrays, varargs and macro constants, and compare the exporter's dump of the index it built with the dump of the one read back. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 18 + README.md | 4 + build.gradle.kts | 26 +- buildSrc/build.gradle.kts | 7 + buildSrc/src/main/kotlin/NativeIndexModel.kt | 72 +++ .../src/main/kotlin/native-index.gradle.kts | 60 ++ gradle.properties | 5 +- .../index/exporter/NativeIndexExporter.kt | 96 ++++ .../index/exporter/NativeIndexWriter.kt | 220 ++++++++ .../github/lemcoder/index/NativeIndexDump.kt | 118 ++++ .../lemcoder/index/ExportNativeIndexTask.kt | 103 ++++ .../lemcoder/index/NativeIndexReader.kt | 189 +++++++ .../io/github/lemcoder/index/NativeIndexer.kt | 26 + .../io/github/lemcoder/util/ExecCapture.kt | 31 +- .../interop/indexer/HeaderToIdMapper.kt | 36 ++ .../native/interop/indexer/IndexModelShim.kt | 17 + .../native/interop/indexer/NativeIndex.kt | 524 ++++++++++++++++++ .../lemcoder/index/native-index.properties | 1 + .../index/NativeIndexRoundTripTest.kt | 121 ++++ src/test/resources/nativeindex/sample.def | 2 + src/test/resources/nativeindex/sample.h | 56 ++ 21 files changed, 1707 insertions(+), 25 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/NativeIndexModel.kt create mode 100644 buildSrc/src/main/kotlin/native-index.gradle.kts create mode 100644 src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt create mode 100644 src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt create mode 100644 src/indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt create mode 100644 src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt create mode 100644 src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt create mode 100644 src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt create mode 100644 src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt create mode 100644 src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt create mode 100644 src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt create mode 100644 src/main/resources/io/github/lemcoder/index/native-index.properties create mode 100644 src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt create mode 100644 src/test/resources/nativeindex/sample.def create mode 100644 src/test/resources/nativeindex/sample.h diff --git a/CLAUDE.md b/CLAUDE.md index 6c2cf52..ea9003d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,24 @@ name: it compiles, links, and dies on the first call. - A nested settings object must be created through `ObjectFactory`; Gradle will not synthesise `abstract val cmake: CMakeSettings`. +## The vendored NativeIndex + +cinterop's C model (`NativeIndex`) is read on both sides of a process boundary: the index exporter +builds it inside the Kotlin/Native distribution's JVM, and the plugin reads the file back into its own +copy of the classes, vendored into `src/main` from JetBrains/kotlin. + +- **One version everywhere.** `kotlinNativeVersion` in `gradle.properties` sets `kotlin-native-utils`, + the vendored model, the exporter's compile-only compiler jar, and the distribution the indexer runs + in. After changing it, run `./gradlew updateNativeIndexModel`; `check` fails while the stamps + disagree. The wiring lives in `buildSrc/src/main/kotlin/native-index.gradle.kts`. +- **The exporter is its own source set** because its compiler jar carries a second copy of the model + classes, and anything it links against exists only in the distribution. +- **It runs through `javaexec`, not the Worker API.** A process-isolated worker puts Gradle's Kotlin + stdlib and the plugin's classes next to the compiler jar: the compiler then ran on Gradle 9.8's + stdlib 2.4.10 instead of its bundled 2.4.20, and picked the real `buildNativeIndex` over the vendored + one only by classpath order. The daemon is out too: KGP's cinterop may already have loaded libclang + there, and a native library loads in one classloader per JVM. + ## Testing and publishing The TestKit functional tests are what catch decoration and classloading failures, because they apply diff --git a/README.md b/README.md index 3d63da6..a03b93c 100644 --- a/README.md +++ b/README.md @@ -317,4 +317,8 @@ Alpha, and the interop DSL moved wholesale β€” there is no compatibility shim. ## License This project is licensed under the Apache 2.0 License. For more details, see the `LICENSE` file. +`NativeIndex.kt` and `HeaderToIdMapper.kt` under `src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer` +are copied unmodified from [JetBrains/kotlin](https://github.com/JetBrains/kotlin) (Apache 2.0); each +file names the version it came from. + Happy coding! 😊 diff --git a/build.gradle.kts b/build.gradle.kts index 5b86184..17937ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,27 +1,24 @@ -repositories { - google() - mavenCentral() -} - plugins { - id("com.gradle.plugin-publish") version "2.2.1" `kotlin-dsl` + id("com.gradle.plugin-publish") version "2.2.1" + // cinterop's NativeIndex exporter and the vendored model: buildSrc/src/main/kotlin/native-index.gradle.kts + id("native-index") } version = "1.2.0-alpha05" group = "io.github.lemcoder" +repositories { + google() + mavenCentral() +} + java { toolchain { languageVersion.set(JavaLanguageVersion.of(17)) } } -tasks.withType { - sourceCompatibility = "17" - targetCompatibility = "17" -} - gradlePlugin { website.set("https://github.com/lemcoder/KonanPlugin") vcsUrl.set("https://github.com/lemcoder/KonanPlugin") @@ -38,11 +35,10 @@ gradlePlugin { } dependencies { - implementation(gradleApi()) // KonanTarget & HostManager: the plugin speaks Kotlin/Native's own target type, so a build can pass // `kotlinNativeTarget.konanTarget` straight in. Not compileOnly β€” it appears in decorated DSL // signatures, which Gradle resolves in projects that have no Kotlin plugin on the classpath. - implementation("org.jetbrains.kotlin:kotlin-native-utils:2.4.20") + implementation(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-native-utils:$it" }) // Used only to auto-wire generated sources/jniLibs into Android projects via the AGP variant API. // compileOnly: the consuming Android project supplies AGP at runtime. compileOnly("com.android.tools.build:gradle-api:9.2.1") @@ -54,6 +50,6 @@ dependencies { testImplementation(kotlin("test")) } -tasks.named("test") { +tasks.test { useJUnitPlatform() -} \ No newline at end of file +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..cc02e63 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + gradlePluginPortal() +} diff --git a/buildSrc/src/main/kotlin/NativeIndexModel.kt b/buildSrc/src/main/kotlin/NativeIndexModel.kt new file mode 100644 index 0000000..3b50f80 --- /dev/null +++ b/buildSrc/src/main/kotlin/NativeIndexModel.kt @@ -0,0 +1,72 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.UntrackedTask +import java.net.URI + +/** The upstream files making up the vendored model. Each copy starts with a stamp naming its version. */ +object NativeIndexModel { + val files = listOf("NativeIndex.kt", "HeaderToIdMapper.kt") + + const val STAMP = "// Vendored from JetBrains/kotlin v" + + const val UPSTREAM_DIR = "kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer" +} + +/** Downloads the model for [version] from JetBrains/kotlin into [directory], stamped with the version. */ +@UntrackedTask(because = "Writes into the source tree from the network") +abstract class UpdateNativeIndexModel : DefaultTask() { + @get:Input + abstract val version: Property + + @get:Internal + abstract val directory: DirectoryProperty + + init { + group = "native index" + description = "Downloads the NativeIndex model for kotlinNativeVersion from JetBrains/kotlin into src/main." + } + + @TaskAction + fun update() { + val base = "https://raw.githubusercontent.com/JetBrains/kotlin/v${version.get()}/${NativeIndexModel.UPSTREAM_DIR}" + NativeIndexModel.files.forEach { name -> + val upstream = URI("$base/$name").toURL().readText() + directory.file(name).get().asFile.writeText( + "${NativeIndexModel.STAMP}${version.get()}, unmodified. Update with ./gradlew updateNativeIndexModel.\n$upstream" + ) + } + } +} + +/** Fails when a vendored model file is not stamped with [version]. */ +@UntrackedTask(because = "A cheap check with no outputs") +abstract class VerifyNativeIndexModel : DefaultTask() { + @get:Input + abstract val version: Property + + @get:InputFiles @get:PathSensitive(PathSensitivity.NAME_ONLY) + abstract val sources: ConfigurableFileCollection + + init { + group = "verification" + description = "Fails when the vendored NativeIndex model is not from kotlinNativeVersion." + } + + @TaskAction + fun verify() { + val expected = "${NativeIndexModel.STAMP}${version.get()}," + val stale = sources.files.filterNot { it.isFile && it.useLines { lines -> lines.first().startsWith(expected) } } + check(stale.isEmpty()) { + "Vendored NativeIndex model is not from Kotlin ${version.get()}: ${stale.joinToString { it.name }}. " + + "Run ./gradlew updateNativeIndexModel." + } + } +} diff --git a/buildSrc/src/main/kotlin/native-index.gradle.kts b/buildSrc/src/main/kotlin/native-index.gradle.kts new file mode 100644 index 0000000..4b470ee --- /dev/null +++ b/buildSrc/src/main/kotlin/native-index.gradle.kts @@ -0,0 +1,60 @@ +// cinterop's NativeIndex, carried out of the Kotlin/Native distribution. +// +// The exporter (src/indexExporter) runs in its own JVM whose classpath is only the distribution's +// compiler jar and the exporter: it builds the index with cinterop's own indexer and writes it to a file. +// The plugin reads that file back into the same model classes, vendored from JetBrains/kotlin into +// src/main. Its own source set because it compiles against the compiler jar and main must not; its own +// process because that jar bundles a Kotlin stdlib and the real model classes, which must not meet +// Gradle's stdlib or the vendored copy. The text dump in src/indexShared is compiled into both the +// exporter and the tests. +// +// Everything follows kotlinNativeVersion in gradle.properties; after changing it, run +// updateNativeIndexModel. + +val kotlinNativeVersion = providers.gradleProperty("kotlinNativeVersion") +val vendoredModel = layout.projectDirectory.dir("src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer") + +pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { + val sourceSets = the() + fun SourceSet.addKotlinSources(dir: String) = + (extensions.getByName("kotlin") as SourceDirectorySet).srcDir(dir) + + val indexExporter = sourceSets.create("indexExporter") { addKotlinSources("src/indexShared/kotlin") } + sourceSets.named("test") { addKotlinSources("src/indexShared/kotlin") } + + dependencies { + // compileOnly: at run time the distribution's own copy of this jar is the classpath. + "indexExporterCompileOnly"(kotlinNativeVersion.map { "org.jetbrains.kotlin:kotlin-native-compiler-embeddable:$it" }) + } + + // The exporter ships inside the plugin jar as a nested jar, extracted when ExportNativeIndexTask runs. + val indexExporterJar = tasks.register("indexExporterJar") { + archiveFileName.set("native-index-exporter.jar") + destinationDirectory.set(layout.buildDirectory.dir("indexExporter")) + from(indexExporter.output) + } + + tasks.named("processResources") { + from(indexExporterJar) { into("io/github/lemcoder/index") } + // The version the plugin looks for at run time is the one the model was vendored from. + val version = kotlinNativeVersion + inputs.property("kotlinNativeVersion", version) + filesMatching("io/github/lemcoder/index/native-index.properties") { + expand("kotlinNativeVersion" to version.get()) + } + } +} + +tasks.register("updateNativeIndexModel") { + version.set(kotlinNativeVersion) + directory.set(vendoredModel) +} + +val verifyNativeIndexModel = tasks.register("verifyNativeIndexModel") { + version.set(kotlinNativeVersion) + sources.from(NativeIndexModel.files.map { vendoredModel.file(it) }) +} + +tasks.named("check") { + dependsOn(verifyNativeIndexModel) +} diff --git a/gradle.properties b/gradle.properties index ac1a35f..5ae12ca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,4 @@ -org.gradle.configuration-cache=true \ No newline at end of file +org.gradle.configuration-cache=true +# The Kotlin/Native version the plugin is built against: kotlin-native-utils, the vendored NativeIndex +# model, and the distribution its exporter runs in. After changing it, run updateNativeIndexModel. +kotlinNativeVersion=2.4.20 diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt new file mode 100644 index 0000000..cb3a620 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -0,0 +1,96 @@ +package io.github.lemcoder.index.exporter + +import io.github.lemcoder.index.dumpNativeIndex +import kotlinx.cinterop.JvmCInteropCallbacks +import org.jetbrains.kotlin.konan.util.DefFile +import org.jetbrains.kotlin.native.interop.gen.HeaderExclusionPolicyImpl +import org.jetbrains.kotlin.native.interop.gen.HeaderInclusionPolicyImpl +import org.jetbrains.kotlin.native.interop.gen.ImportsImpl +import org.jetbrains.kotlin.native.interop.gen.jvm.KotlinPlatform +import org.jetbrains.kotlin.native.interop.indexer.HeaderToIdMapper +import org.jetbrains.kotlin.native.interop.indexer.IncludeInfo +import org.jetbrains.kotlin.native.interop.indexer.Language +import org.jetbrains.kotlin.native.interop.indexer.NativeLibrary +import org.jetbrains.kotlin.native.interop.indexer.NativeLibraryHeaderFilter +import org.jetbrains.kotlin.native.interop.indexer.buildNativeIndex +import org.jetbrains.kotlin.native.interop.indexer.defaultCompilerArgs +import org.jetbrains.kotlin.native.interop.indexer.predefinedMacros +import org.jetbrains.kotlin.native.interop.tool.ToolConfig +import org.jetbrains.kotlin.utils.NativeMemoryAllocator +import java.io.File + +/** + * Entry point run in the Kotlin/Native distribution's JVM: indexes the headers a `.def` names, the way + * cinterop does, and writes the resulting `NativeIndex`. + * + * ``` + * --konan-home --def --target --out + * [--dump ] [--compiler-option ]... + * ``` + */ +fun main(args: Array) { + val options = parseOptions(args) + fun single(name: String) = options[name]?.singleOrNull() ?: error("Expected exactly one --$name") + + val konanHome = single("konan-home") + NativeMemoryAllocator.init() + JvmCInteropCallbacks.init() + try { + val tool = ToolConfig(single("target"), KotlinPlatform.NATIVE, emptyMap(), null, konanHome) + tool.prepare() + val def = DefFile(File(single("def")), tool.target) + val library = nativeLibrary(tool, def, options["compiler-option"].orEmpty()) + val index = buildNativeIndex(library, verbose = false).index + + File(single("out")).writeText(NativeIndexWriter().write(index)) + options["dump"]?.single()?.let { File(it).writeText(dumpNativeIndex(index)) } + } finally { + JvmCInteropCallbacks.dispose() + NativeMemoryAllocator.dispose() + } +} + +/** + * The [NativeLibrary] cinterop builds for a `.def` (`buildNativeLibrary` in upstream `main.kt`, which is + * internal), for header-based libraries. Clang modules β€” Objective-C only β€” are not supported. + */ +private fun nativeLibrary(tool: ToolConfig, def: DefFile, extraCompilerOptions: List): NativeLibrary { + val config = def.config + require(config.modules.isEmpty()) { "Clang modules are not supported by the index exporter yet." } + val language = when (config.language) { + null, "C" -> Language.C + "C++" -> Language.CPP + "Objective-C" -> Language.OBJECTIVE_C + else -> error("Unexpected language '${config.language}'") + } + val compilerOptions = config.compilerOpts + + tool.getDefaultCompilerOptsForLanguage(language) + + extraCompilerOptions + + "-Wno-builtin-macro-redefined" + val includes = config.headers.map { IncludeInfo(it, null) } + return NativeLibrary( + includes = includes, + // Keep generated names and timestamps out of the index, as cinterop does (KT-54284). + additionalPreambleLines = def.defHeaderLines + predefinedMacros.map { "#define $it \"$it\"" }, + compilerArgs = defaultCompilerArgs(language) + compilerOptions + tool.platformCompilerOpts, + headerToIdMapper = HeaderToIdMapper(sysRoot = tool.sysRoot), + language = language, + excludeSystemLibs = config.excludeSystemLibs, + // No dependency klibs, so nothing is imported from elsewhere. + headerExclusionPolicy = HeaderExclusionPolicyImpl(ImportsImpl(emptyMap())), + headerFilter = NativeLibraryHeaderFilter.NameBased( + HeaderInclusionPolicyImpl(config.headerFilter, config.excludeFilter), + config.excludeDependentModules, + ), + objCClassesIncludingCategories = config.objcClassesIncludingCategories.toSet(), + allowIncludingObjCCategoriesFromDefFile = config.allowIncludingObjCCategoriesFromDefFile, + ) +} + +private fun parseOptions(args: Array): Map> { + require(args.size % 2 == 0) { "Options come in --name value pairs: ${args.joinToString(" ")}" } + return args.toList().chunked(2).groupBy( + { (name, _) -> name.removePrefix("--") }, + { (_, value) -> value }, + ) +} diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt new file mode 100644 index 0000000..f94fe06 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt @@ -0,0 +1,220 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.indexer.* +import java.util.IdentityHashMap + +/** + * Writes a [NativeIndex] as JSON, for `NativeIndexReader` on the plugin side. + * + * The model is a graph, not a tree: a struct can point to itself, and one typedef is referenced from + * many places. So structs, enums and typedefs are written once each into tables, every declaration a + * type reaches β€” not only the ones the index lists β€” and types refer to them by table position. + * + * Objective-C is not supported yet: C and C++ headers never produce it. + */ +internal class NativeIndexWriter { + private val structs = Table() + private val enums = Table() + private val typedefs = Table() + + fun write(index: NativeIndex): String { + require(index.objCClasses.isEmpty() && index.objCProtocols.isEmpty() && index.objCCategories.isEmpty()) { + "The index format does not carry Objective-C declarations yet." + } + val root = linkedMapOf( + "format" to FORMAT_VERSION, + "includedHeaders" to index.includedHeaders.map { it.value }, + "structs" to index.structs.map { structs.id(it) }, + "enums" to index.enums.map { enums.id(it) }, + "typedefs" to index.typedefs.map { typedefs.id(it) }, + "functions" to index.functions.map(::function), + "globals" to index.globals.map(::global), + "macroConstants" to index.macroConstants.map(::constant), + "wrappedMacros" to index.wrappedMacros.map { obj("name" to it.name, "type" to type(it.type)) }, + ) + // Encoding a declaration can reach further ones, in any of the three tables. + while (structs.encodePending(::structDecl) or enums.encodePending(::enumDef) or typedefs.encodePending(::typedefDef)) { + // until no table grows + } + root["declarations"] = obj( + "structs" to structs.encoded, + "enums" to enums.encoded, + "typedefs" to typedefs.encoded, + ) + return Json.write(root) + } + + private fun structDecl(d: StructDecl) = obj( + "spelling" to d.spelling, + "anonymous" to d.isAnonymous, + "header" to d.location.headerId.value, + "def" to d.def?.let(::structDef), + ) + + private fun structDef(d: StructDef): Any = obj( + "size" to d.size, + "align" to d.align, + "kind" to d.kind.name, + "members" to d.members.map(::member), + "methods" to d.methods.map(::function), + "staticFields" to d.staticFields.map(::global), + ) + + private fun member(m: StructMember): Any = when (m) { + is Field -> obj( + "k" to "field", "name" to m.name, "type" to type(m.type), + "offset" to m.offset, "typeSize" to m.typeSize, "typeAlign" to m.typeAlign, + ) + is BitField -> obj("k" to "bitField", "name" to m.name, "type" to type(m.type), "offset" to m.offset, "size" to m.size) + is IncompleteField -> obj("k" to "incomplete", "name" to m.name) + is AnonymousInnerRecord -> obj("k" to "anonymous", "def" to structDef(m.def)) + } + + private fun enumDef(d: EnumDef) = obj( + "spelling" to d.spelling, + "baseType" to type(d.baseType), + "anonymous" to d.isAnonymous, + "header" to d.location.headerId.value, + "constants" to d.constants.map { + obj("name" to it.name, "value" to it.value, "explicit" to it.isExplicitlyDefined) + }, + ) + + private fun typedefDef(d: TypedefDef) = obj( + "name" to d.name, + "aliased" to type(d.aliased), + "header" to d.location.headerId.value, + ) + + private fun function(f: FunctionDecl) = obj( + "name" to f.name, + "params" to f.parameters.map { obj("name" to it.name, "type" to type(it.type), "nsConsumed" to it.nsConsumed) }, + "ret" to type(f.returnType), + "vararg" to f.isVararg, + "access" to access(f.directAccess), + "parent" to f.parentName, + ) + + private fun global(g: GlobalDecl) = obj( + "name" to g.name, + "type" to type(g.type), + "const" to g.isConst, + "access" to access(g.directAccess), + "parent" to g.parentName, + ) + + private fun access(a: DirectAccess) = when (a) { + is DirectAccess.Symbol -> obj("symbol" to a.name) + is DirectAccess.Unavailable -> obj("unavailable" to a.reason) + } + + private fun constant(c: ConstantDef) = when (c) { + is IntegerConstantDef -> obj("k" to "int", "name" to c.name, "type" to type(c.type), "value" to c.value) + // Raw bits: exact, and JSON has no NaN or infinity. + is FloatingConstantDef -> obj( + "k" to "float", "name" to c.name, "type" to type(c.type), + "bits" to java.lang.Double.doubleToRawLongBits(c.value), + ) + is StringConstantDef -> obj("k" to "string", "name" to c.name, "type" to type(c.type), "value" to c.value) + else -> error("Unknown macro constant ${c::class.qualifiedName}") + } + + private fun type(t: Type): Any = when (t) { + CharType -> kind("char") + CBoolType -> kind("cbool") + ObjCBoolType -> kind("objcbool") + VoidType -> kind("void") + UnsupportedType -> kind("unsupported") + is BoolType -> kind("bool") + is IntegerType -> obj("k" to "int", "size" to t.size, "signed" to t.isSigned, "spelling" to t.spelling) + is FloatingType -> obj("k" to "float", "size" to t.size, "spelling" to t.spelling) + is VectorType -> obj("k" to "vector", "elem" to type(t.elementType), "count" to t.elementCount, "spelling" to t.spelling) + is RecordType -> obj("k" to "record", "ref" to structs.id(t.decl)) + is EnumType -> obj("k" to "enum", "ref" to enums.id(t.def)) + is Typedef -> obj("k" to "typedef", "ref" to typedefs.id(t.def)) + is PointerType -> obj( + "k" to "pointer", "to" to type(t.pointeeType), "const" to t.pointeeIsConst, + "lvref" to t.isLVReference, "spelling" to t.spelling, + ) + is FunctionType -> obj("k" to "function", "params" to t.parameterTypes.map(::type), "ret" to type(t.returnType)) + is ConstArrayType -> obj("k" to "array", "elem" to type(t.elemType), "length" to t.length) + is IncompleteArrayType -> obj("k" to "incompleteArray", "elem" to type(t.elemType)) + is VariableArrayType -> obj("k" to "variableArray", "elem" to type(t.elemType)) + is ObjCPointer -> error("The index format does not carry Objective-C types yet: ${t::class.simpleName}") + else -> error("Unknown type ${t::class.qualifiedName}") + } + + private fun kind(k: String) = obj("k" to k) + + private fun obj(vararg entries: Pair): Map = linkedMapOf(*entries) + + /** Declarations by identity, numbered in the order first reached. */ + private class Table { + private val ids = IdentityHashMap() + private val items = mutableListOf() + val encoded = mutableListOf() + + fun id(item: T): Int = ids.getOrPut(item) { items.add(item); items.size - 1 } + + /** Encodes what was reached since the last call; true if there was anything. */ + fun encodePending(encode: (T) -> Any): Boolean { + val before = encoded.size + while (encoded.size < items.size) encoded.add(encode(items[encoded.size])) + return encoded.size > before + } + } + + companion object { + const val FORMAT_VERSION = 1 + } +} + +/** Just enough JSON writing for the index: maps, lists, strings, numbers, booleans and null. */ +internal object Json { + fun write(value: Any?): String = StringBuilder().also { write(it, value, "") }.toString() + + private fun write(out: StringBuilder, value: Any?, indent: String) { + when (value) { + null -> out.append("null") + is String -> string(out, value) + is Number, is Boolean -> out.append(value.toString()) + is Map<*, *> -> { + if (value.isEmpty()) return run { out.append("{}") } + out.append("{\n") + value.entries.forEachIndexed { i, (k, v) -> + out.append("$indent ") + string(out, k as String) + out.append(": ") + write(out, v, "$indent ") + out.append(if (i < value.size - 1) ",\n" else "\n") + } + out.append("$indent}") + } + is List<*> -> { + if (value.isEmpty()) return run { out.append("[]") } + out.append("[\n") + value.forEachIndexed { i, v -> + out.append("$indent ") + write(out, v, "$indent ") + out.append(if (i < value.size - 1) ",\n" else "\n") + } + out.append("$indent]") + } + else -> error("Cannot write ${value::class.qualifiedName} as JSON") + } + } + + private fun string(out: StringBuilder, s: String) { + out.append('"') + for (c in s) when { + c == '"' -> out.append("\\\"") + c == '\\' -> out.append("\\\\") + c == '\n' -> out.append("\\n") + c == '\r' -> out.append("\\r") + c == '\t' -> out.append("\\t") + c < ' ' -> out.append("\\u%04x".format(c.code)) + else -> out.append(c) + } + out.append('"') + } +} diff --git a/src/indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt b/src/indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt new file mode 100644 index 0000000..08dd95d --- /dev/null +++ b/src/indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt @@ -0,0 +1,118 @@ +package io.github.lemcoder.index + +import org.jetbrains.kotlin.native.interop.indexer.* + +/** + * Renders a [NativeIndex] as deterministic text, for tests only β€” it is not a format anything reads. + * + * Compiled twice: into the index exporter against the Kotlin/Native distribution's classes, and into + * the tests against the plugin's copy of the model. The exporter dumps the index it built, the test + * dumps the one it read back, and equal text means the file carried everything. + * + * Declarations referenced from a type are rendered by name, not expanded, so cycles terminate. + */ +fun dumpNativeIndex(index: NativeIndex): String = buildString { + index.includedHeaders.map { it.value }.sorted().forEach { appendLine("header $it") } + index.structs.sortedBy { it.spelling }.forEach { appendStruct(it) } + index.enums.sortedBy { it.spelling }.forEach { appendEnum(it) } + index.typedefs.sortedBy { it.name }.forEach { + appendLine("typedef ${it.name} = ${render(it.aliased)} @${it.location.headerId.value}") + } + index.functions.sortedBy { it.fullName }.forEach { appendLine(renderFunction(it)) } + index.globals.sortedBy { it.fullName }.forEach { appendLine(renderGlobal(it)) } + index.macroConstants.sortedBy { it.name }.forEach { appendLine(renderConstant(it)) } + index.wrappedMacros.sortedBy { it.name }.forEach { appendLine("macro ${it.name}: ${render(it.type)}") } +} + +private fun StringBuilder.appendStruct(decl: StructDecl) { + val anonymous = if (decl.isAnonymous) " anonymous" else "" + appendLine("struct ${decl.spelling}$anonymous @${decl.location.headerId.value}") + val def = decl.def + if (def == null) appendLine(" (forward declaration)") else appendStructDef(def, indent = " ") +} + +private fun StringBuilder.appendStructDef(def: StructDef, indent: String) { + appendLine("$indent${def.kind} size=${def.size} align=${def.align}") + def.members.forEach { member -> + when (member) { + is Field -> appendLine( + "$indent field ${member.name}: ${render(member.type)} " + + "offset=${member.offset} size=${member.typeSize} align=${member.typeAlign}" + ) + is BitField -> appendLine( + "$indent bitfield ${member.name}: ${render(member.type)} offset=${member.offset} bits=${member.size}" + ) + is IncompleteField -> appendLine("$indent incomplete ${member.name}") + is AnonymousInnerRecord -> { + appendLine("$indent anonymous record") + appendStructDef(member.def, "$indent ") + } + } + } + def.methods.forEach { appendLine("$indent method ${renderFunction(it)}") } + def.staticFields.forEach { appendLine("$indent static ${renderGlobal(it)}") } +} + +private fun StringBuilder.appendEnum(def: EnumDef) { + val anonymous = if (def.isAnonymous) " anonymous" else "" + appendLine("enum ${def.spelling}$anonymous: ${render(def.baseType)} @${def.location.headerId.value}") + def.constants.forEach { + val explicit = if (it.isExplicitlyDefined) " explicit" else "" + appendLine(" ${it.name} = ${it.value}$explicit") + } +} + +private fun renderFunction(f: FunctionDecl): String { + val params = f.parameters.joinToString { p -> + "${p.name ?: "_"}: ${render(p.type)}" + if (p.nsConsumed) " nsConsumed" else "" + } + val vararg = if (f.isVararg) ", ..." else "" + return "function ${f.fullName}($params$vararg): ${render(f.returnType)} ${renderAccess(f.directAccess)}" +} + +private fun renderGlobal(g: GlobalDecl): String { + val const = if (g.isConst) "const " else "" + return "global $const${g.fullName}: ${render(g.type)} ${renderAccess(g.directAccess)}" +} + +private fun renderAccess(access: DirectAccess): String = when (access) { + is DirectAccess.Symbol -> "symbol=${access.name}" + is DirectAccess.Unavailable -> "unavailable(${access.reason})" +} + +private fun renderConstant(c: ConstantDef): String { + val value = when (c) { + is IntegerConstantDef -> c.value.toString() + is FloatingConstantDef -> "${c.value} (bits ${java.lang.Double.doubleToRawLongBits(c.value)})" + is StringConstantDef -> "\"${c.value}\"" + else -> error("Unknown constant ${c::class.simpleName}") + } + return "const ${c.name}: ${render(c.type)} = $value" +} + +private fun render(type: Type): String = when (type) { + CharType -> "char" + CBoolType -> "_Bool" + ObjCBoolType -> "BOOL" + VoidType -> "void" + UnsupportedType -> "" + is BoolType -> "bool" + is IntegerType -> "int${type.size * 8}${if (type.isSigned) "" else "u"}(${type.spelling})" + is FloatingType -> "float${type.size * 8}(${type.spelling})" + is VectorType -> "vector<${render(type.elementType)} x ${type.elementCount}>(${type.spelling})" + is RecordType -> "record ${type.decl.spelling}" + is EnumType -> "enum ${type.def.spelling}" + is Typedef -> "typedef ${type.def.name}" + is PointerType -> buildString { + append(if (type.isLVReference) "ref " else "ptr ") + if (type.pointeeIsConst) append("const ") + append(render(type.pointeeType)) + type.spelling?.let { append(" `$it`") } + } + is FunctionType -> "fn(${type.parameterTypes.joinToString { render(it) }}): ${render(type.returnType)}" + is ConstArrayType -> "${render(type.elemType)}[${type.length}]" + is IncompleteArrayType -> "${render(type.elemType)}[]" + is VariableArrayType -> "${render(type.elemType)}[*]" + is ObjCPointer -> "" + else -> error("Unknown type ${type::class.qualifiedName}") +} diff --git a/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt new file mode 100644 index 0000000..888a349 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt @@ -0,0 +1,103 @@ +package io.github.lemcoder.index + +import io.github.lemcoder.util.ExecCaptureResult +import io.github.lemcoder.util.javaExecCapture +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +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 +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import org.jetbrains.kotlin.konan.target.KonanTarget +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import javax.inject.Inject + +/** + * Indexes the headers a `.def` names with cinterop's own indexer and writes the `NativeIndex` as JSON, + * for [NativeIndexReader]. + * + * The indexer runs in a JVM of its own whose classpath is only the distribution's compiler jar and the + * exporter. Not in the daemon: a native library such as libclang can be loaded by one classloader per + * JVM, and KGP's cinterop may already have loaded it there. Not in a Gradle worker either: that process + * carries Gradle's Kotlin stdlib and the plugin's classes, which would meet the stdlib and the model + * classes bundled in the compiler jar. + */ +@DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") +abstract class ExportNativeIndexTask @Inject constructor( + private val exec: ExecOperations, +) : DefaultTask() { + @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val defFile: RegularFileProperty + + /** Each directory becomes an `-I` for the indexer's clang. */ + @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val includeDirs: ConfigurableFileCollection + + @get:Input @get:Optional + abstract val compilerOptions: ListProperty + + @get:Input + abstract val target: Property + + /** Defaults to the installed distribution of [NativeIndexer.kotlinNativeVersion], found at execution. */ + @get:Internal + abstract val konanHome: DirectoryProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + /** The exporter's text rendering of the index it built; for tests comparing it with what reads back. */ + @get:OutputFile @get:Optional + abstract val dumpFile: RegularFileProperty + + @TaskAction + fun export() { + val home = konanHome.orNull?.asFile ?: NativeIndexer.pinnedKonanHome() ?: error( + "Kotlin/Native ${NativeIndexer.kotlinNativeVersion} is not installed; the native index needs exactly that version." + ) + val compilerJar = home.resolve("konan/lib/kotlin-native-compiler-embeddable.jar") + check(compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } + + val arguments = buildList { + add("--konan-home"); add(home.absolutePath) + add("--def"); add(defFile.get().asFile.absolutePath) + add("--target"); add(target.get().name) + add("--out"); add(outputFile.get().asFile.absolutePath) + dumpFile.orNull?.let { add("--dump"); add(it.asFile.absolutePath) } + includeDirs.files.forEach { add("--compiler-option"); add("-I${it.absolutePath}") } + compilerOptions.getOrElse(emptyList()).forEach { add("--compiler-option"); add(it) } + } + val exporterJar = temporaryDir.resolve(EXPORTER_JAR) + javaClass.getResourceAsStream("/io/github/lemcoder/index/$EXPORTER_JAR").use { input -> + checkNotNull(input) { "Index exporter missing from the plugin jar: $EXPORTER_JAR" } + Files.copy(input, exporterJar.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + val result = exec.javaExecCapture { + classpath(compilerJar, exporterJar) + mainClass.set(EXPORTER_MAIN) + args(arguments) + systemProperty("konan.home", home.absolutePath) + systemProperty("java.library.path", home.resolve("konan/nativelib").absolutePath) + environment("LIBCLANG_DISABLE_CRASH_RECOVERY", "1") + } + check(result is ExecCaptureResult.Success) { "Indexing ${defFile.get().asFile.name} failed:\n${result.output}" } + } + + private companion object { + const val EXPORTER_JAR = "native-index-exporter.jar" + const val EXPORTER_MAIN = "io.github.lemcoder.index.exporter.NativeIndexExporterKt" + } +} diff --git a/src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt b/src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt new file mode 100644 index 0000000..1cc8b1a --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt @@ -0,0 +1,189 @@ +package io.github.lemcoder.index + +import groovy.json.JsonSlurper +import org.jetbrains.kotlin.native.interop.indexer.* +import java.io.File + +/** + * Reads the file the index exporter writes back into upstream's [NativeIndex] model β€” the plugin's copy + * of it, compiled from the pinned Kotlin sources. + * + * Structs are created first without definitions and filled in afterwards, because a definition can + * refer back to its own struct. Enums and typedefs are built on first use: in C they cannot form a + * cycle except through a struct, so recursion ends. + */ +object NativeIndexReader { + fun read(file: File): NativeIndex = Decoder(JsonSlurper().parse(file).asMap()).index() +} + +private class Decoder(private val root: Map) { + private val declarations = root.map("declarations") + private val structTable = declarations.list("structs").map { it.asMap() } + private val enumTable = declarations.list("enums").map { it.asMap() } + private val typedefTable = declarations.list("typedefs").map { it.asMap() } + + private val structs = structTable.map { DecodedStructDecl(it.string("spelling"), it.bool("anonymous"), location(it)) } + private val enums = arrayOfNulls(enumTable.size) + private val typedefs = arrayOfNulls(typedefTable.size) + + fun index(): NativeIndex { + val format = root.int("format") + check(format == FORMAT_VERSION) { "Index format $format; this plugin reads $FORMAT_VERSION." } + structTable.forEachIndexed { i, s -> structs[i].def = s["def"]?.let { structDef(it.asMap()) } } + return DecodedNativeIndex( + structs = root.ids("structs").map { structs[it] }, + enums = root.ids("enums").map(::enum), + typedefs = root.ids("typedefs").map(::typedef), + functions = root.list("functions").map { function(it.asMap()) }, + globals = root.list("globals").map { global(it.asMap()) }, + macroConstants = root.list("macroConstants").map { constant(it.asMap()) }, + wrappedMacros = root.list("wrappedMacros").map { it.asMap().let { m -> WrappedMacroDef(m.string("name"), type(m.map("type"))) } }, + includedHeaders = root.list("includedHeaders").map { HeaderId(it as String) }, + ) + } + + private fun enum(id: Int): EnumDef = enums[id] ?: enumTable[id].let { e -> + DecodedEnumDef( + spelling = e.string("spelling"), + baseType = type(e.map("baseType")), + isAnonymous = e.bool("anonymous"), + location = location(e), + constants = e.list("constants").map { + it.asMap().let { c -> EnumConstant(c.string("name"), c.long("value"), c.bool("explicit")) } + }, + ) + }.also { enums[id] = it } + + private fun typedef(id: Int): TypedefDef = typedefs[id] ?: typedefTable[id].let { t -> + TypedefDef(type(t.map("aliased")), t.string("name"), location(t)) + }.also { typedefs[id] = it } + + private fun structDef(d: Map): StructDef = DecodedStructDef( + size = d.long("size"), + align = d.int("align"), + kind = StructDef.Kind.valueOf(d.string("kind")), + members = d.list("members").map { member(it.asMap()) }, + methods = d.list("methods").map { function(it.asMap()) }, + staticFields = d.list("staticFields").map { global(it.asMap()) }, + ) + + private fun member(m: Map): StructMember = when (val k = m.string("k")) { + "field" -> Field(m.string("name"), type(m.map("type")), m.long("offset"), m.long("typeSize"), m.long("typeAlign")) + "bitField" -> BitField(m.string("name"), type(m.map("type")), m.long("offset"), m.int("size")) + "incomplete" -> IncompleteField(m.string("name")) + "anonymous" -> AnonymousInnerRecord(structDef(m.map("def"))) + else -> error("Unknown struct member kind '$k'") + } + + private fun function(f: Map) = FunctionDecl( + name = f.string("name"), + parameters = f.list("params").map { + it.asMap().let { p -> Parameter(p["name"] as String?, type(p.map("type")), p.bool("nsConsumed")) } + }, + returnType = type(f.map("ret")), + isVararg = f.bool("vararg"), + directAccess = access(f.map("access")), + parentName = f["parent"] as String?, + ) + + private fun global(g: Map) = GlobalDecl( + name = g.string("name"), + type = type(g.map("type")), + isConst = g.bool("const"), + directAccess = access(g.map("access")), + parentName = g["parent"] as String?, + ) + + private fun access(a: Map): DirectAccess = + (a["symbol"] as String?)?.let { DirectAccess.Symbol(it) } ?: DirectAccess.Unavailable(a.string("unavailable")) + + private fun constant(c: Map): ConstantDef { + val name = c.string("name") + val type = type(c.map("type")) + return when (val k = c.string("k")) { + "int" -> IntegerConstantDef(name, type, c.long("value")) + "float" -> FloatingConstantDef(name, type, java.lang.Double.longBitsToDouble(c.long("bits"))) + "string" -> StringConstantDef(name, type, c.string("value")) + else -> error("Unknown macro constant kind '$k'") + } + } + + private fun type(t: Map): Type = when (val k = t.string("k")) { + "char" -> CharType + "cbool" -> CBoolType + "objcbool" -> ObjCBoolType + "void" -> VoidType + "unsupported" -> UnsupportedType + "bool" -> BoolType() + "int" -> IntegerType(t.int("size"), t.bool("signed"), t.string("spelling")) + "float" -> FloatingType(t.int("size"), t.string("spelling")) + "vector" -> VectorType(type(t.map("elem")), t.int("count"), t.string("spelling")) + "record" -> RecordType(structs[t.int("ref")]) + "enum" -> EnumType(enum(t.int("ref"))) + "typedef" -> Typedef(typedef(t.int("ref"))) + "pointer" -> PointerType(type(t.map("to")), t.bool("const"), t.bool("lvref"), t["spelling"] as String?) + "function" -> FunctionType(t.list("params").map { type(it.asMap()) }, type(t.map("ret"))) + "array" -> ConstArrayType(type(t.map("elem")), t.long("length")) + "incompleteArray" -> IncompleteArrayType(type(t.map("elem"))) + "variableArray" -> VariableArrayType(type(t.map("elem"))) + else -> error("Unknown type kind '$k'") + } + + private fun location(m: Map) = Location(HeaderId(m.string("header"))) + + private companion object { + /** Must match `NativeIndexWriter.FORMAT_VERSION` in the index exporter. */ + const val FORMAT_VERSION = 1 + } +} + +private class DecodedNativeIndex( + override val structs: List, + override val enums: List, + override val typedefs: List, + override val functions: List, + override val globals: List, + override val macroConstants: List, + override val wrappedMacros: List, + override val includedHeaders: List, +) : NativeIndex() { + override val objCClasses: List get() = emptyList() + override val objCProtocols: List get() = emptyList() + override val objCCategories: List get() = emptyList() +} + +private class DecodedStructDecl( + spelling: String, + override val isAnonymous: Boolean, + override val location: Location, +) : StructDecl(spelling) { + /** Set once every struct exists, since a definition can refer to its own struct. */ + override var def: StructDef? = null +} + +private class DecodedStructDef( + size: Long, + align: Int, + override val kind: Kind, + override val members: List, + override val methods: List, + override val staticFields: List, +) : StructDef(size, align) + +private class DecodedEnumDef( + spelling: String, + baseType: Type, + override val isAnonymous: Boolean, + override val location: Location, + override val constants: List, +) : EnumDef(spelling, baseType) + +@Suppress("UNCHECKED_CAST") +private fun Any?.asMap() = this as Map +private fun Map.map(key: String) = getValue(key).asMap() +private fun Map.list(key: String) = getValue(key) as List +private fun Map.ids(key: String) = list(key).map { (it as Number).toInt() } +private fun Map.string(key: String) = getValue(key) as String +private fun Map.bool(key: String) = getValue(key) as Boolean +private fun Map.int(key: String) = (getValue(key) as Number).toInt() +private fun Map.long(key: String) = (getValue(key) as Number).toLong() diff --git a/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt b/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt new file mode 100644 index 0000000..2473355 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt @@ -0,0 +1,26 @@ +package io.github.lemcoder.index + +import java.io.File + +/** + * Where cinterop's header indexer runs: inside a Kotlin/Native distribution, whose compiler jar must be + * the Kotlin version the plugin's copy of the model was vendored from β€” [kotlinNativeVersion] β€” or the + * index may not read back. [ExportNativeIndexTask] runs it. + */ +object NativeIndexer { + /** + * The Kotlin version the model was vendored from β€” `kotlinNativeVersion` in the plugin build β€” + * and so the distribution the indexer must run in. + */ + val kotlinNativeVersion: String by lazy { + java.util.Properties().apply { + NativeIndexer::class.java.getResourceAsStream("native-index.properties").use { load(checkNotNull(it)) } + }.getProperty("version") + } + + /** The installed distribution of [kotlinNativeVersion] under `~/.konan`, if any. */ + fun pinnedKonanHome(): File? = + File(System.getProperty("user.home"), ".konan") + .listFiles { f -> f.isDirectory && f.name.startsWith("kotlin-native-prebuilt-") && f.name.endsWith("-$kotlinNativeVersion") } + ?.singleOrNull() +} diff --git a/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt b/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt index b374a7c..d529400 100644 --- a/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt +++ b/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt @@ -3,20 +3,33 @@ package io.github.lemcoder.util import org.gradle.process.ExecOperations import org.gradle.process.ExecResult import org.gradle.process.ExecSpec +import org.gradle.process.JavaExecSpec import java.io.ByteArrayOutputStream fun ExecOperations.execCapture( configure: ExecSpec.() -> Unit, -): ExecCaptureResult { +): ExecCaptureResult = capture { os -> + exec { + isIgnoreExitValue = true + standardOutput = os + errorOutput = os + configure() + } +} - val (result, output) = ByteArrayOutputStream().use { os -> - exec { - isIgnoreExitValue = true - standardOutput = os - errorOutput = os - configure() - } to os.toString() +fun ExecOperations.javaExecCapture( + configure: JavaExecSpec.() -> Unit, +): ExecCaptureResult = capture { os -> + javaexec { + isIgnoreExitValue = true + standardOutput = os + errorOutput = os + configure() } +} + +private fun capture(run: (ByteArrayOutputStream) -> ExecResult): ExecCaptureResult { + val (result, output) = ByteArrayOutputStream().use { os -> run(os) to os.toString() } return if (result.exitValue != 0) { ExecCaptureResult.Error(output, result) @@ -32,4 +45,4 @@ sealed class ExecCaptureResult( ) : ExecResult by result { class Success(output: String, result: ExecResult) : ExecCaptureResult(output, result) class Error(output: String, result: ExecResult) : ExecCaptureResult(output, result) -} \ No newline at end of file +} diff --git a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt new file mode 100644 index 0000000..b90743a --- /dev/null +++ b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt @@ -0,0 +1,36 @@ +// Vendored from JetBrains/kotlin v2.4.20, unmodified. Update with ./gradlew updateNativeIndexModel. +/* + * Copyright 2010-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.kotlin.native.interop.indexer + +import java.io.File + +class HeaderToIdMapper(sysRoot: String) { + private val headerPathToId = mutableMapOf() + private val sysRoot = File(sysRoot).canonicalFile.toPath() + + internal fun getHeaderId(filePath: String) = headerPathToId.getOrPut(filePath) { + val path = File(filePath).canonicalFile.toPath() + val headerIdValue = if (path.startsWith(sysRoot)) { + val relative = sysRoot.relativize(path) + relative.toString() + } else { + headerContentsHash(filePath) + } + HeaderId(headerIdValue) + } +} diff --git a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt new file mode 100644 index 0000000..5dcd470 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt @@ -0,0 +1,17 @@ +// The three symbols upstream's NativeIndex.kt and HeaderToIdMapper.kt reference from the half of the +// indexer that is bound to libclang. The model only needs them to compile: indexing itself runs in +// the Kotlin/Native distribution's JVM, never against these. +package org.jetbrains.kotlin.native.interop.indexer + +/** Copied from upstream `ModuleSupport.kt`. */ +data class IncludeInfo(val headerPath: String, val moduleName: String?) + +fun headerContentsHash(filePath: String): String = + error("Header hashing belongs to the indexer, which runs in the Kotlin/Native distribution.") + +internal fun buildNativeIndexImpl( + library: NativeLibrary, + verbose: Boolean, + allowPrecompiledHeaders: Boolean, + macroNamesCollectingMode: MacroNamesCollectingMode, +): IndexerResult = error("Indexing runs in the Kotlin/Native distribution; read its output with NativeIndexReader.") diff --git a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt new file mode 100644 index 0000000..278d505 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt @@ -0,0 +1,524 @@ +// Vendored from JetBrains/kotlin v2.4.20, unmodified. Update with ./gradlew updateNativeIndexModel. +/* + * Copyright 2010-2017 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.kotlin.native.interop.indexer + +enum class Language(val sourceFileExtension: String, val clangLanguageName: String) { + C("c", "c"), + CPP("cpp", "c++"), + OBJECTIVE_C("m", "objective-c") +} + +enum class MacroNamesCollectingMode { + LEGACY, + LIBCLANGEXT, + LIBCLANGEXT_PARALLEL, +} + +interface HeaderInclusionPolicy { + /** + * Whether unused declarations from given header should be excluded. + * + * @param headerName header path relative to the appropriate include path element (e.g. `time.h` or `curl/curl.h`), + * or `null` for builtin declarations. + */ + fun excludeUnused(headerName: String?): Boolean +} + +interface HeaderExclusionPolicy { + /** + * Whether all declarations from this header should be excluded. + * + * Note: the declarations from such headers can be actually present in the internal representation, + * but not included into the root collections. + */ + fun excludeAll(headerId: HeaderId): Boolean +} + +sealed class NativeLibraryHeaderFilter { + class NameBased( + val policy: HeaderInclusionPolicy, + val excludeDepdendentModules: Boolean + ) : NativeLibraryHeaderFilter() + + class Predefined( + val headers: Set, + // In "skipNonImportableModules" mode the modules that were skipped (failed to import) are not excluded in this field + val modules: List + ) : NativeLibraryHeaderFilter() +} + +interface Compilation { + val includes: List + val additionalPreambleLines: List + val compilerArgs: List + val language: Language +} + +fun defaultCompilerArgs(language: Language): List = + listOf( + // We compile with -O2 because Clang may insert inline asm in bitcode at -O0. + // It is undesirable in case of watchos_arm64 since we target armv7k + // for this target instead of arm64_32 because it is not supported in LLVM 8. + // + // Note that PCH and the *.c file should be compiled with the same optimization level. + "-O2", + // Allow throwing exceptions through generated stubs. + "-fexceptions", + ) + when (language) { + Language.C -> emptyList() + Language.CPP -> emptyList() + Language.OBJECTIVE_C -> listOf( + // "Objective-C" within interop means "Objective-C with ARC": + "-fobjc-arc", + // Using this flag here has two effects: + // 1. The headers are parsed with ARC enabled, thus the API is visible correctly. + // 2. The generated Objective-C stubs are compiled with ARC enabled, so reference counting + // calls are inserted automatically. + + // Workaround for https://youtrack.jetbrains.com/issue/KT-48807. + // TODO(LLVM): Remove after update to a version with + // https://github.com/apple/llvm-project/commit/2de0a18a8949f0235fb3a08dcc55ff3aa7d969e7. + "-DNS_FORMAT_ARGUMENT(A)=" + ) + } + +data class CompilationWithPCH( + override val compilerArgs: List, + override val language: Language +) : Compilation { + constructor(compilerArgs: List, precompiledHeader: String, language: Language) + : this(compilerArgs + listOf("-include-pch", precompiledHeader), language) + + override val includes: List + get() = emptyList() + + override val additionalPreambleLines: List + get() = emptyList() +} + +/** + * + * @param objCClassesIncludingCategories Objective-C classes that should be merged with categories from the same file. + * + * TODO: Compilation hierarchy seems to require some refactoring. + */ +data class NativeLibrary( + override val includes: List, + override val additionalPreambleLines: List, + override val compilerArgs: List, + val headerToIdMapper: HeaderToIdMapper, + override val language: Language, + val excludeSystemLibs: Boolean, // TODO: drop? + val headerExclusionPolicy: HeaderExclusionPolicy, + val headerFilter: NativeLibraryHeaderFilter, + val objCClassesIncludingCategories: Set, + val allowIncludingObjCCategoriesFromDefFile: Boolean, +) : Compilation + +data class IndexerResult(val index: NativeIndex, val compilation: Compilation) + +/** + * Retrieves the definitions from given C header file using given compiler arguments (e.g. defines). + */ +fun buildNativeIndex( + library: NativeLibrary, + verbose: Boolean, + allowPrecompiledHeaders: Boolean = true, + macroNamesCollectingMode: MacroNamesCollectingMode = MacroNamesCollectingMode.LEGACY, +): IndexerResult = buildNativeIndexImpl(library, verbose, allowPrecompiledHeaders, macroNamesCollectingMode) + +/** + * This class describes the IR of definitions from C header file(s). + */ +abstract class NativeIndex { + abstract val structs: Collection + abstract val enums: Collection + abstract val objCClasses: Collection + abstract val objCProtocols: Collection + abstract val objCCategories: Collection + abstract val typedefs: Collection + abstract val functions: Collection + abstract val macroConstants: Collection + abstract val wrappedMacros: Collection + abstract val globals: Collection + abstract val includedHeaders: Collection +} + +/** + * The (contents-based) header id. + * Its [value] remains valid across different runs of the indexer and the process, + * and thus can be used to 'serialize' the id. + */ +data class HeaderId(val value: String) + +data class Location(val headerId: HeaderId) + +interface LocatableDeclaration { + val location: Location +} + +interface TypeDeclaration : LocatableDeclaration + +sealed class StructMember(val name: String) { + abstract val offset: Long? +} + +/** + * C struct field. + */ +class Field(name: String, val type: Type, override val offset: Long, val typeSize: Long, val typeAlign: Long) + : StructMember(name) + +val Field.isAligned: Boolean + get() = offset % (typeAlign * 8) == 0L + +class BitField(name: String, val type: Type, override val offset: Long, val size: Int) : StructMember(name) + +class IncompleteField(name: String) : StructMember(name) { + override val offset: Long? get() = null +} + +class AnonymousInnerRecord(val def: StructDef) : StructMember("") { + override val offset: Long? get() = null + val typeSize: Long = def.size +} + +/** + * C struct declaration. + */ +abstract class StructDecl(val spelling: String) : TypeDeclaration { + + abstract val def: StructDef? + abstract val isAnonymous: Boolean +} + +/** + * C struct definition. + * + * @param hasNaturalLayout must be `false` if the struct has unnatural layout, e.g. it is `packed`. + * May be `false` even if the struct has natural layout. + */ +abstract class StructDef(val size: Long, val align: Int) { + + enum class Kind { + STRUCT, UNION, CLASS + } + + abstract val kind: Kind + abstract val members: List + abstract val methods: List + abstract val staticFields: List + + val fields: List + get() = mutableListOf().apply { + members.forEach { + when (it) { + is Field -> add(it) + is AnonymousInnerRecord -> addAll(it.def.fields) + is BitField, + is IncompleteField -> {} + } + } + } + + val bitFields: List + get() = mutableListOf().apply { + members.forEach { + when (it) { + is BitField -> add(it) + is AnonymousInnerRecord -> addAll(it.def.bitFields) + is Field, + is IncompleteField -> {} + } + } + } +} + +/** + * C enum value. + */ +class EnumConstant(val name: String, val value: Long, val isExplicitlyDefined: Boolean) + +/** + * C enum definition. + */ +abstract class EnumDef(val spelling: String, val baseType: Type) : TypeDeclaration { + + abstract val constants: List + abstract val isAnonymous: Boolean +} + +sealed class ObjCContainer { + abstract val protocols: List + abstract val methods: List + abstract val unavailableMethods: List + abstract val properties: List +} + +sealed class ObjCClassOrProtocol(val name: String) : ObjCContainer(), TypeDeclaration { + abstract val isForwardDeclaration: Boolean + abstract val binaryName: String? + open val swiftName: String? get() = null +} + +/** + * An indexed Obj-C method declaration β€” either [ObjCMethod] (available, with full signature) or + * [ObjCUnavailableMethod] (selector-only record for declarations marked unavailable). + */ +sealed interface ObjCMethodOrUnavailableMethod { + val selector: String + val isClass: Boolean +} + +data class ObjCMethod( + override val selector: String, + val encoding: String, + val parameters: List, + private val returnType: Type, + val isVariadic: Boolean, + override val isClass: Boolean, + val nsConsumesSelf: Boolean, + val nsReturnsRetained: Boolean, + val isOptional: Boolean, + val isInit: Boolean, + val isExplicitlyDesignatedInitializer: Boolean, + val isDirect: Boolean, + val swiftName: String?, +) : ObjCMethodOrUnavailableMethod { + + fun containsInstancetype(): Boolean = returnType.containsInstancetype() // Clang doesn't allow parameter types to use instancetype. + + fun getReturnType(container: ObjCClassOrProtocol): Type = if (returnType.containsInstancetype()) { + returnType.substituteInstancetype(container) + } else { + // Fast path, avoid allocating copies. + returnType + } +} + +/** + * Selector-only record for an Obj-C method declared as unavailable (e.g. via `NS_UNAVAILABLE`). + * Kept as a distinct type from [ObjCMethod] so the index does not have to import the method's parameter + * and return types (those types may themselves be unavailable). + */ +data class ObjCUnavailableMethod( + override val selector: String, + override val isClass: Boolean, +) : ObjCMethodOrUnavailableMethod + +// Clang seems to allow using instancetype only inside certain kinds of types. +// The implementation below therefore covers only particular cases, based on the experiments with Clang and common sense. +private fun Type.containsInstancetype(): Boolean = when (this) { + is ObjCInstanceType -> true + + is ObjCBlockPointer -> this.returnType.containsInstancetype() + is FunctionType -> this.returnType.containsInstancetype() + is PointerType -> this.pointeeType.containsInstancetype() + + else -> false +} + +private fun Type.substituteInstancetype(container: ObjCClassOrProtocol): Type = when (this) { + is ObjCInstanceType -> when (container) { + is ObjCClass -> ObjCObjectPointer(container, this.nullability, protocols = emptyList()) + is ObjCProtocol -> ObjCIdType(this.nullability, protocols = listOf(container)) + } + + is ObjCBlockPointer -> this.copy(returnType = this.returnType.substituteInstancetype(container)) + is FunctionType -> this.copy(returnType = this.returnType.substituteInstancetype(container)) + is PointerType -> this.copy(pointeeType = this.pointeeType.substituteInstancetype(container)) + + else -> this +} + +data class ObjCProperty( + val name: String, + val getter: ObjCMethod, val setter: ObjCMethod?, + val swiftName: String? +) { + fun getType(container: ObjCClassOrProtocol): Type = getter.getReturnType(container) +} + +abstract class ObjCClass(name: String) : ObjCClassOrProtocol(name) { + abstract val baseClass: ObjCClass? + /** + * Categories whose methods and properties should be generated as members of Kotlin class. + */ + abstract val includedCategories: List + + open val typeParameters: List get() = emptyList() +} +abstract class ObjCProtocol(name: String) : ObjCClassOrProtocol(name) + +abstract class ObjCCategory(val name: String, val clazz: ObjCClass) : ObjCContainer(), LocatableDeclaration + +/** + * C function parameter. + */ +data class Parameter(val name: String?, val type: Type, val nsConsumed: Boolean) + +/** + * Describes how a declaration is accessible directly (in the `-Xccall-mode direct` mode), or why it is not. + */ +sealed class DirectAccess { + /** + * The declaration is accessible directly using the specified symbol name. + * + * @property name + * The name of the declaration in the binary code (i.e. the symbol name as seen by the linker). + * It usually equals to `"_$name"` on Apple platforms and `name` on other platforms, + * but can be different if some modifiers like `__asm("foo")` are at play. + */ + class Symbol(val name: String) : DirectAccess() + + /** + * The declaration is not accessible directly or not supported in the `-Xccall-mode direct` mode. + * + * @property reason + * Briefly describes why the declaration is not accessible. + */ + class Unavailable(val reason: String) : DirectAccess() +} + +/** + * C function declaration. + */ +class FunctionDecl( + /** + * The name of the function in the source code. + */ + val name: String, + + val parameters: List, + val returnType: Type, + val isVararg: Boolean, + + val directAccess: DirectAccess, + + val parentName: String? = null +) { + + val fullName: String = parentName?.let { "$parentName::$name" } ?: name +} + +/** + * C typedef definition. + * + * ``` + * typedef $aliased $name; + * ``` + */ +class TypedefDef(val aliased: Type, val name: String, override val location: Location) : TypeDeclaration + +abstract class MacroDef(val name: String) + +abstract class ConstantDef(name: String, val type: Type): MacroDef(name) +abstract class TypedConstantDef(name: String, type: Type) : ConstantDef(name, type) { + abstract val value: V +} +class IntegerConstantDef(name: String, type: Type, override val value: Long) : TypedConstantDef(name, type) +class FloatingConstantDef(name: String, type: Type, override val value: Double) : TypedConstantDef(name, type) +class StringConstantDef(name: String, type: Type, override val value: String) : TypedConstantDef(name, type) + +class WrappedMacroDef(name: String, val type: Type) : MacroDef(name) + +class GlobalDecl(val name: String, val type: Type, val isConst: Boolean, val directAccess: DirectAccess, val parentName: String? = null) { + val fullName: String get() = parentName?.let { "$it::$name" } ?: name +} + +/** + * C type. + */ +interface Type + +interface PrimitiveType : Type + +object CharType : PrimitiveType + +open class BoolType : PrimitiveType + +object CBoolType : BoolType() + +object ObjCBoolType : BoolType() +// We omit `const` qualifier for IntegerType and FloatingType to make `CBridgeGen` simpler. +// See KT-28102. +data class IntegerType(val size: Int, val isSigned: Boolean, val spelling: String) : PrimitiveType + +// TODO: floating type is not actually defined entirely by its size. +data class FloatingType(val size: Int, val spelling: String) : PrimitiveType + +data class VectorType(val elementType: Type, val elementCount: Int, val spelling: String) : PrimitiveType + +object VoidType : Type + +data class RecordType(val decl: StructDecl) : Type + +data class EnumType(val def: EnumDef) : Type + +// when pointer type is provided by clang we'll use ots correct spelling +data class PointerType(val pointeeType: Type, val pointeeIsConst: Boolean = false, + val isLVReference: Boolean = false, val spelling: String? = null) : Type +// TODO: refactor type representation and support type modifiers more generally. + +data class FunctionType(val parameterTypes: List, val returnType: Type) : Type + +interface ArrayType : Type { + val elemType: Type +} + +data class ConstArrayType(override val elemType: Type, val length: Long) : ArrayType +data class IncompleteArrayType(override val elemType: Type) : ArrayType +data class VariableArrayType(override val elemType: Type) : ArrayType + +data class Typedef(val def: TypedefDef) : Type + +sealed class ObjCPointer : Type { + enum class Nullability { + Nullable, NonNull, Unspecified + } + + abstract val nullability: Nullability +} + +sealed class ObjCQualifiedPointer : ObjCPointer() { + abstract val protocols: List +} + +data class ObjCObjectPointer( + val def: ObjCClass, + override val nullability: Nullability, + override val protocols: List +) : ObjCQualifiedPointer() + +data class ObjCClassPointer( + override val nullability: Nullability, + override val protocols: List +) : ObjCQualifiedPointer() + +data class ObjCIdType( + override val nullability: Nullability, + override val protocols: List +) : ObjCQualifiedPointer() + +data class ObjCInstanceType(override val nullability: Nullability) : ObjCPointer() +data class ObjCBlockPointer( + override val nullability: Nullability, + val parameterTypes: List, val returnType: Type +) : ObjCPointer() + +object UnsupportedType : Type diff --git a/src/main/resources/io/github/lemcoder/index/native-index.properties b/src/main/resources/io/github/lemcoder/index/native-index.properties new file mode 100644 index 0000000..431a3bd --- /dev/null +++ b/src/main/resources/io/github/lemcoder/index/native-index.properties @@ -0,0 +1 @@ +version=${kotlinNativeVersion} diff --git a/src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt b/src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt new file mode 100644 index 0000000..53bd6f6 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt @@ -0,0 +1,121 @@ +package io.github.lemcoder.index + +import org.gradle.testkit.runner.GradleRunner +import org.jetbrains.kotlin.native.interop.indexer.* +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame + +/** + * Indexes headers with cinterop's own indexer, through [ExportNativeIndexTask] in a TestKit build, then + * reads the file back into the plugin's copy of the model. + * + * Skipped when the pinned Kotlin/Native distribution is not installed; any Kotlin Multiplatform build of + * [NativeIndexer.kotlinNativeVersion] downloads it. + */ +class NativeIndexRoundTripTest { + + @Test + fun `mymath functions come back with their signatures`() { + val index = index(example.resolve("src/main/nativeInterop/mymath.def")).first + + val functions = index.functions.associateBy { it.name } + assertEquals(setOf("my_add", "my_scale"), functions.keys) + + val add = functions.getValue("my_add") + assertEquals(listOf("a", "b"), add.parameters.map { it.name }) + add.parameters.forEach { assertInt(it.type, bits = 32) } + assertInt(add.returnType, bits = 32) + + val scale = functions.getValue("my_scale") + assertEquals(FloatingType(8, "double"), scale.parameters.single().type) + assertEquals(FloatingType(8, "double"), scale.returnType) + } + + @Test + fun `the index read back renders exactly as the one the exporter built`() { + val (index, exporterDump) = index(sampleDef) + assertEquals(exporterDump, dumpNativeIndex(index)) + } + + @Test + fun `a struct that points to itself resolves to the same declaration`() { + val index = index(sampleDef).first + val node = index.structs.single { it.spelling == "struct node" } + val fields = node.def!!.fields.associateBy { it.name } + + val prev = assertIs(fields.getValue("prev").type) + assertSame(node, assertIs(prev.pointeeType).decl) + + val next = assertIs(fields.getValue("next").type) + val nodeT = assertIs(next.pointeeType).def + assertEquals("node_t", nodeT.name) + assertSame(node, assertIs(nodeT.aliased).decl) + assertSame(nodeT, index.typedefs.single { it.name == "node_t" }) + } + + @Test + fun `macro constants keep their values`() { + val constants = index(sampleDef).first.macroConstants.associateBy { it.name } + assertEquals(3L, assertIs(constants.getValue("SAMPLE_VERSION")).value) + assertEquals(16L, assertIs(constants.getValue("SAMPLE_MASK")).value) + assertEquals(3.14159, assertIs(constants.getValue("SAMPLE_PI")).value) + assertEquals("sample", assertIs(constants.getValue("SAMPLE_NAME")).value) + } + + /** Indexes [def], returning the index read back and the exporter's own dump of what it built. */ + private fun index(def: File): Pair { + assumeTrue(NativeIndexer.pinnedKonanHome() != null, "Kotlin/Native ${NativeIndexer.kotlinNativeVersion} is not installed") + val (json, dump) = exported.getValue(def.nameWithoutExtension) + return NativeIndexReader.read(json) to dump.readText() + } + + private companion object { + private val sampleDef = File(checkNotNull(NativeIndexRoundTripTest::class.java.classLoader.getResource("nativeindex/sample.def")).toURI()) + private val example = File("examples/jvm").absoluteFile + + /** One build exports every header the tests use: (json, dump) by .def name. */ + val exported: Map> by lazy { + val projectDir = createTempDirectory("native-index").toFile() + fun path(f: File) = f.absolutePath.replace("\\", "/") + fun exportTask(name: String, def: File, includeDir: File) = """ + tasks.register("export_$name") { + defFile.set(file("${path(def)}")) + includeDirs.from("${path(includeDir)}") + target.set(hostKonanTarget()) + outputFile.set(layout.buildDirectory.file("$name.json")) + dumpFile.set(layout.buildDirectory.file("$name.txt")) + } + """.trimIndent() + projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "native-index"""") + projectDir.resolve("build.gradle.kts").writeText( + listOf( + "import io.github.lemcoder.hostKonanTarget", + "import io.github.lemcoder.index.ExportNativeIndexTask", + "plugins { id(\"io.github.lemcoder.konanplugin\") }", + exportTask("mymath", example.resolve("src/main/nativeInterop/mymath.def"), example.resolve("native")), + exportTask("sample", sampleDef, sampleDef.parentFile), + ).joinToString("\n") + ) + GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments("export_mymath", "export_sample", "--stacktrace") + .build() + listOf("mymath", "sample").associateWith { + projectDir.resolve("build/$it.json") to projectDir.resolve("build/$it.txt") + } + } + } + + private fun assertInt(type: Type, bits: Int) { + val int = assertIs(type) + assertEquals(bits, int.size * 8) + assertEquals(true, int.isSigned) + } + +} diff --git a/src/test/resources/nativeindex/sample.def b/src/test/resources/nativeindex/sample.def new file mode 100644 index 0000000..9d7cede --- /dev/null +++ b/src/test/resources/nativeindex/sample.def @@ -0,0 +1,2 @@ +headers = sample.h +headerFilter = sample.h diff --git a/src/test/resources/nativeindex/sample.h b/src/test/resources/nativeindex/sample.h new file mode 100644 index 0000000..b4852e2 --- /dev/null +++ b/src/test/resources/nativeindex/sample.h @@ -0,0 +1,56 @@ +// Covers the shapes the index file has to carry: self-referential structs, unions, bit-fields, +// anonymous members, enums, typedef chains, function pointers, arrays and macro constants. +#include +#include + +#define SAMPLE_VERSION 3 +#define SAMPLE_PI 3.14159 +#define SAMPLE_NAME "sample" +#define SAMPLE_MASK (1u << 4) + +typedef struct node node_t; +struct node { + int32_t value; + node_t *next; /* back to itself through a typedef */ + struct node *prev; /* and directly */ +}; + +typedef union { + uint32_t as_int; + float as_float; +} bits_t; + +struct flags { + unsigned ready : 1; + unsigned mode : 3; + struct { int x, y; } point; + union { int i; char c; }; +}; + +enum color { RED, GREEN = 5, BLUE }; +typedef enum { SMALL = 1, LARGE = 100 } size_class; + +typedef int (*compare_fn)(const void *, const void *); + +struct buffer { + size_t length; + uint8_t data[16]; +}; + +struct packet { + int count; + int payload[]; +}; + +struct opaque; + +extern const int sample_global; + +int sample_sum(const int *values, size_t count); +const char *sample_name(void); +void sample_sort(void *base, size_t n, compare_fn cmp); +double sample_scale(node_t *node, double factor); +struct buffer sample_buffer(struct opaque *handle, enum color c, size_class s); +int sample_log(const char *format, ...); +size_t sample_packet_size(const struct packet *p); +bits_t sample_bits(struct flags f); From ae253c1caf33be82bcc370fa01b59383492b6a5c Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sat, 3 Oct 2026 10:26:52 +0200 Subject: [PATCH 05/57] chore: remove the Kotlin sources submodule The NativeIndex model is now vendored into src/main and refreshed with updateNativeIndexModel, so nothing builds from third_party/kotlin any more. Co-Authored-By: Claude Opus 5.5 --- .gitmodules | 4 ---- scripts/init-kotlin-submodule.sh | 20 -------------------- third_party/kotlin | 1 - 3 files changed, 25 deletions(-) delete mode 100644 .gitmodules delete mode 100755 scripts/init-kotlin-submodule.sh delete mode 160000 third_party/kotlin diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 025a482..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "third_party/kotlin"] - path = third_party/kotlin - url = https://github.com/JetBrains/kotlin.git - shallow = true diff --git a/scripts/init-kotlin-submodule.sh b/scripts/init-kotlin-submodule.sh deleted file mode 100755 index 1b4d42b..0000000 --- a/scripts/init-kotlin-submodule.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Checks out third_party/kotlin with only kotlin-native/Interop, at the commit this repo pins. -# A plain `git submodule update --init` would check out the whole Kotlin repository: sparse-checkout -# is local config and is not recorded in .gitmodules. -set -euo pipefail -cd "$(dirname "$0")/.." - -path=third_party/kotlin -url=$(git config -f .gitmodules "submodule.$path.url") -sha=$(git ls-tree HEAD "$path" | awk '{print $3}') - -git submodule init "$path" -if [ ! -e "$path/.git" ]; then - git init -q "$path" - git -C "$path" remote add origin "$url" -fi -git -C "$path" fetch -q --depth 1 --filter=blob:none origin "$sha" -git -C "$path" sparse-checkout set kotlin-native/Interop -git -C "$path" checkout -q --detach "$sha" -git submodule absorbgitdirs "$path" diff --git a/third_party/kotlin b/third_party/kotlin deleted file mode 160000 index 890ac1d..0000000 --- a/third_party/kotlin +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 890ac1d94fdb80eb85f0eeb5be5e4352df987b2f From 1bba4ecb79c82733fda1368e17aac41fc74fba74 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sun, 4 Oct 2026 13:05:02 +0200 Subject: [PATCH 06/57] refactor: build the index through cinterop's own buildNativeLibrary The exporter reimplemented cinterop's internal buildNativeLibrary to turn a .def into a NativeLibrary, missing clang modules, -header and the header-filter search prefixes. It now takes a cinterop command line after `--` and follows processCLib's own steps: CInteropArguments, prepareTool, DefFile, buildNativeLibrary, buildNativeIndex. The index for the sample header is unchanged. prepareTool and buildNativeLibrary are internal to the compiler jar, so the exporter compiles with that jar as a friend module; a signature change fails compilation at the kotlinNativeVersion bump. Java is not an alternative: `native` is a Java keyword, so Java cannot import any org.jetbrains.kotlin.native class. Dependency klibs (-library) are rejected rather than silently ignored. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 + buildSrc/build.gradle.kts | 5 + .../src/main/kotlin/native-index.gradle.kts | 13 +++ .../index/exporter/NativeIndexExporter.kt | 103 +++++++----------- .../lemcoder/index/ExportNativeIndexTask.kt | 12 +- 5 files changed, 71 insertions(+), 67 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea9003d..f8a9983 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,11 @@ copy of the classes, vendored into `src/main` from JetBrains/kotlin. disagree. The wiring lives in `buildSrc/src/main/kotlin/native-index.gradle.kts`. - **The exporter is its own source set** because its compiler jar carries a second copy of the model classes, and anything it links against exists only in the distribution. +- **The exporter calls cinterop's `internal` functions** (`prepareTool`, `buildNativeLibrary`) by + compiling with the compiler jar as a friend module (`friendPaths` in the buildSrc plugin). Java is not + a way around `internal` here: `native` is a Java keyword, so Java cannot import any + `org.jetbrains.kotlin.native.…` class. And Kotlin cannot see the jar's types through Java sources in + the same source set. - **It runs through `javaexec`, not the Worker API.** A process-isolated worker puts Gradle's Kotlin stdlib and the plugin's classes next to the compiler jar: the compiler then ran on Gradle 9.8's stdlib 2.4.10 instead of its bundled 2.4.20, and picked the real `buildNativeIndex` over the vendored diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index cc02e63..33fa30f 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -5,3 +5,8 @@ plugins { repositories { gradlePluginPortal() } + +dependencies { + // For KotlinCompile in native-index.gradle.kts; the version kotlin-dsl itself uses. + implementation(kotlin("gradle-plugin", embeddedKotlinVersion)) +} diff --git a/buildSrc/src/main/kotlin/native-index.gradle.kts b/buildSrc/src/main/kotlin/native-index.gradle.kts index 4b470ee..0e3d092 100644 --- a/buildSrc/src/main/kotlin/native-index.gradle.kts +++ b/buildSrc/src/main/kotlin/native-index.gradle.kts @@ -11,6 +11,8 @@ // Everything follows kotlinNativeVersion in gradle.properties; after changing it, run // updateNativeIndexModel. +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + val kotlinNativeVersion = providers.gradleProperty("kotlinNativeVersion") val vendoredModel = layout.projectDirectory.dir("src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer") @@ -27,6 +29,17 @@ pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { "indexExporterCompileOnly"(kotlinNativeVersion.map { "org.jetbrains.kotlin:kotlin-native-compiler-embeddable:$it" }) } + // The exporter calls cinterop's prepareTool and buildNativeLibrary, which are internal to the compiler + // jar. Compiling against that jar as a friend module makes them visible, still type-checked: a + // signature change in a Kotlin release fails this compilation at the kotlinNativeVersion bump. + tasks.named("compileIndexExporterKotlin") { + friendPaths.from( + configurations.named("indexExporterCompileClasspath").map { classpath -> + classpath.filter { it.name.startsWith("kotlin-native-compiler-embeddable-") } + } + ) + } + // The exporter ships inside the plugin jar as a nested jar, extracted when ExportNativeIndexTask runs. val indexExporterJar = tasks.register("indexExporterJar") { archiveFileName.set("native-index-exporter.jar") diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt index cb3a620..874f5e7 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -3,47 +3,38 @@ package io.github.lemcoder.index.exporter import io.github.lemcoder.index.dumpNativeIndex import kotlinx.cinterop.JvmCInteropCallbacks import org.jetbrains.kotlin.konan.util.DefFile -import org.jetbrains.kotlin.native.interop.gen.HeaderExclusionPolicyImpl -import org.jetbrains.kotlin.native.interop.gen.HeaderInclusionPolicyImpl import org.jetbrains.kotlin.native.interop.gen.ImportsImpl import org.jetbrains.kotlin.native.interop.gen.jvm.KotlinPlatform -import org.jetbrains.kotlin.native.interop.indexer.HeaderToIdMapper -import org.jetbrains.kotlin.native.interop.indexer.IncludeInfo -import org.jetbrains.kotlin.native.interop.indexer.Language -import org.jetbrains.kotlin.native.interop.indexer.NativeLibrary -import org.jetbrains.kotlin.native.interop.indexer.NativeLibraryHeaderFilter +import org.jetbrains.kotlin.native.interop.gen.jvm.buildNativeLibrary +import org.jetbrains.kotlin.native.interop.gen.jvm.parseKeyValuePairs +import org.jetbrains.kotlin.native.interop.gen.jvm.prepareTool +import org.jetbrains.kotlin.native.interop.indexer.NativeIndex import org.jetbrains.kotlin.native.interop.indexer.buildNativeIndex -import org.jetbrains.kotlin.native.interop.indexer.defaultCompilerArgs -import org.jetbrains.kotlin.native.interop.indexer.predefinedMacros -import org.jetbrains.kotlin.native.interop.tool.ToolConfig +import org.jetbrains.kotlin.native.interop.tool.CInteropArguments import org.jetbrains.kotlin.utils.NativeMemoryAllocator import java.io.File /** - * Entry point run in the Kotlin/Native distribution's JVM: indexes the headers a `.def` names, the way - * cinterop does, and writes the resulting `NativeIndex`. + * Entry point run in the Kotlin/Native distribution's JVM: indexes headers with cinterop's own code and + * writes the resulting [NativeIndex]. * * ``` - * --konan-home --def --target --out - * [--dump ] [--compiler-option ]... + * --out [--dump ] -- * ``` */ fun main(args: Array) { - val options = parseOptions(args) - fun single(name: String) = options[name]?.singleOrNull() ?: error("Expected exactly one --$name") + val separator = args.indexOf("--") + require(separator >= 0) { "Expected '--' before the cinterop command line: ${args.joinToString(" ")}" } + val options = parseOptions(args.copyOfRange(0, separator)) + val cinteropArgs = args.copyOfRange(separator + 1, args.size) - val konanHome = single("konan-home") + // As cinterop's own entry point does around its run. NativeMemoryAllocator.init() JvmCInteropCallbacks.init() try { - val tool = ToolConfig(single("target"), KotlinPlatform.NATIVE, emptyMap(), null, konanHome) - tool.prepare() - val def = DefFile(File(single("def")), tool.target) - val library = nativeLibrary(tool, def, options["compiler-option"].orEmpty()) - val index = buildNativeIndex(library, verbose = false).index - - File(single("out")).writeText(NativeIndexWriter().write(index)) - options["dump"]?.single()?.let { File(it).writeText(dumpNativeIndex(index)) } + val index = index(cinteropArgs) + File(options.getValue("out")).writeText(NativeIndexWriter().write(index)) + options["dump"]?.let { File(it).writeText(dumpNativeIndex(index)) } } finally { JvmCInteropCallbacks.dispose() NativeMemoryAllocator.dispose() @@ -51,46 +42,34 @@ fun main(args: Array) { } /** - * The [NativeLibrary] cinterop builds for a `.def` (`buildNativeLibrary` in upstream `main.kt`, which is - * internal), for header-based libraries. Clang modules β€” Objective-C only β€” are not supported. + * cinterop's own path from a command line to a [NativeIndex]: the steps its private `processCLib` takes + * before generating any Kotlin. `-def`, `-target`, `-compiler-option`, `-Xkonan-home` and the rest are + * cinterop's options, parsed by cinterop. + * + * [prepareTool] and [buildNativeLibrary] are `internal` to the compiler jar; this source set compiles with + * that jar as a friend module (see buildSrc's native-index plugin), so a signature change in a Kotlin + * release fails compilation here, at the `kotlinNativeVersion` bump. */ -private fun nativeLibrary(tool: ToolConfig, def: DefFile, extraCompilerOptions: List): NativeLibrary { - val config = def.config - require(config.modules.isEmpty()) { "Clang modules are not supported by the index exporter yet." } - val language = when (config.language) { - null, "C" -> Language.C - "C++" -> Language.CPP - "Objective-C" -> Language.OBJECTIVE_C - else -> error("Unexpected language '${config.language}'") - } - val compilerOptions = config.compilerOpts + - tool.getDefaultCompilerOptsForLanguage(language) + - extraCompilerOptions + - "-Wno-builtin-macro-redefined" - val includes = config.headers.map { IncludeInfo(it, null) } - return NativeLibrary( - includes = includes, - // Keep generated names and timestamps out of the index, as cinterop does (KT-54284). - additionalPreambleLines = def.defHeaderLines + predefinedMacros.map { "#define $it \"$it\"" }, - compilerArgs = defaultCompilerArgs(language) + compilerOptions + tool.platformCompilerOpts, - headerToIdMapper = HeaderToIdMapper(sysRoot = tool.sysRoot), - language = language, - excludeSystemLibs = config.excludeSystemLibs, - // No dependency klibs, so nothing is imported from elsewhere. - headerExclusionPolicy = HeaderExclusionPolicyImpl(ImportsImpl(emptyMap())), - headerFilter = NativeLibraryHeaderFilter.NameBased( - HeaderInclusionPolicyImpl(config.headerFilter, config.excludeFilter), - config.excludeDependentModules, - ), - objCClassesIncludingCategories = config.objcClassesIncludingCategories.toSet(), - allowIncludingObjCCategoriesFromDefFile = config.allowIncludingObjCCategoriesFromDefFile, +private fun index(cinteropArgs: Array): NativeIndex { + val arguments = CInteropArguments() + arguments.argParser.parse(cinteropArgs) + require(arguments.library.isEmpty()) { "Dependency klibs (-library) are not supported by the index exporter yet." } + + val tool = prepareTool( + arguments.target, + KotlinPlatform.NATIVE, + runFromDaemon = false, + parseKeyValuePairs(arguments.overrideKonanProperties), + konanDataDir = arguments.konanDataDir, + konanHome = arguments.konanHome, ) + val def = DefFile(arguments.def?.let(::File), tool.target) + // No dependency klibs, so no headers are imported from elsewhere. + val library = buildNativeLibrary(tool, def, arguments, ImportsImpl(emptyMap())) + return buildNativeIndex(library, arguments.verbose, macroNamesCollectingMode = arguments.macroCollectionImpl).index } -private fun parseOptions(args: Array): Map> { +private fun parseOptions(args: Array): Map { require(args.size % 2 == 0) { "Options come in --name value pairs: ${args.joinToString(" ")}" } - return args.toList().chunked(2).groupBy( - { (name, _) -> name.removePrefix("--") }, - { (_, value) -> value }, - ) + return args.toList().chunked(2).associate { (name, value) -> name.removePrefix("--") to value } } diff --git a/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt index 888a349..6a7b284 100644 --- a/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt @@ -71,13 +71,15 @@ abstract class ExportNativeIndexTask @Inject constructor( check(compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } val arguments = buildList { - add("--konan-home"); add(home.absolutePath) - add("--def"); add(defFile.get().asFile.absolutePath) - add("--target"); add(target.get().name) add("--out"); add(outputFile.get().asFile.absolutePath) dumpFile.orNull?.let { add("--dump"); add(it.asFile.absolutePath) } - includeDirs.files.forEach { add("--compiler-option"); add("-I${it.absolutePath}") } - compilerOptions.getOrElse(emptyList()).forEach { add("--compiler-option"); add(it) } + // From here on, a cinterop command line. + add("--") + add("-def"); add(defFile.get().asFile.absolutePath) + add("-target"); add(target.get().name) + add("-Xkonan-home"); add(home.absolutePath) + includeDirs.files.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } + compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } } val exporterJar = temporaryDir.resolve(EXPORTER_JAR) javaClass.getResourceAsStream("/io/github/lemcoder/index/$EXPORTER_JAR").use { input -> From 3468ccaa2f80f00679abc2ccac24841064f7ff82 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sun, 4 Oct 2026 22:06:17 +0200 Subject: [PATCH 07/57] refactor: run generators inside the index exporter The plugin no longer reads the NativeIndex back: generators run in the exporter's process, next to the index cinterop built, and only their output files leave it. This drops everything that carried the index across the process boundary: the vendored NativeIndex model and its shim, the JSON writer and reader, the updateNativeIndexModel/verifyNativeIndexModel tasks and the shared dump source set. - BindingGenerator is the exporter's generator interface; generators are chosen by name on its command line (--generator --output -- ). - The dump becomes the first generator, writing index.txt. - ExportNativeIndexTask becomes GenerateFromNativeIndexTask, taking a generator name and an output directory. The tests run the dump generator through TestKit and check signatures, a self-referential struct and macro constants, and that an unknown generator fails naming the known ones. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 32 +- README.md | 4 - build.gradle.kts | 2 +- buildSrc/src/main/kotlin/NativeIndexModel.kt | 72 --- .../src/main/kotlin/native-index.gradle.kts | 44 +- .../index/exporter/BindingGenerator.kt | 21 + .../lemcoder/index/exporter/DumpGenerator.kt} | 20 +- .../index/exporter/NativeIndexExporter.kt | 14 +- .../index/exporter/NativeIndexWriter.kt | 220 -------- ...Task.kt => GenerateFromNativeIndexTask.kt} | 23 +- .../lemcoder/index/NativeIndexReader.kt | 189 ------- .../io/github/lemcoder/index/NativeIndexer.kt | 12 +- .../interop/indexer/HeaderToIdMapper.kt | 36 -- .../native/interop/indexer/IndexModelShim.kt | 17 - .../native/interop/indexer/NativeIndex.kt | 524 ------------------ .../index/GenerateFromNativeIndexTest.kt | 104 ++++ .../index/NativeIndexRoundTripTest.kt | 121 ---- 17 files changed, 191 insertions(+), 1264 deletions(-) delete mode 100644 buildSrc/src/main/kotlin/NativeIndexModel.kt create mode 100644 src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt rename src/{indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt => indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt} (90%) delete mode 100644 src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt rename src/main/kotlin/io/github/lemcoder/index/{ExportNativeIndexTask.kt => GenerateFromNativeIndexTask.kt} (87%) delete mode 100644 src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt delete mode 100644 src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt delete mode 100644 src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt delete mode 100644 src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt create mode 100644 src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt delete mode 100644 src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index f8a9983..ec50d49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,28 +71,26 @@ name: it compiles, links, and dies on the first call. - A nested settings object must be created through `ObjectFactory`; Gradle will not synthesise `abstract val cmake: CMakeSettings`. -## The vendored NativeIndex +## The index exporter -cinterop's C model (`NativeIndex`) is read on both sides of a process boundary: the index exporter -builds it inside the Kotlin/Native distribution's JVM, and the plugin reads the file back into its own -copy of the classes, vendored into `src/main` from JetBrains/kotlin. +Bindings are generated from cinterop's own C model (`NativeIndex`), inside the Kotlin/Native +distribution's JVM: the exporter (`src/indexExporter`) indexes headers with cinterop's code and runs a +`BindingGenerator` over the index in the same process. Only generated files leave it; the plugin never +holds a `NativeIndex`. - **One version everywhere.** `kotlinNativeVersion` in `gradle.properties` sets `kotlin-native-utils`, - the vendored model, the exporter's compile-only compiler jar, and the distribution the indexer runs - in. After changing it, run `./gradlew updateNativeIndexModel`; `check` fails while the stamps - disagree. The wiring lives in `buildSrc/src/main/kotlin/native-index.gradle.kts`. -- **The exporter is its own source set** because its compiler jar carries a second copy of the model - classes, and anything it links against exists only in the distribution. -- **The exporter calls cinterop's `internal` functions** (`prepareTool`, `buildNativeLibrary`) by - compiling with the compiler jar as a friend module (`friendPaths` in the buildSrc plugin). Java is not - a way around `internal` here: `native` is a Java keyword, so Java cannot import any - `org.jetbrains.kotlin.native.…` class. And Kotlin cannot see the jar's types through Java sources in - the same source set. + the compiler jar the exporter compiles against, and the distribution it must run in. The wiring lives + in `buildSrc/src/main/kotlin/native-index.gradle.kts`. +- **The exporter is its own source set** because it compiles against the compiler jar, and anything it + links against exists only in the distribution. +- **It calls cinterop's `internal` functions** (`prepareTool`, `buildNativeLibrary`) by compiling with + the compiler jar as a friend module (`friendPaths` in the buildSrc plugin). Java is not a way around + `internal` here: `native` is a Java keyword, so Java cannot import any `org.jetbrains.kotlin.native.…` + class. And Kotlin cannot see the jar's types through Java sources in the same source set. - **It runs through `javaexec`, not the Worker API.** A process-isolated worker puts Gradle's Kotlin stdlib and the plugin's classes next to the compiler jar: the compiler then ran on Gradle 9.8's - stdlib 2.4.10 instead of its bundled 2.4.20, and picked the real `buildNativeIndex` over the vendored - one only by classpath order. The daemon is out too: KGP's cinterop may already have loaded libclang - there, and a native library loads in one classloader per JVM. + stdlib 2.4.10 instead of its bundled 2.4.20. The daemon is out too: KGP's cinterop may already have + loaded libclang there, and a native library loads in one classloader per JVM. ## Testing and publishing diff --git a/README.md b/README.md index a03b93c..3d63da6 100644 --- a/README.md +++ b/README.md @@ -317,8 +317,4 @@ Alpha, and the interop DSL moved wholesale β€” there is no compatibility shim. ## License This project is licensed under the Apache 2.0 License. For more details, see the `LICENSE` file. -`NativeIndex.kt` and `HeaderToIdMapper.kt` under `src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer` -are copied unmodified from [JetBrains/kotlin](https://github.com/JetBrains/kotlin) (Apache 2.0); each -file names the version it came from. - Happy coding! 😊 diff --git a/build.gradle.kts b/build.gradle.kts index 17937ea..f105526 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ plugins { `kotlin-dsl` id("com.gradle.plugin-publish") version "2.2.1" - // cinterop's NativeIndex exporter and the vendored model: buildSrc/src/main/kotlin/native-index.gradle.kts + // The index exporter and its generators: buildSrc/src/main/kotlin/native-index.gradle.kts id("native-index") } diff --git a/buildSrc/src/main/kotlin/NativeIndexModel.kt b/buildSrc/src/main/kotlin/NativeIndexModel.kt deleted file mode 100644 index 3b50f80..0000000 --- a/buildSrc/src/main/kotlin/NativeIndexModel.kt +++ /dev/null @@ -1,72 +0,0 @@ -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import org.gradle.api.tasks.UntrackedTask -import java.net.URI - -/** The upstream files making up the vendored model. Each copy starts with a stamp naming its version. */ -object NativeIndexModel { - val files = listOf("NativeIndex.kt", "HeaderToIdMapper.kt") - - const val STAMP = "// Vendored from JetBrains/kotlin v" - - const val UPSTREAM_DIR = "kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer" -} - -/** Downloads the model for [version] from JetBrains/kotlin into [directory], stamped with the version. */ -@UntrackedTask(because = "Writes into the source tree from the network") -abstract class UpdateNativeIndexModel : DefaultTask() { - @get:Input - abstract val version: Property - - @get:Internal - abstract val directory: DirectoryProperty - - init { - group = "native index" - description = "Downloads the NativeIndex model for kotlinNativeVersion from JetBrains/kotlin into src/main." - } - - @TaskAction - fun update() { - val base = "https://raw.githubusercontent.com/JetBrains/kotlin/v${version.get()}/${NativeIndexModel.UPSTREAM_DIR}" - NativeIndexModel.files.forEach { name -> - val upstream = URI("$base/$name").toURL().readText() - directory.file(name).get().asFile.writeText( - "${NativeIndexModel.STAMP}${version.get()}, unmodified. Update with ./gradlew updateNativeIndexModel.\n$upstream" - ) - } - } -} - -/** Fails when a vendored model file is not stamped with [version]. */ -@UntrackedTask(because = "A cheap check with no outputs") -abstract class VerifyNativeIndexModel : DefaultTask() { - @get:Input - abstract val version: Property - - @get:InputFiles @get:PathSensitive(PathSensitivity.NAME_ONLY) - abstract val sources: ConfigurableFileCollection - - init { - group = "verification" - description = "Fails when the vendored NativeIndex model is not from kotlinNativeVersion." - } - - @TaskAction - fun verify() { - val expected = "${NativeIndexModel.STAMP}${version.get()}," - val stale = sources.files.filterNot { it.isFile && it.useLines { lines -> lines.first().startsWith(expected) } } - check(stale.isEmpty()) { - "Vendored NativeIndex model is not from Kotlin ${version.get()}: ${stale.joinToString { it.name }}. " + - "Run ./gradlew updateNativeIndexModel." - } - } -} diff --git a/buildSrc/src/main/kotlin/native-index.gradle.kts b/buildSrc/src/main/kotlin/native-index.gradle.kts index 0e3d092..999efd8 100644 --- a/buildSrc/src/main/kotlin/native-index.gradle.kts +++ b/buildSrc/src/main/kotlin/native-index.gradle.kts @@ -1,28 +1,20 @@ -// cinterop's NativeIndex, carried out of the Kotlin/Native distribution. +// The index exporter (src/indexExporter): runs in its own JVM, started by GenerateFromNativeIndexTask +// with javaexec, whose classpath is only the Kotlin/Native distribution's compiler jar and the exporter. +// It indexes headers with cinterop's own code and runs a generator over the resulting NativeIndex, so +// only generated files leave that process. Its own source set because it compiles against the compiler +// jar and main must not; its own process because that jar bundles a Kotlin stdlib that must not meet +// Gradle's. // -// The exporter (src/indexExporter) runs in its own JVM whose classpath is only the distribution's -// compiler jar and the exporter: it builds the index with cinterop's own indexer and writes it to a file. -// The plugin reads that file back into the same model classes, vendored from JetBrains/kotlin into -// src/main. Its own source set because it compiles against the compiler jar and main must not; its own -// process because that jar bundles a Kotlin stdlib and the real model classes, which must not meet -// Gradle's stdlib or the vendored copy. The text dump in src/indexShared is compiled into both the -// exporter and the tests. -// -// Everything follows kotlinNativeVersion in gradle.properties; after changing it, run -// updateNativeIndexModel. +// kotlinNativeVersion in gradle.properties picks the compiler jar it compiles against, and so the +// distribution it must run in. import org.jetbrains.kotlin.gradle.tasks.KotlinCompile val kotlinNativeVersion = providers.gradleProperty("kotlinNativeVersion") -val vendoredModel = layout.projectDirectory.dir("src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer") pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { val sourceSets = the() - fun SourceSet.addKotlinSources(dir: String) = - (extensions.getByName("kotlin") as SourceDirectorySet).srcDir(dir) - - val indexExporter = sourceSets.create("indexExporter") { addKotlinSources("src/indexShared/kotlin") } - sourceSets.named("test") { addKotlinSources("src/indexShared/kotlin") } + val indexExporter = sourceSets.create("indexExporter") dependencies { // compileOnly: at run time the distribution's own copy of this jar is the classpath. @@ -40,7 +32,7 @@ pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { ) } - // The exporter ships inside the plugin jar as a nested jar, extracted when ExportNativeIndexTask runs. + // The exporter ships inside the plugin jar as a nested jar, extracted when GenerateFromNativeIndexTask runs. val indexExporterJar = tasks.register("indexExporterJar") { archiveFileName.set("native-index-exporter.jar") destinationDirectory.set(layout.buildDirectory.dir("indexExporter")) @@ -49,7 +41,7 @@ pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { tasks.named("processResources") { from(indexExporterJar) { into("io/github/lemcoder/index") } - // The version the plugin looks for at run time is the one the model was vendored from. + // The distribution the plugin looks for at run time is the one the exporter was compiled against. val version = kotlinNativeVersion inputs.property("kotlinNativeVersion", version) filesMatching("io/github/lemcoder/index/native-index.properties") { @@ -57,17 +49,3 @@ pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { } } } - -tasks.register("updateNativeIndexModel") { - version.set(kotlinNativeVersion) - directory.set(vendoredModel) -} - -val verifyNativeIndexModel = tasks.register("verifyNativeIndexModel") { - version.set(kotlinNativeVersion) - sources.from(NativeIndexModel.files.map { vendoredModel.file(it) }) -} - -tasks.named("check") { - dependsOn(verifyNativeIndexModel) -} diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt new file mode 100644 index 0000000..965911a --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt @@ -0,0 +1,21 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.indexer.NativeIndex +import java.io.File + +/** + * Turns the [NativeIndex] cinterop built into generated files. + * + * Runs in the exporter's process, next to the index, so it reads upstream's model directly: nothing about + * the index crosses into the Gradle daemon, only what the generator writes. + */ +interface BindingGenerator { + /** How the exporter's command line and the Gradle task name this generator. */ + val name: String + + /** Writes this generator's output for [index] into [outputDirectory], which starts out empty. */ + fun generate(index: NativeIndex, outputDirectory: File) +} + +/** Every generator the exporter can run, by [BindingGenerator.name]. */ +val bindingGenerators: Map = listOf(DumpGenerator).associateBy { it.name } diff --git a/src/indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt similarity index 90% rename from src/indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt rename to src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt index 08dd95d..3d2a0e5 100644 --- a/src/indexShared/kotlin/io/github/lemcoder/index/NativeIndexDump.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt @@ -1,17 +1,23 @@ -package io.github.lemcoder.index +package io.github.lemcoder.index.exporter import org.jetbrains.kotlin.native.interop.indexer.* +import java.io.File /** - * Renders a [NativeIndex] as deterministic text, for tests only β€” it is not a format anything reads. - * - * Compiled twice: into the index exporter against the Kotlin/Native distribution's classes, and into - * the tests against the plugin's copy of the model. The exporter dumps the index it built, the test - * dumps the one it read back, and equal text means the file carried everything. + * Renders the [NativeIndex] as deterministic text into `index.txt`: what cinterop sees in the headers, + * for reading, diffing across Kotlin versions, and tests. * * Declarations referenced from a type are rendered by name, not expanded, so cycles terminate. */ -fun dumpNativeIndex(index: NativeIndex): String = buildString { +object DumpGenerator : BindingGenerator { + override val name = "dump" + + override fun generate(index: NativeIndex, outputDirectory: File) { + outputDirectory.resolve("index.txt").writeText(dumpNativeIndex(index)) + } +} + +internal fun dumpNativeIndex(index: NativeIndex): String = buildString { index.includedHeaders.map { it.value }.sorted().forEach { appendLine("header $it") } index.structs.sortedBy { it.spelling }.forEach { appendStruct(it) } index.enums.sortedBy { it.spelling }.forEach { appendEnum(it) } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt index 874f5e7..9f9e31a 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -1,6 +1,5 @@ package io.github.lemcoder.index.exporter -import io.github.lemcoder.index.dumpNativeIndex import kotlinx.cinterop.JvmCInteropCallbacks import org.jetbrains.kotlin.konan.util.DefFile import org.jetbrains.kotlin.native.interop.gen.ImportsImpl @@ -16,10 +15,10 @@ import java.io.File /** * Entry point run in the Kotlin/Native distribution's JVM: indexes headers with cinterop's own code and - * writes the resulting [NativeIndex]. + * hands the resulting [NativeIndex] to a [BindingGenerator]. * * ``` - * --out [--dump ] -- + * --generator --output -- * ``` */ fun main(args: Array) { @@ -27,14 +26,19 @@ fun main(args: Array) { require(separator >= 0) { "Expected '--' before the cinterop command line: ${args.joinToString(" ")}" } val options = parseOptions(args.copyOfRange(0, separator)) val cinteropArgs = args.copyOfRange(separator + 1, args.size) + val generatorName = options.getValue("generator") + val generator = bindingGenerators[generatorName] + ?: error("Unknown generator '$generatorName'. Known: ${bindingGenerators.keys.sorted().joinToString()}") + val output = File(options.getValue("output")) // As cinterop's own entry point does around its run. NativeMemoryAllocator.init() JvmCInteropCallbacks.init() try { val index = index(cinteropArgs) - File(options.getValue("out")).writeText(NativeIndexWriter().write(index)) - options["dump"]?.let { File(it).writeText(dumpNativeIndex(index)) } + output.deleteRecursively() + output.mkdirs() + generator.generate(index, output) } finally { JvmCInteropCallbacks.dispose() NativeMemoryAllocator.dispose() diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt deleted file mode 100644 index f94fe06..0000000 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexWriter.kt +++ /dev/null @@ -1,220 +0,0 @@ -package io.github.lemcoder.index.exporter - -import org.jetbrains.kotlin.native.interop.indexer.* -import java.util.IdentityHashMap - -/** - * Writes a [NativeIndex] as JSON, for `NativeIndexReader` on the plugin side. - * - * The model is a graph, not a tree: a struct can point to itself, and one typedef is referenced from - * many places. So structs, enums and typedefs are written once each into tables, every declaration a - * type reaches β€” not only the ones the index lists β€” and types refer to them by table position. - * - * Objective-C is not supported yet: C and C++ headers never produce it. - */ -internal class NativeIndexWriter { - private val structs = Table() - private val enums = Table() - private val typedefs = Table() - - fun write(index: NativeIndex): String { - require(index.objCClasses.isEmpty() && index.objCProtocols.isEmpty() && index.objCCategories.isEmpty()) { - "The index format does not carry Objective-C declarations yet." - } - val root = linkedMapOf( - "format" to FORMAT_VERSION, - "includedHeaders" to index.includedHeaders.map { it.value }, - "structs" to index.structs.map { structs.id(it) }, - "enums" to index.enums.map { enums.id(it) }, - "typedefs" to index.typedefs.map { typedefs.id(it) }, - "functions" to index.functions.map(::function), - "globals" to index.globals.map(::global), - "macroConstants" to index.macroConstants.map(::constant), - "wrappedMacros" to index.wrappedMacros.map { obj("name" to it.name, "type" to type(it.type)) }, - ) - // Encoding a declaration can reach further ones, in any of the three tables. - while (structs.encodePending(::structDecl) or enums.encodePending(::enumDef) or typedefs.encodePending(::typedefDef)) { - // until no table grows - } - root["declarations"] = obj( - "structs" to structs.encoded, - "enums" to enums.encoded, - "typedefs" to typedefs.encoded, - ) - return Json.write(root) - } - - private fun structDecl(d: StructDecl) = obj( - "spelling" to d.spelling, - "anonymous" to d.isAnonymous, - "header" to d.location.headerId.value, - "def" to d.def?.let(::structDef), - ) - - private fun structDef(d: StructDef): Any = obj( - "size" to d.size, - "align" to d.align, - "kind" to d.kind.name, - "members" to d.members.map(::member), - "methods" to d.methods.map(::function), - "staticFields" to d.staticFields.map(::global), - ) - - private fun member(m: StructMember): Any = when (m) { - is Field -> obj( - "k" to "field", "name" to m.name, "type" to type(m.type), - "offset" to m.offset, "typeSize" to m.typeSize, "typeAlign" to m.typeAlign, - ) - is BitField -> obj("k" to "bitField", "name" to m.name, "type" to type(m.type), "offset" to m.offset, "size" to m.size) - is IncompleteField -> obj("k" to "incomplete", "name" to m.name) - is AnonymousInnerRecord -> obj("k" to "anonymous", "def" to structDef(m.def)) - } - - private fun enumDef(d: EnumDef) = obj( - "spelling" to d.spelling, - "baseType" to type(d.baseType), - "anonymous" to d.isAnonymous, - "header" to d.location.headerId.value, - "constants" to d.constants.map { - obj("name" to it.name, "value" to it.value, "explicit" to it.isExplicitlyDefined) - }, - ) - - private fun typedefDef(d: TypedefDef) = obj( - "name" to d.name, - "aliased" to type(d.aliased), - "header" to d.location.headerId.value, - ) - - private fun function(f: FunctionDecl) = obj( - "name" to f.name, - "params" to f.parameters.map { obj("name" to it.name, "type" to type(it.type), "nsConsumed" to it.nsConsumed) }, - "ret" to type(f.returnType), - "vararg" to f.isVararg, - "access" to access(f.directAccess), - "parent" to f.parentName, - ) - - private fun global(g: GlobalDecl) = obj( - "name" to g.name, - "type" to type(g.type), - "const" to g.isConst, - "access" to access(g.directAccess), - "parent" to g.parentName, - ) - - private fun access(a: DirectAccess) = when (a) { - is DirectAccess.Symbol -> obj("symbol" to a.name) - is DirectAccess.Unavailable -> obj("unavailable" to a.reason) - } - - private fun constant(c: ConstantDef) = when (c) { - is IntegerConstantDef -> obj("k" to "int", "name" to c.name, "type" to type(c.type), "value" to c.value) - // Raw bits: exact, and JSON has no NaN or infinity. - is FloatingConstantDef -> obj( - "k" to "float", "name" to c.name, "type" to type(c.type), - "bits" to java.lang.Double.doubleToRawLongBits(c.value), - ) - is StringConstantDef -> obj("k" to "string", "name" to c.name, "type" to type(c.type), "value" to c.value) - else -> error("Unknown macro constant ${c::class.qualifiedName}") - } - - private fun type(t: Type): Any = when (t) { - CharType -> kind("char") - CBoolType -> kind("cbool") - ObjCBoolType -> kind("objcbool") - VoidType -> kind("void") - UnsupportedType -> kind("unsupported") - is BoolType -> kind("bool") - is IntegerType -> obj("k" to "int", "size" to t.size, "signed" to t.isSigned, "spelling" to t.spelling) - is FloatingType -> obj("k" to "float", "size" to t.size, "spelling" to t.spelling) - is VectorType -> obj("k" to "vector", "elem" to type(t.elementType), "count" to t.elementCount, "spelling" to t.spelling) - is RecordType -> obj("k" to "record", "ref" to structs.id(t.decl)) - is EnumType -> obj("k" to "enum", "ref" to enums.id(t.def)) - is Typedef -> obj("k" to "typedef", "ref" to typedefs.id(t.def)) - is PointerType -> obj( - "k" to "pointer", "to" to type(t.pointeeType), "const" to t.pointeeIsConst, - "lvref" to t.isLVReference, "spelling" to t.spelling, - ) - is FunctionType -> obj("k" to "function", "params" to t.parameterTypes.map(::type), "ret" to type(t.returnType)) - is ConstArrayType -> obj("k" to "array", "elem" to type(t.elemType), "length" to t.length) - is IncompleteArrayType -> obj("k" to "incompleteArray", "elem" to type(t.elemType)) - is VariableArrayType -> obj("k" to "variableArray", "elem" to type(t.elemType)) - is ObjCPointer -> error("The index format does not carry Objective-C types yet: ${t::class.simpleName}") - else -> error("Unknown type ${t::class.qualifiedName}") - } - - private fun kind(k: String) = obj("k" to k) - - private fun obj(vararg entries: Pair): Map = linkedMapOf(*entries) - - /** Declarations by identity, numbered in the order first reached. */ - private class Table { - private val ids = IdentityHashMap() - private val items = mutableListOf() - val encoded = mutableListOf() - - fun id(item: T): Int = ids.getOrPut(item) { items.add(item); items.size - 1 } - - /** Encodes what was reached since the last call; true if there was anything. */ - fun encodePending(encode: (T) -> Any): Boolean { - val before = encoded.size - while (encoded.size < items.size) encoded.add(encode(items[encoded.size])) - return encoded.size > before - } - } - - companion object { - const val FORMAT_VERSION = 1 - } -} - -/** Just enough JSON writing for the index: maps, lists, strings, numbers, booleans and null. */ -internal object Json { - fun write(value: Any?): String = StringBuilder().also { write(it, value, "") }.toString() - - private fun write(out: StringBuilder, value: Any?, indent: String) { - when (value) { - null -> out.append("null") - is String -> string(out, value) - is Number, is Boolean -> out.append(value.toString()) - is Map<*, *> -> { - if (value.isEmpty()) return run { out.append("{}") } - out.append("{\n") - value.entries.forEachIndexed { i, (k, v) -> - out.append("$indent ") - string(out, k as String) - out.append(": ") - write(out, v, "$indent ") - out.append(if (i < value.size - 1) ",\n" else "\n") - } - out.append("$indent}") - } - is List<*> -> { - if (value.isEmpty()) return run { out.append("[]") } - out.append("[\n") - value.forEachIndexed { i, v -> - out.append("$indent ") - write(out, v, "$indent ") - out.append(if (i < value.size - 1) ",\n" else "\n") - } - out.append("$indent]") - } - else -> error("Cannot write ${value::class.qualifiedName} as JSON") - } - } - - private fun string(out: StringBuilder, s: String) { - out.append('"') - for (c in s) when { - c == '"' -> out.append("\\\"") - c == '\\' -> out.append("\\\\") - c == '\n' -> out.append("\\n") - c == '\r' -> out.append("\\r") - c == '\t' -> out.append("\\t") - c < ' ' -> out.append("\\u%04x".format(c.code)) - else -> out.append(c) - } - out.append('"') - } -} diff --git a/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt similarity index 87% rename from src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt rename to src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index 6a7b284..f828e37 100644 --- a/src/main/kotlin/io/github/lemcoder/index/ExportNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -13,7 +13,7 @@ import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -25,8 +25,8 @@ import java.nio.file.StandardCopyOption import javax.inject.Inject /** - * Indexes the headers a `.def` names with cinterop's own indexer and writes the `NativeIndex` as JSON, - * for [NativeIndexReader]. + * Indexes the headers a `.def` names with cinterop's own indexer and runs a [generator] over the + * resulting `NativeIndex`, in the exporter's process, writing into [outputDirectory]. * * The indexer runs in a JVM of its own whose classpath is only the distribution's compiler jar and the * exporter. Not in the daemon: a native library such as libclang can be loaded by one classloader per @@ -35,7 +35,7 @@ import javax.inject.Inject * classes bundled in the compiler jar. */ @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") -abstract class ExportNativeIndexTask @Inject constructor( +abstract class GenerateFromNativeIndexTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) @@ -55,12 +55,13 @@ abstract class ExportNativeIndexTask @Inject constructor( @get:Internal abstract val konanHome: DirectoryProperty - @get:OutputFile - abstract val outputFile: RegularFileProperty + /** A generator the exporter knows by name, e.g. `dump`. */ + @get:Input + abstract val generator: Property - /** The exporter's text rendering of the index it built; for tests comparing it with what reads back. */ - @get:OutputFile @get:Optional - abstract val dumpFile: RegularFileProperty + /** Replaced on every run with the generator's output. */ + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty @TaskAction fun export() { @@ -71,8 +72,8 @@ abstract class ExportNativeIndexTask @Inject constructor( check(compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } val arguments = buildList { - add("--out"); add(outputFile.get().asFile.absolutePath) - dumpFile.orNull?.let { add("--dump"); add(it.asFile.absolutePath) } + add("--generator"); add(generator.get()) + add("--output"); add(outputDirectory.get().asFile.absolutePath) // From here on, a cinterop command line. add("--") add("-def"); add(defFile.get().asFile.absolutePath) diff --git a/src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt b/src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt deleted file mode 100644 index 1cc8b1a..0000000 --- a/src/main/kotlin/io/github/lemcoder/index/NativeIndexReader.kt +++ /dev/null @@ -1,189 +0,0 @@ -package io.github.lemcoder.index - -import groovy.json.JsonSlurper -import org.jetbrains.kotlin.native.interop.indexer.* -import java.io.File - -/** - * Reads the file the index exporter writes back into upstream's [NativeIndex] model β€” the plugin's copy - * of it, compiled from the pinned Kotlin sources. - * - * Structs are created first without definitions and filled in afterwards, because a definition can - * refer back to its own struct. Enums and typedefs are built on first use: in C they cannot form a - * cycle except through a struct, so recursion ends. - */ -object NativeIndexReader { - fun read(file: File): NativeIndex = Decoder(JsonSlurper().parse(file).asMap()).index() -} - -private class Decoder(private val root: Map) { - private val declarations = root.map("declarations") - private val structTable = declarations.list("structs").map { it.asMap() } - private val enumTable = declarations.list("enums").map { it.asMap() } - private val typedefTable = declarations.list("typedefs").map { it.asMap() } - - private val structs = structTable.map { DecodedStructDecl(it.string("spelling"), it.bool("anonymous"), location(it)) } - private val enums = arrayOfNulls(enumTable.size) - private val typedefs = arrayOfNulls(typedefTable.size) - - fun index(): NativeIndex { - val format = root.int("format") - check(format == FORMAT_VERSION) { "Index format $format; this plugin reads $FORMAT_VERSION." } - structTable.forEachIndexed { i, s -> structs[i].def = s["def"]?.let { structDef(it.asMap()) } } - return DecodedNativeIndex( - structs = root.ids("structs").map { structs[it] }, - enums = root.ids("enums").map(::enum), - typedefs = root.ids("typedefs").map(::typedef), - functions = root.list("functions").map { function(it.asMap()) }, - globals = root.list("globals").map { global(it.asMap()) }, - macroConstants = root.list("macroConstants").map { constant(it.asMap()) }, - wrappedMacros = root.list("wrappedMacros").map { it.asMap().let { m -> WrappedMacroDef(m.string("name"), type(m.map("type"))) } }, - includedHeaders = root.list("includedHeaders").map { HeaderId(it as String) }, - ) - } - - private fun enum(id: Int): EnumDef = enums[id] ?: enumTable[id].let { e -> - DecodedEnumDef( - spelling = e.string("spelling"), - baseType = type(e.map("baseType")), - isAnonymous = e.bool("anonymous"), - location = location(e), - constants = e.list("constants").map { - it.asMap().let { c -> EnumConstant(c.string("name"), c.long("value"), c.bool("explicit")) } - }, - ) - }.also { enums[id] = it } - - private fun typedef(id: Int): TypedefDef = typedefs[id] ?: typedefTable[id].let { t -> - TypedefDef(type(t.map("aliased")), t.string("name"), location(t)) - }.also { typedefs[id] = it } - - private fun structDef(d: Map): StructDef = DecodedStructDef( - size = d.long("size"), - align = d.int("align"), - kind = StructDef.Kind.valueOf(d.string("kind")), - members = d.list("members").map { member(it.asMap()) }, - methods = d.list("methods").map { function(it.asMap()) }, - staticFields = d.list("staticFields").map { global(it.asMap()) }, - ) - - private fun member(m: Map): StructMember = when (val k = m.string("k")) { - "field" -> Field(m.string("name"), type(m.map("type")), m.long("offset"), m.long("typeSize"), m.long("typeAlign")) - "bitField" -> BitField(m.string("name"), type(m.map("type")), m.long("offset"), m.int("size")) - "incomplete" -> IncompleteField(m.string("name")) - "anonymous" -> AnonymousInnerRecord(structDef(m.map("def"))) - else -> error("Unknown struct member kind '$k'") - } - - private fun function(f: Map) = FunctionDecl( - name = f.string("name"), - parameters = f.list("params").map { - it.asMap().let { p -> Parameter(p["name"] as String?, type(p.map("type")), p.bool("nsConsumed")) } - }, - returnType = type(f.map("ret")), - isVararg = f.bool("vararg"), - directAccess = access(f.map("access")), - parentName = f["parent"] as String?, - ) - - private fun global(g: Map) = GlobalDecl( - name = g.string("name"), - type = type(g.map("type")), - isConst = g.bool("const"), - directAccess = access(g.map("access")), - parentName = g["parent"] as String?, - ) - - private fun access(a: Map): DirectAccess = - (a["symbol"] as String?)?.let { DirectAccess.Symbol(it) } ?: DirectAccess.Unavailable(a.string("unavailable")) - - private fun constant(c: Map): ConstantDef { - val name = c.string("name") - val type = type(c.map("type")) - return when (val k = c.string("k")) { - "int" -> IntegerConstantDef(name, type, c.long("value")) - "float" -> FloatingConstantDef(name, type, java.lang.Double.longBitsToDouble(c.long("bits"))) - "string" -> StringConstantDef(name, type, c.string("value")) - else -> error("Unknown macro constant kind '$k'") - } - } - - private fun type(t: Map): Type = when (val k = t.string("k")) { - "char" -> CharType - "cbool" -> CBoolType - "objcbool" -> ObjCBoolType - "void" -> VoidType - "unsupported" -> UnsupportedType - "bool" -> BoolType() - "int" -> IntegerType(t.int("size"), t.bool("signed"), t.string("spelling")) - "float" -> FloatingType(t.int("size"), t.string("spelling")) - "vector" -> VectorType(type(t.map("elem")), t.int("count"), t.string("spelling")) - "record" -> RecordType(structs[t.int("ref")]) - "enum" -> EnumType(enum(t.int("ref"))) - "typedef" -> Typedef(typedef(t.int("ref"))) - "pointer" -> PointerType(type(t.map("to")), t.bool("const"), t.bool("lvref"), t["spelling"] as String?) - "function" -> FunctionType(t.list("params").map { type(it.asMap()) }, type(t.map("ret"))) - "array" -> ConstArrayType(type(t.map("elem")), t.long("length")) - "incompleteArray" -> IncompleteArrayType(type(t.map("elem"))) - "variableArray" -> VariableArrayType(type(t.map("elem"))) - else -> error("Unknown type kind '$k'") - } - - private fun location(m: Map) = Location(HeaderId(m.string("header"))) - - private companion object { - /** Must match `NativeIndexWriter.FORMAT_VERSION` in the index exporter. */ - const val FORMAT_VERSION = 1 - } -} - -private class DecodedNativeIndex( - override val structs: List, - override val enums: List, - override val typedefs: List, - override val functions: List, - override val globals: List, - override val macroConstants: List, - override val wrappedMacros: List, - override val includedHeaders: List, -) : NativeIndex() { - override val objCClasses: List get() = emptyList() - override val objCProtocols: List get() = emptyList() - override val objCCategories: List get() = emptyList() -} - -private class DecodedStructDecl( - spelling: String, - override val isAnonymous: Boolean, - override val location: Location, -) : StructDecl(spelling) { - /** Set once every struct exists, since a definition can refer to its own struct. */ - override var def: StructDef? = null -} - -private class DecodedStructDef( - size: Long, - align: Int, - override val kind: Kind, - override val members: List, - override val methods: List, - override val staticFields: List, -) : StructDef(size, align) - -private class DecodedEnumDef( - spelling: String, - baseType: Type, - override val isAnonymous: Boolean, - override val location: Location, - override val constants: List, -) : EnumDef(spelling, baseType) - -@Suppress("UNCHECKED_CAST") -private fun Any?.asMap() = this as Map -private fun Map.map(key: String) = getValue(key).asMap() -private fun Map.list(key: String) = getValue(key) as List -private fun Map.ids(key: String) = list(key).map { (it as Number).toInt() } -private fun Map.string(key: String) = getValue(key) as String -private fun Map.bool(key: String) = getValue(key) as Boolean -private fun Map.int(key: String) = (getValue(key) as Number).toInt() -private fun Map.long(key: String) = (getValue(key) as Number).toLong() diff --git a/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt b/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt index 2473355..beabf3e 100644 --- a/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt +++ b/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt @@ -3,15 +3,13 @@ package io.github.lemcoder.index import java.io.File /** - * Where cinterop's header indexer runs: inside a Kotlin/Native distribution, whose compiler jar must be - * the Kotlin version the plugin's copy of the model was vendored from β€” [kotlinNativeVersion] β€” or the - * index may not read back. [ExportNativeIndexTask] runs it. + * Where cinterop's header indexer runs: inside a Kotlin/Native distribution of exactly + * [kotlinNativeVersion]. The exporter is compiled against that version's compiler jar β€” its internal + * functions and the `NativeIndex` model the generators read β€” so another version may not link. + * [GenerateFromNativeIndexTask] runs it. */ object NativeIndexer { - /** - * The Kotlin version the model was vendored from β€” `kotlinNativeVersion` in the plugin build β€” - * and so the distribution the indexer must run in. - */ + /** `kotlinNativeVersion` in the plugin build: the compiler jar the exporter was compiled against. */ val kotlinNativeVersion: String by lazy { java.util.Properties().apply { NativeIndexer::class.java.getResourceAsStream("native-index.properties").use { load(checkNotNull(it)) } diff --git a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt deleted file mode 100644 index b90743a..0000000 --- a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/HeaderToIdMapper.kt +++ /dev/null @@ -1,36 +0,0 @@ -// Vendored from JetBrains/kotlin v2.4.20, unmodified. Update with ./gradlew updateNativeIndexModel. -/* - * Copyright 2010-2017 JetBrains s.r.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.jetbrains.kotlin.native.interop.indexer - -import java.io.File - -class HeaderToIdMapper(sysRoot: String) { - private val headerPathToId = mutableMapOf() - private val sysRoot = File(sysRoot).canonicalFile.toPath() - - internal fun getHeaderId(filePath: String) = headerPathToId.getOrPut(filePath) { - val path = File(filePath).canonicalFile.toPath() - val headerIdValue = if (path.startsWith(sysRoot)) { - val relative = sysRoot.relativize(path) - relative.toString() - } else { - headerContentsHash(filePath) - } - HeaderId(headerIdValue) - } -} diff --git a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt deleted file mode 100644 index 5dcd470..0000000 --- a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/IndexModelShim.kt +++ /dev/null @@ -1,17 +0,0 @@ -// The three symbols upstream's NativeIndex.kt and HeaderToIdMapper.kt reference from the half of the -// indexer that is bound to libclang. The model only needs them to compile: indexing itself runs in -// the Kotlin/Native distribution's JVM, never against these. -package org.jetbrains.kotlin.native.interop.indexer - -/** Copied from upstream `ModuleSupport.kt`. */ -data class IncludeInfo(val headerPath: String, val moduleName: String?) - -fun headerContentsHash(filePath: String): String = - error("Header hashing belongs to the indexer, which runs in the Kotlin/Native distribution.") - -internal fun buildNativeIndexImpl( - library: NativeLibrary, - verbose: Boolean, - allowPrecompiledHeaders: Boolean, - macroNamesCollectingMode: MacroNamesCollectingMode, -): IndexerResult = error("Indexing runs in the Kotlin/Native distribution; read its output with NativeIndexReader.") diff --git a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt b/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt deleted file mode 100644 index 278d505..0000000 --- a/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt +++ /dev/null @@ -1,524 +0,0 @@ -// Vendored from JetBrains/kotlin v2.4.20, unmodified. Update with ./gradlew updateNativeIndexModel. -/* - * Copyright 2010-2017 JetBrains s.r.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.jetbrains.kotlin.native.interop.indexer - -enum class Language(val sourceFileExtension: String, val clangLanguageName: String) { - C("c", "c"), - CPP("cpp", "c++"), - OBJECTIVE_C("m", "objective-c") -} - -enum class MacroNamesCollectingMode { - LEGACY, - LIBCLANGEXT, - LIBCLANGEXT_PARALLEL, -} - -interface HeaderInclusionPolicy { - /** - * Whether unused declarations from given header should be excluded. - * - * @param headerName header path relative to the appropriate include path element (e.g. `time.h` or `curl/curl.h`), - * or `null` for builtin declarations. - */ - fun excludeUnused(headerName: String?): Boolean -} - -interface HeaderExclusionPolicy { - /** - * Whether all declarations from this header should be excluded. - * - * Note: the declarations from such headers can be actually present in the internal representation, - * but not included into the root collections. - */ - fun excludeAll(headerId: HeaderId): Boolean -} - -sealed class NativeLibraryHeaderFilter { - class NameBased( - val policy: HeaderInclusionPolicy, - val excludeDepdendentModules: Boolean - ) : NativeLibraryHeaderFilter() - - class Predefined( - val headers: Set, - // In "skipNonImportableModules" mode the modules that were skipped (failed to import) are not excluded in this field - val modules: List - ) : NativeLibraryHeaderFilter() -} - -interface Compilation { - val includes: List - val additionalPreambleLines: List - val compilerArgs: List - val language: Language -} - -fun defaultCompilerArgs(language: Language): List = - listOf( - // We compile with -O2 because Clang may insert inline asm in bitcode at -O0. - // It is undesirable in case of watchos_arm64 since we target armv7k - // for this target instead of arm64_32 because it is not supported in LLVM 8. - // - // Note that PCH and the *.c file should be compiled with the same optimization level. - "-O2", - // Allow throwing exceptions through generated stubs. - "-fexceptions", - ) + when (language) { - Language.C -> emptyList() - Language.CPP -> emptyList() - Language.OBJECTIVE_C -> listOf( - // "Objective-C" within interop means "Objective-C with ARC": - "-fobjc-arc", - // Using this flag here has two effects: - // 1. The headers are parsed with ARC enabled, thus the API is visible correctly. - // 2. The generated Objective-C stubs are compiled with ARC enabled, so reference counting - // calls are inserted automatically. - - // Workaround for https://youtrack.jetbrains.com/issue/KT-48807. - // TODO(LLVM): Remove after update to a version with - // https://github.com/apple/llvm-project/commit/2de0a18a8949f0235fb3a08dcc55ff3aa7d969e7. - "-DNS_FORMAT_ARGUMENT(A)=" - ) - } - -data class CompilationWithPCH( - override val compilerArgs: List, - override val language: Language -) : Compilation { - constructor(compilerArgs: List, precompiledHeader: String, language: Language) - : this(compilerArgs + listOf("-include-pch", precompiledHeader), language) - - override val includes: List - get() = emptyList() - - override val additionalPreambleLines: List - get() = emptyList() -} - -/** - * - * @param objCClassesIncludingCategories Objective-C classes that should be merged with categories from the same file. - * - * TODO: Compilation hierarchy seems to require some refactoring. - */ -data class NativeLibrary( - override val includes: List, - override val additionalPreambleLines: List, - override val compilerArgs: List, - val headerToIdMapper: HeaderToIdMapper, - override val language: Language, - val excludeSystemLibs: Boolean, // TODO: drop? - val headerExclusionPolicy: HeaderExclusionPolicy, - val headerFilter: NativeLibraryHeaderFilter, - val objCClassesIncludingCategories: Set, - val allowIncludingObjCCategoriesFromDefFile: Boolean, -) : Compilation - -data class IndexerResult(val index: NativeIndex, val compilation: Compilation) - -/** - * Retrieves the definitions from given C header file using given compiler arguments (e.g. defines). - */ -fun buildNativeIndex( - library: NativeLibrary, - verbose: Boolean, - allowPrecompiledHeaders: Boolean = true, - macroNamesCollectingMode: MacroNamesCollectingMode = MacroNamesCollectingMode.LEGACY, -): IndexerResult = buildNativeIndexImpl(library, verbose, allowPrecompiledHeaders, macroNamesCollectingMode) - -/** - * This class describes the IR of definitions from C header file(s). - */ -abstract class NativeIndex { - abstract val structs: Collection - abstract val enums: Collection - abstract val objCClasses: Collection - abstract val objCProtocols: Collection - abstract val objCCategories: Collection - abstract val typedefs: Collection - abstract val functions: Collection - abstract val macroConstants: Collection - abstract val wrappedMacros: Collection - abstract val globals: Collection - abstract val includedHeaders: Collection -} - -/** - * The (contents-based) header id. - * Its [value] remains valid across different runs of the indexer and the process, - * and thus can be used to 'serialize' the id. - */ -data class HeaderId(val value: String) - -data class Location(val headerId: HeaderId) - -interface LocatableDeclaration { - val location: Location -} - -interface TypeDeclaration : LocatableDeclaration - -sealed class StructMember(val name: String) { - abstract val offset: Long? -} - -/** - * C struct field. - */ -class Field(name: String, val type: Type, override val offset: Long, val typeSize: Long, val typeAlign: Long) - : StructMember(name) - -val Field.isAligned: Boolean - get() = offset % (typeAlign * 8) == 0L - -class BitField(name: String, val type: Type, override val offset: Long, val size: Int) : StructMember(name) - -class IncompleteField(name: String) : StructMember(name) { - override val offset: Long? get() = null -} - -class AnonymousInnerRecord(val def: StructDef) : StructMember("") { - override val offset: Long? get() = null - val typeSize: Long = def.size -} - -/** - * C struct declaration. - */ -abstract class StructDecl(val spelling: String) : TypeDeclaration { - - abstract val def: StructDef? - abstract val isAnonymous: Boolean -} - -/** - * C struct definition. - * - * @param hasNaturalLayout must be `false` if the struct has unnatural layout, e.g. it is `packed`. - * May be `false` even if the struct has natural layout. - */ -abstract class StructDef(val size: Long, val align: Int) { - - enum class Kind { - STRUCT, UNION, CLASS - } - - abstract val kind: Kind - abstract val members: List - abstract val methods: List - abstract val staticFields: List - - val fields: List - get() = mutableListOf().apply { - members.forEach { - when (it) { - is Field -> add(it) - is AnonymousInnerRecord -> addAll(it.def.fields) - is BitField, - is IncompleteField -> {} - } - } - } - - val bitFields: List - get() = mutableListOf().apply { - members.forEach { - when (it) { - is BitField -> add(it) - is AnonymousInnerRecord -> addAll(it.def.bitFields) - is Field, - is IncompleteField -> {} - } - } - } -} - -/** - * C enum value. - */ -class EnumConstant(val name: String, val value: Long, val isExplicitlyDefined: Boolean) - -/** - * C enum definition. - */ -abstract class EnumDef(val spelling: String, val baseType: Type) : TypeDeclaration { - - abstract val constants: List - abstract val isAnonymous: Boolean -} - -sealed class ObjCContainer { - abstract val protocols: List - abstract val methods: List - abstract val unavailableMethods: List - abstract val properties: List -} - -sealed class ObjCClassOrProtocol(val name: String) : ObjCContainer(), TypeDeclaration { - abstract val isForwardDeclaration: Boolean - abstract val binaryName: String? - open val swiftName: String? get() = null -} - -/** - * An indexed Obj-C method declaration β€” either [ObjCMethod] (available, with full signature) or - * [ObjCUnavailableMethod] (selector-only record for declarations marked unavailable). - */ -sealed interface ObjCMethodOrUnavailableMethod { - val selector: String - val isClass: Boolean -} - -data class ObjCMethod( - override val selector: String, - val encoding: String, - val parameters: List, - private val returnType: Type, - val isVariadic: Boolean, - override val isClass: Boolean, - val nsConsumesSelf: Boolean, - val nsReturnsRetained: Boolean, - val isOptional: Boolean, - val isInit: Boolean, - val isExplicitlyDesignatedInitializer: Boolean, - val isDirect: Boolean, - val swiftName: String?, -) : ObjCMethodOrUnavailableMethod { - - fun containsInstancetype(): Boolean = returnType.containsInstancetype() // Clang doesn't allow parameter types to use instancetype. - - fun getReturnType(container: ObjCClassOrProtocol): Type = if (returnType.containsInstancetype()) { - returnType.substituteInstancetype(container) - } else { - // Fast path, avoid allocating copies. - returnType - } -} - -/** - * Selector-only record for an Obj-C method declared as unavailable (e.g. via `NS_UNAVAILABLE`). - * Kept as a distinct type from [ObjCMethod] so the index does not have to import the method's parameter - * and return types (those types may themselves be unavailable). - */ -data class ObjCUnavailableMethod( - override val selector: String, - override val isClass: Boolean, -) : ObjCMethodOrUnavailableMethod - -// Clang seems to allow using instancetype only inside certain kinds of types. -// The implementation below therefore covers only particular cases, based on the experiments with Clang and common sense. -private fun Type.containsInstancetype(): Boolean = when (this) { - is ObjCInstanceType -> true - - is ObjCBlockPointer -> this.returnType.containsInstancetype() - is FunctionType -> this.returnType.containsInstancetype() - is PointerType -> this.pointeeType.containsInstancetype() - - else -> false -} - -private fun Type.substituteInstancetype(container: ObjCClassOrProtocol): Type = when (this) { - is ObjCInstanceType -> when (container) { - is ObjCClass -> ObjCObjectPointer(container, this.nullability, protocols = emptyList()) - is ObjCProtocol -> ObjCIdType(this.nullability, protocols = listOf(container)) - } - - is ObjCBlockPointer -> this.copy(returnType = this.returnType.substituteInstancetype(container)) - is FunctionType -> this.copy(returnType = this.returnType.substituteInstancetype(container)) - is PointerType -> this.copy(pointeeType = this.pointeeType.substituteInstancetype(container)) - - else -> this -} - -data class ObjCProperty( - val name: String, - val getter: ObjCMethod, val setter: ObjCMethod?, - val swiftName: String? -) { - fun getType(container: ObjCClassOrProtocol): Type = getter.getReturnType(container) -} - -abstract class ObjCClass(name: String) : ObjCClassOrProtocol(name) { - abstract val baseClass: ObjCClass? - /** - * Categories whose methods and properties should be generated as members of Kotlin class. - */ - abstract val includedCategories: List - - open val typeParameters: List get() = emptyList() -} -abstract class ObjCProtocol(name: String) : ObjCClassOrProtocol(name) - -abstract class ObjCCategory(val name: String, val clazz: ObjCClass) : ObjCContainer(), LocatableDeclaration - -/** - * C function parameter. - */ -data class Parameter(val name: String?, val type: Type, val nsConsumed: Boolean) - -/** - * Describes how a declaration is accessible directly (in the `-Xccall-mode direct` mode), or why it is not. - */ -sealed class DirectAccess { - /** - * The declaration is accessible directly using the specified symbol name. - * - * @property name - * The name of the declaration in the binary code (i.e. the symbol name as seen by the linker). - * It usually equals to `"_$name"` on Apple platforms and `name` on other platforms, - * but can be different if some modifiers like `__asm("foo")` are at play. - */ - class Symbol(val name: String) : DirectAccess() - - /** - * The declaration is not accessible directly or not supported in the `-Xccall-mode direct` mode. - * - * @property reason - * Briefly describes why the declaration is not accessible. - */ - class Unavailable(val reason: String) : DirectAccess() -} - -/** - * C function declaration. - */ -class FunctionDecl( - /** - * The name of the function in the source code. - */ - val name: String, - - val parameters: List, - val returnType: Type, - val isVararg: Boolean, - - val directAccess: DirectAccess, - - val parentName: String? = null -) { - - val fullName: String = parentName?.let { "$parentName::$name" } ?: name -} - -/** - * C typedef definition. - * - * ``` - * typedef $aliased $name; - * ``` - */ -class TypedefDef(val aliased: Type, val name: String, override val location: Location) : TypeDeclaration - -abstract class MacroDef(val name: String) - -abstract class ConstantDef(name: String, val type: Type): MacroDef(name) -abstract class TypedConstantDef(name: String, type: Type) : ConstantDef(name, type) { - abstract val value: V -} -class IntegerConstantDef(name: String, type: Type, override val value: Long) : TypedConstantDef(name, type) -class FloatingConstantDef(name: String, type: Type, override val value: Double) : TypedConstantDef(name, type) -class StringConstantDef(name: String, type: Type, override val value: String) : TypedConstantDef(name, type) - -class WrappedMacroDef(name: String, val type: Type) : MacroDef(name) - -class GlobalDecl(val name: String, val type: Type, val isConst: Boolean, val directAccess: DirectAccess, val parentName: String? = null) { - val fullName: String get() = parentName?.let { "$it::$name" } ?: name -} - -/** - * C type. - */ -interface Type - -interface PrimitiveType : Type - -object CharType : PrimitiveType - -open class BoolType : PrimitiveType - -object CBoolType : BoolType() - -object ObjCBoolType : BoolType() -// We omit `const` qualifier for IntegerType and FloatingType to make `CBridgeGen` simpler. -// See KT-28102. -data class IntegerType(val size: Int, val isSigned: Boolean, val spelling: String) : PrimitiveType - -// TODO: floating type is not actually defined entirely by its size. -data class FloatingType(val size: Int, val spelling: String) : PrimitiveType - -data class VectorType(val elementType: Type, val elementCount: Int, val spelling: String) : PrimitiveType - -object VoidType : Type - -data class RecordType(val decl: StructDecl) : Type - -data class EnumType(val def: EnumDef) : Type - -// when pointer type is provided by clang we'll use ots correct spelling -data class PointerType(val pointeeType: Type, val pointeeIsConst: Boolean = false, - val isLVReference: Boolean = false, val spelling: String? = null) : Type -// TODO: refactor type representation and support type modifiers more generally. - -data class FunctionType(val parameterTypes: List, val returnType: Type) : Type - -interface ArrayType : Type { - val elemType: Type -} - -data class ConstArrayType(override val elemType: Type, val length: Long) : ArrayType -data class IncompleteArrayType(override val elemType: Type) : ArrayType -data class VariableArrayType(override val elemType: Type) : ArrayType - -data class Typedef(val def: TypedefDef) : Type - -sealed class ObjCPointer : Type { - enum class Nullability { - Nullable, NonNull, Unspecified - } - - abstract val nullability: Nullability -} - -sealed class ObjCQualifiedPointer : ObjCPointer() { - abstract val protocols: List -} - -data class ObjCObjectPointer( - val def: ObjCClass, - override val nullability: Nullability, - override val protocols: List -) : ObjCQualifiedPointer() - -data class ObjCClassPointer( - override val nullability: Nullability, - override val protocols: List -) : ObjCQualifiedPointer() - -data class ObjCIdType( - override val nullability: Nullability, - override val protocols: List -) : ObjCQualifiedPointer() - -data class ObjCInstanceType(override val nullability: Nullability) : ObjCPointer() -data class ObjCBlockPointer( - override val nullability: Nullability, - val parameterTypes: List, val returnType: Type -) : ObjCPointer() - -object UnsupportedType : Type diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt new file mode 100644 index 0000000..1807857 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -0,0 +1,104 @@ +package io.github.lemcoder.index + +import org.gradle.testkit.runner.GradleRunner +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertContains + +/** + * Runs [GenerateFromNativeIndexTask] in a TestKit build: cinterop's own indexer in the pinned Kotlin/Native + * distribution, then the `dump` generator over the index it built. + * + * Skipped when that distribution is not installed; any Kotlin Multiplatform build of + * [NativeIndexer.kotlinNativeVersion] downloads it. + */ +class GenerateFromNativeIndexTest { + + @Test + fun `mymath functions come out with their C signatures`() { + val dump = dump("mymath") + assertContains(dump, "function my_add(a: int32(int), b: int32(int)): int32(int)") + assertContains(dump, "function my_scale(x: float64(double)): float64(double)") + } + + @Test + fun `a struct that points to itself is indexed through both paths`() { + val dump = dump("sample") + assertContains(dump, "typedef node_t = record struct node") + assertContains(dump, "field next: ptr typedef node_t `node_t *` offset=64 size=8 align=8") + assertContains(dump, "field prev: ptr record struct node `struct node *` offset=128 size=8 align=8") + } + + @Test + fun `macro constants are evaluated`() { + val dump = dump("sample") + assertContains(dump, "const SAMPLE_VERSION: int32(int) = 3") + assertContains(dump, "const SAMPLE_MASK: int32u(unsigned int) = 16") + assertContains(dump, "const SAMPLE_PI: float64(double) = 3.14159") + assertContains(dump, "const SAMPLE_NAME: ptr char `char *` = \"sample\"") + } + + @Test + fun `an unknown generator fails with the ones that exist`() { + assumeInstalled() + val projectDir = project(exportTask("bogus", "mymath", mymathDef, mymathHeaders)) + val result = runner(projectDir, "generate_mymath").buildAndFail() + assertContains(result.output, "Unknown generator 'bogus'. Known: dump") + } + + private fun dump(name: String): String { + assumeInstalled() + return generated.getValue(name).resolve("index.txt").readText() + } + + private fun assumeInstalled() = + assumeTrue(NativeIndexer.pinnedKonanHome() != null, "Kotlin/Native ${NativeIndexer.kotlinNativeVersion} is not installed") + + private companion object { + val sampleDef = File(checkNotNull(GenerateFromNativeIndexTest::class.java.classLoader.getResource("nativeindex/sample.def")).toURI()) + val mymathDef = File("examples/jvm/src/main/nativeInterop/mymath.def").absoluteFile + val mymathHeaders = File("examples/jvm/native").absoluteFile + + /** One build runs the dump generator for every header the tests read: output directory by name. */ + val generated: Map by lazy { + val projectDir = project( + exportTask("dump", "mymath", mymathDef, mymathHeaders), + exportTask("dump", "sample", sampleDef, sampleDef.parentFile), + ) + runner(projectDir, "generate_mymath", "generate_sample").build() + listOf("mymath", "sample").associateWith { projectDir.resolve("build/generated/$it") } + } + + fun exportTask(generator: String, name: String, def: File, includeDir: File) = """ + tasks.register("generate_$name") { + defFile.set(file("${path(def)}")) + includeDirs.from("${path(includeDir)}") + target.set(hostKonanTarget()) + this.generator.set("$generator") + outputDirectory.set(layout.buildDirectory.dir("generated/$name")) + } + """.trimIndent() + + fun project(vararg tasks: String): File { + val projectDir = createTempDirectory("native-index").toFile() + projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "native-index"""") + projectDir.resolve("build.gradle.kts").writeText( + (listOf( + "import io.github.lemcoder.hostKonanTarget", + "import io.github.lemcoder.index.GenerateFromNativeIndexTask", + "plugins { id(\"io.github.lemcoder.konanplugin\") }", + ) + tasks).joinToString("\n") + ) + return projectDir + } + + fun runner(projectDir: File, vararg tasks: String): GradleRunner = GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(*tasks, "--stacktrace") + + fun path(file: File) = file.absolutePath.replace("\\", "/") + } +} diff --git a/src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt b/src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt deleted file mode 100644 index 53bd6f6..0000000 --- a/src/test/kotlin/io/github/lemcoder/index/NativeIndexRoundTripTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package io.github.lemcoder.index - -import org.gradle.testkit.runner.GradleRunner -import org.jetbrains.kotlin.native.interop.indexer.* -import org.junit.jupiter.api.Assumptions.assumeTrue -import java.io.File -import kotlin.io.path.createTempDirectory -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertSame - -/** - * Indexes headers with cinterop's own indexer, through [ExportNativeIndexTask] in a TestKit build, then - * reads the file back into the plugin's copy of the model. - * - * Skipped when the pinned Kotlin/Native distribution is not installed; any Kotlin Multiplatform build of - * [NativeIndexer.kotlinNativeVersion] downloads it. - */ -class NativeIndexRoundTripTest { - - @Test - fun `mymath functions come back with their signatures`() { - val index = index(example.resolve("src/main/nativeInterop/mymath.def")).first - - val functions = index.functions.associateBy { it.name } - assertEquals(setOf("my_add", "my_scale"), functions.keys) - - val add = functions.getValue("my_add") - assertEquals(listOf("a", "b"), add.parameters.map { it.name }) - add.parameters.forEach { assertInt(it.type, bits = 32) } - assertInt(add.returnType, bits = 32) - - val scale = functions.getValue("my_scale") - assertEquals(FloatingType(8, "double"), scale.parameters.single().type) - assertEquals(FloatingType(8, "double"), scale.returnType) - } - - @Test - fun `the index read back renders exactly as the one the exporter built`() { - val (index, exporterDump) = index(sampleDef) - assertEquals(exporterDump, dumpNativeIndex(index)) - } - - @Test - fun `a struct that points to itself resolves to the same declaration`() { - val index = index(sampleDef).first - val node = index.structs.single { it.spelling == "struct node" } - val fields = node.def!!.fields.associateBy { it.name } - - val prev = assertIs(fields.getValue("prev").type) - assertSame(node, assertIs(prev.pointeeType).decl) - - val next = assertIs(fields.getValue("next").type) - val nodeT = assertIs(next.pointeeType).def - assertEquals("node_t", nodeT.name) - assertSame(node, assertIs(nodeT.aliased).decl) - assertSame(nodeT, index.typedefs.single { it.name == "node_t" }) - } - - @Test - fun `macro constants keep their values`() { - val constants = index(sampleDef).first.macroConstants.associateBy { it.name } - assertEquals(3L, assertIs(constants.getValue("SAMPLE_VERSION")).value) - assertEquals(16L, assertIs(constants.getValue("SAMPLE_MASK")).value) - assertEquals(3.14159, assertIs(constants.getValue("SAMPLE_PI")).value) - assertEquals("sample", assertIs(constants.getValue("SAMPLE_NAME")).value) - } - - /** Indexes [def], returning the index read back and the exporter's own dump of what it built. */ - private fun index(def: File): Pair { - assumeTrue(NativeIndexer.pinnedKonanHome() != null, "Kotlin/Native ${NativeIndexer.kotlinNativeVersion} is not installed") - val (json, dump) = exported.getValue(def.nameWithoutExtension) - return NativeIndexReader.read(json) to dump.readText() - } - - private companion object { - private val sampleDef = File(checkNotNull(NativeIndexRoundTripTest::class.java.classLoader.getResource("nativeindex/sample.def")).toURI()) - private val example = File("examples/jvm").absoluteFile - - /** One build exports every header the tests use: (json, dump) by .def name. */ - val exported: Map> by lazy { - val projectDir = createTempDirectory("native-index").toFile() - fun path(f: File) = f.absolutePath.replace("\\", "/") - fun exportTask(name: String, def: File, includeDir: File) = """ - tasks.register("export_$name") { - defFile.set(file("${path(def)}")) - includeDirs.from("${path(includeDir)}") - target.set(hostKonanTarget()) - outputFile.set(layout.buildDirectory.file("$name.json")) - dumpFile.set(layout.buildDirectory.file("$name.txt")) - } - """.trimIndent() - projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "native-index"""") - projectDir.resolve("build.gradle.kts").writeText( - listOf( - "import io.github.lemcoder.hostKonanTarget", - "import io.github.lemcoder.index.ExportNativeIndexTask", - "plugins { id(\"io.github.lemcoder.konanplugin\") }", - exportTask("mymath", example.resolve("src/main/nativeInterop/mymath.def"), example.resolve("native")), - exportTask("sample", sampleDef, sampleDef.parentFile), - ).joinToString("\n") - ) - GradleRunner.create() - .withProjectDir(projectDir) - .withPluginClasspath() - .withArguments("export_mymath", "export_sample", "--stacktrace") - .build() - listOf("mymath", "sample").associateWith { - projectDir.resolve("build/$it.json") to projectDir.resolve("build/$it.txt") - } - } - } - - private fun assertInt(type: Type, bits: Int) { - val int = assertIs(type) - assertEquals(bits, int.size * 8) - assertEquals(true, int.isSigned) - } - -} From 2f9921a9c16e782ab4e68b41aafb8b1a48456250 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sun, 4 Oct 2026 22:06:38 +0200 Subject: [PATCH 08/57] refactor: name the pinned distribution for what it is NativeIndexer no longer indexes anything: it holds the Kotlin/Native version the exporter was compiled against and finds that distribution. Rename it PinnedKotlinNative (version, home()) and its resource to kotlin-native.properties. Co-Authored-By: Claude Opus 5.5 --- .../src/main/kotlin/native-index.gradle.kts | 2 +- .../index/GenerateFromNativeIndexTask.kt | 6 ++--- .../io/github/lemcoder/index/NativeIndexer.kt | 24 ------------------- .../lemcoder/index/PinnedKotlinNative.kt | 22 +++++++++++++++++ ...ex.properties => kotlin-native.properties} | 0 .../index/GenerateFromNativeIndexTest.kt | 4 ++-- 6 files changed, 28 insertions(+), 30 deletions(-) delete mode 100644 src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt create mode 100644 src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt rename src/main/resources/io/github/lemcoder/index/{native-index.properties => kotlin-native.properties} (100%) diff --git a/buildSrc/src/main/kotlin/native-index.gradle.kts b/buildSrc/src/main/kotlin/native-index.gradle.kts index 999efd8..141dcae 100644 --- a/buildSrc/src/main/kotlin/native-index.gradle.kts +++ b/buildSrc/src/main/kotlin/native-index.gradle.kts @@ -44,7 +44,7 @@ pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { // The distribution the plugin looks for at run time is the one the exporter was compiled against. val version = kotlinNativeVersion inputs.property("kotlinNativeVersion", version) - filesMatching("io/github/lemcoder/index/native-index.properties") { + filesMatching("io/github/lemcoder/index/kotlin-native.properties") { expand("kotlinNativeVersion" to version.get()) } } diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index f828e37..73d997a 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -51,7 +51,7 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @get:Input abstract val target: Property - /** Defaults to the installed distribution of [NativeIndexer.kotlinNativeVersion], found at execution. */ + /** Defaults to the installed distribution of [PinnedKotlinNative.version], found at execution. */ @get:Internal abstract val konanHome: DirectoryProperty @@ -65,8 +65,8 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @TaskAction fun export() { - val home = konanHome.orNull?.asFile ?: NativeIndexer.pinnedKonanHome() ?: error( - "Kotlin/Native ${NativeIndexer.kotlinNativeVersion} is not installed; the native index needs exactly that version." + val home = konanHome.orNull?.asFile ?: PinnedKotlinNative.home() ?: error( + "Kotlin/Native ${PinnedKotlinNative.version} is not installed; the native index needs exactly that version." ) val compilerJar = home.resolve("konan/lib/kotlin-native-compiler-embeddable.jar") check(compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } diff --git a/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt b/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt deleted file mode 100644 index beabf3e..0000000 --- a/src/main/kotlin/io/github/lemcoder/index/NativeIndexer.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.lemcoder.index - -import java.io.File - -/** - * Where cinterop's header indexer runs: inside a Kotlin/Native distribution of exactly - * [kotlinNativeVersion]. The exporter is compiled against that version's compiler jar β€” its internal - * functions and the `NativeIndex` model the generators read β€” so another version may not link. - * [GenerateFromNativeIndexTask] runs it. - */ -object NativeIndexer { - /** `kotlinNativeVersion` in the plugin build: the compiler jar the exporter was compiled against. */ - val kotlinNativeVersion: String by lazy { - java.util.Properties().apply { - NativeIndexer::class.java.getResourceAsStream("native-index.properties").use { load(checkNotNull(it)) } - }.getProperty("version") - } - - /** The installed distribution of [kotlinNativeVersion] under `~/.konan`, if any. */ - fun pinnedKonanHome(): File? = - File(System.getProperty("user.home"), ".konan") - .listFiles { f -> f.isDirectory && f.name.startsWith("kotlin-native-prebuilt-") && f.name.endsWith("-$kotlinNativeVersion") } - ?.singleOrNull() -} diff --git a/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt b/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt new file mode 100644 index 0000000..e0f684c --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt @@ -0,0 +1,22 @@ +package io.github.lemcoder.index + +import java.io.File + +/** + * The Kotlin/Native distribution the index exporter was compiled against, and so the one it must run in: + * the exporter links against that version's compiler jar β€” its internal functions and the `NativeIndex` + * model the generators read β€” so another version may not link. `kotlinNativeVersion` in the plugin build. + */ +object PinnedKotlinNative { + val version: String by lazy { + java.util.Properties().apply { + PinnedKotlinNative::class.java.getResourceAsStream("kotlin-native.properties").use { load(checkNotNull(it)) } + }.getProperty("version") + } + + /** The installed distribution of [version] under `~/.konan`, if any. */ + fun home(): File? = + File(System.getProperty("user.home"), ".konan") + .listFiles { f -> f.isDirectory && f.name.startsWith("kotlin-native-prebuilt-") && f.name.endsWith("-$version") } + ?.singleOrNull() +} diff --git a/src/main/resources/io/github/lemcoder/index/native-index.properties b/src/main/resources/io/github/lemcoder/index/kotlin-native.properties similarity index 100% rename from src/main/resources/io/github/lemcoder/index/native-index.properties rename to src/main/resources/io/github/lemcoder/index/kotlin-native.properties diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index 1807857..b3607d8 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -12,7 +12,7 @@ import kotlin.test.assertContains * distribution, then the `dump` generator over the index it built. * * Skipped when that distribution is not installed; any Kotlin Multiplatform build of - * [NativeIndexer.kotlinNativeVersion] downloads it. + * [PinnedKotlinNative.version] downloads it. */ class GenerateFromNativeIndexTest { @@ -54,7 +54,7 @@ class GenerateFromNativeIndexTest { } private fun assumeInstalled() = - assumeTrue(NativeIndexer.pinnedKonanHome() != null, "Kotlin/Native ${NativeIndexer.kotlinNativeVersion} is not installed") + assumeTrue(PinnedKotlinNative.home() != null, "Kotlin/Native ${PinnedKotlinNative.version} is not installed") private companion object { val sampleDef = File(checkNotNull(GenerateFromNativeIndexTest::class.java.classLoader.getResource("nativeindex/sample.def")).toURI()) From adf5a17d0b9a01ed346929d532e2c03bbb111a37 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sun, 4 Oct 2026 23:32:02 +0200 Subject: [PATCH 09/57] feat: generate JNI bindings from cinterop's NativeIndex The JNI path ran cinterop's JVM flavor, whose bridges pass every pointer as a raw address, and rewrote its output with regexes so strings and buffers crossed as Kotlin types. A jni generator in the index exporter now writes both sides of each bridge from the C declarations: - each C function becomes an `external fun` under its own name (`my_add`, not `kniBridge0`), with its C signature as a doc comment; - integers, char, bool, floating point and enums cross as the same-size primitive; `const char*` as String? both ways; pointers to integers or floating point as primitive arrays, written back unless the pointee is const; structs by value as their bytes, size-checked; other pointers as addresses; - functions it cannot bind (variadic, long double) are listed with the reason. GenerateJvmInteropTask keeps its outputs, so linking and packaging are unchanged. Generators get a GenerationContext (index, package, headers, the .def's preamble, --option pairs), and runIndexExporter is the one launcher for the exporter. Removed: JniMarshalling, CInteropFormatter and their tests, the empty ExampleTest, DefFile.render and the fields only it read, and the unused detectKonanHome. The JVM example gains string and buffer functions and calls the bindings by their C names. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 +- README.md | 43 +-- examples/README.md | 13 +- examples/android/README.md | 2 +- .../android/src/main/kotlin/example/Api.kt | 8 +- examples/jvm/native/mymath.c | 14 + examples/jvm/native/mymath.h | 6 + examples/jvm/src/main/kotlin/Main.kt | 18 +- .../index/exporter/BindingGenerator.kt | 22 +- .../lemcoder/index/exporter/DumpGenerator.kt | 4 +- .../lemcoder/index/exporter/JniGenerator.kt | 312 ++++++++++++++++++ .../index/exporter/NativeIndexExporter.kt | 47 ++- .../index/GenerateFromNativeIndexTask.kt | 70 ++-- .../io/github/lemcoder/index/IndexExporter.kt | 65 ++++ .../io/github/lemcoder/interop/DefFile.kt | 39 +-- .../lemcoder/interop/JvmInteropRegistry.kt | 2 - .../lemcoder/jvm/GenerateJvmInteropTask.kt | 107 ++---- .../github/lemcoder/jvm/JvmInteropSupport.kt | 5 +- .../github/lemcoder/util/CInteropFormatter.kt | 75 ----- .../io/github/lemcoder/util/JniMarshalling.kt | 218 ------------ .../kotlin/io/github/lemcoder/ExampleTest.kt | 5 - .../io/github/lemcoder/JniMarshallingTest.kt | 198 ----------- .../index/GenerateFromNativeIndexTest.kt | 42 ++- 23 files changed, 595 insertions(+), 735 deletions(-) create mode 100644 src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt create mode 100644 src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt delete mode 100644 src/test/kotlin/io/github/lemcoder/ExampleTest.kt delete mode 100644 src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index ec50d49..8443ee3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,14 +52,13 @@ resolved the moment the plugin is applied, in *every* project that applies it. ## The generated bindings -cinterop's JVM flavor emits **address-only** bridges: every pointer is a raw `Long`, which assumes a -caller that can allocate native memory. The marshalling in `util/JniMarshalling.kt` rewrites both -sides so strings and primitive buffers cross as Kotlin types β€” that transform is the plugin's main -reason to exist, and it is driven by parsing cinterop's own wrapper output. - -Bindings are `internal` by default and each carries **`@JvmName`**. Without it Kotlin mangles -internal functions (`kniBridge0$module`) while JNI resolves the C symbol from the unmangled method -name: it compiles, links, and dies on the first call. +The `jni` generator (`src/indexExporter/.../JniGenerator.kt`) writes both sides of each bridge from +cinterop's `NativeIndex`: the Kotlin `external fun` named after the C function, and the `JNIEXPORT` +function that marshals strings, primitive buffers and structs by value. cinterop's own JVM flavor is +not used β€” it emits address-only bridges, every pointer a raw `Long`. + +Bindings are `internal` by default and each carries **`@JvmName`**. JNI resolves the C symbol from the +unmangled method name, so a mangled internal name compiles, links, and dies on the first call. ## Kotlin DSL gotchas diff --git a/README.md b/README.md index 3d63da6..88a15c0 100644 --- a/README.md +++ b/README.md @@ -170,25 +170,30 @@ target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arriv ``` ### Generated bindings -One `external fun kniBridgeN(...)` per C function, in header order, each carrying its C-derived -signature as a doc comment. Parameters are marshalled by shape: - -| C parameter | Kotlin parameter | Crosses as | -|------------------------------------|------------------|------------------------------------------------| -| `const char*` input | `String?` | `GetStringUTFChars` | -| `char*` / `float*` / … buffer | `ByteArray?` / `FloatArray?` / … | `GetArrayElements` (writes copied back) | -| struct by value, struct return | `ByteArray?` | raw struct bytes | -| opaque handle, pointer to struct | `Long` | raw address | - -The bridges are `internal` by default β€” they are an implementation detail of the module that writes -the idiomatic API over them, and this keeps `kniBridge0…N` out of its published surface. Each carries -`@JvmName`, because Kotlin mangles internal functions on the JVM and JNI resolves the symbol from the -unmangled method name. Set `visibility.set(BindingVisibility.PUBLIC)` if the wrapper is in a -different Gradle module. - -Functions returning `const char*` return the address; read it with the generated -`kniCString(ptr: Long): String?`. Struct arguments are raw bytes, so the caller writes the fields β€” -`ByteBuffer.order(ByteOrder.nativeOrder())` with the layout from the C header. +One `external fun` per C function, named after it and carrying its C signature as a doc comment. +cinterop's own indexer reads the headers β€” the same `.def` the Kotlin/Native targets use β€” and the +plugin writes both sides of each JNI bridge from the C declarations: + +| C type | Kotlin type | Crosses as | +|----------------------------------------------|----------------------------------|----------------------------------------------------| +| integers, `char`, `bool`, floating point, enums | `Int`, `Long`, `Byte`, `Boolean`, `Double`, … | the JNI primitive of the same size | +| `const char*` (argument or result) | `String?` | `GetStringUTFChars` / `NewStringUTF` | +| `int*`, `uint8_t*`, `float*`, … buffer | `IntArray?`, `ByteArray?`, `FloatArray?`, … | pinned for the call; written back unless `const` | +| struct by value (argument or result) | `ByteArray` | the struct's bytes | +| opaque handle, `void*`, struct or function pointer | `Long` | the address | + +Functions that cannot be bound β€” variadic ones, `long double` β€” are listed with the reason at the end of +the generated file. Unsigned integers cross as the signed Kotlin type of the same size. Struct bytes are +in native order, so read them with `ByteBuffer.order(ByteOrder.nativeOrder())` and the layout from the +C header. + +The bindings are `internal` by default β€” they are an implementation detail of the module that writes +the idiomatic API over them. Each carries `@JvmName`, because JNI resolves the symbol from the +unmangled method name. Set `visibility.set(BindingVisibility.PUBLIC)` if the wrapper is in a different +Gradle module. + +Generating needs the Kotlin/Native distribution the plugin was built against (2.4.20) in `~/.konan`; +any Kotlin Multiplatform build of that version with a native target downloads it. Platform-specific link arguments go in `linkerArgsFor`; `-framework` in `additionalLinkerArgs` would reach the Android linker, which rejects it. diff --git a/examples/README.md b/examples/README.md index 502bebc..658f741 100644 --- a/examples/README.md +++ b/examples/README.md @@ -33,7 +33,7 @@ native/*.c ──(konanConfig: runKonanClang)──> libmymath.a per target ## The expect/actual split (you write tier 2) The plugin generates the **low-level** tier per platform. You write the idiomatic `expect`/`actual` -on top β€” because how strings/pointers are marshalled is your call: +on top: ```kotlin // commonMain @@ -42,14 +42,15 @@ expect fun add(a: Int, b: Int): Int // nativeMain β€” over the cinterop binding actual fun add(a: Int, b: Int): Int = mymath.my_add(a, b) -// jvmMain / androidMain β€” over the generated JNI bridge -actual fun add(a: Int, b: Int): Int = example.kniBridge0(a, b) +// jvmMain / androidMain β€” over the generated JNI binding +actual fun add(a: Int, b: Int): Int = example.my_add(a, b) ``` -Pointer/string params arrive at the JNI bridge as a raw address (`Long`); convert in your `actual`. +Strings and primitive buffers are marshalled by the binding; opaque pointers arrive as an address +(`Long`). See the README's "Generated bindings". ## What's verified here vs. needs a device -- `jvm` and `native` run to completion and print `5` / `40.0`. -- `android` produces and verifies the `.so` (ELF + exported `Java_..._kniBridgeN` symbols). Running it +- `jvm` and `native` run to completion; `jvm` also round-trips strings and primitive buffers. +- `android` produces and verifies the `.so` (ELF + exported `Java_example_example_my_1add`-style symbols). Running it on Android needs an emulator/device and an AGP module β€” see [`android/README.md`](android/README.md). diff --git a/examples/android/README.md b/examples/android/README.md index ef5e6bf..3d0f7fd 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -74,7 +74,7 @@ directory and the per-ABI `jniLibs/` as native libraries (via the AGP variant AP `dependsOn` boilerplate needed. `MainActivity` calls `example.add/scale` (in `src/main/kotlin/example/Api.kt`), which call the -generated `kniBridgeN`. `System.loadLibrary("examplestubs")` loads the `.so` from the APK β€” no +generated `my_add` / `my_scale`. `System.loadLibrary("examplestubs")` loads the `.so` from the APK β€” no `java.library.path` on Android. The `.so` gets `jni.h` from the NDK sysroot; the bridges are runtime-free (no `kotlinx.cinterop`), so diff --git a/examples/android/src/main/kotlin/example/Api.kt b/examples/android/src/main/kotlin/example/Api.kt index 3b16370..aa7d6cb 100644 --- a/examples/android/src/main/kotlin/example/Api.kt +++ b/examples/android/src/main/kotlin/example/Api.kt @@ -1,6 +1,6 @@ package example -// Idiomatic API the user writes on top of the generated low-level JNI bridges -// (build/generated/jvmInterop/kotlin/example/example.kt). -fun add(a: Int, b: Int): Int = kniBridge0(a, b) // C: my_add(a: Int, b: Int): Int -fun scale(x: Double): Double = kniBridge1(x) // C: my_scale(x: Double): Double +// Idiomatic API over the generated JNI bindings (build/generated/jvmInterop/kotlin/example/example.kt), +// which are named after the C functions. +fun add(a: Int, b: Int): Int = my_add(a, b) +fun scale(x: Double): Double = my_scale(x) diff --git a/examples/jvm/native/mymath.c b/examples/jvm/native/mymath.c index e24c976..b2969f0 100644 --- a/examples/jvm/native/mymath.c +++ b/examples/jvm/native/mymath.c @@ -1,4 +1,18 @@ +#include #include "mymath.h" int my_add(int a, int b) { return a + b; } double my_scale(double x) { return x * 10.0; } + +int my_length(const char* text) { return text ? (int)strlen(text) : -1; } +const char* my_greeting(void) { return "hello from C"; } + +int my_sum(const int* values, int count) { + int total = 0; + for (int i = 0; i < count; i++) total += values[i]; + return total; +} + +void my_fill(int* out, int count, int value) { + for (int i = 0; i < count; i++) out[i] = value; +} diff --git a/examples/jvm/native/mymath.h b/examples/jvm/native/mymath.h index c951cb1..6c65efe 100644 --- a/examples/jvm/native/mymath.h +++ b/examples/jvm/native/mymath.h @@ -4,4 +4,10 @@ int my_add(int a, int b); double my_scale(double x); +/* Marshalled shapes: strings both ways, a read-only buffer, and one the callee writes into. */ +int my_length(const char* text); +const char* my_greeting(void); +int my_sum(const int* values, int count); +void my_fill(int* out, int count, int value); + #endif diff --git a/examples/jvm/src/main/kotlin/Main.kt b/examples/jvm/src/main/kotlin/Main.kt index 6c4f177..68e371f 100644 --- a/examples/jvm/src/main/kotlin/Main.kt +++ b/examples/jvm/src/main/kotlin/Main.kt @@ -1,11 +1,15 @@ package example -// Idiomatic API the *user* writes on top of the generated low-level JNI bridges. -// (See build/generated/jvmInterop/kotlin/.../example.kt for the generated `kniBridgeN` + their C signatures.) -fun add(a: Int, b: Int): Int = kniBridge0(a, b) // C: my_add(a: Int, b: Int): Int -fun scale(x: Double): Double = kniBridge1(x) // C: my_scale(x: Double): Double - +// The generated bindings (build/generated/jvmInterop/mymath/kotlin/example/example.kt) are named after +// the C functions, with strings and primitive buffers marshalled. fun main() { - println("add(2, 3) = ${add(2, 3)}") // expect 5 - println("scale(4.0) = ${scale(4.0)}") // expect 40.0 + println("my_add(2, 3) = ${my_add(2, 3)}") // 5 + println("my_scale(4.0) = ${my_scale(4.0)}") // 40.0 + println("my_length(\"kotlin\") = ${my_length("kotlin")}") // 6 + println("my_greeting() = ${my_greeting()}") // hello from C + println("my_sum([1, 2, 3, 4]) = ${my_sum(intArrayOf(1, 2, 3, 4), 4)}") // 10 + + val buffer = IntArray(3) + my_fill(buffer, buffer.size, 7) + println("my_fill -> ${buffer.toList()}") // [7, 7, 7] } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt index 965911a..b47d25c 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt @@ -10,12 +10,26 @@ import java.io.File * the index crosses into the Gradle daemon, only what the generator writes. */ interface BindingGenerator { - /** How the exporter's command line and the Gradle task name this generator. */ + /** How the exporter's command line and the Gradle tasks name this generator. */ val name: String - /** Writes this generator's output for [index] into [outputDirectory], which starts out empty. */ - fun generate(index: NativeIndex, outputDirectory: File) + /** Writes this generator's output into [outputDirectory], which starts out empty. */ + fun generate(context: GenerationContext, outputDirectory: File) } +/** What a generator gets: the index, and the facts about the library cinterop derived it from. */ +class GenerationContext( + val index: NativeIndex, + /** The bindings' package, decided the way cinterop decides it: `-pkg`, the `.def`'s `package`, or its file name. */ + val packageName: String, + /** The headers the library includes, as the `.def` (or `-header`) names them. */ + val headers: List, + /** The `.def`'s lines after `---`: C code every translation unit of the library sees. */ + val preambleLines: List, + /** `--option key=value` pairs from the exporter's command line, for the generator to interpret. */ + val options: Map, +) + /** Every generator the exporter can run, by [BindingGenerator.name]. */ -val bindingGenerators: Map = listOf(DumpGenerator).associateBy { it.name } +val bindingGenerators: Map = + listOf(DumpGenerator, JniGenerator).associateBy { it.name } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt index 3d2a0e5..bdc639d 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt @@ -12,8 +12,8 @@ import java.io.File object DumpGenerator : BindingGenerator { override val name = "dump" - override fun generate(index: NativeIndex, outputDirectory: File) { - outputDirectory.resolve("index.txt").writeText(dumpNativeIndex(index)) + override fun generate(context: GenerationContext, outputDirectory: File) { + outputDirectory.resolve("index.txt").writeText(dumpNativeIndex(context.index)) } } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt new file mode 100644 index 0000000..87986be --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -0,0 +1,312 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.indexer.* +import java.io.File + +/** + * JNI bindings for the plain JVM and Android: a Kotlin file of `external fun`s named after the C + * functions, and the C stub that implements them. Writes `kotlin//.kt` and + * `c/.c`, where the facade class is the package's last segment. + * + * How values cross: + * - integers, `char`, `bool`, floating point and enums: the Kotlin primitive of the same size; + * - `const char*`: `String?` in both directions, through `GetStringUTFChars` / `NewStringUTF`; + * - other pointers to integers or floating point (`int*`, `uint8_t*`, `float*`, …): the matching + * primitive array, pinned for the call and written back afterwards unless the pointee is `const`; + * - structs by value: a `ByteArray` holding the struct's bytes; + * - every other pointer (opaque handles, `void*`, structs, function pointers): its address as a `Long`. + * + * Functions it cannot bind (variadic, `long double`, an anonymous struct by value, …) are listed with the + * reason at the end of the Kotlin file instead. + * + * Options: `stubLibrary` (required) is the library `System.loadLibrary` loads β€” the plugin owns that name; + * `visibility` is `internal` (default) or `public`. + */ +object JniGenerator : BindingGenerator { + override val name = "jni" + + override fun generate(context: GenerationContext, outputDirectory: File) { + val stubLibrary = requireNotNull(context.options["stubLibrary"]) { "The jni generator needs --option stubLibrary=" } + val visibility = when (val v = context.options["visibility"] ?: "internal") { + "internal", "public" -> v + else -> error("visibility must be internal or public, got '$v'") + } + val packageName = context.packageName + val facade = packageName.substringAfterLast('.').ifEmpty { "bindings" } + + val bound = mutableListOf() + val skipped = mutableListOf>() + context.index.functions.sortedBy { it.name }.forEach { function -> + try { + bound += bind(function) + } catch (e: Unsupported) { + skipped += function.name to e.message.orEmpty() + } + } + + val kotlinFile = outputDirectory.resolve("kotlin/${packageName.replace('.', '/')}/$facade.kt") + kotlinFile.parentFile.mkdirs() + kotlinFile.writeText(kotlinSource(packageName, facade, stubLibrary, visibility, bound, skipped)) + + val cFile = outputDirectory.resolve("c/$stubLibrary.c") + cFile.parentFile.mkdirs() + cFile.writeText(cSource(context, jniClassPrefix(packageName, facade), bound)) + } + + private class Unsupported(reason: String) : Exception(reason) + + /** One C function, decided: how each parameter and the result cross. */ + private class Binding(val function: FunctionDecl, val params: List>, val result: Crossing) + + /** How one value crosses the JNI boundary. */ + private sealed class Crossing(val kotlinType: String, val jniType: String) { + /** Passed as is; the C compiler converts between the JNI and C types of the same size. */ + class Primitive(kotlinType: String, jniType: String) : Crossing(kotlinType, jniType) + + /** A pointer as an address; [cType] is how C spells the pointer, for the cast. */ + class Address(val cType: String) : Crossing("Long", "jlong") + + /** `const char*` as a `String?`. */ + object CString : Crossing("String?", "jstring") + + /** + * A pointer to [element] values as a pinned primitive array; [cType] spells the pointer. A [readOnly] + * one (`const` pointee) is not copied back. + */ + class PrimitiveArray(val element: String, val cType: String, val readOnly: Boolean) : + Crossing("${element}Array?", "j${element.lowercase()}Array") + + /** A struct by value as its bytes; [cType] spells the struct. */ + class StructBytes(val cType: String) : Crossing("ByteArray", "jbyteArray") + + object Void : Crossing("Unit", "void") + } + + private fun bind(function: FunctionDecl): Binding { + if (function.isVararg) throw Unsupported("variadic") + if (function.parentName != null) throw Unsupported("C++ member") + val params = function.parameters.mapIndexed { i, p -> kotlinName(p.name, i) to parameter(p.type) } + if (params.map { it.first }.toSet().size != params.size) throw Unsupported("duplicate parameter names") + return Binding(function, params, result(function.returnType)) + } + + private fun parameter(type: Type): Crossing = when (val t = resolved(type)) { + is PointerType, is ArrayType -> pointer(type, t) + is RecordType -> Crossing.StructBytes(spelling(type)) + else -> primitive(t) + } + + private fun result(type: Type): Crossing = when (val t = resolved(type)) { + VoidType -> Crossing.Void + is PointerType -> + if (resolved(t.pointeeType) == CharType && t.pointeeIsConst) Crossing.CString else Crossing.Address(spelling(type)) + is RecordType -> Crossing.StructBytes(spelling(type)) + else -> primitive(t) + } + + private fun pointer(declared: Type, pointer: Type): Crossing { + val (pointee, isConst) = when (pointer) { + is PointerType -> resolved(pointer.pointeeType) to pointer.pointeeIsConst + is ArrayType -> resolved(pointer.elemType) to false + else -> error("not a pointer") + } + if (pointee == CharType && isConst) return Crossing.CString + val cType = spelling(declared) + val element = when (pointee) { + CharType -> "Byte" + is IntegerType -> integerKotlinType(pointee.size) + is FloatingType -> when (pointee.size) { 4 -> "Float"; 8 -> "Double"; else -> null } + else -> null + } + return if (element != null) Crossing.PrimitiveArray(element, cType, isConst) else Crossing.Address(cType) + } + + private fun primitive(type: Type): Crossing.Primitive = when (type) { + CharType -> Crossing.Primitive("Byte", "jbyte") + is BoolType -> Crossing.Primitive("Boolean", "jboolean") + is IntegerType -> integerKotlinType(type.size)?.let { Crossing.Primitive(it, "j${it.lowercase()}") } + ?: throw Unsupported("${type.size * 8}-bit integer") + is FloatingType -> when (type.size) { + 4 -> Crossing.Primitive("Float", "jfloat") + 8 -> Crossing.Primitive("Double", "jdouble") + else -> throw Unsupported(type.spelling) + } + is EnumType -> primitive(resolved(type.def.baseType)) + else -> throw Unsupported("type ${type::class.simpleName}") + } + + private fun integerKotlinType(size: Int): String? = when (size) { + 1 -> "Byte"; 2 -> "Short"; 4 -> "Int"; 8 -> "Long"; else -> null + } + + /** [type] with typedefs looked through. */ + private fun resolved(type: Type): Type = if (type is Typedef) resolved(type.def.aliased) else type + + /** How C spells [type], for casts and declarations in the stub. */ + private fun spelling(type: Type): String = when (type) { + is Typedef -> type.def.name + is RecordType -> if (type.decl.isAnonymous) throw Unsupported("anonymous struct by value") else type.decl.spelling + is EnumType -> type.def.spelling + is PointerType -> type.spelling ?: "${if (type.pointeeIsConst) "const " else ""}${spelling(type.pointeeType)}*" + is ArrayType -> "${spelling(type.elemType)}*" + is IntegerType -> type.spelling + is FloatingType -> type.spelling + CharType -> "char" + CBoolType -> "_Bool" + is BoolType -> "bool" + VoidType -> "void" + else -> throw Unsupported("type ${type::class.simpleName}") + } + + private fun kotlinSource( + packageName: String, + facade: String, + stubLibrary: String, + visibility: String, + bound: List, + skipped: List>, + ) = buildString { + appendLine("""@file:JvmName("$facade")""") + appendLine("""@file:Suppress("FunctionName", "LocalVariableName", "unused")""") + if (packageName.isNotEmpty()) appendLine("package $packageName") + appendLine() + appendLine("// Generated JNI bindings; do not edit. Strings and primitive buffers are marshalled,") + appendLine("// structs by value cross as their bytes, and other pointers as addresses (Long).") + appendLine() + appendLine("""private val nativeLibrary: Unit = System.loadLibrary("$stubLibrary")""") + bound.forEach { b -> + val params = b.params.joinToString { (name, crossing) -> "${kotlinIdentifier(name)}: ${crossing.kotlinType}" } + appendLine() + appendLine("/** C: `${cSignature(b.function)}` */") + // JNI resolves the C symbol from the JVM method name; without @JvmName an internal function + // could be mangled. + appendLine("""@JvmName("${b.function.name}")""") + appendLine("$visibility external fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType}") + } + if (skipped.isNotEmpty()) { + appendLine() + appendLine("// Not bound:") + skipped.forEach { (name, reason) -> appendLine("// - $name: $reason") } + } + } + + private fun cSignature(function: FunctionDecl): String { + val params = function.parameters.joinToString { p -> + runCatching { spelling(p.type) }.getOrDefault("?") + (p.name?.let { " $it" } ?: "") + } + return "${runCatching { spelling(function.returnType) }.getOrDefault("?")} ${function.name}($params)" + } + + private fun cSource(context: GenerationContext, jniClassPrefix: String, bound: List) = buildString { + appendLine("// Generated JNI stub; do not edit.") + appendLine("#include ") + appendLine("#include ") + appendLine("#include ") + context.headers.forEach { appendLine("#include <$it>") } + context.preambleLines.forEach { appendLine(it) } + if (bound.any { b -> b.params.any { it.second is Crossing.StructBytes } }) { + appendLine() + appendLine("static void kniThrowIllegalArgument(JNIEnv* env, const char* message) {") + appendLine(" jclass type = (*env)->FindClass(env, \"java/lang/IllegalArgumentException\");") + appendLine(" if (type) (*env)->ThrowNew(env, type, message);") + appendLine("}") + } + bound.forEach { appendLine(); append(cFunction(it, jniClassPrefix)) } + } + + private fun cFunction(b: Binding, jniClassPrefix: String) = buildString { + val name = b.function.name + val params = b.params.mapIndexed { i, (_, c) -> "${c.jniType} p$i" } + val returnType = b.result.jniType + val failure = if (b.result is Crossing.Void) "return;" else "return 0;" + appendLine("JNIEXPORT $returnType JNICALL ${jniClassPrefix}_${jniMangle(name)}(${(listOf("JNIEnv* env", "jclass cls") + params).joinToString(", ")}) {") + + // Checks first, so a failing one leaves nothing acquired. + b.params.forEachIndexed { i, (_, c) -> + if (c is Crossing.StructBytes) { + appendLine(" if (!p$i || (*env)->GetArrayLength(env, p$i) < (jsize)sizeof(${c.cType})) {") + appendLine(" kniThrowIllegalArgument(env, \"$name: argument $i must hold sizeof(${c.cType}) bytes\");") + appendLine(" $failure") + appendLine(" }") + } + } + val arguments = b.params.mapIndexed { i, (_, c) -> + when (c) { + is Crossing.Primitive -> "p$i" + is Crossing.Address -> "(${c.cType})(intptr_t)p$i" + Crossing.CString -> { + appendLine(" const char* s$i = p$i ? (*env)->GetStringUTFChars(env, p$i, 0) : 0;") + "s$i" + } + is Crossing.PrimitiveArray -> { + appendLine(" j${c.element.lowercase()}* a$i = p$i ? (*env)->Get${c.element}ArrayElements(env, p$i, 0) : 0;") + "(${c.cType})a$i" + } + is Crossing.StructBytes -> { + appendLine(" jbyte* b$i = (*env)->GetByteArrayElements(env, p$i, 0);") + "*(${c.cType}*)b$i" + } + Crossing.Void -> error("void parameter") + } + } + val call = "$name(${arguments.joinToString(", ")})" + when (val r = b.result) { + Crossing.Void -> appendLine(" $call;") + is Crossing.Primitive -> appendLine(" $returnType result = ($returnType)$call;") + is Crossing.Address -> appendLine(" jlong result = (jlong)(intptr_t)$call;") + Crossing.CString -> { + appendLine(" const char* r = $call;") + appendLine(" jstring result = r ? (*env)->NewStringUTF(env, r) : 0;") + } + is Crossing.StructBytes -> { + appendLine(" ${r.cType} r = $call;") + appendLine(" jbyteArray result = (*env)->NewByteArray(env, (jsize)sizeof r);") + appendLine(" if (result) (*env)->SetByteArrayRegion(env, result, 0, (jsize)sizeof r, (const jbyte*)&r);") + } + is Crossing.PrimitiveArray -> error("array result") + } + // Released in reverse acquisition order; writable arrays with mode 0, so the callee's writes are + // copied back. + b.params.indices.reversed().forEach { i -> + when (val c = b.params[i].second) { + Crossing.CString -> appendLine(" if (p$i) (*env)->ReleaseStringUTFChars(env, p$i, s$i);") + is Crossing.PrimitiveArray -> { + val mode = if (c.readOnly) "JNI_ABORT" else "0" + appendLine(" if (p$i) (*env)->Release${c.element}ArrayElements(env, p$i, a$i, $mode);") + } + is Crossing.StructBytes -> appendLine(" (*env)->ReleaseByteArrayElements(env, p$i, b$i, JNI_ABORT);") + else -> {} + } + } + if (b.result !is Crossing.Void) appendLine(" return result;") + appendLine("}") + } + + /** `Java_`: the prefix of every JNI symbol for methods of the facade class. */ + private fun jniClassPrefix(packageName: String, facade: String): String = + "Java_" + (packageName.split('.').filter { it.isNotEmpty() } + facade).joinToString("_") { jniMangle(it) } + + /** JNI's escaping of one name segment. */ + private fun jniMangle(segment: String): String = buildString { + segment.forEach { c -> + when { + c == '_' -> append("_1") + c == ';' -> append("_2") + c == '[' -> append("_3") + c.code < 128 && c.isLetterOrDigit() -> append(c) + else -> append("_0%04x".format(c.code)) + } + } + } + + private fun kotlinName(cName: String?, index: Int): String = + cName?.takeIf { it.isNotEmpty() && it.all { c -> c == '_' || c.isLetterOrDigit() } && !it.first().isDigit() } ?: "p$index" + + private fun kotlinIdentifier(name: String) = if (name in KOTLIN_KEYWORDS) "`$name`" else name + + private val KOTLIN_KEYWORDS = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", "interface", "is", + "null", "object", "package", "return", "super", "this", "throw", "true", "try", "typealias", "typeof", + "val", "var", "when", "while", + ) +} diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt index 9f9e31a..a68d6c2 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -7,7 +7,6 @@ import org.jetbrains.kotlin.native.interop.gen.jvm.KotlinPlatform import org.jetbrains.kotlin.native.interop.gen.jvm.buildNativeLibrary import org.jetbrains.kotlin.native.interop.gen.jvm.parseKeyValuePairs import org.jetbrains.kotlin.native.interop.gen.jvm.prepareTool -import org.jetbrains.kotlin.native.interop.indexer.NativeIndex import org.jetbrains.kotlin.native.interop.indexer.buildNativeIndex import org.jetbrains.kotlin.native.interop.tool.CInteropArguments import org.jetbrains.kotlin.utils.NativeMemoryAllocator @@ -15,10 +14,10 @@ import java.io.File /** * Entry point run in the Kotlin/Native distribution's JVM: indexes headers with cinterop's own code and - * hands the resulting [NativeIndex] to a [BindingGenerator]. + * hands the resulting index to a [BindingGenerator]. * * ``` - * --generator --output -- + * --generator --output [--option =]... -- * ``` */ fun main(args: Array) { @@ -26,19 +25,25 @@ fun main(args: Array) { require(separator >= 0) { "Expected '--' before the cinterop command line: ${args.joinToString(" ")}" } val options = parseOptions(args.copyOfRange(0, separator)) val cinteropArgs = args.copyOfRange(separator + 1, args.size) - val generatorName = options.getValue("generator") + + fun single(name: String) = options[name]?.singleOrNull() ?: error("Expected exactly one --$name") + val generatorName = single("generator") val generator = bindingGenerators[generatorName] ?: error("Unknown generator '$generatorName'. Known: ${bindingGenerators.keys.sorted().joinToString()}") - val output = File(options.getValue("output")) + val output = File(single("output")) + val generatorOptions = options["option"].orEmpty().associate { + require('=' in it) { "Expected --option key=value, got '$it'" } + it.substringBefore('=') to it.substringAfter('=') + } // As cinterop's own entry point does around its run. NativeMemoryAllocator.init() JvmCInteropCallbacks.init() try { - val index = index(cinteropArgs) + val context = index(cinteropArgs, generatorOptions) output.deleteRecursively() output.mkdirs() - generator.generate(index, output) + generator.generate(context, output) } finally { JvmCInteropCallbacks.dispose() NativeMemoryAllocator.dispose() @@ -46,15 +51,15 @@ fun main(args: Array) { } /** - * cinterop's own path from a command line to a [NativeIndex]: the steps its private `processCLib` takes - * before generating any Kotlin. `-def`, `-target`, `-compiler-option`, `-Xkonan-home` and the rest are - * cinterop's options, parsed by cinterop. + * cinterop's own path from a command line to a `NativeIndex`: the steps its private `processCLib` takes + * before generating any Kotlin. `-def`, `-pkg`, `-target`, `-compiler-option`, `-Xkonan-home` and the rest + * are cinterop's options, parsed by cinterop. * * [prepareTool] and [buildNativeLibrary] are `internal` to the compiler jar; this source set compiles with * that jar as a friend module (see buildSrc's native-index plugin), so a signature change in a Kotlin * release fails compilation here, at the `kotlinNativeVersion` bump. */ -private fun index(cinteropArgs: Array): NativeIndex { +private fun index(cinteropArgs: Array, generatorOptions: Map): GenerationContext { val arguments = CInteropArguments() arguments.argParser.parse(cinteropArgs) require(arguments.library.isEmpty()) { "Dependency klibs (-library) are not supported by the index exporter yet." } @@ -67,13 +72,25 @@ private fun index(cinteropArgs: Array): NativeIndex { konanDataDir = arguments.konanDataDir, konanHome = arguments.konanHome, ) - val def = DefFile(arguments.def?.let(::File), tool.target) + val defFile = arguments.def?.let(::File) + val def = DefFile(defFile, tool.target) // No dependency klibs, so no headers are imported from elsewhere. val library = buildNativeLibrary(tool, def, arguments, ImportsImpl(emptyMap())) - return buildNativeIndex(library, arguments.verbose, macroNamesCollectingMode = arguments.macroCollectionImpl).index + val index = buildNativeIndex(library, arguments.verbose, macroNamesCollectingMode = arguments.macroCollectionImpl).index + + // As processCLib names the package. + val packageName = arguments.pkg ?: def.config.packageName + ?: checkNotNull(defFile) { "Either -def or -pkg is required" }.name.split('.').reversed().drop(1).joinToString(".") + return GenerationContext( + index = index, + packageName = packageName, + headers = library.includes.map { it.headerPath }, + preambleLines = def.defHeaderLines, + options = generatorOptions, + ) } -private fun parseOptions(args: Array): Map { +private fun parseOptions(args: Array): Map> { require(args.size % 2 == 0) { "Options come in --name value pairs: ${args.joinToString(" ")}" } - return args.toList().chunked(2).associate { (name, value) -> name.removePrefix("--") to value } + return args.toList().chunked(2).groupBy({ (name, _) -> name.removePrefix("--") }, { (_, value) -> value }) } diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index 73d997a..520891e 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -1,12 +1,11 @@ package io.github.lemcoder.index -import io.github.lemcoder.util.ExecCaptureResult -import io.github.lemcoder.util.javaExecCapture import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile @@ -20,19 +19,12 @@ import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault import org.jetbrains.kotlin.konan.target.KonanTarget -import java.nio.file.Files -import java.nio.file.StandardCopyOption import javax.inject.Inject /** * Indexes the headers a `.def` names with cinterop's own indexer and runs a [generator] over the - * resulting `NativeIndex`, in the exporter's process, writing into [outputDirectory]. - * - * The indexer runs in a JVM of its own whose classpath is only the distribution's compiler jar and the - * exporter. Not in the daemon: a native library such as libclang can be loaded by one classloader per - * JVM, and KGP's cinterop may already have loaded it there. Not in a Gradle worker either: that process - * carries Gradle's Kotlin stdlib and the plugin's classes, which would meet the stdlib and the model - * classes bundled in the compiler jar. + * resulting `NativeIndex`, in the exporter's process, writing into [outputDirectory]. See + * [runIndexExporter] for where that process runs. */ @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") abstract class GenerateFromNativeIndexTask @Inject constructor( @@ -55,52 +47,32 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @get:Internal abstract val konanHome: DirectoryProperty - /** A generator the exporter knows by name, e.g. `dump`. */ + /** A generator the exporter knows by name, e.g. `dump` or `jni`. */ @get:Input abstract val generator: Property + /** Passed to the generator as `key=value` options. */ + @get:Input @get:Optional + abstract val generatorOptions: MapProperty + /** Replaced on every run with the generator's output. */ @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction - fun export() { - val home = konanHome.orNull?.asFile ?: PinnedKotlinNative.home() ?: error( - "Kotlin/Native ${PinnedKotlinNative.version} is not installed; the native index needs exactly that version." + fun generate() { + exec.runIndexExporter( + generator = generator.get(), + outputDirectory = outputDirectory.get().asFile, + cinteropArgs = buildList { + add("-def"); add(defFile.get().asFile.absolutePath) + add("-target"); add(target.get().name) + includeDirs.files.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } + compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } + }, + generatorOptions = generatorOptions.getOrElse(emptyMap()), + konanHome = konanHome.orNull?.asFile, + workDir = temporaryDir, ) - val compilerJar = home.resolve("konan/lib/kotlin-native-compiler-embeddable.jar") - check(compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } - - val arguments = buildList { - add("--generator"); add(generator.get()) - add("--output"); add(outputDirectory.get().asFile.absolutePath) - // From here on, a cinterop command line. - add("--") - add("-def"); add(defFile.get().asFile.absolutePath) - add("-target"); add(target.get().name) - add("-Xkonan-home"); add(home.absolutePath) - includeDirs.files.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } - compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } - } - val exporterJar = temporaryDir.resolve(EXPORTER_JAR) - javaClass.getResourceAsStream("/io/github/lemcoder/index/$EXPORTER_JAR").use { input -> - checkNotNull(input) { "Index exporter missing from the plugin jar: $EXPORTER_JAR" } - Files.copy(input, exporterJar.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - - val result = exec.javaExecCapture { - classpath(compilerJar, exporterJar) - mainClass.set(EXPORTER_MAIN) - args(arguments) - systemProperty("konan.home", home.absolutePath) - systemProperty("java.library.path", home.resolve("konan/nativelib").absolutePath) - environment("LIBCLANG_DISABLE_CRASH_RECOVERY", "1") - } - check(result is ExecCaptureResult.Success) { "Indexing ${defFile.get().asFile.name} failed:\n${result.output}" } - } - - private companion object { - const val EXPORTER_JAR = "native-index-exporter.jar" - const val EXPORTER_MAIN = "io.github.lemcoder.index.exporter.NativeIndexExporterKt" } } diff --git a/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt b/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt new file mode 100644 index 0000000..f264f8e --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt @@ -0,0 +1,65 @@ +package io.github.lemcoder.index + +import io.github.lemcoder.util.ExecCaptureResult +import io.github.lemcoder.util.javaExecCapture +import org.gradle.process.ExecOperations +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * Runs the index exporter: cinterop's own indexer over [cinteropArgs], then [generator] over the index, + * writing into [outputDirectory]. + * + * It runs in a JVM of its own whose classpath is only the distribution's compiler jar and the exporter. + * Not in the daemon: a native library such as libclang can be loaded by one classloader per JVM, and KGP's + * cinterop may already have loaded it there. Not in a Gradle worker either: that process carries Gradle's + * Kotlin stdlib, which would meet the one bundled in the compiler jar. + * + * @param konanHome the distribution to run in; by default the installed one of [PinnedKotlinNative.version]. + * @param workDir where the exporter jar is extracted, e.g. the task's temporary directory. + */ +internal fun ExecOperations.runIndexExporter( + generator: String, + outputDirectory: File, + cinteropArgs: List, + generatorOptions: Map = emptyMap(), + konanHome: File? = null, + workDir: File, +) { + val home = konanHome ?: PinnedKotlinNative.home() ?: error( + "Kotlin/Native ${PinnedKotlinNative.version} is not installed; generating bindings needs exactly that " + + "version. A Kotlin Multiplatform build of ${PinnedKotlinNative.version} with a native target downloads it." + ) + val compilerJar = home.resolve("konan/lib/kotlin-native-compiler-embeddable.jar") + check(compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } + + val exporterJar = workDir.resolve(EXPORTER_JAR) + workDir.mkdirs() + PinnedKotlinNative::class.java.getResourceAsStream("/io/github/lemcoder/index/$EXPORTER_JAR").use { input -> + checkNotNull(input) { "Index exporter missing from the plugin jar: $EXPORTER_JAR" } + Files.copy(input, exporterJar.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + val arguments = buildList { + add("--generator"); add(generator) + add("--output"); add(outputDirectory.absolutePath) + generatorOptions.forEach { (key, value) -> add("--option"); add("$key=$value") } + // From here on, a cinterop command line. + add("--") + add("-Xkonan-home"); add(home.absolutePath) + addAll(cinteropArgs) + } + val result = javaExecCapture { + classpath(compilerJar, exporterJar) + mainClass.set(EXPORTER_MAIN) + args(arguments) + systemProperty("konan.home", home.absolutePath) + systemProperty("java.library.path", home.resolve("konan/nativelib").absolutePath) + environment("LIBCLANG_DISABLE_CRASH_RECOVERY", "1") + } + check(result is ExecCaptureResult.Success) { "Generating $generator bindings failed:\n${result.output}" } +} + +private const val EXPORTER_JAR = "native-index-exporter.jar" +private const val EXPORTER_MAIN = "io.github.lemcoder.index.exporter.NativeIndexExporterKt" diff --git a/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt b/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt index 558bb33..338365c 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt +++ b/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt @@ -3,26 +3,18 @@ package io.github.lemcoder.interop import java.io.File /** - * The subset of a cinterop `.def` this plugin reads. - * - * The same file drives both interop legs: Kotlin/Native binds it with cinterop, and `jvmInterops` - * binds it for the JVM. Fields the JVM leg has no meaning for are carried through untouched, and the - * body after the `---` separator is preserved so a def with inline C still works. + * The few facts of a cinterop `.def` the plugin needs on its own side; everything else in it is read by + * cinterop itself, in the index exporter. * + * @property packageName the bindings' package when the interop does not override it. * @property staticLibraries archives to link into the JNI library. On the native side cinterop embeds * these into the klib; here they decide whether there is anything to link at all β€” a def without * them yields bindings and a `.c` stub for someone else's build to compile. */ data class DefFile( - val headers: List, - val headerFilter: String?, val packageName: String?, val staticLibraries: List, val libraryPaths: List, - val compilerOpts: List, - val linkerOpts: List, - val properties: Map, - val body: String?, ) { /** Resolves [staticLibraries] against [libraryPaths], relative to [base] for relative paths. */ fun resolveStaticLibraries(base: File): List = staticLibraries.mapNotNull { name -> @@ -36,39 +28,18 @@ data class DefFile( fun parse(file: File): DefFile = parse(file.readText()) fun parse(text: String): DefFile { - val split = SEPARATOR.split(text, limit = 2) - val properties = LinkedHashMap() - split[0].lineSequence() + val properties = SEPARATOR.split(text, limit = 2)[0].lineSequence() .map { it.trim() } .filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') } - .forEach { line -> - val key = line.substringBefore('=').trim() - if (key.isNotEmpty()) properties[key] = line.substringAfter('=').trim() - } + .associate { it.substringBefore('=').trim() to it.substringAfter('=').trim() } fun tokens(key: String) = properties[key]?.split(' ')?.filter { it.isNotBlank() } ?: emptyList() return DefFile( - headers = tokens("headers"), - headerFilter = properties["headerFilter"], packageName = properties["package"], staticLibraries = tokens("staticLibraries"), libraryPaths = tokens("libraryPaths"), - compilerOpts = tokens("compilerOpts"), - linkerOpts = tokens("linkerOpts"), - properties = properties, - body = split.getOrNull(1)?.takeIf { it.isNotBlank() }, ) } } - - /** - * Renders this def back out, overriding `package`. The JVM stub generator needs a package, and the - * native leg's def may legitimately omit it or use a different one. - */ - fun render(packageOverride: String): String = buildString { - properties.forEach { (key, value) -> if (key != "package") appendLine("$key = $value") } - appendLine("package = $packageOverride") - body?.let { appendLine("---"); append(it) } - } } diff --git a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt index f369a14..d28d57c 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt +++ b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt @@ -118,12 +118,10 @@ abstract class JvmInteropRegistry @Inject constructor( group = "interop" description = "Generate JNI bindings for '${settings.name}' from its .def." mustRunAfter(project.cleanTask()) - konanPath.set(konanConfig.konanPath) defFile.set(settings.defFile) this.packageName.set(packageName) includeDirs.from(settings.includeDirs) hostTarget.set(hostKonanTarget()) - this.jniIncludeDirs.set(jniIncludeDirs.map { dirs -> dirs.map { it.absolutePath } }) additionalCompilerArgs.set(settings.additionalCompilerArgs) internalBindings.set(settings.visibility.map { it == BindingVisibility.INTERNAL }) outputDirectory.set(generatedRoot) diff --git a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt index a7fd404..d461baf 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt +++ b/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt @@ -1,22 +1,15 @@ package io.github.lemcoder.jvm -import org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.interop.DefFile -import io.github.lemcoder.util.ParamKind -import io.github.lemcoder.util.execCapture -import io.github.lemcoder.util.marshalStub -import io.github.lemcoder.util.parseBridgeKinds -import io.github.lemcoder.util.stripCinterop +import io.github.lemcoder.index.runIndexExporter import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.Directory import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Provider import org.gradle.api.provider.Property -import java.io.File +import org.gradle.api.provider.Provider import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles @@ -28,20 +21,21 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault +import org.jetbrains.kotlin.konan.target.KonanTarget import javax.inject.Inject /** - * Generates the JNI `.c` stub and the runtime-free Kotlin bindings from a cinterop `.def`. + * Generates the JNI `.c` stub and the Kotlin bindings from a cinterop `.def`, with the index exporter's + * `jni` generator: cinterop's own indexer reads the headers, and the generator writes both sides of each + * bridge from the C declarations. * - * The same def feeds the native targets' cinterop, so the two legs cannot drift: only `package` is + * The same def feeds the native targets' cinterop, so the two legs cannot drift: only the package is * overridden here, since the JVM bindings live in their own package. */ @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") abstract class GenerateJvmInteropTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { - @get:Input @get:Optional abstract val konanPath: Property - @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) abstract val defFile: RegularFileProperty @@ -51,11 +45,11 @@ abstract class GenerateJvmInteropTask @Inject constructor( @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val includeDirs: ConfigurableFileCollection + /** The headers are indexed once, for the host; the bridges are the same on every target. */ @get:Input abstract val hostTarget: Property - @get:Input abstract val jniIncludeDirs: ListProperty @get:Input @get:Optional abstract val additionalCompilerArgs: ListProperty - /** Emit the bridges as `internal`, keeping them out of the module's published API. */ + /** Emit the bindings as `internal`, keeping them out of the module's published API. */ @get:Input abstract val internalBindings: Property @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @@ -84,73 +78,28 @@ abstract class GenerateJvmInteropTask @Inject constructor( @TaskAction fun run() { - // Resolved here rather than through the property's convention: that is queried while the - // configuration cache is stored, which is before the Kotlin plugin has downloaded the - // distribution, and an absent value would then be cached for the run that needs it. - val konanHome = konanPath.orNull?.let(::File) - ?: JvmInteropSupport.findKonanHome() - ?: error(JvmInteropSupport.MISSING_KONAN) - val embeddableJar = konanHome.resolve("konan/lib/kotlin-native-compiler-embeddable.jar") - check(embeddableJar.isFile) { "Embeddable compiler jar not found: $embeddableJar" } - val nativeLibDir = konanHome.resolve("konan/nativelib") - - val out = outputDirectory.get().asFile - out.deleteRecursively(); out.mkdirs() - - val userDef = defFile.get().asFile - val parsed = DefFile.parse(userDef) - val pkg = packageName.orNull ?: parsed.packageName ?: error( - "jvmInterops: neither packageName nor a `package =` line in ${userDef.name}." + val def = defFile.get().asFile + val includes = listOf(def.parentFile) + includeDirs.files + exec.runIndexExporter( + generator = "jni", + outputDirectory = outputDirectory.get().asFile, + cinteropArgs = buildList { + add("-def"); add(def.absolutePath) + add("-pkg"); add(packageName.get()) + add("-target"); add(hostTarget.get().name) + includes.filter { it.exists() }.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } + additionalCompilerArgs.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } + }, + generatorOptions = mapOf( + "stubLibrary" to stubLibraryBaseName.get(), + "visibility" to if (internalBindings.getOrElse(true)) "internal" else "public", + ), + workDir = temporaryDir, ) - - // The generator wants a def of its own: same contents, our package. - val effectiveDef = out.resolve("${name}.def") - effectiveDef.writeText(parsed.render(pkg)) - - val includes = (listOf(userDef.parentFile) + includeDirs.files + jniIncludeDirs.get().map(::File)) - .filter { it.exists() } - .flatMap { listOf("-compiler-option", "-I${it.absolutePath}") } - val extraOpts = additionalCompilerArgs.getOrElse(emptyList()).flatMap { listOf("-compiler-option", it) } - - val result = exec.execCapture { - executable("java") - args( - "-ea", - "-Dkonan.home=${konanHome.absolutePath}", - "-Djava.library.path=${nativeLibDir.absolutePath}", - "-cp", embeddableJar.absolutePath, - "org.jetbrains.kotlin.native.interop.gen.jvm.MainKt", - "-flavor", "jvm", - "-def", effectiveDef.absolutePath, - "-generated", out.resolve(KOTLIN_DIR).absolutePath, - "-Xtemporary-files-dir", out.resolve(C_DIR).absolutePath, - // The generator indexes headers once on the host; the bridges are platform-independent. - "-target", hostTarget.get().name, - ) - args(includes); args(extraOpts) - environment("LIBCLANG_DISABLE_CRASH_RECOVERY", "1") - // Since 2.4 the generator looks for jni.h itself, under its own java.home and then - // JAVA_HOME. The `java` on PATH is often a JBR with include/ stripped, so point it at the - // JDK whose headers were resolved for the stub. - jniIncludeDirs.get().firstOrNull()?.let { environment("JAVA_HOME", File(it).parent) } - } - logger.lifecycle(result.output) - result.assertNormalExitValue() - - // Strip kotlinx.cinterop from the generated Kotlin so it ships on plain JVM / Android, and - // marshal strings/primitive buffers on both sides of the bridge β€” the raw output passes them - // as addresses, which only a Kotlin/Native caller can produce. - val kotlinFiles = out.resolve(KOTLIN_DIR).walkTopDown().filter { it.extension == "kt" }.toList() - val kinds = kotlinFiles.fold(emptyMap>()) { acc, kt -> - acc + parseBridgeKinds(kt.readText()) - } - val internal = internalBindings.getOrElse(true) - kotlinFiles.forEach { kt -> kt.writeText(stripCinterop(kt.readText(), kinds, internal)) } - out.resolve(C_DIR).walkTopDown().filter { it.extension == "c" } - .forEach { c -> c.writeText(marshalStub(c.readText(), kinds)) } } private companion object { + // The jni generator's layout: kotlin//.kt and c/.c. const val C_DIR = "c" const val KOTLIN_DIR = "kotlin" } diff --git a/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt b/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt index e9b8231..779ee0c 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt +++ b/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt @@ -2,7 +2,7 @@ package io.github.lemcoder.jvm import java.io.File -/** Pure helpers for the JVM/JNI interop leg: toolchain discovery, naming, and the cinterop strip transform. */ +/** Pure helpers for the JVM/JNI interop leg: toolchain discovery and naming. */ internal object JvmInteropSupport { // --- toolchain discovery ------------------------------------------------- @@ -15,9 +15,6 @@ internal object JvmInteropSupport { ?.maxByOrNull { it.name } } - /** As [findKonanHome], failing with the message a build needs when there is none. */ - fun detectKonanHome(): File = findKonanHome() ?: error(MISSING_KONAN) - const val MISSING_KONAN: String = "No Kotlin/Native distribution found. The Kotlin plugin downloads one when it first compiles " + "a native target, so build one of those first, or set konanConfig.konanPath or KONAN_HOME." diff --git a/src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt b/src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt deleted file mode 100644 index f221ea3..0000000 --- a/src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt +++ /dev/null @@ -1,75 +0,0 @@ -package io.github.lemcoder.util - -private val JVM_NAME = Regex("""@file:JvmName\("([^"]+)"\)""") -private val EXTERN = Regex("""(?m)^\s*(?:private\s+)?external\s+fun\s+(kniBridge\d+)\s*\(([^)]*)\)\s*:\s*(\S+)\s*$""") -private val LOAD_LIB = Regex("""loadKonanLibrary\("([^"]+)"\)""") -// Captures each friendly wrapper's name/params/return and the kniBridge it delegates to, so we can keep -// the original C-derived signature as documentation next to the otherwise opaque bridge. -private val WRAPPER = Regex("""(?s)fun\s+(\w+)\(([^)]*)\)\s*:\s*([^{]+?)\s*\{[^}]*?kniBridge(\d+)""") - -/** - * Rewrites a cinterop-generated JVM `.kt` into a runtime-free form: keeps `@file:JvmName` + package + - * the `external fun kniBridgeN` declarations (so the JNI symbol mapping is preserved), erases the - * `NativePtr` typealias to `Long`, replaces `loadKonanLibrary` with `System.loadLibrary`, and drops - * the `kotlinx.cinterop` wrappers/imports entirely. - * - * [kinds] β€” from [parseBridgeKinds] β€” retypes the parameters that [marshalStub] marshals on the C - * side, so strings and primitive buffers cross as `String?` / `ByteArray?` / … instead of as addresses - * the JVM has no way to produce. Pass an empty map to keep every parameter address-typed. - */ -fun stripCinterop( - src: String, - kinds: Map> = emptyMap(), - internalBindings: Boolean = false, -): String { - val jvmName = JVM_NAME.find(src)?.groupValues?.get(1) - val pkg = src.lineSequence().firstOrNull { it.trimStart().startsWith("package ") }?.trim() - val libName = LOAD_LIB.find(src)?.groupValues?.get(1) - ?: error("Generated .kt has no loadKonanLibrary(...) call; cannot determine native library name.") - - // bridge index -> original C-derived signature (for documentation). - val origByBridge = WRAPPER.findAll(src).associate { m -> - val name = m.groupValues[1] - val params = m.groupValues[2].trim() - val ret = m.groupValues[3].trim() - m.groupValues[4] to "$name($params): $ret" - } - - val modifier = if (internalBindings) "internal " else "" - val externs = EXTERN.findAll(src).map { m -> - val bridge = m.groupValues[1] - val idx = bridge.removePrefix("kniBridge") - val declared = splitTopLevel(m.groupValues[2]).map { it.replace("NativePtr", "Long") } - val marshalled = kinds[idx.toInt()] - val params = declared.mapIndexed { i, p -> - val kotlinType = marshalled?.getOrNull(i)?.kotlinType ?: return@mapIndexed p - "${p.substringBefore(':')}: $kotlinType" - }.joinToString(", ") - val ret = m.groupValues[3].replace("NativePtr", "Long") - val doc = origByBridge[idx]?.let { "/** C: $it */\n" } ?: "" - // @JvmName because Kotlin mangles internal functions on the JVM ("kniBridge0${'$'}module"), - // and JNI resolves the C symbol from the unmangled method name. - val jvmNameAnnotation = if (internalBindings) "@JvmName(\"$bridge\")\n" else "" - "$doc$jvmNameAnnotation${modifier}external fun $bridge($params): $ret" - }.toList() - - return buildString { - jvmName?.let { appendLine("""@file:JvmName("$it")""") } - appendLine("""@file:Suppress("FunctionName", "unused")""") - pkg?.let { appendLine(it) } - appendLine() - appendLine("// AUTO-GENERATED runtime-free JNI bridges. Do not edit; regenerated by generateJvmInterop.") - appendLine("// Strings and primitive buffers are marshalled; other pointers are raw addresses (Long).") - appendLine("// Write your idiomatic expect/actual on top.") - appendLine() - appendLine("""private val nativeLibrary: Unit = System.loadLibrary("$libName")""") - appendLine() - externs.forEach { appendLine(it) } - if (kinds.isNotEmpty()) { - appendLine() - appendLine("/** Reads the NUL-terminated C string at [ptr], for bridges returning `const char*`. */") - if (internalBindings) appendLine("""@JvmName("$C_STRING_HELPER")""") - appendLine("${modifier}external fun $C_STRING_HELPER(ptr: Long): String?") - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt b/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt deleted file mode 100644 index de14ba7..0000000 --- a/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt +++ /dev/null @@ -1,218 +0,0 @@ -package io.github.lemcoder.util - -/** - * Marshalling for the generated JNI bridges. - * - * cinterop's JVM flavor passes every pointer as a raw address, which assumes a caller that can - * allocate native memory β€” true for Kotlin/Native, not for plain JVM or Android. The transforms here - * recover the information cinterop already knows (it is visible in the generated wrappers) and - * rewrite both sides of the bridge so that: - * - * - `const char*` inputs take a `String?` and are marshalled with `GetStringUTFChars`, - * - pointers to primitives (`char*`, `float*`, …) take the matching Kotlin array and are marshalled - * with `GetArrayElements` / `ReleaseArrayElements` (mode 0, so writes are copied back), - * - struct-by-value arguments and struct returns take a `ByteArray?` holding the raw struct bytes. - * - * Everything else β€” opaque handles, pointers to structs, primitives β€” is left as a raw address, which - * is what a handle-style C API wants anyway. - */ - -/** How one bridge parameter crosses the JNI boundary. */ -enum class ParamKind( - /** Kotlin type of the parameter, or `null` to keep the address-typed original. */ - val kotlinType: String?, - /** JNI type of the parameter, or `null` to keep `jlong`. */ - val jniType: String?, - /** C type of the local the body sees. */ - val cType: String?, - /** `GetArrayElements` infix, for array kinds. */ - val arrayAccessor: String?, -) { - RAW(null, null, null, null), - STRING("String?", "jstring", "const char*", null), - BYTE_ARRAY("ByteArray?", "jbyteArray", "jbyte*", "Byte"), - SHORT_ARRAY("ShortArray?", "jshortArray", "jshort*", "Short"), - INT_ARRAY("IntArray?", "jintArray", "jint*", "Int"), - LONG_ARRAY("LongArray?", "jlongArray", "jlong*", "Long"), - FLOAT_ARRAY("FloatArray?", "jfloatArray", "jfloat*", "Float"), - DOUBLE_ARRAY("DoubleArray?", "jdoubleArray", "jdouble*", "Double"), - ; - - val isArray: Boolean get() = arrayAccessor != null -} - -/** The name of the generated helper that reads a NUL-terminated C string at a raw address. */ -const val C_STRING_HELPER = "kniCString" - -private val FUN_HEADER = Regex("""(?m)^fun\s+(\w+)\s*\(([^)]*)\)\s*:""") -private val CSTR_ARG = Regex("""^(\w+)\??\.cstr\??\.getPointer\(memScope\)\.rawValue$""") -private val PTR_ARG = Regex("""^(\w+)\??\.getPointer\(memScope\)\.rawValue$""") -private val STRUCT_RETURN_ARG = Regex("""^kniRetVal\.rawPtr$""") -private val VAR_ELEMENT = Regex("""^CValuesRef<(\w+)Var>\??$""") -private val STRUCT_VALUE = Regex("""^CValue<\w+>$""") - -/** - * Derives, from the *unstripped* cinterop output, how each bridge parameter should be marshalled. - * The wrapper bodies spell the mapping out β€” `path?.cstr?.getPointer(memScope).rawValue` is a string, - * `out_buf?.getPointer(memScope).rawValue` with `out_buf: CValuesRef?` is a float buffer β€” - * so the classification reads them rather than re-parsing the C headers. - * - * @return bridge index -> parameter kinds, positional. Bridges with nothing to marshal are omitted. - */ -fun parseBridgeKinds(rawKotlin: String): Map> { - val headers = FUN_HEADER.findAll(rawKotlin).toList() - val result = mutableMapOf>() - - var searchFrom = 0 - while (true) { - val callStart = rawKotlin.indexOf("kniBridge", searchFrom).takeIf { it >= 0 } ?: break - val open = rawKotlin.indexOf('(', callStart) - val bridgeIndex = rawKotlin.substring(callStart + "kniBridge".length, open).toIntOrNull() - if (open < 0 || bridgeIndex == null) { searchFrom = callStart + 1; continue } - val close = matchingParen(rawKotlin, open) - if (close < 0) { searchFrom = callStart + 1; continue } - searchFrom = close + 1 - - // The declarations at the bottom of the file are calls to nothing; only wrapper bodies count. - val header = headers.lastOrNull { it.range.first < callStart } ?: continue - if (rawKotlin.startsWith("private external fun ", rawKotlin.lastIndexOf('\n', callStart) + 1)) continue - - val paramTypes = splitTopLevel(header.groupValues[2]) - .mapNotNull { p -> p.substringBefore(':').trim().takeIf { it.isNotEmpty() }?.to(p.substringAfter(':').trim()) } - .toMap() - - val kinds = splitTopLevel(rawKotlin.substring(open + 1, close)).map { arg -> classify(arg.trim(), paramTypes) } - if (kinds.any { it != ParamKind.RAW }) result[bridgeIndex] = kinds - } - return result -} - -private fun classify(arg: String, paramTypes: Map): ParamKind { - CSTR_ARG.find(arg)?.let { return ParamKind.STRING } - // A struct return is written through an out pointer the wrapper allocates for the caller. - if (STRUCT_RETURN_ARG.matches(arg)) return ParamKind.BYTE_ARRAY - val pointee = PTR_ARG.find(arg)?.groupValues?.get(1) ?: return ParamKind.RAW - val type = paramTypes[pointee] ?: return ParamKind.RAW - if (STRUCT_VALUE.matches(type)) return ParamKind.BYTE_ARRAY - val element = VAR_ELEMENT.find(type)?.groupValues?.get(1) ?: return ParamKind.RAW - return when (element) { - "Byte" -> ParamKind.BYTE_ARRAY - "Short" -> ParamKind.SHORT_ARRAY - "Int" -> ParamKind.INT_ARRAY - "Long" -> ParamKind.LONG_ARRAY - "Float" -> ParamKind.FLOAT_ARRAY - "Double" -> ParamKind.DOUBLE_ARRAY - // Pointers to opaque or struct types stay raw addresses; there is no JVM shape for them. - else -> ParamKind.RAW - } -} - -private val C_FUNCTION = Regex( - """(?s)JNIEXPORT\s+(\w+)\s+JNICALL\s+(Java_\w+_kniBridge(\d+))\s*\(([^)]*)\)\s*\{(.*?)\n\}""" -) -private val C_RETURN = Regex("""(?s)^(.*?)\breturn\s+(.*?);\s*$""") - -/** - * Rewrites the generated JNI `.c` so the marshalled parameters arrive as `jstring` / `jArray`, - * and appends the [C_STRING_HELPER] used to read `const char*` returns. - * - * The incoming object is renamed to `j` and the marshalled pointer keeps the original `p` name, - * so the generated call expression β€” casts and all β€” is reused verbatim. - */ -fun marshalStub(cSource: String, kinds: Map>): String { - val rewritten = C_FUNCTION.replace(cSource) { m -> - val (returnType, jniName, indexText, paramText, body) = m.destructured - val bridgeKinds = kinds[indexText.toInt()] ?: return@replace m.value - - val params = splitTopLevel(paramText).map { it.trim() } - val fixed = params.take(2) // JNIEnv*, jclass - val bridgeParams = params.drop(2) - - // Only the uniform `return expr;` / no-return shapes are safe to wrap; anything else keeps the - // address-typed signature rather than risking a use-after-release. - val returns = Regex("""\breturn\b""").findAll(body).count() - if (returns > 1 || bridgeParams.size != bridgeKinds.size) return@replace m.value - - val declarations = bridgeParams.mapIndexed { i, p -> - val kind = bridgeKinds[i] - if (kind == ParamKind.RAW) p else "${kind.jniType} j$i" - } - val prologue = bridgeParams.indices.mapNotNull { i -> - val kind = bridgeKinds[i] - when { - kind == ParamKind.STRING -> - " ${kind.cType} p$i = j$i ? (*jniEnv)->GetStringUTFChars(jniEnv, j$i, 0) : 0;" - kind.isArray -> - " ${kind.cType} p$i = j$i ? (*jniEnv)->Get${kind.arrayAccessor}ArrayElements(jniEnv, j$i, 0) : 0;" - else -> null - } - } - // Arrays are released first: they are acquired last, and a released string must outlive nothing. - val epilogue = bridgeParams.indices.reversed().mapNotNull { i -> - val kind = bridgeKinds[i] - when { - kind == ParamKind.STRING -> - " if (j$i) (*jniEnv)->ReleaseStringUTFChars(jniEnv, j$i, p$i);" - kind.isArray -> - " if (j$i) (*jniEnv)->Release${kind.arrayAccessor}ArrayElements(jniEnv, j$i, p$i, 0);" - else -> null - } - } - - val (callBody, tail) = C_RETURN.find(body)?.destructured?.let { (before, expression) -> - before.trimEnd('\n') to listOf(" $returnType kniResult = $expression;") - } ?: (body to emptyList()) - - buildString { - append("JNIEXPORT $returnType JNICALL $jniName (") - append((fixed + declarations).joinToString(", ")) - appendLine(") {") - prologue.forEach { appendLine(it) } - if (callBody.isNotBlank()) appendLine(callBody.trimEnd()) - tail.forEach { appendLine(it) } - epilogue.forEach { appendLine(it) } - if (tail.isNotEmpty()) appendLine(" return kniResult;") - append("}") - } - } - - val jniPrefix = C_FUNCTION.find(cSource)?.groupValues?.get(2)?.substringBeforeLast("_kniBridge") - ?: return rewritten - return buildString { - appendLine(rewritten.trimEnd()) - appendLine() - appendLine("// Reads a NUL-terminated C string at a raw address, for functions returning const char*.") - appendLine("JNIEXPORT jstring JNICALL ${jniPrefix}_$C_STRING_HELPER (JNIEnv* jniEnv, jclass jclss, jlong p0) {") - appendLine(" return p0 ? (*jniEnv)->NewStringUTF(jniEnv, (const char*)p0) : 0;") - appendLine("}") - } -} - -/** Index of the `)` matching the `(` at [open], or -1. */ -private fun matchingParen(text: String, open: Int): Int { - var depth = 0 - for (i in open until text.length) { - when (text[i]) { - '(' -> depth++ - ')' -> if (--depth == 0) return i - } - } - return -1 -} - -/** Splits on commas that are not nested in brackets or parentheses. */ -internal fun splitTopLevel(text: String): List { - if (text.isBlank()) return emptyList() - val parts = mutableListOf() - var depth = 0 - var start = 0 - text.forEachIndexed { i, c -> - when (c) { - '(', '<', '[' -> depth++ - ')', '>', ']' -> depth-- - ',' -> if (depth == 0) { parts += text.substring(start, i); start = i + 1 } - } - } - parts += text.substring(start) - return parts.map { it.trim() }.filter { it.isNotEmpty() } -} diff --git a/src/test/kotlin/io/github/lemcoder/ExampleTest.kt b/src/test/kotlin/io/github/lemcoder/ExampleTest.kt deleted file mode 100644 index 20f5758..0000000 --- a/src/test/kotlin/io/github/lemcoder/ExampleTest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.lemcoder - -class ExampleTest { - -} \ No newline at end of file diff --git a/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt b/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt deleted file mode 100644 index f2efc30..0000000 --- a/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt +++ /dev/null @@ -1,198 +0,0 @@ -package io.github.lemcoder - -import io.github.lemcoder.util.ParamKind -import io.github.lemcoder.util.marshalStub -import io.github.lemcoder.util.parseBridgeKinds -import io.github.lemcoder.util.stripCinterop -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Unit tests for the marshalling transforms, over cinterop output for a header that covers the shapes - * that matter: a plain function, a `const char*` return, a string argument, a struct return, a - * struct-by-value argument, and primitive out-buffers. - */ -class JniMarshallingTest { - - private val rawKotlin = """ - @file:JvmName("probe") - package probe - - import kotlinx.cinterop.* - - @ExperimentalForeignApi - fun koi_backend_init(): Unit { - return kniBridge0() - } - - @ExperimentalForeignApi - fun koi_system_info(): CPointer? { - return interpretCPointer(kniBridge1()) - } - - @ExperimentalForeignApi - fun koi_model_load(path: String?): CPointer? { - memScoped { - return interpretCPointer(kniBridge2(path?.cstr?.getPointer(memScope).rawValue)) - } - } - - @ExperimentalForeignApi - fun koi_default_session_params(): CValue { - val kniRetVal = nativeHeap.alloc() - try { - kniBridge3(kniRetVal.rawPtr) - return kniRetVal.readValue() - } finally { nativeHeap.free(kniRetVal) } - } - - @ExperimentalForeignApi - fun koi_session_create(model: CValuesRef?, params: CValue): CPointer? { - memScoped { - return interpretCPointer(kniBridge4(model?.getPointer(memScope).rawValue, params.getPointer(memScope).rawValue)) - } - } - - @ExperimentalForeignApi - fun koi_embed(session: CValuesRef?, text: String?, out_buf: CValuesRef?, buf_size: Int): Int { - memScoped { - return kniBridge5(session?.getPointer(memScope).rawValue, text?.cstr?.getPointer(memScope).rawValue, out_buf?.getPointer(memScope).rawValue, buf_size) - } - } - private external fun kniBridge0(): Unit - private external fun kniBridge1(): NativePtr - private external fun kniBridge2(p0: NativePtr): NativePtr - private external fun kniBridge3(p0: NativePtr): Unit - private external fun kniBridge4(p0: NativePtr, p1: NativePtr): NativePtr - private external fun kniBridge5(p0: NativePtr, p1: NativePtr, p2: NativePtr, p3: Int): Int - private val loadLibrary = loadKonanLibrary("probestubs") - """.trimIndent() - - private val rawC = """ - #include - - JNIEXPORT void JNICALL Java_probe_probe_kniBridge0 (JNIEnv* jniEnv, jclass jclss) { - koi_backend_init(); - } - JNIEXPORT jlong JNICALL Java_probe_probe_kniBridge1 (JNIEnv* jniEnv, jclass jclss) { - return (jlong)koi_system_info(); - } - JNIEXPORT jlong JNICALL Java_probe_probe_kniBridge2 (JNIEnv* jniEnv, jclass jclss, jlong p0) { - return (jlong)koi_model_load((char*)p0); - } - JNIEXPORT void JNICALL Java_probe_probe_kniBridge3 (JNIEnv* jniEnv, jclass jclss, jlong p0) { - KoiSessionParams kniStructResult = koi_default_session_params(); - memcpy((void*) p0, &kniStructResult, sizeof(kniStructResult)); - } - JNIEXPORT jlong JNICALL Java_probe_probe_kniBridge4 (JNIEnv* jniEnv, jclass jclss, jlong p0, jlong p1) { - return (jlong)koi_session_create((struct KoiModel*)p0, *(KoiSessionParams*)p1); - } - JNIEXPORT jint JNICALL Java_probe_probe_kniBridge5 (JNIEnv* jniEnv, jclass jclss, jlong p0, jlong p1, jlong p2, jint p3) { - return (jint)koi_embed((struct KoiSession*)p0, (char*)p1, (float*)p2, p3); - } - """.trimIndent() - - private val kinds = parseBridgeKinds(rawKotlin) - - @Test - fun `classifies strings, buffers and structs`() { - assertEquals(listOf(ParamKind.STRING), kinds[2]) - assertEquals(listOf(ParamKind.BYTE_ARRAY), kinds[3], "struct returns are written through an out pointer") - assertEquals(listOf(ParamKind.RAW, ParamKind.BYTE_ARRAY), kinds[4], "opaque handle stays an address") - assertEquals(listOf(ParamKind.RAW, ParamKind.STRING, ParamKind.FLOAT_ARRAY, ParamKind.RAW), kinds[5]) - } - - @Test - fun `bridges with nothing to marshal are left alone`() { - assertFalse(kinds.containsKey(0)) - assertFalse(kinds.containsKey(1), "a const char* return is read via kniCString, not marshalled here") - } - - @Test - fun `kotlin declarations take the marshalled types`() { - val kotlin = stripCinterop(rawKotlin, kinds) - - assertContains(kotlin, "external fun kniBridge2(p0: String?): Long") - assertContains(kotlin, "external fun kniBridge3(p0: ByteArray?): Unit") - assertContains(kotlin, "external fun kniBridge4(p0: Long, p1: ByteArray?): Long") - assertContains(kotlin, "external fun kniBridge5(p0: Long, p1: String?, p2: FloatArray?, p3: Int): Int") - assertContains(kotlin, "external fun kniCString(ptr: Long): String?") - } - - @Test - fun `internal bindings carry a JvmName so JNI still resolves them`() { - val kotlin = stripCinterop(rawKotlin, kinds, internalBindings = true) - - // Kotlin mangles internal functions on the JVM (kniBridge2${'$'}module); JNI looks the symbol up - // by the unmangled name, so every bridge needs the annotation. - assertContains(kotlin, "@JvmName(\"kniBridge2\")\ninternal external fun kniBridge2(p0: String?): Long") - assertContains(kotlin, "@JvmName(\"kniCString\")\ninternal external fun kniCString(ptr: Long): String?") - } - - @Test - fun `bindings are public unless asked otherwise`() { - val kotlin = stripCinterop(rawKotlin, kinds) - - assertContains(kotlin, "external fun kniBridge2(p0: String?): Long") - assertFalse(kotlin.contains("internal external")) - assertFalse(kotlin.contains("@JvmName(\"kniBridge")) - } - - @Test - fun `without kinds every parameter stays an address`() { - val kotlin = stripCinterop(rawKotlin) - - assertContains(kotlin, "external fun kniBridge2(p0: Long): Long") - assertFalse(kotlin.contains("kniCString"), "the helper is only emitted alongside marshalling") - } - - @Test - fun `strings are acquired and released around the call`() { - val c = marshalStub(rawC, kinds) - - assertContains(c, "Java_probe_probe_kniBridge2 (JNIEnv* jniEnv, jclass jclss, jstring j0)") - assertContains(c, "const char* p0 = j0 ? (*jniEnv)->GetStringUTFChars(jniEnv, j0, 0) : 0;") - assertContains(c, "jlong kniResult = (jlong)koi_model_load((char*)p0);") - assertContains(c, "if (j0) (*jniEnv)->ReleaseStringUTFChars(jniEnv, j0, p0);") - assertContains(c, " return kniResult;") - } - - @Test - fun `buffers are released in reverse order, after the result is captured`() { - val c = marshalStub(rawC, kinds).substringAfter("kniBridge5 (") - - assertContains(c, "jlong p0, jstring j1, jfloatArray j2, jint p3)") - assertTrue( - c.indexOf("GetStringUTFChars") < c.indexOf("GetFloatArrayElements"), - "strings must be acquired before array elements", - ) - assertTrue( - c.indexOf("ReleaseFloatArrayElements") < c.indexOf("ReleaseStringUTFChars"), - "releases run in reverse acquisition order", - ) - assertTrue(c.indexOf("kniResult =") < c.indexOf("ReleaseFloatArrayElements")) - } - - @Test - fun `void bridges release without a result variable`() { - val c = marshalStub(rawC, kinds).substringAfter("kniBridge3 (").substringBefore("JNIEXPORT") - - assertContains(c, "jbyteArray j0)") - assertContains(c, "memcpy((void*) p0, &kniStructResult, sizeof(kniStructResult));") - assertContains(c, "if (j0) (*jniEnv)->ReleaseByteArrayElements(jniEnv, j0, p0, 0);") - assertFalse(c.contains("kniResult")) - } - - @Test - fun `unmarshalled bridges keep their generated body and the helper is appended`() { - val c = marshalStub(rawC, kinds) - - assertContains(c, "Java_probe_probe_kniBridge0 (JNIEnv* jniEnv, jclass jclss) {") - assertContains(c, "return (jlong)koi_system_info();") - assertContains(c, "JNIEXPORT jstring JNICALL Java_probe_probe_kniCString (JNIEnv* jniEnv, jclass jclss, jlong p0)") - assertContains(c, "return p0 ? (*jniEnv)->NewStringUTF(jniEnv, (const char*)p0) : 0;") - } -} diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index b3607d8..bc9e05d 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -40,12 +40,36 @@ class GenerateFromNativeIndexTest { assertContains(dump, "const SAMPLE_NAME: ptr char `char *` = \"sample\"") } + @Test + fun `the jni generator binds each C function under its own name`() { + val kotlin = jni().resolve("kotlin/sample/sample.kt").readText() + assertContains(kotlin, "private val nativeLibrary: Unit = System.loadLibrary(\"samplestubs\")") + assertContains(kotlin, "@JvmName(\"sample_sum\")\ninternal external fun sample_sum(values: IntArray?, count: Long): Int") + assertContains(kotlin, "internal external fun sample_name(): String?") + assertContains(kotlin, "internal external fun sample_buffer(handle: Long, c: Int, s: Int): ByteArray") + assertContains(kotlin, "internal external fun sample_bits(f: ByteArray): ByteArray") + assertContains(kotlin, "internal external fun sample_sort(base: Long, n: Long, cmp: Long): Unit") + assertContains(kotlin, "// - sample_log: variadic") + } + + @Test + fun `the jni stub marshals each parameter and escapes the JNI symbol`() { + val c = jni().resolve("c/samplestubs.c").readText() + assertContains(c, "#include ") + assertContains(c, "Java_sample_sample_sample_1sum(JNIEnv* env, jclass cls, jintArray p0, jlong p1)") + // const int*: pinned for the call, not copied back. + assertContains(c, "if (p0) (*env)->ReleaseIntArrayElements(env, p0, a0, JNI_ABORT);") + assertContains(c, "jstring result = r ? (*env)->NewStringUTF(env, r) : 0;") + assertContains(c, "if (!p0 || (*env)->GetArrayLength(env, p0) < (jsize)sizeof(struct flags)) {") + assertContains(c, "sample_sort((void *)(intptr_t)p0, p1, (compare_fn)(intptr_t)p2);") + } + @Test fun `an unknown generator fails with the ones that exist`() { assumeInstalled() val projectDir = project(exportTask("bogus", "mymath", mymathDef, mymathHeaders)) val result = runner(projectDir, "generate_mymath").buildAndFail() - assertContains(result.output, "Unknown generator 'bogus'. Known: dump") + assertContains(result.output, "Unknown generator 'bogus'. Known: dump, jni") } private fun dump(name: String): String { @@ -53,6 +77,11 @@ class GenerateFromNativeIndexTest { return generated.getValue(name).resolve("index.txt").readText() } + private fun jni(): File { + assumeInstalled() + return generated.getValue("jni_sample") + } + private fun assumeInstalled() = assumeTrue(PinnedKotlinNative.home() != null, "Kotlin/Native ${PinnedKotlinNative.version} is not installed") @@ -61,22 +90,25 @@ class GenerateFromNativeIndexTest { val mymathDef = File("examples/jvm/src/main/nativeInterop/mymath.def").absoluteFile val mymathHeaders = File("examples/jvm/native").absoluteFile - /** One build runs the dump generator for every header the tests read: output directory by name. */ + /** One build runs every generator the tests read: output directory by task name. */ val generated: Map by lazy { val projectDir = project( exportTask("dump", "mymath", mymathDef, mymathHeaders), exportTask("dump", "sample", sampleDef, sampleDef.parentFile), + exportTask("jni", "jni_sample", sampleDef, sampleDef.parentFile, "stubLibrary" to "samplestubs"), ) - runner(projectDir, "generate_mymath", "generate_sample").build() - listOf("mymath", "sample").associateWith { projectDir.resolve("build/generated/$it") } + val names = listOf("mymath", "sample", "jni_sample") + runner(projectDir, *names.map { "generate_$it" }.toTypedArray()).build() + names.associateWith { projectDir.resolve("build/generated/$it") } } - fun exportTask(generator: String, name: String, def: File, includeDir: File) = """ + fun exportTask(generator: String, name: String, def: File, includeDir: File, vararg options: Pair) = """ tasks.register("generate_$name") { defFile.set(file("${path(def)}")) includeDirs.from("${path(includeDir)}") target.set(hostKonanTarget()) this.generator.set("$generator") + generatorOptions.set(mapOf(${options.joinToString { (k, v) -> "\"$k\" to \"$v\"" }})) outputDirectory.set(layout.buildDirectory.dir("generated/$name")) } """.trimIndent() From 63647fc05189fb7f5b9f11ee363565847408bb21 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sun, 4 Oct 2026 23:32:29 +0200 Subject: [PATCH 10/57] fix: register an Android app's jniLibs once In a com.android.base module both wireIntoAndroid and wireJniLibsIntoAndroid added the per-ABI jniLibs directory to every variant, and mergeDebugJniLibFolders failed with "Duplicate resources" for each library. wireJniLibsIntoAndroid already covers com.android.base, so wireIntoAndroid now adds only the generated Kotlin sources. Co-Authored-By: Claude Opus 5.5 --- .../github/lemcoder/interop/JvmInteropRegistry.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt index d28d57c..ba33200 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt +++ b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt @@ -139,7 +139,7 @@ abstract class JvmInteropRegistry @Inject constructor( project.tasks.matching { it.name.contains("ArtProfile") } .configureEach { dependsOn(generate) } } else { - wireIntoAndroid(settings, generate.get().kotlinSourceDirectory, jniLibsRoot) + wireIntoAndroid(settings, generate.get().kotlinSourceDirectory) } // One link task per target. Whether it does anything depends on the def naming a library; a @@ -344,24 +344,21 @@ abstract class JvmInteropRegistry @Inject constructor( } /** - * AGP has no Kotlin compilation to attach to, so the generated sources and the per-ABI `jniLibs` - * go in through the variant API. Registered at configuration time β€” the variant API rejects - * callbacks added later β€” and the directories must exist by then. + * AGP has no Kotlin compilation to attach to, so the generated sources go in through the variant + * API. Registered at configuration time β€” the variant API rejects callbacks added later β€” and the + * directory must exist by then. The per-ABI `jniLibs` are wired by [wireJniLibsIntoAndroid]; + * adding them here too fails the merge with "Duplicate resources". */ private fun wireIntoAndroid( settings: JvmInteropSettings, kotlinSources: org.gradle.api.provider.Provider, - jniLibsRoot: org.gradle.api.provider.Provider, ) { project.plugins.withId("com.android.base") { val kotlinDir = kotlinSources.get().asFile.apply { mkdirs() } - val jniLibsDir = jniLibsRoot.get().asFile.apply { mkdirs() } val kotlinRel = kotlinDir.relativeTo(project.projectDir).path - val jniRel = jniLibsDir.relativeTo(project.projectDir).path project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { variant -> variant.sources.kotlin?.addStaticSourceDirectory(kotlinRel) - variant.sources.jniLibs?.addStaticSourceDirectory(jniRel) } val suffix = settings.name.replaceFirstChar { it.uppercase() } From 9c4b34e75ace116bf16be16debdfe5ba6fc5d9b6 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Sun, 4 Oct 2026 23:32:40 +0200 Subject: [PATCH 11/57] feat: generate FFM bindings with jextract from the same index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A jextract generator in the index exporter writes java.lang.foreign bindings into java//. jextract parses the headers with its own libclang, so the work splits: cinterop's index decides what is bound β€” every function, struct, union, typedef, constant and global that passed the .def's header filter becomes a jextract --include-* filter, so the FFM and JNI bindings cover the same declarations β€” and jextract decides how. Only the -I/-D/-U compiler options reach jextract; the bindings load the library with System.loadLibrary. The two parsers model typedef'd anonymous records differently: cinterop folds `typedef union { … } bits_t` into a record named bits_t, jextract keeps it a typedef and binds it only under --include-typedef. Records and enums spelled without a struct/union/enum keyword are passed as typedefs. GenerationContext now carries the clang arguments cinterop parsed with. The test runs when JEXTRACT points at a jextract executable and is skipped otherwise. Co-Authored-By: Claude Opus 5.5 --- .../index/exporter/BindingGenerator.kt | 7 +- .../index/exporter/JextractGenerator.kt | 98 +++++++++++++++++++ .../index/exporter/NativeIndexExporter.kt | 1 + .../index/GenerateFromNativeIndexTest.kt | 21 +++- 4 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt index b47d25c..9040b87 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt @@ -26,10 +26,15 @@ class GenerationContext( val headers: List, /** The `.def`'s lines after `---`: C code every translation unit of the library sees. */ val preambleLines: List, + /** + * The clang arguments cinterop parsed the headers with: the `.def`'s and the command line's + * `compilerOpts`, plus the target's own (`-target`, sysroot, builtin includes). + */ + val compilerArgs: List, /** `--option key=value` pairs from the exporter's command line, for the generator to interpret. */ val options: Map, ) /** Every generator the exporter can run, by [BindingGenerator.name]. */ val bindingGenerators: Map = - listOf(DumpGenerator, JniGenerator).associateBy { it.name } + listOf(DumpGenerator, JniGenerator, JextractGenerator).associateBy { it.name } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt new file mode 100644 index 0000000..d5c105a --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt @@ -0,0 +1,98 @@ +package io.github.lemcoder.index.exporter + +import java.io.File +import java.nio.file.Files + +/** + * FFM (`java.lang.foreign`) bindings, written by jextract into `java//`. + * + * jextract parses the headers itself, with its own libclang, so this generator splits the work: cinterop's + * index decides *what* is bound β€” every function, struct, union, typedef, constant and global that passed + * the `.def`'s header filter becomes a jextract `--include-*` filter, so the FFM and JNI bindings cover the + * same declarations β€” and jextract decides *how*. + * + * Only the user-level `-I`, `-D` and `-U` options reach jextract: it brings its own clang and builtin + * headers, so the target flags cinterop parsed with do not apply. + * + * Options: `library` (required) is the shared library the bindings load with `System.loadLibrary`, so + * from `java.library.path`; `jextract` is the jextract executable (default: `jextract` on the PATH); + * `headerClass` names the class holding the functions (default: the package's last segment). + */ +object JextractGenerator : BindingGenerator { + override val name = "jextract" + + override fun generate(context: GenerationContext, outputDirectory: File) { + val library = requireNotNull(context.options["library"]) { "The jextract generator needs --option library=" } + val jextract = context.options["jextract"] ?: "jextract" + val headerClass = context.options["headerClass"] ?: context.packageName.substringAfterLast('.') + + val scratch = Files.createTempDirectory("jextract").toFile() + try { + // jextract takes one header; this one sees exactly what cinterop's translation unit saw. + val header = scratch.resolve("${headerClass}_bindings.h") + header.writeText( + (context.headers.map { "#include <$it>" } + context.preambleLines).joinToString("\n", postfix = "\n") + ) + + val command = buildList { + add(jextract) + add("--output"); add(outputDirectory.resolve("java").absolutePath) + add("--target-package"); add(context.packageName) + add("--header-class-name"); add(headerClass) + add("--library"); add(library) + add("--use-system-load-library") + addAll(userLevelClangArgs(context.compilerArgs)) + includeFilters(context).forEach { (option, name) -> add(option); add(name) } + add(header.absolutePath) + } + val process = ProcessBuilder(command).redirectErrorStream(true).start() + val log = process.inputStream.bufferedReader().readText() + check(process.waitFor() == 0) { "jextract failed:\n${command.joinToString(" ")}\n$log" } + } finally { + scratch.deleteRecursively() + } + } + + /** + * `--include-*` filters for everything cinterop's index holds, as (option, name). + * + * The two tools name typedef'd anonymous records differently: cinterop folds + * `typedef union { … } bits_t` into a record spelled `bits_t`, jextract keeps it a typedef. A record + * or enum whose spelling has no `struct`/`union`/`enum` keyword is therefore a typedef to jextract. + */ + private fun includeFilters(context: GenerationContext): List> = buildList { + val index = context.index + index.functions.filter { it.parentName == null }.forEach { add("--include-function" to it.name) } + index.structs.filter { !it.isAnonymous && it.def != null }.forEach { add(tagFilter(it.spelling)) } + index.enums.filter { !it.isAnonymous }.forEach { enum -> + if (!enum.spelling.startsWith("enum ")) add("--include-typedef" to enum.spelling) + } + index.typedefs.forEach { add("--include-typedef" to it.name) } + index.macroConstants.forEach { add("--include-constant" to it.name) } + index.enums.flatMap { it.constants }.forEach { add("--include-constant" to it.name) } + index.globals.filter { it.parentName == null }.forEach { add("--include-var" to it.name) } + } + + private fun tagFilter(spelling: String): Pair = when { + spelling.startsWith("struct ") -> "--include-struct" to spelling.removePrefix("struct ") + spelling.startsWith("union ") -> "--include-union" to spelling.removePrefix("union ") + else -> "--include-typedef" to spelling + } + + /** The `-I`, `-D` and `-U` options of [args], in either spelling (`-Ifoo` or `-I foo`). */ + private fun userLevelClangArgs(args: List): List = buildList { + var i = 0 + while (i < args.size) { + val arg = args[i] + val flag = USER_FLAGS.firstOrNull { arg.startsWith(it) } + when { + flag == null -> {} + arg == flag && i + 1 < args.size -> { add(arg + args[i + 1]); i++ } + else -> add(arg) + } + i++ + } + } + + private val USER_FLAGS = listOf("-I", "-D", "-U") +} diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt index a68d6c2..fd3a50a 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -86,6 +86,7 @@ private fun index(cinteropArgs: Array, generatorOptions: Map Date: Mon, 5 Oct 2026 20:48:01 +0200 Subject: [PATCH 12/57] feat: bind Kotlin/Native through KGP's cinterop with kotlinNativeInterop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A klib is a compiler artifact, so Kotlin/Native bindings come from KGP rather than the index exporter: the klib must match the consumer's Kotlin version, not the pinned one the exporter runs in. kotlinNativeInterop(name) { defFile; includeDirs; compilerOptions; packageName; targets } is an adapter: it declares KGP's own cinterops {} entry on the main compilation of every Kotlin/Native target, so KGP builds the klib with the project's Kotlin version and handles the per-target tasks, -Xmodule-name, commonization, the IDE and publication. Where konanConfig builds the library for a target, cinterop gets -libraryPath to its archive directory and runs after runKonanClang. The KGP code is reached only inside plugins.withId("org.jetbrains.kotlin.multiplatform"); the plugin compiles against KGP compileOnly. The native example uses the adapter instead of a hand-written cinterops {} block. Running cinterop's own pipeline inside the exporter (Interop().interop("native", …)) was tried: it works and yields a klib matching KGP's apart from the header hash and unique_name, but is pinned to kotlinNativeVersion. What it left behind is kept: the index and library facts in GenerationContext are built on first use, so a generator that needs neither skips parsing the headers, and GenerateFromNativeIndexTask takes further cinterop options (extraCinteropArgs). TestKit loads the plugin under test where a separately applied KGP cannot see it, so the KMP test passes KGP on the plugin-under-test classpath itself; the other tests keep proving the plugin loads without KGP. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 ++ README.md | 18 +++ build.gradle.kts | 11 ++ examples/README.md | 2 +- examples/native/build.gradle.kts | 21 ++-- .../index/exporter/BindingGenerator.kt | 30 +++-- .../index/exporter/NativeIndexExporter.kt | 43 ++++--- .../index/GenerateFromNativeIndexTask.kt | 7 +- .../lemcoder/interop/KgpCinteropAdapter.kt | 41 +++++++ .../lemcoder/interop/KotlinNativeInterop.kt | 52 +++++++++ .../lemcoder/KotlinNativeInteropTest.kt | 106 ++++++++++++++++++ 11 files changed, 298 insertions(+), 44 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt create mode 100644 src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt create mode 100644 src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 8443ee3..bf2d65f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,11 @@ holds a `NativeIndex`. the compiler jar as a friend module (`friendPaths` in the buildSrc plugin). Java is not a way around `internal` here: `native` is a Java keyword, so Java cannot import any `org.jetbrains.kotlin.native.…` class. And Kotlin cannot see the jar's types through Java sources in the same source set. +- **Kotlin/Native bindings are not an exporter generator.** `kotlinNativeInterop` adapts a binding to + KGP's own `cinterops {}`, so the klib comes from the consumer's Kotlin version β€” a klib is a compiler + artifact, unlike the sources the exporter writes. Running cinterop's pipeline in the exporter works + (`Interop().interop("native", …)`), but would pin the klib to `kotlinNativeVersion`; and its native + allocator setup must not nest inside the exporter's, which is why the index is built lazily. - **It runs through `javaexec`, not the Worker API.** A process-isolated worker puts Gradle's Kotlin stdlib and the plugin's classes next to the compiler jar: the compiler then ran on Gradle 9.8's stdlib 2.4.10 instead of its bundled 2.4.20. The daemon is out too: KGP's cinterop may already have @@ -96,6 +101,12 @@ holds a `NativeIndex`. The TestKit functional tests are what catch decoration and classloading failures, because they apply the plugin to a project with neither KGP nor AGP. Keep at least one fixture like that. +`withPluginClasspath()` loads the plugin under test where a plugin the test build applies on its own +(`kotlin("multiplatform") version …`) cannot see it: code reaching KGP's types fails with +`NoClassDefFoundError`, though a real build loads both together. A test that needs KGP passes it on the +plugin-under-test classpath itself (`kgp.classpath`, see `KotlinNativeInteropTest`); never add KGP to +the shared metadata, or the no-KGP fixtures stop proving anything. + Portal versions are immutable β€” bump and publish, never reuse an alpha. Consumers with `mavenLocal()` ahead of the portal will silently use a local build instead, so a version that "works locally" proves nothing about what CI resolves. Verify a publish by configuring a throwaway project with only diff --git a/README.md b/README.md index 88a15c0..4fa63b3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ The **Konan Plugin** is a custom Gradle plugin that facilitates compiling C/C++ - Generates runtime-free JNI bindings + a self-contained stub shared library (`jvmInterops`). - Marshals `String`s and primitive arrays across the bridge, so a C API taking `const char*` or `float*` buffers is callable without an off-heap allocator on the Kotlin side. +- Declares Kotlin/Native cinterops for every native target from the same `.def` + (`kotlinNativeInterop`), linked against the library `konanConfig` builds. - Auto-wires generated sources and per-ABI `jniLibs` into Android projects (AGP). - Auto-detects the Kotlin/Native distribution, the JDK that supplies `jni.h`, and the compiler-rt builtins each target needs (NDK on Android, Xcode on macOS). @@ -198,6 +200,22 @@ any Kotlin Multiplatform build of that version with a native target downloads it Platform-specific link arguments go in `linkerArgsFor`; `-framework` in `additionalLinkerArgs` would reach the Android linker, which rejects it. +## `kotlinNativeInterop` β€” Kotlin/Native bindings +Declares the cinterop for a `.def` on every Kotlin/Native target, through KGP's own `cinterops {}`, so +the klib comes from the project's Kotlin version and KGP handles commonization, the IDE and publishing. +Where `konanConfig` builds the library for a target, cinterop is pointed at its archive (`-libraryPath`) +and runs after `runKonanClang`, so a `.def` with `staticLibraries = lib.a` embeds it. + +```kotlin +import io.github.lemcoder.interop.kotlinNativeInterop + +kotlinNativeInterop("mymath") { + defFile.set(file("src/nativeInterop/cinterop/mymath.def")) + includeDirs.from("native") + // packageName, compilerOptions, and targets (every Kotlin/Native target when empty) are optional. +} +``` + ## Usage ### Static library only diff --git a/build.gradle.kts b/build.gradle.kts index f105526..d906139 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,11 +34,16 @@ gradlePlugin { } } +val kgpForTests: Configuration = configurations.create("kgpForTests") + dependencies { + "kgpForTests"(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-gradle-plugin:$it" }) // KonanTarget & HostManager: the plugin speaks Kotlin/Native's own target type, so a build can pass // `kotlinNativeTarget.konanTarget` straight in. Not compileOnly β€” it appears in decorated DSL // signatures, which Gradle resolves in projects that have no Kotlin plugin on the classpath. implementation(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-native-utils:$it" }) + // Used only by the KGP cinterop adapter, inside plugins.withId: the consuming project supplies KGP. + compileOnly(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-gradle-plugin:$it" }) // Used only to auto-wire generated sources/jniLibs into Android projects via the AGP variant API. // compileOnly: the consuming Android project supplies AGP at runtime. compileOnly("com.android.tools.build:gradle-api:9.2.1") @@ -52,4 +57,10 @@ dependencies { tasks.test { useJUnitPlatform() + // TestKit loads the plugin under test where plugins the test build applies cannot see it, so the + // KGP adapter's test puts KGP on that same classpath itself. Only that test: the others must keep + // proving the plugin loads without KGP. + val kgp: FileCollection = kgpForTests + inputs.files(kgp).withPropertyName("kgpForTests").withNormalizer(ClasspathNormalizer::class.java) + jvmArgumentProviders.add(CommandLineArgumentProvider { listOf("-Dkgp.classpath=${kgp.asPath}") }) } diff --git a/examples/README.md b/examples/README.md index 658f741..19b877e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -7,7 +7,7 @@ two flows the plugin provides: |---|---|---|---| | [`jvm/`](jvm) | `konanConfig` (.a) + `jvmInterop` (JNI .dylib + bridges) | full JNI path, end to end | `../../gradlew -p jvm run` | | [`android/`](android) | same, cross-compiled per ABI, real app | APK with `.so` (arm64-v8a + x86_64) + `MainActivity` | `cd android && ./gradlew installDebug` | -| [`native/`](native) | `konanConfig` (.a) + K/N `cinterops` | the existing native path | `../../gradlew -p native runDebugExecutableMacosArm64` | +| [`native/`](native) | `konanConfig` (.a) + `kotlinNativeInterop` β†’ KGP cinterop | the native path | `../../gradlew -p native runDebugExecutableMacosArm64` | `jvm` and `native` build the plugin from source via `includeBuild`. `android` needs AGP 9 (Gradle 9.4, its own wrapper), so it consumes the plugin from mavenLocal β€” publish it first: diff --git a/examples/native/build.gradle.kts b/examples/native/build.gradle.kts index a3ce359..73eb428 100644 --- a/examples/native/build.gradle.kts +++ b/examples/native/build.gradle.kts @@ -1,7 +1,8 @@ -// Kotlin/Native consumer of the SAME C library via standard cinterop (no JNI). -// The plugin's role here is only the cross-compiler: it produces libmymath.a; -// the K/N `cinterops {}` block does the binding, exactly as it does today. +// Kotlin/Native consumer of the SAME C library through cinterop (no JNI). +// The plugin cross-compiles native/*.c to libmymath.a, and kotlinNativeInterop declares the cinterop +// for every Kotlin/Native target: KGP then builds the interop klib with this project's Kotlin version. +import io.github.lemcoder.interop.kotlinNativeInterop import org.jetbrains.kotlin.konan.target.KonanTarget plugins { @@ -22,17 +23,13 @@ konanConfig { kotlin { macosArm64 { - compilations.getByName("main").cinterops.create("mymath") { - defFile("src/nativeInterop/cinterop/mymath.def") - includeDirs("native") - // Where the static library referenced by the .def lives. - extraOpts("-libraryPath", layout.buildDirectory.dir("native/${host.name}").get().asFile.absolutePath) - } binaries.executable { entryPoint = "main" } } } -// cinterop links the static lib, so build it first. -tasks.matching { it.name.startsWith("cinteropMymath") }.configureEach { - dependsOn("runKonanClang") +// The .def names staticLibraries = libmymath.a; the adapter points cinterop at konanConfig's archive +// for each target and runs it after runKonanClang. +kotlinNativeInterop("mymath") { + defFile.set(file("src/nativeInterop/cinterop/mymath.def")) + includeDirs.from("native") } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt index 9040b87..be1c01c 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt @@ -17,23 +17,37 @@ interface BindingGenerator { fun generate(context: GenerationContext, outputDirectory: File) } -/** What a generator gets: the index, and the facts about the library cinterop derived it from. */ +/** + * What a generator gets: the index, and the facts about the library cinterop derived it from. + * + * The index and the library facts are built on first use, so a generator that needs neither does not + * pay for parsing the headers. + */ class GenerationContext( - val index: NativeIndex, /** The bindings' package, decided the way cinterop decides it: `-pkg`, the `.def`'s `package`, or its file name. */ val packageName: String, - /** The headers the library includes, as the `.def` (or `-header`) names them. */ - val headers: List, /** The `.def`'s lines after `---`: C code every translation unit of the library sees. */ val preambleLines: List, + /** `--option key=value` pairs from the exporter's command line, for the generator to interpret. */ + val options: Map, + indexLibrary: () -> IndexedLibrary, +) { + private val indexed by lazy(indexLibrary) + + val index: NativeIndex get() = indexed.index + + /** The headers the library includes, as the `.def` (or `-header`) names them. */ + val headers: List get() = indexed.headers + /** * The clang arguments cinterop parsed the headers with: the `.def`'s and the command line's * `compilerOpts`, plus the target's own (`-target`, sysroot, builtin includes). */ - val compilerArgs: List, - /** `--option key=value` pairs from the exporter's command line, for the generator to interpret. */ - val options: Map, -) + val compilerArgs: List get() = indexed.compilerArgs +} + +/** cinterop's view of the library: its index, and what it was built from. */ +class IndexedLibrary(val index: NativeIndex, val headers: List, val compilerArgs: List) /** Every generator the exporter can run, by [BindingGenerator.name]. */ val bindingGenerators: Map = diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt index fd3a50a..4eac024 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -13,8 +13,8 @@ import org.jetbrains.kotlin.utils.NativeMemoryAllocator import java.io.File /** - * Entry point run in the Kotlin/Native distribution's JVM: indexes headers with cinterop's own code and - * hands the resulting index to a [BindingGenerator]. + * Entry point run in the Kotlin/Native distribution's JVM: hands cinterop's view of a library to a + * [BindingGenerator]. * * ``` * --generator --output [--option =]... -- @@ -36,18 +36,10 @@ fun main(args: Array) { it.substringBefore('=') to it.substringAfter('=') } - // As cinterop's own entry point does around its run. - NativeMemoryAllocator.init() - JvmCInteropCallbacks.init() - try { - val context = index(cinteropArgs, generatorOptions) - output.deleteRecursively() - output.mkdirs() - generator.generate(context, output) - } finally { - JvmCInteropCallbacks.dispose() - NativeMemoryAllocator.dispose() - } + val context = context(cinteropArgs, generatorOptions) + output.deleteRecursively() + output.mkdirs() + generator.generate(context, output) } /** @@ -59,7 +51,7 @@ fun main(args: Array) { * that jar as a friend module (see buildSrc's native-index plugin), so a signature change in a Kotlin * release fails compilation here, at the `kotlinNativeVersion` bump. */ -private fun index(cinteropArgs: Array, generatorOptions: Map): GenerationContext { +private fun context(cinteropArgs: Array, generatorOptions: Map): GenerationContext { val arguments = CInteropArguments() arguments.argParser.parse(cinteropArgs) require(arguments.library.isEmpty()) { "Dependency klibs (-library) are not supported by the index exporter yet." } @@ -74,21 +66,28 @@ private fun index(cinteropArgs: Array, generatorOptions: Map): Map> { diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index 520891e..a65a666 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -40,6 +40,10 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @get:Input @get:Optional abstract val compilerOptions: ListProperty + /** Further cinterop options, as cinterop spells them, e.g. `-libraryPath `. */ + @get:Input @get:Optional + abstract val extraCinteropArgs: ListProperty + @get:Input abstract val target: Property @@ -47,7 +51,7 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @get:Internal abstract val konanHome: DirectoryProperty - /** A generator the exporter knows by name, e.g. `dump` or `jni`. */ + /** A generator the exporter knows by name: `dump`, `jni` or `jextract`. */ @get:Input abstract val generator: Property @@ -69,6 +73,7 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( add("-target"); add(target.get().name) includeDirs.files.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } + addAll(extraCinteropArgs.getOrElse(emptyList())) }, generatorOptions = generatorOptions.getOrElse(emptyMap()), konanHome = konanHome.orNull?.asFile, diff --git a/src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt b/src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt new file mode 100644 index 0000000..acbd1f6 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt @@ -0,0 +1,41 @@ +package io.github.lemcoder.interop + +import io.github.lemcoder.KonanPluginExtension +import org.gradle.api.Project +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +/** + * The KGP side of [kotlinNativeInterop]. Its own object so that KGP's types are referenced only from code + * that runs once the Kotlin Multiplatform plugin is applied. + */ +internal object KgpCinteropAdapter { + fun declare(project: Project, name: String, settings: KotlinNativeInteropSettings) { + val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) + val konanConfig = project.extensions.findByType(KonanPluginExtension::class.java) + + kotlin.targets.withType(KotlinNativeTarget::class.java).configureEach { + val target = this + val wanted = settings.targets.getOrElse(emptyList()) + if (wanted.isNotEmpty() && target.konanTarget !in wanted) return@configureEach + + target.compilations.getByName("main").cinterops.create(name) { + definitionFile.set(settings.defFile) + settings.packageName.orNull?.let { packageName = it } + includeDirs(settings.includeDirs) + compilerOpts(settings.compilerOptions.getOrElse(emptyList())) + + if (konanConfig != null) { + // Where runKonanClang puts this target's archive; cinterop ignores a missing directory. + val archives = project.layout.projectDirectory.dir(konanConfig.outputDir.get()).dir(target.konanTarget.name) + extraOpts("-libraryPath", archives.asFile.absolutePath) + project.tasks.named(interopProcessingTaskName).configure { + dependsOn(project.provider { + if (target.konanTarget in konanConfig.targets.getOrElse(emptyList())) listOf("runKonanClang") else emptyList() + }) + } + } + } + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt b/src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt new file mode 100644 index 0000000..af39ca4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt @@ -0,0 +1,52 @@ +package io.github.lemcoder.interop + +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.jetbrains.kotlin.konan.target.KonanTarget + +/** + * Binds a cinterop `.def` for Kotlin/Native through KGP's own cinterop: an adapter that declares, on the + * main compilation of every Kotlin/Native target, the `cinterops {}` entry a build would otherwise write + * by hand. KGP then produces the interop klib with the project's own Kotlin version, per target, and wires + * it into compilation, commonization, the IDE and publication. + * + * Where `konanConfig` builds the library for a target, cinterop sees its archive: `-libraryPath` points at + * `/` and the cinterop task runs after `runKonanClang`, so a `.def` naming + * `staticLibraries = lib.a` embeds it. + * + * ```kotlin + * kotlinNativeInterop("mymath") { + * defFile.set(file("src/nativeInterop/cinterop/mymath.def")) + * includeDirs.from("native") + * } + * ``` + * + * Does nothing in a project without the Kotlin Multiplatform plugin. + */ +fun Project.kotlinNativeInterop(name: String, configure: Action) { + // Configured before KGP sees it: parts of KGP's cinterop settings are read eagerly. + val settings = objects.newInstance(KotlinNativeInteropSettings::class.java) + configure.execute(settings) + // Behind withId so KGP's types are only resolved where KGP exists. + plugins.withId("org.jetbrains.kotlin.multiplatform") { KgpCinteropAdapter.declare(this@kotlinNativeInterop, name, settings) } +} + +/** What [kotlinNativeInterop] binds: the same inputs the plugin's other binding generators take. */ +abstract class KotlinNativeInteropSettings { + abstract val defFile: RegularFileProperty + + /** Overrides the `.def`'s `package`. */ + abstract val packageName: Property + + /** Each directory becomes an `-I` for cinterop's clang. */ + abstract val includeDirs: ConfigurableFileCollection + + abstract val compilerOptions: ListProperty + + /** The Kotlin/Native targets to bind for; every one in the project when empty. */ + abstract val targets: ListProperty +} diff --git a/src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt b/src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt new file mode 100644 index 0000000..ad4f164 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt @@ -0,0 +1,106 @@ +package io.github.lemcoder + +import io.github.lemcoder.index.PinnedKotlinNative +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assumptions.assumeTrue +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** [io.github.lemcoder.interop.kotlinNativeInterop]: the adapter over KGP's own cinterop. */ +class KotlinNativeInteropTest { + private val projectDir = createTempDirectory("kotlin-native-interop").toFile() + + private fun runner(vararg args: String) = GradleRunner.create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(*args, "--stacktrace") + + /** + * As [runner], with KGP on the plugin-under-test classpath: TestKit loads the plugin under test where + * a plugin the test build applies on its own cannot be seen, which is not how a real build loads them. + */ + private fun runnerWithKgp(vararg args: String): GradleRunner { + val metadata = java.util.Properties().apply { + KotlinNativeInteropTest::class.java.classLoader.getResourceAsStream("plugin-under-test-metadata.properties").use { load(it) } + } + val pluginClasspath = metadata.getProperty("implementation-classpath").split(File.pathSeparator) + val kgp = System.getProperty("kgp.classpath").split(File.pathSeparator) + return runner(*args).withPluginClasspath((pluginClasspath + kgp).filter { it.isNotEmpty() }.map(::File)) + } + + @Test + fun `without the Kotlin plugin it declares nothing and loads no KGP type`() { + projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "no-kgp"""") + projectDir.resolve("build.gradle.kts").writeText( + """ + import io.github.lemcoder.interop.kotlinNativeInterop + + plugins { id("io.github.lemcoder.konanplugin") } + + kotlinNativeInterop("mymath") { + defFile.set(file("mymath.def")) + } + """.trimIndent() + ) + val result = runner("tasks", "--all").build() + assertTrue("cinterop" !in result.output, "no cinterop tasks without KGP:\n${result.output}") + } + + @Test + fun `KGP binds the def for the host target, after konanConfig built its archive`() { + assumeTrue(PinnedKotlinNative.home() != null, "Kotlin/Native ${PinnedKotlinNative.version} is not installed") + val host = hostKonanTarget() + // KMP's DSL names targets in camelCase: macos_arm64 -> macosArm64. + val dslName = host.name.split('_').mapIndexed { i, part -> if (i == 0) part else part.replaceFirstChar { it.uppercase() } }.joinToString("") + + projectDir.resolve("native").mkdirs() + projectDir.resolve("native/mymath.h").writeText("int my_add(int a, int b);\n") + projectDir.resolve("native/mymath.c").writeText("#include \"mymath.h\"\nint my_add(int a, int b) { return a + b; }\n") + projectDir.resolve("mymath.def").writeText( + "headers = mymath.h\nheaderFilter = mymath.h\npackage = mymath\nstaticLibraries = libmymath.a\n" + ) + projectDir.resolve("src/${dslName}Main/kotlin").mkdirs() + projectDir.resolve("src/${dslName}Main/kotlin/Use.kt").writeText( + "@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)\n\nfun sum(): Int = mymath.my_add(2, 3)\n" + ) + projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "kmp"""") + projectDir.resolve("build.gradle.kts").writeText( + """ + import io.github.lemcoder.hostKonanTarget + import io.github.lemcoder.interop.kotlinNativeInterop + + plugins { + kotlin("multiplatform") + id("io.github.lemcoder.konanplugin") + } + + repositories { mavenCentral() } + + konanConfig { + targets(hostKonanTarget()) + libName.set("mymath") + } + + kotlin { $dslName() } + + kotlinNativeInterop("mymath") { + defFile.set(file("mymath.def")) + includeDirs.from("native") + } + """.trimIndent() + ) + + val targetSuffix = dslName.replaceFirstChar { it.uppercase() } + val result = runnerWithKgp("compileKotlin$targetSuffix").build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":runKonanClang")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":cinteropMymath$targetSuffix")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":compileKotlin$targetSuffix")?.outcome) + val order = result.tasks.map { it.path } + assertTrue(order.indexOf(":runKonanClang") < order.indexOf(":cinteropMymath$targetSuffix"), "archive before cinterop: $order") + } +} From 2506bd17e2c7fae272118544d67bd63fabed2e10 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 5 Oct 2026 20:48:01 +0200 Subject: [PATCH 13/57] docs: record the generator architecture as built Bring the interop architecture doc up to date with what was built and verified: cinterop's NativeIndex as the binding spec, the index exporter and why it runs in its own process, the two kinds of generator (exporter generators jni, jextract and dump; the KGP cinterop adapter), what the jextract and cinterop spikes taught (two parsers need translation rules; a klib must come from the consumer's Kotlin), what each consumer needs for packaging and which of it is still done by hand, and the open questions that remain. Co-Authored-By: Claude Opus 5.5 --- doc/interop-architecture.md | 214 ++++++++++++++++++++++++++---------- 1 file changed, 153 insertions(+), 61 deletions(-) diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index 607120a..4cbfe98 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -1,6 +1,7 @@ # KonanPlugin: Interop Architecture -Status: draft Β· 2026-09-28 +Status: draft Β· 2026-10-05 Β· components settled; generators built and verified end to end; DSL, +packaging and provisioning still open. ## Context and goals @@ -8,10 +9,11 @@ The plugin moves from "cinterop with a JVM hack" to a set of swappable parts: a generators, toolchains and packaging, each behind an interface. Having dependencies inside an implementation is fine; the goal is that no one implementation shapes the whole design. -Today the plugin does three things, all tied to the Kotlin/Native distribution: it compiles C with -konan's clang, generates JNI bindings by running cinterop's undocumented JVM flavor, and rewrites -that output so strings and buffers cross as Kotlin types. The 2.4 upgrade broke the second of these -(cinterop started looking for `jni.h` on its own), which shows how fragile that path is. +The plugin started out doing three things, all tied to the Kotlin/Native distribution: it compiled C +with konan's clang, generated JNI bindings by running cinterop's undocumented JVM flavor, and rewrote +that output with regexes so strings and buffers crossed as Kotlin types. The 2.4 upgrade broke the +second of these (cinterop started looking for `jni.h` on its own). The JVM flavor and the rewriting are +now gone: every generator works from cinterop's own model of the headers (section B). The plugin must serve two kinds of project: @@ -32,20 +34,20 @@ Non-goals: ## What the Kotlin/Native distribution ships The distribution is two things: a JVM compiler in one 82 MB jar, and a per-host set of cross -toolchains downloaded on demand. The plugin uses the toolchains to compile C and one internal entry -point of the jar to generate JNI bindings; everything else is Kotlin-specific. Measured on +toolchains downloaded on demand. The plugin uses the toolchains to compile C, and the jar's cinterop +code to read headers and produce bindings; everything else is Kotlin-specific. Measured on `kotlin-native-prebuilt-macos-aarch64-2.4.20` (1.1 GB) plus `~/.konan/dependencies` (3.8 GB). -| Part | Where | What it is | Used by the plugin today | +| Part | Where | What it is | Used by the plugin | | --- | --- | --- | --- | | Launchers | `bin/` | `konanc`, `cinterop`, `klib`, `run_konan`, `generate-platform`: shell scripts that start the jar | `run_konan clang` for compiling C | -| Compiler jar | `konan/lib/kotlin-native-compiler-embeddable.jar` (82 MB) | Compiler, cinterop (`interop.gen.jvm.MainKt`), header indexer (`interop.indexer`), `ClangArgs` (per-target clang flags) | cinterop's JVM flavor, run as an undocumented `main` | -| JNI helpers | `konan/nativelib/` | `libclangstubs`, `libllvmstubs`, `libkotlinxcinteropjvmcallbacks`: JNI glue so the jar can call libclang and LLVM | On `java.library.path` of the generator | -| Target config | `konan/konan.properties` | Per host and target: which LLVM, sysroot, toolchain, libffi to use and download | Indirectly, through `run_konan` | +| Compiler jar | `konan/lib/kotlin-native-compiler-embeddable.jar` (82 MB) | Compiler, cinterop (`interop.gen.jvm`), header indexer (`interop.indexer`), `ClangArgs` (per-target clang flags) | The index exporter's classpath: `buildNativeIndex`, the internal `prepareTool`/`buildNativeLibrary`, and `Interop().interop` | +| JNI helpers | `konan/nativelib/` | `libclangstubs`, `libllvmstubs`, `libkotlinxcinteropjvmcallbacks`: JNI glue so the jar can call libclang and LLVM | On the exporter's `java.library.path` | +| Target config | `konan/konan.properties` | Per host and target: which LLVM, sysroot, toolchain, libffi to use and download | Indirectly, through `run_konan` and `prepareTool` | | Platform defs | `konan/platformDef//` | 176 `.def` files for macOS alone (AppKit, Metal, POSIX…) | No | | Platform klibs | `klib/platform//` | Prebuilt cinterop output of those defs, 21 targets | No | | Stdlib | `klib/common/stdlib`, `sources/` | Kotlin/Native stdlib | No | -| LLVM | `dependencies/llvm-21-…-essentials` (497 MB) | `clang`, `clang++`, `lld`, `llvm-ar`, `libclang` | clang, llvm-ar | +| LLVM | `dependencies/llvm-21-…-essentials` (497 MB) | `clang`, `clang++`, `lld`, `llvm-ar`, `libclang` | clang, llvm-ar, and libclang through the indexer | | Android toolchain | `dependencies/target-toolchain-…-android_ndk` (1.5 GB) + sysroot (546 MB) | A trimmed Android NDK | Links Android JNI libraries; compiler-rt lookup | | Linux toolchain | `dependencies/x86_64-unknown-linux-gnu-gcc-8.3.0-glibc-2.19` (268 MB) | GCC sysroot with glibc 2.19 | Cross-compiling to Linux | | Apple toolchain | Not downloaded | `konan.properties` points at the local Xcode | Through `run_konan` | @@ -56,29 +58,30 @@ What this means for the design: - **The valuable part is the cross toolchain, not the compiler.** One clang plus a sysroot per target, already downloaded by any Kotlin Multiplatform build, is what makes the seam path work with zero setup. It stays as one toolchain implementation. -- **The jar's cinterop and indexer are internal APIs.** They are fine behind a generator interface, - but nothing outside that implementation should know they exist. +- **The jar's cinterop and indexer are internal APIs.** They are confined to the index exporter + (`src/indexExporter`), which compiles against exactly one Kotlin version; nothing else in the plugin + references them. - **Apple targets always need Xcode.** Konan only borrows it, so an Xcode-based toolchain is not a loss of capability. ## System components The system is eight components that meet at two contracts: **native artifacts per target** (headers -plus static and shared libraries, however they came to exist) and **generated bindings** (Kotlin -sources plus any native glue). Everything upstream of the first contract decides how binaries are -made; everything downstream decides how Kotlin reaches them. That split is what lets a prebuilt JUCE -and a three-file C seam flow through the same pipeline. +plus static and shared libraries, however they came to exist) and **generated bindings** (sources, +klibs and any native glue). Everything upstream of the first contract decides how binaries are made; +everything downstream decides how Kotlin reaches them. That split is what lets a prebuilt JUCE and a +three-file C seam flow through the same pipeline. ```mermaid flowchart TD DSL["1 Β· DSL
declares libraries, bindings, toolchains, packaging"] TC["3 Β· Toolchain
Konan Β· Xcode Β· Android NDK Β· system clang/GCC"] LP["4 Β· Library provider
Sources Β· External build Β· Prebuilt"] - SPEC["5 Β· Binding spec
headers, includes, defines, filters, package"] + SPEC["5 Β· Binding spec
.def + cinterop command line β†’ cinterop's NativeIndex"] NA[["Native artifacts per target
headers Β· .a Β· .so / .dylib / .dll"]] - GEN["6 Β· Binding generator
cinterop Β· JNI Β· FFM"] - GB[["Generated bindings
Kotlin sources + native glue"]] - GLUE["7 Β· Glue builder
links the JNI stub"] + GEN["6 Β· Binding generator
jni Β· jextract Β· dump Β· KGP cinterop adapter"] + GB[["Generated bindings
klib Β· Kotlin + C stub Β· FFM Java"]] + GLUE["7 Β· Glue builder
links the JNI stub; shared library for FFM"] PKG["8 Β· Packaging and wiring
static or shared per consumer β†’ KMP native Β· JVM Β· Android"] TM["2 Β· Target model β€” one target type, mapped for every component"] @@ -101,22 +104,22 @@ flowchart TD | # | Component | Responsibility | Consumes β†’ produces | Implementations | | --- | --- | --- | --- | --- | -| 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | One, modelled on `cinterops {}` and `externalNativeBuild {}` (section A) | -| 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | Maps to `KonanTarget`, Android ABI, JVM `os.name`/`os.arch`, clang triple, CMake toolchain | -| 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots), Xcode, Android NDK, system clang/GCC/MSVC; Zig later | -| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | **Sources** (built with a toolchain), **External build** (CMake, any command), **Prebuilt** (paths or a Maven artifact per target) | -| 5 | **Binding spec** | What to bind: headers, include paths, defines, filters, package name | Headers + DSL (or a `.def`) β†’ one spec shared by all generators | One; `.def` read and written as an interchange format | -| 6 | **Binding generator** | Turns a spec into bindings for one kind of consumer | Spec + native artifacts β†’ generated bindings | **cinterop** (Kotlin/Native, delegates to KGP), **JNI** (cinterop JVM flavor now, clang AST later), **FFM** (jextract) | -| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | One, reusing the toolchain; or hands the glue to an external build (today's `KONAN_JNI_STUB_DIR` contract) | -| 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, jar resources, klib link options, publications | KMP native, JVM, Android; each in a `plugins.withId` block | +| 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | Today `konanConfig {}` and `jvmInterops {}`; the redesign is section A | +| 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `KonanTarget` today, mapped to Android ABI and JVM library paths | +| 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots) today; Xcode, Android NDK, system clang/GCC/MSVC, Zig later | +| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | **Sources** (`runKonanClang`), **External build** (CMake); **Prebuilt** not yet | +| 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | +| 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`kotlinNativeInterop`) | +| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `LinkJvmInteropTask`, or an external build through the `KONAN_JNI_STUB_DIR` contract; a no-glue shared library for FFM is missing (section D) | +| 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | JVM and Android for JNI; Kotlin/Native through the KGP adapter; FFM and publications not yet (section D) | How the two paths use them: - **Seam**: DSL β†’ Sources provider with the Konan toolchain β†’ any generator β†’ packaging. This is today's plugin, with zero setup on a machine that already builds Kotlin Multiplatform. - **Systems integration**: DSL β†’ External or Prebuilt provider β†’ generator against the vendor's - headers β†’ packaging. No toolchain is needed unless a generator emits glue (JNI), and then only to - link a small stub. + headers β†’ packaging. No toolchain is needed unless a generator emits glue (JNI) or a consumer needs a + shared library the provider does not produce (FFM). Rules that fall out of this: @@ -125,9 +128,9 @@ Rules that fall out of this: - **Linkage is a provider fact and a packaging choice.** A provider declares what it can produce (static, shared, or both); packaging picks per consumer, for example static into a Kotlin/Native binary and shared on the JVM. -- **KGP and AGP types live only in component 8 and in the cinterop generator.** Everything else can - be applied to a project without either plugin, which keeps the TestKit fixture that catches - decoration failures meaningful. +- **KGP and AGP types live only in component 8.** Generators run in their own process and produce + files; everything else can be applied to a project without either plugin, which keeps the TestKit + fixture that catches decoration failures meaningful. ## A. DSL @@ -139,54 +142,143 @@ Not designed yet; next round. It must decide: `defaultConfig.externalNativeBuild { cmake { arguments; abiFilters } }`, where the build is declared once and tuned per variant. - How a binding names its library (by reference, so provider and generator stay decoupled) and picks - generators per consumer. + generators per consumer β€” section B shows each consumer has exactly one natural generator. - Prior art to read first: the current KGP `cinterops {}` DSL, AGP `externalNativeBuild` and `ndk {}`, and Gradle's own `cpp-library` plugin. ## B. Generator model -Not designed yet. It must decide: +Generators come in two kinds. **Exporter generators** (`jni`, `jextract`, `dump`) run in one process, the +**index exporter**, next to cinterop's model of the headers, and only generated sources leave it. A +**pipeline adapter** generates nothing itself: the KGP cinterop adapter (`kotlinNativeInterop`) +declares KGP's own `cinterops {}` for every Kotlin/Native target. Built and verified: the same +`mymath.def` produces bindings for all three consumers, each called from Kotlin with identical results +(integers, doubles, strings both ways, a read-only buffer, and a buffer the C side fills). -- The interface shape: roughly `generate(spec, artifacts, target, outputDir): GeneratedBindings`, - where the result reports Kotlin source roots, glue sources, the library name to load and runtime - needs (JDK 22+ for FFM). -- Whether the cinterop generator produces bindings at all, or only configures KGP's own cinterop - task, since KGP already owns that pipeline. -- Where the string and buffer marshalling lives: inside the JNI generator, or as a transform any - generator can reuse. +``` +Gradle task ──javaexec──► index exporter (classpath: the distribution's compiler jar + the exporter) + cinterop command line CInteropArguments β†’ prepareTool β†’ DefFile β†’ buildNativeLibrary + β†’ buildNativeIndex (lazy; only generators that read the index pay for it) + β†’ BindingGenerator.generate(context, outputDirectory) +◄──────────────── output directory ── +``` -## C. File tree layout +**The spec is cinterop's own `NativeIndex`.** No model of our own: the exporter follows the steps of +cinterop's private `processCLib` up to the index, calling the internal `prepareTool` and +`buildNativeLibrary` by compiling against the compiler jar as a Kotlin friend module. A `.def` therefore +means exactly what it means to cinterop β€” header filters, modules, `-header`, preamble β€” and a Kotlin +release that changes those functions fails the exporter's compilation at the version bump. + +**Why its own process.** The compiler jar bundles its own unrelocated Kotlin stdlib, and libclang can be +loaded by one classloader per JVM. In the daemon, KGP's cinterop may already have loaded it; in a Gradle +worker, the compiler ran on Gradle's stdlib (2.4.10) instead of its own (2.4.20). `javaexec` with a +classpath of exactly the compiler jar and the exporter avoids both. The distribution must be the exact +version the exporter was compiled against (`kotlinNativeVersion`, 2.4.20). -Not designed yet. Two trees need a layout: +**The interface.** `BindingGenerator.generate(context, outputDirectory)`, chosen by name. The +`GenerationContext` carries the package (decided as cinterop decides it), the `.def`'s preamble, the +cinterop command line, generator options, and β€” built on first use β€” the index, the included headers +and the clang arguments. -- **Plugin source**: one package per component (`dsl`, `target`, `toolchain`, `provider`, `spec`, - `generator/{cinterop,jni,ffm}`, `glue`, `packaging/{kmp,jvm,android}`), so the rule "KGP and AGP - types only in packaging and the cinterop generator" can be checked by package. -- **Consumer build output**: a fixed scheme such as `build/native///` for artifacts - and `build/generated/bindings///` for bindings, keeping the existing rule that - only ABI-named directories sit under `jniLibs`. +| Exporter generator | Reads the index? | Output | Consumer | Notes | +| --- | --- | --- | --- | --- | +| `jni` | Yes, for everything | `kotlin//.kt` + `c/.c` | JVM, Android | Functions under their C names; strings, primitive buffers and structs by value marshalled in the stub; variadic and `long double` listed as not bound | +| `jextract` | Yes, for *what*: every indexed symbol becomes an `--include-*` filter | `java//*.java` | JVM 22+ (FFM) | jextract parses the headers again with its own libclang 13 | +| `dump` | Yes | `index.txt` | Tests, diffing across Kotlin versions | | + +**Kotlin/Native goes through KGP, not the exporter.** A klib is a compiler artifact: it has to come from +the consumer's Kotlin version, which KGP's cinterop is and the exporter's pinned distribution is not. +The adapter also inherits what KGP does around it β€” a task per target, change detection, +`-Xmodule-name`, the compile dependency, commonization, IDE import and publication β€” and wires the +provider's archive in (`-libraryPath` to `konanConfig`'s output, the cinterop task after +`runKonanClang`). Running cinterop's pipeline in the exporter (`Interop().interop("native", …)`) was +tried and works, with a klib matching KGP's apart from the header hash and `unique_name`; it is not +used because of the version pin. + +What building them showed: + +- **The index decides what, the generator decides how.** JNI and FFM bind the same declarations because + both derive their set from one index. Where a generator cannot bind something, it says so (`// Not + bound: sample_log: variadic`); jextract, which does support varargs, binds it. +- **A second parser means translation rules.** cinterop folds `typedef union { … } bits_t` into a record + named `bits_t`; jextract keeps it a typedef and drops it unless filtered as one, after which its Java + did not compile. jextract also skips bit-fields and opaque structs that cinterop indexes. Each + generator that re-parses needs a test against the dump of the same header. +- **The index is built lazily**, so a generator that needs neither the index nor the library facts does + not pay for parsing the headers; cinterop's own pipeline also must not nest inside that setup. +- **Marshalling lives in the JNI generator.** It is generated C, not a reusable transform: FFM has its + own marshalling (`Arena`, `MemorySegment`) and Kotlin/Native has cinterop's. + +Not yet: + +- **`expect`/`actual` in common code.** One Kotlin API over all three: the index decides the common + signatures, the JNI and FFM generators the JVM `actual`s, and the native `actual`s call the API KGP's + cinterop generates, + whose exact shape (names, enum style, string conversion) cinterop's own `StubIr` can tell. +- **Per-target indexing for JNI.** It indexes the host only; C types that change size per target + (`long` on Windows) need either an index per target or the wider Kotlin type. + +## C. File tree layout + +Partly settled by what exists: + +- **Plugin source**: `src/indexExporter` is the exporter and every generator (its own source set: it + compiles against the compiler jar, which `main` must not); `src/main/.../index` holds the launcher + (`runIndexExporter`), the generic `GenerateFromNativeIndexTask` and `PinnedKotlinNative`; + `buildSrc/.../native-index.gradle.kts` wires the source set, the friend path and the nested exporter + jar. The remaining components (DSL, providers, packaging) still sit in `interop/` and `jvm/` and get + one package each in the redesign, so "KGP and AGP types only in packaging" can be checked by package. +- **Generator output**: each exporter generator owns its layout under the task's output directory β€” + `jni`: `kotlin//.kt` and `c/.c`; `jextract`: `java//`; `dump`: + `index.txt`. Kotlin/Native klibs stay where KGP puts them. +- **Consumer build output**: still to fix, e.g. `build/native///` for artifacts and + `build/generated/bindings///` for bindings, keeping the rule that only ABI-named + directories sit under `jniLibs`. ## D. Toolchains, prebuilt binaries and packaging -Not designed yet. It must decide: +Still to design. What the generators showed each consumer needs; the JNI and Kotlin/Native rows are +wired by the plugin today, the FFM row was done by hand in a verification project: + +| Consumer | Binary | Glue | Wiring | +| --- | --- | --- | --- | +| Kotlin/Native | Static archive, embedded in the klib (`staticLibraries` + `-libraryPath`) | None | KGP's `cinterops {}` on each target's main compilation, declared by the adapter | +| JVM (JNI) | Shared library: stub + archive | The JNI stub | Generated Kotlin as sources; the library on `java.library.path` or in resources | +| JVM (FFM) | Shared library of the C code itself | None | Generated Java as sources, compiled with a JDK 22+ toolchain; `--enable-native-access`; the library on `java.library.path` | +| Android | Shared library: stub + archive, per ABI | The JNI stub | Generated Kotlin as sources; `jniLibs/` via the variant API. No FFM on Android | + +Gaps this exposes: + +- **A shared library without glue.** FFM needs `libmymath.dylib`, which neither the Sources provider + (static only) nor the glue builder (stub + archive) produces. The glue builder needs a mode that links + an archive into a shared library with nothing added (`-force_load` / `--whole-archive`). +- **Wiring for FFM consumers**, today written by hand: the Java source set, the JDK 22+ toolchain, the + runtime flags. +- **Tool provisioning.** Generation needs the Kotlin/Native distribution of exactly + `kotlinNativeVersion` in `~/.konan` (any KMP build of that version downloads it); jextract is a path + option. The plugin should locate or download both. + +Still undecided: - How a toolchain is chosen per target: a default per host and target (Konan where available), overridable per library. - How prebuilt binaries arrive: local paths per target, a Maven artifact with a classifier per target, or an archive to unpack; and how their headers travel with them. -- Packaging per consumer: static into Kotlin/Native binaries via the klib's link options, shared on - the JVM (resources extracted at load, or `java.library.path`), shared under `jniLibs/` on - Android, and what a published library carries for its own consumers. +- What a published library carries for its own consumers. ## Open questions -- [ ] Should the cinterop generator run KGP's own cinterop task, or should the plugin run cinterop - itself so all three generators behave the same way? +- [x] Should the cinterop generator run KGP's own cinterop task, or the plugin run cinterop itself? + KGP's: the klib must come from the consumer's Kotlin version. `kotlinNativeInterop` adapts a + binding to `cinterops {}`; running cinterop in the exporter was tried, works, and is not used. - [ ] Lowest JDK for the Gradle daemon and for consumers at runtime? It decides whether FFM is a - default or an opt-in, and whether any header parsing can run in-process. + default or an opt-in. Header parsing no longer depends on it: it runs in the exporter's process. - [ ] Does the `.def` stay a first-class input, or become an import format next to a header-first - DSL? + DSL? Today it is the input, read by cinterop itself. - [ ] Is a published multi-target library (bindings plus binaries per target) in scope for the first release, or only in-build consumption? - [ ] Migration: does the current `konanConfig {}` and `jvmInterops {}` DSL keep working behind the - new model, or is this a major version with a break? + new model, or is this a major version with a break? The JNI bindings already changed their + names (`my_add`, not `kniBridge0`). +- [ ] Should the plugin pin one Kotlin/Native version for generation (today) or support several, at the + cost of one exporter build per version? From e14289fdca28176f27874aa8d45ae2cd14def9bc Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 00:32:50 +0200 Subject: [PATCH 14/57] feat!: replace the plugin DSL with a typed konan extension One project extension, `konan { libraries { }; bindings { } }`, replaces konanConfig, jvmInterops and kotlinNativeInterop. A binding references its library as an object, and consumers (Kotlin/Native via KGP's cinterop, JNI, FFM, Android) are wired from the project's plugins. - Library providers: `LibraryProvider` with Kotlin/Native's clang as the default and Zig (`zig { }`, the project's own build.zig) as a second build system, both behind `compileNativeLibrary` and the `NativeArtifacts` layout contract. - Typed exporter protocol: a sealed `GeneratorRequest` and `ExporterInvocation`, compiled into both the plugin and the index exporter, replace the string-keyed generator options. - Enums and sealed classes in place of string choices: ZigOptimize, GeneratedVisibility, LibraryProviderKind, GradlePluginId, KonanTarget-only targets; tool executables are file properties. - Sources reorganised into dsl, library, jni, link, toolchain, index and wiring packages; README, examples and design doc updated. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 43 +- README.md | 385 +++++++---------- .../src/main/kotlin/native-index.gradle.kts | 6 + doc/interop-architecture.md | 142 +++++-- examples/README.md | 17 +- examples/android/README.md | 22 +- examples/android/build.gradle.kts | 35 +- examples/jvm/build.gradle.kts | 37 +- examples/native/build.gradle.kts | 31 +- .../src/nativeInterop/cinterop/mymath.def | 1 - .../github/lemcoder/index/ExporterProtocol.kt | 118 ++++++ .../index/exporter/BindingGenerator.kt | 20 +- .../lemcoder/index/exporter/DumpGenerator.kt | 7 +- .../index/exporter/JextractGenerator.kt | 18 +- .../lemcoder/index/exporter/JniGenerator.kt | 83 ++-- .../index/exporter/NativeIndexExporter.kt | 38 +- .../kotlin/io/github/lemcoder/JniToolchain.kt | 4 +- .../kotlin/io/github/lemcoder/KonanPlugin.kt | 77 +--- .../github/lemcoder/KonanPluginExtension.kt | 64 --- .../kotlin/io/github/lemcoder/KonanTargets.kt | 11 +- .../CMakeSettings.kt} | 38 +- .../io/github/lemcoder/dsl/KonanExtension.kt | 252 +++++++++++ .../index/GenerateFromNativeIndexTask.kt | 22 +- .../io/github/lemcoder/index/IndexExporter.kt | 21 +- .../lemcoder/index/PinnedKotlinNative.kt | 2 +- .../lemcoder/interop/JvmInteropRegistry.kt | 396 ------------------ .../lemcoder/interop/JvmInteropSettings.kt | 117 ------ .../io/github/lemcoder/interop/JvmInterops.kt | 62 --- .../lemcoder/interop/KgpCinteropAdapter.kt | 41 -- .../lemcoder/interop/KotlinNativeInterop.kt | 52 --- .../lemcoder/{interop => jni}/CMakeTasks.kt | 6 +- .../GenerateJniBindingsTask.kt} | 25 +- .../CompileNativeLibraryTask.kt} | 6 +- .../lemcoder/library/KonanClangProvider.kt | 48 +++ .../lemcoder/library/LibraryProvider.kt | 44 ++ .../lemcoder/library/NativeArtifacts.kt | 44 ++ .../github/lemcoder/library/ZigBuildTask.kt | 84 ++++ .../io/github/lemcoder/library/ZigProvider.kt | 37 ++ .../LinkSharedLibraryTask.kt} | 42 +- .../NativeToolchain.kt} | 4 +- .../github/lemcoder/toolchain/ZigTargets.kt | 24 ++ .../io/github/lemcoder/util/ExecCapture.kt | 6 +- .../io/github/lemcoder/wiring/AgpWiring.kt | 54 +++ .../io/github/lemcoder/wiring/Bindings.kt | 298 +++++++++++++ .../lemcoder/{interop => wiring}/DefFile.kt | 4 +- .../github/lemcoder/wiring/GradlePluginId.kt | 19 + .../io/github/lemcoder/wiring/KgpWiring.kt | 63 +++ .../io/github/lemcoder/wiring/Libraries.kt | 38 ++ .../lemcoder/KonanPluginFunctionalTest.kt | 286 +++++++------ .../lemcoder/KotlinNativeInteropTest.kt | 106 ----- .../io/github/lemcoder/TestKitSupport.kt | 21 + .../lemcoder/index/ExporterProtocolTest.kt | 24 ++ .../index/GenerateFromNativeIndexTest.kt | 32 +- 53 files changed, 1857 insertions(+), 1620 deletions(-) create mode 100644 src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/KonanPluginExtension.kt rename src/main/kotlin/io/github/lemcoder/{interop/ExternalNativeBuild.kt => dsl/CMakeSettings.kt} (69%) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/interop/JvmInteropSettings.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/interop/JvmInterops.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt rename src/main/kotlin/io/github/lemcoder/{interop => jni}/CMakeTasks.kt (96%) rename src/main/kotlin/io/github/lemcoder/{jvm/GenerateJvmInteropTask.kt => jni/GenerateJniBindingsTask.kt} (83%) rename src/main/kotlin/io/github/lemcoder/{RunKonanClangTask.kt => library/CompileNativeLibraryTask.kt} (96%) create mode 100644 src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt create mode 100644 src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt create mode 100644 src/main/kotlin/io/github/lemcoder/library/NativeArtifacts.kt create mode 100644 src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt create mode 100644 src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt rename src/main/kotlin/io/github/lemcoder/{jvm/LinkJvmInteropTask.kt => link/LinkSharedLibraryTask.kt} (71%) rename src/main/kotlin/io/github/lemcoder/{jvm/JvmInteropSupport.kt => toolchain/NativeToolchain.kt} (98%) create mode 100644 src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt create mode 100644 src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt create mode 100644 src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt rename src/main/kotlin/io/github/lemcoder/{interop => wiring}/DefFile.kt (96%) create mode 100644 src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt create mode 100644 src/main/kotlin/io/github/lemcoder/wiring/KgpWiring.kt create mode 100644 src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt delete mode 100644 src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt create mode 100644 src/test/kotlin/io/github/lemcoder/TestKitSupport.kt create mode 100644 src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index bf2d65f..51ba9fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,10 +9,11 @@ Any type appearing in a **decorated signature** β€” extension properties, task p resolved the moment the plugin is applied, in *every* project that applies it. - `KotlinCompilation` and `KotlinTarget` are neither `ExtensionAware` nor open to third-party DSL - blocks, which is why `jvmInterops` is an extension function over a project-scoped registry rather - than a real extension. Putting a KGP type in a registry method signature fails with - *"Could not generate a decorated class … KotlinCompilation"* in any project without KGP. The - registry takes a `SourceWiring` callback instead. + blocks, which is why the whole DSL is one project extension, `konan`, and consumers are found from + the project's plugins and targets. No KGP or AGP type may appear in the DSL classes: putting one in a + decorated signature fails with *"Could not generate a decorated class … KotlinCompilation"* in any + project without KGP. All KGP and AGP code lives in `wiring/KgpWiring` and `wiring/AgpWiring`, reached + only from `plugins.withId` callbacks or once the plugin is known to be applied. - `kotlin-native-utils` is an `implementation` dependency, not the compile-only it arrives as transitively: `KonanTarget` appears in decorated DSL signatures, and the published POM previously declared no dependencies at all. @@ -23,15 +24,16 @@ resolved the moment the plugin is applied, in *every* project that applies it. ## Ordering rules that are not obvious -- **Configuration cache resolves conventions when it stores.** A `konanPath` convention that threw +- **Configuration cache resolves conventions when it stores.** A `konanHome` convention that threw broke *configuration*; one that returned absent got the absent value cached before KGP had downloaded the distribution. Toolchain detection happens in the task action. -- **`whenObjectAdded` fires before `create(name) { … }` runs its configure block.** Reading - `settings.targets` there gives the default, not what the build declared β€” per-target task - registration is deferred to `afterEvaluate`. +- **`all`/`whenObjectAdded` fires before `create(name) { … }` runs its configure block.** Reading a + binding's `library` or a library's `targets` there gives the default, not what the build declared β€” + per-target tasks, consumer detection and KGP's cinterops are deferred to `afterEvaluate` (KGP + accepts cinterops declared there). - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with *"It is too late to add actions as the callbacks already executed"*, so the jniLibs wiring runs at - interop-registration time and tolerates an empty directory. + binding-registration time and tolerates an empty directory. - **A CMake build task needs `CMakeCache.txt` as an input.** Otherwise changing a configure argument re-runs configure, leaves the build `UP-TO-DATE`, and silently keeps the previous library β€” a linker flag appears to do nothing. @@ -40,7 +42,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. - **Only ABI-named directories may sit under `jniLibs`.** AGP reads each subdirectory as an ABI, so a host library in the root fails the merge with *"… is not an ABI"*. Host builds write to - `jvmInterop//lib/`. This only reproduces on Linux: AGP ignores `.dylib`. + `konan/bindings//jni/lib/`. This only reproduces on Linux: AGP ignores `.dylib`. - **`Family.dynamicSuffix` has no leading dot.** `"${prefix}$name${suffix}"` yields `libfoostubsdylib`. - **The stub library name is derived from the binding package** and baked into the generated @@ -64,9 +66,14 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t - The `kotlin-dsl` plugin adds a `T.() -> Unit` overload of `whenObjectAdded`, making the SAM form ambiguous β€” use an object expression. -- Do not expose both a function and a property with the same name (`jvmInterops`): `x.jvmInterops {}` - then reads as either the call or property-plus-`invoke`, and the IDE flags a suspicious receiver. - The read-back accessor is `jvmInteropsContainer()`. +- Do not add an *extension* function and a property of the same name on a foreign receiver: `x.foo {}` + then reads as either the call or property-plus-`invoke`, and the IDE flags a suspicious receiver (the + old `KotlinCompilation.jvmInterops`). Members `val jni` plus `fun jni(Action)` are Gradle's own + pattern for nested objects and are fine. +- Receivers shadow: `name` inside a `tasks.register(…) { }` block is the task's name, not the enclosing + object's (a path built from it landed in `generated/konan/generateJniMymath/…`), and `executable` + inside `exec { }` is the `ExecSpec`'s, not the task's. Read such values into differently named locals + before the block. - A nested settings object must be created through `ObjectFactory`; Gradle will not synthesise `abstract val cmake: CMakeSettings`. @@ -86,11 +93,15 @@ holds a `NativeIndex`. the compiler jar as a friend module (`friendPaths` in the buildSrc plugin). Java is not a way around `internal` here: `native` is a Java keyword, so Java cannot import any `org.jetbrains.kotlin.native.…` class. And Kotlin cannot see the jar's types through Java sources in the same source set. -- **Kotlin/Native bindings are not an exporter generator.** `kotlinNativeInterop` adapts a binding to - KGP's own `cinterops {}`, so the klib comes from the consumer's Kotlin version β€” a klib is a compiler +- **Kotlin/Native bindings are not an exporter generator.** `KgpWiring.declareCinterops` adapts a + binding to KGP's own `cinterops {}`, so the klib comes from the consumer's Kotlin version β€” a klib is a compiler artifact, unlike the sources the exporter writes. Running cinterop's pipeline in the exporter works (`Interop().interop("native", …)`), but would pin the klib to `kotlinNativeVersion`; and its native allocator setup must not nest inside the exporter's, which is why the index is built lazily. +- **The request is typed on both sides.** `src/exporterProtocol` (`GeneratorRequest`, + `ExporterInvocation`) is compiled into both main and the exporter, so the command line is encoded and + decoded by the same code. Add a generator by adding a request subclass; the `when` dispatch is + exhaustive. - **It runs through `javaexec`, not the Worker API.** A process-isolated worker puts Gradle's Kotlin stdlib and the plugin's classes next to the compiler jar: the compiler then ran on Gradle 9.8's stdlib 2.4.10 instead of its bundled 2.4.20. The daemon is out too: KGP's cinterop may already have @@ -104,7 +115,7 @@ the plugin to a project with neither KGP nor AGP. Keep at least one fixture like `withPluginClasspath()` loads the plugin under test where a plugin the test build applies on its own (`kotlin("multiplatform") version …`) cannot see it: code reaching KGP's types fails with `NoClassDefFoundError`, though a real build loads both together. A test that needs KGP passes it on the -plugin-under-test classpath itself (`kgp.classpath`, see `KotlinNativeInteropTest`); never add KGP to +plugin-under-test classpath itself (`withPluginClasspathAndKgp()` in `TestKitSupport.kt`); never add KGP to the shared metadata, or the no-KGP fixtures stop proving anything. Portal versions are immutable β€” bump and publish, never reuse an alpha. Consumers with `mavenLocal()` diff --git a/README.md b/README.md index 4fa63b3..ba6df91 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,23 @@ # Konan Plugin -The **Konan Plugin** is a custom Gradle plugin that facilitates compiling C/C++ sources using the `run_konan` utility. It simplifies multi-platform native compilation for Kotlin Native projects, and can generate JNI bindings so the same C library is callable from JVM and Android. +The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and binds them for every Kotlin consumer from one cinterop `.def`: Kotlin/Native, the JVM and Android. > This plugin is based on awesome work by [aSemy](https://gist.github.com/aSemy) πŸš€ ## Features -- Compiles C/C++ source files to `.o` object files. -- Supports linking object files into static libraries (`.a`). -- Works with all Kotlin Native targets, using Kotlin/Native's own `KonanTarget` β€” pass - `kotlinNativeTarget.konanTarget` straight in, and new targets arrive with the Kotlin version. -- Generates runtime-free JNI bindings + a self-contained stub shared library (`jvmInterops`). -- Marshals `String`s and primitive arrays across the bridge, so a C API taking `const char*` or - `float*` buffers is callable without an off-heap allocator on the Kotlin side. -- Declares Kotlin/Native cinterops for every native target from the same `.def` - (`kotlinNativeInterop`), linked against the library `konanConfig` builds. -- Auto-wires generated sources and per-ABI `jniLibs` into Android projects (AGP). +- Builds C/C++ libraries into a static archive per Kotlin/Native target β€” with Kotlin/Native's own + clang, or with the project's own `build.zig`. +- Binds one cinterop `.def` for every kind of Kotlin consumer the project has: + - **Kotlin/Native** targets through KGP's own cinterop, the library's archive embedded in the klib; + - **JVM** targets and **Android** through generated JNI bindings and a stub library per target, with + strings and primitive buffers marshalled; + - the **JVM 22+** through jextract's FFM bindings, on request. +- Speaks Kotlin/Native's own `KonanTarget`: pass `kotlinNativeTarget.konanTarget` straight in, and new + targets arrive with the Kotlin version. +- Wires generated sources, `jniLibs` and task order into Kotlin and Android builds; can hand the JNI + stub to an external CMake build instead. - Auto-detects the Kotlin/Native distribution, the JDK that supplies `jni.h`, and the compiler-rt builtins each target needs (NDK on Android, Xcode on macOS). -- Zero runtime dependencies (only requires Gradle API). -- Support for custom compiler arguments. ## Installation 1. Apply the plugin in your `build.gradle.kts`: @@ -29,95 +28,113 @@ The **Konan Plugin** is a custom Gradle plugin that facilitates compiling C/C++ ``` 2. Ensure a Kotlin/Native distribution is installed. It is found automatically via `$KONAN_HOME`, else - the newest `~/.konan/kotlin-native-prebuilt-*`; override with `konanConfig.konanPath`. + the newest `~/.konan/kotlin-native-prebuilt-*`; override with `konan.konanHome`. Generating JNI or + FFM bindings needs the version the plugin was built against (2.4.20), which any Kotlin + Multiplatform build of that version with a native target downloads. ## Configuration -`konanConfig` compiles, and nothing else β€” the Kotlin/Native side of the plugin. Everything except -`targets` and `libName` has a convention, so most builds only declare those. JNI bindings are a -separate declaration, `jvmInterops`, described below. - -| Property | Type | Default | Description | -|--------------------------|---------------------|----------------|--------------------------------------------------------------------| -| `targets` | `List` | β€” | Targets to compile for. Use `targets(...)` or `androidTargets()`. | -| `libName` | `String` | project name | Static library base name (no `lib` prefix / `.a` suffix). | -| `sourceDir` | `String` | `native` | Directory containing C/C++ sources. | -| `headerDir` | `String` | `sourceDir` | Include root passed as `-I`. | -| `outputDir` | `String` | `build/native` | Root for the per-target `.a`s (`//lib.a`). | -| `konanPath` | `String` | auto-detected | Root of the Kotlin/Native distribution. | -| `additionalCompilerArgs` | `List` | `[]` | Appended to the default `-fno-sanitize=undefined`. Put `-std=…` here β€” C and C++ sources share one invocation. | - -## `jvmInterops { }` β€” JNI bindings - -Declared like a cinterop, on the compilation it belongs to, and driven by the same `.def` the native -targets bind: +Everything lives in one block: the **libraries** the project builds, and the **bindings** that reach +them from Kotlin. ```kotlin -import io.github.lemcoder.interop.jvmInterops - -kotlin { - jvm() +import org.jetbrains.kotlin.konan.target.KonanTarget - jvm().compilations["main"].jvmInterops { +konan { + val mymath = libraries.create("mymath") { + targets(KonanTarget.MACOS_ARM64, KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) + } + bindings { create("mymath") { - // defFile defaults to src/nativeInterop/cinterop/mymath.def - includeDirs.from(file("native")) + library(mymath) + packageName.set("example") } } } ``` -`KotlinCompilation` is not `ExtensionAware`, so this is an extension function rather than a block the -Kotlin plugin owns β€” the `import` is the only visible difference. A module with no Kotlin compilation -(an AGP application or library) declares interops on the project instead, and the generated bindings -and per-ABI `jniLibs` are wired into the variants: +A binding is consumed wherever the project's Kotlin targets need it, with nothing more to declare: -```kotlin -jvmInterops { - create("mymath") { - defFile(project.file("src/main/nativeInterop/mymath.def")) - targets.set(listOf(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64)) - } -} -``` - -| Property | Type | Default | Description | -|--------------------------|---------------------|--------------------|------------------------------------------------------| -| `defFile` | `File` | `src/nativeInterop/cinterop/.def` | The cinterop `.def` to bind β€” cinterop's own default location, so one file serves both legs. | -| `packageName` | `String` | the def's `package`| Kotlin package for the generated bindings. | -| `visibility` | `BindingVisibility` | `INTERNAL` | `PUBLIC` when the idiomatic wrapper lives in another module. | -| `targets` | `List` | the build host | Targets to link the JNI library for. | -| `library` | `File` | see below | Archive to link, overriding the def. | -| `includeDirs` | `FileCollection` | the def's directory| Extra include roots. | -| `jniHome` | `String` | auto-detected | A JDK shipping `include/jni.h`. | -| `additionalCompilerArgs` | `List` | `[]` | Extra `-compiler-option` values for the generator. | -| `additionalLinkerArgs` | `List` | `[]` | Extra link arguments for every target. | -| `targetLinkerArgs` | `Map<…>` | `{}` | Per-target link arguments; set via `linkerArgsFor(target, …)`. | - -### How far it goes depends on the library +| The project has | It gets | Through | +|---|---|---| +| Kotlin/Native targets (`kotlin("multiplatform")`) | a cinterop klib per target the library is built for | KGP's own `cinterops {}`, with the library's archive embedded | +| `kotlin("jvm")`, multiplatform `jvm()` or Android targets, or an Android app | JNI bindings in the main sources, a JNI library per target | the plugin's JNI generator and link | +| `ffm()` on the binding, in a `kotlin("jvm")` project | FFM bindings in place of JNI, the library as a shared library | jextract | -Same question as cinterop's `staticLibraries`, one step further along: cinterop embeds the archive -into the klib, and the JVM leg has to *link* a shared library, because a JVM can only `dlopen`. +### `libraries { }` +A C/C++ library built to `//lib.a` for each target: by Kotlin/Native's +clang from `sourceDirectory`, or by Zig. Bindings see the same archives either way. -- **Something to link** β€” `library(...)`, the def's `staticLibraries`, or whatever `konanConfig` - compiled for that target β€” and you get `libstubs.{so,dylib,dll}` per target. -- **Nothing to link** and it stops after the Kotlin bindings and the `.c` stub, for another build - system to compile. This is the mode for a native library owned by CMake, Bazel or a vendor: linking - an archive built by a foreign toolchain with konan's linker is what produces mismatched C++ - runtimes and missing compiler-rt. +| Property | Type | Default | Description | +|-------------------|---------------------|----------------------------------|-------------| +| `targets` | `List` | β€” | Targets to build for. `targets(...)` takes `KonanTarget`s; `androidTargets()` is every Android ABI. | +| `sourceDirectory` | `Directory` | `native/` | The C/C++ sources; also an include root. | +| `includeDirs` | `FileCollection` | β€” | Further include roots. | +| `compilerOptions` | `List` | β€” | Appended to `-fno-sanitize=undefined -fPIC`. Put `-std=…` here β€” C and C++ sources share one invocation. | +| `outputDirectory` | `Directory` | `build/konan/libraries/` | Root of the per-target archives; `archive(target)` gives one. | +| `zig { }` | | β€” | Build with the project's `build.zig` instead; below. | -Preferring the `konanConfig` output over a def entry keeps the def portable β€” naming the archive in -it would hardcode a target directory. - -### Letting another build link it β€” `externalNativeBuild` +#### Building with Zig β€” `zig { }` +```kotlin +create("mymath") { + targets(KonanTarget.MACOS_ARM64, KonanTarget.LINUX_X64, KonanTarget.MINGW_X64) + zig { } // runs native/build.zig once per target +} +``` -When the native library belongs to a CMake project, the plugin can drive that build instead of -linking anything itself, the way AGP's `externalNativeBuild` hands a module's native code to CMake: +`zig build` runs once per target, with `-Dtarget` set to the Zig triple matching Kotlin/Native's +sysroot (glibc 2.19 on Linux, macOS 12.0, iOS 14.0), and its static library is copied to where the +plugin's own build would put it β€” Windows' `mymath.lib` included. The `build.zig` must install a static +library (`b.addLibrary(.{ .linkage = .static, … })`). + +| Property | Type | Default | Description | +|--------------------|----------------|---------------------|-------------| +| `projectDirectory` | `Directory` | `sourceDirectory` | Where `build.zig` is. | +| `executable` | `File` | `zig` on the PATH | The `zig` executable. | +| `optimize` | `ZigOptimize` | `RELEASE_FAST` | `-Doptimize`. Zig's `DEBUG` and `RELEASE_SAFE` compile C with UBSan traps, whose runtime the Kotlin/Native and JNI links do not have. | +| `arguments` | `List` | β€” | Further `zig build` arguments. | +| `artifactName` | `String` | the library's name | The static library `build.zig` installs. | + +Android targets need a libc file for the NDK's bionic, which Zig does not bundle. + +### `bindings { }` +A C API described by a cinterop `.def`. + +| Property | Type | Default | Description | +|-------------------|------------------|-----------------------------------------|-------------| +| `defFile` | `File` | `src/nativeInterop/cinterop/.def` | cinterop's own default location. | +| `library` | `NativeLibrarySpec` | β€” | One of `libraries` β€” `library(mymath)`: its archive is linked for every consumer and built first, so the `.def` needs no `staticLibraries`. | +| `packageName` | `String` | the `.def`'s `package` | Package of the generated bindings. | +| `includeDirs` | `FileCollection` | β€” | Include roots beyond the `.def`'s directory. | +| `compilerOptions` | `List` | β€” | Extra clang options for reading the headers. | +| `jni { }` | | | How the JNI bindings are generated and linked; below. | +| `ffm { }`, `ffm()`| | off | Turns on FFM bindings; below. | + +### `jni { }` + +| Property | Type | Default | Description | +|----------------------|--------------------------|---------|-------------| +| `visibility` | `BindingVisibility` | `INTERNAL` | `PUBLIC` when the idiomatic wrapper lives in another module. | +| `targets` | `List` | the library's targets a JVM can load β€” the host and Android β€” else the host | Targets to link the JNI library for. | +| `archive` | `File` | the library's | An archive to link instead, e.g. one built elsewhere. | +| `jdkHome` | `Directory` | auto-detected | A JDK shipping `include/jni.h`. | +| `linkerOptions` | `List` | β€” | Extra options for every target's link. | +| `linkerOptionsFor(target, …)` | | β€” | Extra options for one target's link; `-framework` belongs here, since the Android linker rejects it. | +| `libraryDirectory` | `Directory` | output | Where the host's JNI library ends up: put it on `java.library.path`. | +| `cmake { }` | | β€” | An external CMake build that compiles the stub instead; below. | + +Without a library, `jni.archive` or `staticLibraries` in the `.def`, the plugin stops after the Kotlin +bindings and the `.c` stub, for another build system to compile. That is the mode for a native library +owned by CMake, Bazel or a vendor: linking an archive built by a foreign toolchain with konan's linker +is what produces mismatched C++ runtimes and missing compiler-rt. + +#### Letting CMake link it β€” `jni { cmake { } }` +When the native library belongs to a CMake project, the plugin drives that build instead of linking +anything itself, the way AGP's `externalNativeBuild` hands a module's native code to CMake: ```kotlin create("koinference") { packageName.set("com.example.native") - - externalNativeBuild { + jni { cmake { path.set(file("native/CMakeLists.txt")) preset.set("macosArm64") // or buildDirectory, for preset-less projects @@ -129,26 +146,11 @@ create("koinference") { ``` No link task is registered; `cmakeConfigure` and `cmakeBuild` take its place, ordered -after generation, and `linkJvmInterop` depends on the build so "make the JNI library exist" -means the same thing either way. - -Android needs a library per ABI rather than one for the host, so declare them: - -```kotlin -externalNativeBuild { - cmake { - path.set(file("native/CMakeLists.txt")) - targets.add("koinference-jni") - - abi("arm64-v8a") { preset.set("androidNativeArm64") } - abi("x86_64") { preset.set("androidNativeX64") } - } -} -``` - -Each ABI gets its own configure/build pair, the libraries land in `jniLibs//`, and the plugin -wires that directory into the Android variants so they are packaged into the AAR. The plugin sets -`CMAKE_LIBRARY_OUTPUT_DIRECTORY`, so the CMakeLists needs no knowledge of the layout. +after generation, and `linkJni` depends on the build so "make the JNI library exist" means the +same thing either way. Android needs a library per ABI, so declare them: +`abi("arm64-v8a") { preset.set("androidNativeArm64") }`. Each ABI gets its own configure/build pair and +lands in `jniLibs//`, which the plugin packages; it sets `CMAKE_LIBRARY_OUTPUT_DIRECTORY`, so the +CMakeLists needs no knowledge of the layout. The plugin supplies what only it knows, as cache entries your `CMakeLists.txt` reads: @@ -171,7 +173,7 @@ set_target_properties(mylib-jni PROPERTIES OUTPUT_NAME "${KONAN_JNI_LIB_NAME}") target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arrive transitively ``` -### Generated bindings +### The generated JNI bindings One `external fun` per C function, named after it and carrying its C signature as a doc comment. cinterop's own indexer reads the headers β€” the same `.def` the Kotlin/Native targets use β€” and the plugin writes both sides of each JNI bridge from the C declarations: @@ -191,151 +193,60 @@ C header. The bindings are `internal` by default β€” they are an implementation detail of the module that writes the idiomatic API over them. Each carries `@JvmName`, because JNI resolves the symbol from the -unmangled method name. Set `visibility.set(BindingVisibility.PUBLIC)` if the wrapper is in a different -Gradle module. - -Generating needs the Kotlin/Native distribution the plugin was built against (2.4.20) in `~/.konan`; -any Kotlin Multiplatform build of that version with a native target downloads it. - -Platform-specific link arguments go in `linkerArgsFor`; `-framework` in `additionalLinkerArgs` would -reach the Android linker, which rejects it. - -## `kotlinNativeInterop` β€” Kotlin/Native bindings -Declares the cinterop for a `.def` on every Kotlin/Native target, through KGP's own `cinterops {}`, so -the klib comes from the project's Kotlin version and KGP handles commonization, the IDE and publishing. -Where `konanConfig` builds the library for a target, cinterop is pointed at its archive (`-libraryPath`) -and runs after `runKonanClang`, so a `.def` with `staticLibraries = lib.a` embeds it. - -```kotlin -import io.github.lemcoder.interop.kotlinNativeInterop - -kotlinNativeInterop("mymath") { - defFile.set(file("src/nativeInterop/cinterop/mymath.def")) - includeDirs.from("native") - // packageName, compilerOptions, and targets (every Kotlin/Native target when empty) are optional. -} -``` - -## Usage - -### Static library only -```kotlin -import org.jetbrains.kotlin.konan.target.KonanTarget +unmangled method name. Set `jni { visibility.set(BindingVisibility.PUBLIC) }` if the wrapper is in a +different Gradle module. -konanConfig { - targets(KonanTarget.LINUX_X64, KonanTarget.MINGW_X64, KonanTarget.MACOS_ARM64) - libName.set("mylib") +### `ffm { }` +FFM (`java.lang.foreign`) bindings, written by jextract from the same `.def`, and the library linked +as a shared library for the build host β€” FFM needs no stub. In a `kotlin("jvm")` project they replace +the JNI bindings and are added to the main Java sources; compile them with a JDK 22+ toolchain and run +with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. - sourceDir.set("native/src") - headerDir.set("native/include") - outputDir.set("native/lib") - - additionalCompilerArgs.addAll("-O2", "-Wall") -} -``` - -Target names are also accepted as strings β€” `targets("linux_x64", "mingw_x64")` β€” and resolved -against `KonanTarget.predefinedTargets`, so an unknown name fails at configuration time instead of -during the clang invocation. `hostKonanTarget()` is the build host; `androidKonanTargets()` is every -Android ABI. - -### Android, with JNI bindings -AGP modules declare interops on the project; AGP wiring is automatic: - -```kotlin -import org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.interop.jvmInterops - -konanConfig { - targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) // device + emulator - libName.set("mymath") -} - -jvmInterops { - create("mymath") { - defFile(project.file("src/main/nativeInterop/mymath.def")) - includeDirs.from(file("native")) - targets.set(listOf(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64)) - } -} -``` - -That is the whole configuration: the `.a`s land in `build/native//`, the stubs in -`build/jvmInterop//jniLibs//`, and the generated Kotlin bridges become a source directory -of every variant. See `examples/android`. +| Property | Type | Default | Description | +|--------------------|-------------|----------------------|-------------| +| `jextract` | `File` | `jextract` on the PATH | The jextract executable. | +| `libraryDirectory` | `Directory` | output | Where the shared library ends up. | ## Tasks -| Task | Description | -|-----------------------------|-------------------------------------------------------------------------| -| `runKonanClang` | Compiles the sources to `.o` and links them into `lib.a` per target. | -| `generateJvmInterop` | Generates the JNI `.c` stub + runtime-free Kotlin bridges for one interop. | -| `linkJvmInterop` | Umbrella task: links that interop for every target. | -| `linkJvmInterop` | Links one interop for one target. | -| `cmakeConfigure` | Configures an interop's external CMake build. | -| `cmakeBuild` | Runs it, replacing the plugin's own link. | +| Task | Description | +|-------------------------------|-------------| +| `compileNativeLibrary` | Builds a library to `lib.a` per target, with whichever provider it uses. | +| `konanClang`, `zigBuild` | The provider doing it: Kotlin/Native's clang, or `zig build`. | +| `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | +| `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | +| `linkJni` | Links it for one target. | +| `cmakeConfigure`, `cmakeBuild` | The external CMake build, in place of the link. | +| `generateFfm`, `linkFfm` | jextract's bindings, and the shared library they load. | +| `cinterop` | KGP's own cinterop, declared by the plugin. | On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks automatically, so `assembleDebug` alone runs the whole chain. -### Example -1. Place your C/C++ source files in `sourceDir` (default `native/`). -2. Place your `.h` headers in `headerDir` (defaults to `sourceDir`). -3. Run the build: - ```bash - ./gradlew runKonanClang - ``` - -The compiled static libraries will be output to `outputDir`. - -## Example Workflow -### Directory Structure -```plaintext -project-root/ -β”œβ”€β”€ build.gradle.kts -β”œβ”€β”€ native/ -β”‚ β”œβ”€β”€ src/ -β”‚ β”‚ β”œβ”€β”€ file1.c -β”‚ β”œβ”€β”€ include/ -β”‚ β”‚ β”œβ”€β”€ file1.h -β”‚ └── lib/ (generated output) -└── settings.gradle.kts -``` - -### Output -After running the task: -```plaintext -native/lib/ -β”œβ”€β”€ linux_x64/ -β”‚ └── lib.a -β”œβ”€β”€ mingw_x64/ -β”‚ └── lib.a -``` - ## Examples -- `examples/native` β€” Kotlin/Native consumer via standard `cinterop` (no JNI). -- `examples/jvm` β€” plain JVM app loading the stub from `java.library.path`. -- `examples/android` β€” Android app calling C through the generated bridges. +- `examples/native` β€” Kotlin/Native consumer through KGP's cinterop. +- `examples/jvm` β€” plain JVM app loading the JNI library from `java.library.path`. +- `examples/android` β€” Android app calling C through the generated bindings. `scripts/run-android.sh` publishes the plugin, builds, installs and launches it in one command. -## Migrating to 1.2.0 - -Alpha, and the interop DSL moved wholesale β€” there is no compatibility shim. - -- `konanConfig { jvmInterop { … } }` is gone. Declare `jvmInterops { create("name") { … } }` on a - Kotlin compilation, or on the project for AGP modules. `konanConfig` is compilation only now, - mirroring the Kotlin/Native side. -- Bindings come from a `.def`, not from scanned headers: `defFile(...)` replaces `headers`, - `headerFilter` and `headerDir`. The same def can drive cinterop. -- `staticLibraryDir` / `staticLibraryName` are gone; the archive is the def's `staticLibraries`, - an explicit `library(...)`, or `konanConfig`'s own output for that target. -- Task names carry the interop name: `generateJvmInteropMymath`, `linkJvmInteropMymathMacos_arm64`. -- Targets are `org.jetbrains.kotlin.konan.target.KonanTarget`, not the plugin's own enum. Change the - import; `KonanTarget.host()` becomes `hostKonanTarget()` and `KonanTarget.ANDROID` becomes - `androidKonanTargets()`. -- `targets(...)` on `konanConfig` no longer implies anything about JNI; an interop declares its own. -- The default clang arguments are `-fno-sanitize=undefined -fPIC`. 1.1.x also passed `-std=c99`, - which made any `.cpp` in the source dir a hard error; add it via `additionalCompilerArgs` if your - C sources rely on it. +## Migrating to 2.0 +The DSL is new and there is no compatibility shim. + +- `konanConfig { targets; libName; sourceDir; … }` becomes a library: + `konan { libraries { create("") { targets(…); sourceDirectory.set(…) } } }`. Its archives move + from `build/native//` to `build/konan/libraries///`, and `runKonanClang` becomes + `compileNativeLibrary` (which runs `konanClang`). +- `jvmInterops { create(…) { … } }` β€” on a compilation or on the project β€” becomes + `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` β†’ + `jdkHome`, `library` β†’ `archive`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ + `linkerOptionsFor`, `externalNativeBuild { cmake { } }` β†’ `cmake { }`, `resolvedLibraryDirectory` β†’ + `libraryDirectory`. `additionalCompilerArgs` becomes `compilerOptions`. Bindings are wired into the + project's JVM and Android targets automatically. +- Refer to the library instead of naming the archive: `library(mymath)`, where + `val mymath = libraries.create("mymath") { … }`. Drop `staticLibraries` for it from the + `.def`, or Kotlin/Native would embed it twice. +- `kotlinNativeInterop` is gone: a binding is declared for every Kotlin/Native target by itself. +- JNI bindings are named after the C functions (`my_add`), not `kniBridge0`; tasks are `generateJni…` and + `linkJni…`. ## License This project is licensed under the Apache 2.0 License. For more details, see the `LICENSE` file. diff --git a/buildSrc/src/main/kotlin/native-index.gradle.kts b/buildSrc/src/main/kotlin/native-index.gradle.kts index 141dcae..3247f79 100644 --- a/buildSrc/src/main/kotlin/native-index.gradle.kts +++ b/buildSrc/src/main/kotlin/native-index.gradle.kts @@ -16,6 +16,12 @@ pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { val sourceSets = the() val indexExporter = sourceSets.create("indexExporter") + // The request types both sides of the process boundary speak: one source, compiled into each. + val protocol = "src/exporterProtocol/kotlin" + listOf(sourceSets.getByName("main"), indexExporter).forEach { + (it.extensions.getByName("kotlin") as SourceDirectorySet).srcDir(protocol) + } + dependencies { // compileOnly: at run time the distribution's own copy of this jar is the classpath. "indexExporterCompileOnly"(kotlinNativeVersion.map { "org.jetbrains.kotlin:kotlin-native-compiler-embeddable:$it" }) diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index 4cbfe98..ddbab64 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -104,12 +104,12 @@ flowchart TD | # | Component | Responsibility | Consumes β†’ produces | Implementations | | --- | --- | --- | --- | --- | -| 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | Today `konanConfig {}` and `jvmInterops {}`; the redesign is section A | +| 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `KonanTarget` today, mapped to Android ABI and JVM library paths | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots) today; Xcode, Android NDK, system clang/GCC/MSVC, Zig later | -| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | **Sources** (`runKonanClang`), **External build** (CMake); **Prebuilt** not yet | +| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`: **KonanClangProvider** (default, `konanClang`) and **ZigProvider** (`zig { }`, `zigBuild`), both behind `compileNativeLibrary`; CMake only for the JNI stub; **Prebuilt** not yet | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | -| 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`kotlinNativeInterop`) | +| 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KgpWiring.declareCinterops`) | | 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `LinkJvmInteropTask`, or an external build through the `KONAN_JNI_STUB_DIR` contract; a no-glue shared library for FFM is missing (section D) | | 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | JVM and Android for JNI; Kotlin/Native through the KGP adapter; FFM and publications not yet (section D) | @@ -134,23 +134,83 @@ Rules that fall out of this: ## A. DSL -Not designed yet; next round. It must decide: +One project extension, `konan`, with a container per component: **libraries** (component 4) and +**bindings** (5 and 6). A binding names its library; consumers follow from the project's plugins and +Kotlin targets, so a build declares what it has, not how each consumer is wired. + +```kotlin +konan { + val mymath = libraries.create("mymath") { + targets(KonanTarget.MACOS_ARM64, KonanTarget.ANDROID_ARM64) // sourceDirectory = native/ + } + bindings { + create("mymath") { + library(mymath) // archive per target, -staticLibrary/-libraryPath, task order + packageName.set("example") + jni { visibility.set(BindingVisibility.PUBLIC) } // optional per-consumer settings + ffm() // opt-in; replaces JNI in a kotlin("jvm") project + } + } +} +``` -- Where declarations live: top-level `nativeLibraries {}` and `bindings {}` containers, or per - compilation like `compilations["main"].cinterops {}`, or both with one referencing the other. -- How much to borrow from AGP's `externalNativeBuild { cmake { path; version } }` and - `defaultConfig.externalNativeBuild { cmake { arguments; abiFilters } }`, where the build is declared - once and tuned per variant. -- How a binding names its library (by reference, so provider and generator stay decoupled) and picks - generators per consumer β€” section B shows each consumer has exactly one natural generator. -- Prior art to read first: the current KGP `cinterops {}` DSL, AGP `externalNativeBuild` and - `ndk {}`, and Gradle's own `cpp-library` plugin. +| The project has | The binding becomes | +| --- | --- | +| Kotlin/Native targets (`kotlin("multiplatform")`) | KGP's `cinterops {}` on each target the library is built for, archive embedded | +| `kotlin("jvm")`, multiplatform `jvm()`/Android targets, an Android app | JNI bindings in the main sources; a JNI library per target a JVM can load (host and Android, from the library's targets) | +| `ffm()` in a `kotlin("jvm")` project | jextract's FFM bindings in the main Java sources, the library linked as a shared library | + +Choices made: + +- **Containers, not per-compilation blocks.** `KotlinCompilation` is not extensible, and consumers are + derived anyway; per-compilation declarations would repeat one binding per target. +- **A binding refers to its library as an object** (`library(mymath)`), not by name. Providers and generators stay decoupled (component + rule 1), and the `.def` no longer needs `staticLibraries`: the plugin passes cinterop + `-staticLibrary`/`-libraryPath` and links the archive for JNI and FFM. +- **Per-consumer settings nest under the binding** (`jni { }`, `ffm { }`), with AGP-style nested CMake + under `jni { cmake { } }`, since only the JNI stub is ever compiled by an external build. +- **One naming scheme**: `compilerOptions`, `linkerOptions`, file and directory properties, vararg + `targets(…)`. Tasks are `compileNativeLibrary`, `generateJni`, `linkJni[]`, + `generateFfm`, `linkFfm`, and KGP's own `cinterop`. +- **No compatibility shim** for `konanConfig`/`jvmInterops`/`kotlinNativeInterop`: a major version. + +**A second library provider: Zig.** `libraries { create(…) { zig { } } }` runs the project's own +`build.zig` once per target. Adding it touched only the library side β€” a `ZigBuildTask`, a `zig { }` +block on the library, a Kotlin/Native β†’ Zig triple mapping β€” and left bindings, generators and consumer +wiring unchanged. One Zig-built archive was then consumed by all three consumers: JNI and KGP's +cinterop in one multiplatform build (`jvm()` + `macosArm64()`), FFM in a `kotlin("jvm")` build, and a +`linuxX64` executable cross-linked from macOS. What made that possible, and what it cost: + +- **The archive path is the contract, now a type.** `NativeArtifacts` writes the layout + (`/lib.a` under the library's output directory) in one place and carries the targets + and `builtBy`, the lifecycle task `compileNativeLibrary`. Bindings and consumers take a + `NativeArtifacts` and never learn which provider ran. Zig names Windows archives `mymath.lib`; its + provider renames on copy. +- **A provider is an implementation of `LibraryProvider`**: its DSL block name, its conventions, and + the tasks that leave the archives in place. A library selects one by calling its block β€” two is an + error, none means `KonanClangProvider`. A new build system is a settings class with its block on the + library, a `LibraryProvider`, and an entry in `libraryProviders`; nothing on the consumer side. +- **The target model has to map to each toolchain's triple.** Kotlin/Native targets become Zig triples + pinned to konan's sysroots (glibc 2.19, macOS 12.0, iOS 14.0). The glibc pin is defensive: in this + test, unpinned Zig also produced plain `strtol`. Android needs bionic, which Zig does not bundle. +- **Mixing toolchains brings runtime mismatches.** Zig's `Debug`/`ReleaseSafe` compile C with UBSan + traps the other links cannot resolve, so the provider defaults to `ReleaseFast` β€” the same reason + konan's clang passes `-fno-sanitize=undefined`. +- **The provider is decided after the build script runs**, so it is registered in `afterEvaluate` + behind a lifecycle task that exists from the start. + +Bazel would slot in the same way: `bazel build //target --platforms=…` per Kotlin/Native target, with a +platform per target and the output copied to the archive path. Its cost is the platforms and C +toolchains a Bazel workspace must declare to cross-compile, which Zig brings built in. + +Still open: a **Prebuilt** library provider (binaries and headers per target from paths or a Maven +artifact), FFM in multiplatform projects, and publishing a library's bindings and binaries. ## B. Generator model Generators come in two kinds. **Exporter generators** (`jni`, `jextract`, `dump`) run in one process, the **index exporter**, next to cinterop's model of the headers, and only generated sources leave it. A -**pipeline adapter** generates nothing itself: the KGP cinterop adapter (`kotlinNativeInterop`) +**pipeline adapter** generates nothing itself: the KGP cinterop adapter declares KGP's own `cinterops {}` for every Kotlin/Native target. Built and verified: the same `mymath.def` produces bindings for all three consumers, each called from Kotlin with identical results (integers, doubles, strings both ways, a read-only buffer, and a buffer the C side fills). @@ -175,10 +235,13 @@ worker, the compiler ran on Gradle's stdlib (2.4.10) instead of its own (2.4.20) classpath of exactly the compiler jar and the exporter avoids both. The distribution must be the exact version the exporter was compiled against (`kotlinNativeVersion`, 2.4.20). -**The interface.** `BindingGenerator.generate(context, outputDirectory)`, chosen by name. The -`GenerationContext` carries the package (decided as cinterop decides it), the `.def`'s preamble, the -cinterop command line, generator options, and β€” built on first use β€” the index, the included headers -and the clang arguments. +**The interface.** What to generate is a sealed `GeneratorRequest` β€” `Dump`, `Jni(stubLibrary, +visibility)`, `Jextract(library, jextract, headerClass)` β€” each carrying its generator's typed settings, +dispatched with an exhaustive `when` to `BindingGenerator.generate(request, context, outputDirectory)`. +`ExporterInvocation` encodes a request on the exporter's command line and decodes it on the other side; +its source is compiled into both the plugin and the exporter, so the two cannot disagree. The +`GenerationContext` carries the package (decided as cinterop decides it), the `.def`'s preamble, and β€” +built on first use β€” the index, the included headers and the clang arguments. | Exporter generator | Reads the index? | Output | Consumer | Notes | | --- | --- | --- | --- | --- | @@ -190,8 +253,8 @@ and the clang arguments. the consumer's Kotlin version, which KGP's cinterop is and the exporter's pinned distribution is not. The adapter also inherits what KGP does around it β€” a task per target, change detection, `-Xmodule-name`, the compile dependency, commonization, IDE import and publication β€” and wires the -provider's archive in (`-libraryPath` to `konanConfig`'s output, the cinterop task after -`runKonanClang`). Running cinterop's pipeline in the exporter (`Interop().interop("native", …)`) was +provider's archive in (`-staticLibrary` and `-libraryPath` to the library's archive, the cinterop task +after the library's compile task). Running cinterop's pipeline in the exporter (`Interop().interop("native", …)`) was tried and works, with a klib matching KGP's apart from the header hash and `unique_name`; it is not used because of the version pin. @@ -226,34 +289,33 @@ Partly settled by what exists: compiles against the compiler jar, which `main` must not); `src/main/.../index` holds the launcher (`runIndexExporter`), the generic `GenerateFromNativeIndexTask` and `PinnedKotlinNative`; `buildSrc/.../native-index.gradle.kts` wires the source set, the friend path and the nested exporter - jar. The remaining components (DSL, providers, packaging) still sit in `interop/` and `jvm/` and get - one package each in the redesign, so "KGP and AGP types only in packaging" can be checked by package. + jar. The other components have a package each: `dsl` (everything a build script touches), `library`, + `jni`, `link`, `toolchain`, and `wiring` (which turns the DSL into tasks; `KgpWiring` and `AgpWiring` + are the only files touching KGP and AGP types). - **Generator output**: each exporter generator owns its layout under the task's output directory β€” `jni`: `kotlin//.kt` and `c/.c`; `jextract`: `java//`; `dump`: `index.txt`. Kotlin/Native klibs stay where KGP puts them. -- **Consumer build output**: still to fix, e.g. `build/native///` for artifacts and - `build/generated/bindings///` for bindings, keeping the rule that only ABI-named - directories sit under `jniLibs`. +- **Consumer build output**: archives in `build/konan/libraries///`; generated sources + in `build/generated/konan//{jni,ffm}/`; linked libraries in + `build/konan/bindings//{jni/lib/,jni/jniLibs/,ffm/lib/}/` β€” only + ABI-named directories under `jniLibs`. ## D. Toolchains, prebuilt binaries and packaging -Still to design. What the generators showed each consumer needs; the JNI and Kotlin/Native rows are -wired by the plugin today, the FFM row was done by hand in a verification project: +What the generators showed each consumer needs. The plugin wires every row today, FFM only in a +`kotlin("jvm")` project: | Consumer | Binary | Glue | Wiring | | --- | --- | --- | --- | -| Kotlin/Native | Static archive, embedded in the klib (`staticLibraries` + `-libraryPath`) | None | KGP's `cinterops {}` on each target's main compilation, declared by the adapter | +| Kotlin/Native | Static archive, embedded in the klib (`-staticLibrary` + `-libraryPath`) | None | KGP's `cinterops {}` on each target's main compilation, declared by the adapter | | JVM (JNI) | Shared library: stub + archive | The JNI stub | Generated Kotlin as sources; the library on `java.library.path` or in resources | -| JVM (FFM) | Shared library of the C code itself | None | Generated Java as sources, compiled with a JDK 22+ toolchain; `--enable-native-access`; the library on `java.library.path` | +| JVM (FFM) | Shared library of the C code itself (`linkFfm`: the archive linked whole) | None | Generated Java in the main Java sources; the build supplies a JDK 22+ toolchain, `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path` | | Android | Shared library: stub + archive, per ABI | The JNI stub | Generated Kotlin as sources; `jniLibs/` via the variant API. No FFM on Android | -Gaps this exposes: +Gaps that remain: -- **A shared library without glue.** FFM needs `libmymath.dylib`, which neither the Sources provider - (static only) nor the glue builder (stub + archive) produces. The glue builder needs a mode that links - an archive into a shared library with nothing added (`-force_load` / `--whole-archive`). -- **Wiring for FFM consumers**, today written by hand: the Java source set, the JDK 22+ toolchain, the - runtime flags. +- **FFM outside `kotlin("jvm")`.** A multiplatform JVM target gets the bindings generated but not + wired; the runtime flags (`--enable-native-access`, `java.library.path`) stay the build's. - **Tool provisioning.** Generation needs the Kotlin/Native distribution of exactly `kotlinNativeVersion` in `~/.konan` (any KMP build of that version downloads it); jextract is a path option. The plugin should locate or download both. @@ -269,16 +331,16 @@ Still undecided: ## Open questions - [x] Should the cinterop generator run KGP's own cinterop task, or the plugin run cinterop itself? - KGP's: the klib must come from the consumer's Kotlin version. `kotlinNativeInterop` adapts a - binding to `cinterops {}`; running cinterop in the exporter was tried, works, and is not used. + KGP's: the klib must come from the consumer's Kotlin version. A binding is adapted to + `cinterops {}` for every Kotlin/Native target; running cinterop in the exporter was tried, works, and is not used. - [ ] Lowest JDK for the Gradle daemon and for consumers at runtime? It decides whether FFM is a default or an opt-in. Header parsing no longer depends on it: it runs in the exporter's process. - [ ] Does the `.def` stay a first-class input, or become an import format next to a header-first DSL? Today it is the input, read by cinterop itself. - [ ] Is a published multi-target library (bindings plus binaries per target) in scope for the first release, or only in-build consumption? -- [ ] Migration: does the current `konanConfig {}` and `jvmInterops {}` DSL keep working behind the - new model, or is this a major version with a break? The JNI bindings already changed their - names (`my_add`, not `kniBridge0`). +- [x] Migration: does the `konanConfig {}` and `jvmInterops {}` DSL keep working behind the new model? + No: `konan { libraries; bindings }` replaces it in a major version, with migration notes in the + README. - [ ] Should the plugin pin one Kotlin/Native version for generation (today) or support several, at the cost of one exporter build per version? diff --git a/examples/README.md b/examples/README.md index 19b877e..1953574 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,13 +1,14 @@ # Examples -One C library (`native/mymath.c`: `my_add`, `my_scale`) consumed from three platforms, showing the -two flows the plugin provides: +One C library (`native/mymath.c`: `my_add`, `my_scale`) consumed from three platforms. Each declares it +the same way β€” `konan { val mymath = libraries.create("mymath"); bindings { create("mymath") { library(mymath) } } }` β€” +and gets the binding its Kotlin targets need: | Example | Flow | What it proves | Run | |---|---|---|---| -| [`jvm/`](jvm) | `konanConfig` (.a) + `jvmInterop` (JNI .dylib + bridges) | full JNI path, end to end | `../../gradlew -p jvm run` | +| [`jvm/`](jvm) | a library (.a) + a binding β†’ JNI bindings and library | full JNI path, end to end | `../../gradlew -p jvm run` | | [`android/`](android) | same, cross-compiled per ABI, real app | APK with `.so` (arm64-v8a + x86_64) + `MainActivity` | `cd android && ./gradlew installDebug` | -| [`native/`](native) | `konanConfig` (.a) + `kotlinNativeInterop` β†’ KGP cinterop | the native path | `../../gradlew -p native runDebugExecutableMacosArm64` | +| [`native/`](native) | a library (.a) + a binding β†’ KGP's cinterop | the native path | `../../gradlew -p native runDebugExecutableMacosArm64` | `jvm` and `native` build the plugin from source via `includeBuild`. `android` needs AGP 9 (Gradle 9.4, its own wrapper), so it consumes the plugin from mavenLocal β€” publish it first: @@ -20,13 +21,13 @@ from the NDK sysroot, so no JDK needed there). ## The two flows ``` -native/*.c ──(konanConfig: runKonanClang)──> libmymath.a per target +native/*.c ──(compileNativeLibraryMymath)──> libmymath.a per target β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚ JVM / Android leg (jvmInterop) β”‚ Native leg (cinterops) β”‚ + β”‚ JVM / Android (generateJni, linkJni) β”‚ Kotlin/Native (cinterop) β”‚ β”‚ β”‚ β”‚ - β”‚ .def(headers) ─> JNI .c stubs + bridges .kt β”‚ .def(headers) ─> K/N klib β”‚ - β”‚ .c + libmymath.a ─link─> libfoostubs.so/.dylibβ”‚ links libmymath.a β”‚ + β”‚ .def(headers) ─> JNI .c stub + Kotlin .kt β”‚ .def(headers) ─> K/N klib β”‚ + β”‚ .c + libmymath.a ─link─> libstubs.so β”‚ embeds libmymath.a β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` diff --git a/examples/android/README.md b/examples/android/README.md index 3d0f7fd..70081cd 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -56,21 +56,25 @@ my_scale(4.0) = 40.0 One config block β€” the plugin does the wiring: ```kotlin -konanConfig { - targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) - libName.set("mymath") - jvmInterop { packageName.set("example") } +konan { + val mymath = libraries.create("mymath") { targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) } // device + emulator + bindings { + create("mymath") { + defFile.set(file("src/main/nativeInterop/mymath.def")) + includeDirs.from("native") + library(mymath) + } + } } ``` -- `targets` + `libName` β†’ the per-ABI `.a`s, from `sourceDir` (default `native`). -- the nested `jvmInterop` β†’ bridges + `.so`s. It inherits the targets, header dir and static library - from the enclosing block, and is enabled automatically because Android targets are present; the - Kotlin/Native distribution is auto-detected from `$KONAN_HOME` / `~/.konan`. +- the library β†’ `libmymath.a` per ABI, from `native/`, in `build/konan/libraries/mymath//`. +- the binding β†’ Kotlin bindings and a `.so` per ABI the library is built for: an Android app gets JNI. + The Kotlin/Native distribution is auto-detected from `$KONAN_HOME` / `~/.konan`. When AGP is present the plugin **automatically** adds the generated Kotlin bridges as a source directory and the per-ABI `jniLibs/` as native libraries (via the AGP variant API), and orders -`generateJvmInterop` / `linkJvmInterop` / `runKonanClang` ahead of the build. No `sourceSets` or +`compileNativeLibraryMymath` / `generateJniMymath` / `linkJniMymath` ahead of the build. No `sourceSets` or `dependsOn` boilerplate needed. `MainActivity` calls `example.add/scale` (in `src/main/kotlin/example/Api.kt`), which call the diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts index f0cc99d..7a336b8 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -1,30 +1,25 @@ // A real Android app that calls a C library through JNI bindings generated by the plugin. -// Both plugins are applied to this one module: the konan plugin produces the .a + the JNI stub .so -// + the Kotlin bridges; AGP packages the .so and compiles the app. +// Both plugins are applied to this one module: the konan plugin produces the .a, the JNI stub .so and the +// Kotlin bindings; AGP packages the .so and compiles the app. import org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.interop.jvmInterops plugins { id("com.android.application") // AGP 9 has built-in Kotlin support id("io.github.lemcoder.konanplugin") } -// konanConfig compiles only: native/*.c -> build/native//libmymath.a per ABI. The -// Kotlin/Native distribution is auto-detected ($KONAN_HOME, else the newest ~/.konan/kotlin-native-prebuilt-*). -konanConfig { - targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) // device + emulator - libName.set("mymath") -} - -// An AGP module has no Kotlin compilation to hang interops on, so they are declared on the project; -// the generated bindings and the per-ABI jniLibs are wired into the variants. The archive to link -// defaults to what konanConfig built for each target. -jvmInterops { - create("mymath") { - defFile(project.file("src/main/nativeInterop/mymath.def")) - includeDirs.from(file("native")) - targets.set(listOf(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64)) +konan { + // native/*.c -> one static archive per ABI: device + emulator. + val mymath = libraries.create("mymath") { targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) } + // An Android app gets JNI bindings, wired into its variants, and a JNI library per ABI the library + // is built for, packaged as jniLibs. + bindings { + create("mymath") { + defFile.set(file("src/main/nativeInterop/mymath.def")) + includeDirs.from("native") + library(mymath) + } } } @@ -46,5 +41,5 @@ android { } } -// That's it β€” the plugin auto-wires the generated Kotlin bridges + per-ABI jniLibs into AGP and -// orders generateJvmInteropMymath / linkJvmInteropMymath / runKonanClang before the build. +// That's it β€” the plugin wires the generated Kotlin bindings and per-ABI jniLibs into AGP and orders +// compileNativeLibraryMymath / generateJniMymath / linkJniMymath before the build. diff --git a/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts index e6cded3..edb0014 100644 --- a/examples/jvm/build.gradle.kts +++ b/examples/jvm/build.gradle.kts @@ -1,6 +1,4 @@ import io.github.lemcoder.hostKonanTarget -import org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.interop.jvmInterops plugins { kotlin("jvm") version "2.4.20" @@ -10,36 +8,27 @@ plugins { repositories { mavenCentral() } -val host = hostKonanTarget() - -// konanConfig compiles, nothing else: native/*.c -> build/native//libmymath.a. -konanConfig { - targets(host) - libName.set("mymath") -} - -lateinit var mymathLibDir: Provider - -kotlin { - jvmToolchain(17) - - // Declared like a cinterop, on the compilation it belongs to. The def names the archive above, - // so the plugin also links the JNI library; drop `staticLibraries` from it and you get the - // bindings and the .c stub only, for another build system to compile. - target.compilations["main"].jvmInterops { +konan { + // native/*.c -> one static archive per target. + val mymath = libraries.create("mymath") { targets(hostKonanTarget()) } + // The C API in the .def, linked against that library. A kotlin("jvm") project gets JNI bindings, + // added to its main source set, and a JNI library per target a JVM can load. + bindings { create("mymath") { - defFile(project.file("src/main/nativeInterop/mymath.def")) - includeDirs.from(file("native")) - mymathLibDir = resolvedLibraryDirectory + defFile.set(file("src/main/nativeInterop/mymath.def")) + includeDirs.from("native") + library(mymath) } } } +kotlin { jvmToolchain(17) } + application { mainClass.set("example.MainKt") } tasks.named("run") { - dependsOn("linkJvmInteropMymath") + dependsOn("linkJniMymath") // loadLibrary() resolves the stub from java.library.path. - val libDir = mymathLibDir + val libDir = konan.bindings.named("mymath").flatMap { it.jni.libraryDirectory } jvmArgumentProviders.add { listOf("-Djava.library.path=${libDir.get().asFile.absolutePath}") } } diff --git a/examples/native/build.gradle.kts b/examples/native/build.gradle.kts index 73eb428..43fcf9f 100644 --- a/examples/native/build.gradle.kts +++ b/examples/native/build.gradle.kts @@ -1,8 +1,5 @@ -// Kotlin/Native consumer of the SAME C library through cinterop (no JNI). -// The plugin cross-compiles native/*.c to libmymath.a, and kotlinNativeInterop declares the cinterop -// for every Kotlin/Native target: KGP then builds the interop klib with this project's Kotlin version. +// Kotlin/Native consumer of the same C library, through KGP's own cinterop (no JNI). -import io.github.lemcoder.interop.kotlinNativeInterop import org.jetbrains.kotlin.konan.target.KonanTarget plugins { @@ -12,24 +9,22 @@ plugins { repositories { mavenCentral() } -val host = KonanTarget.MACOS_ARM64 - -// Build native/*.c -> build/native/macos_arm64/libmymath.a. No jvmInterop block and no Android -// target, so the JNI leg stays off β€” this example binds through K/N cinterop instead. -konanConfig { - targets(host) - libName.set("mymath") -} - kotlin { macosArm64 { binaries.executable { entryPoint = "main" } } } -// The .def names staticLibraries = libmymath.a; the adapter points cinterop at konanConfig's archive -// for each target and runs it after runKonanClang. -kotlinNativeInterop("mymath") { - defFile.set(file("src/nativeInterop/cinterop/mymath.def")) - includeDirs.from("native") +konan { + // native/*.c -> build/konan/libraries/mymath/macos_arm64/libmymath.a. + val mymath = libraries.create("mymath") { targets(KonanTarget.MACOS_ARM64) } + // Every Kotlin/Native target the library is built for gets KGP's cinterop for the .def, with the + // library's archive embedded in the klib. + bindings { + create("mymath") { + defFile.set(file("src/nativeInterop/cinterop/mymath.def")) + includeDirs.from("native") + library(mymath) + } + } } diff --git a/examples/native/src/nativeInterop/cinterop/mymath.def b/examples/native/src/nativeInterop/cinterop/mymath.def index e1140a3..6fd817f 100644 --- a/examples/native/src/nativeInterop/cinterop/mymath.def +++ b/examples/native/src/nativeInterop/cinterop/mymath.def @@ -1,4 +1,3 @@ headers = mymath.h headerFilter = mymath.h package = mymath -staticLibraries = libmymath.a diff --git a/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt new file mode 100644 index 0000000..f4b9815 --- /dev/null +++ b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt @@ -0,0 +1,118 @@ +package io.github.lemcoder.index + +import java.io.File +import java.io.Serializable + +/** + * What the index exporter is asked to generate: one subtype per generator, carrying that generator's + * settings. Compiled into both the plugin and the exporter, so the two sides cannot disagree on it. + */ +sealed class GeneratorRequest : Serializable { + /** A text rendering of the index, `index.txt`. */ + data object Dump : GeneratorRequest() { + private fun readResolve(): Any = Dump + } + + /** + * JNI bindings and their C stub. + * + * @property stubLibrary the library the bindings `System.loadLibrary`; the plugin owns that name. + */ + data class Jni(val stubLibrary: String, val visibility: GeneratedVisibility = GeneratedVisibility.INTERNAL) : + GeneratorRequest() + + /** + * FFM bindings, by jextract. + * + * @property library the shared library the bindings `System.loadLibrary`. + * @property jextract the jextract executable; `jextract` on the PATH when null. + * @property headerClass the class holding the functions; the package's last segment when null. + */ + data class Jextract(val library: String, val jextract: File? = null, val headerClass: String? = null) : + GeneratorRequest() +} + +/** Visibility of generated declarations. */ +enum class GeneratedVisibility { INTERNAL, PUBLIC } + +/** + * One run of the exporter, as its command line: the [request], where its output goes, and the cinterop + * command line that describes the library. [toArgs] and [parse] are the only places the flags are spelled. + */ +data class ExporterInvocation( + val request: GeneratorRequest, + val outputDirectory: File, + val cinteropArgs: List, +) { + fun toArgs(): List = buildList { + fun flag(flag: Flag, value: Any) { add(flag.spelling); add(value.toString()) } + flag(Flag.GENERATOR, Kind.of(request)) + flag(Flag.OUTPUT, outputDirectory.absolutePath) + when (request) { + GeneratorRequest.Dump -> {} + is GeneratorRequest.Jni -> { + flag(Flag.STUB_LIBRARY, request.stubLibrary) + flag(Flag.VISIBILITY, request.visibility) + } + is GeneratorRequest.Jextract -> { + flag(Flag.LIBRARY, request.library) + request.jextract?.let { flag(Flag.JEXTRACT, it.absolutePath) } + request.headerClass?.let { flag(Flag.HEADER_CLASS, it) } + } + } + add(SEPARATOR) + addAll(cinteropArgs) + } + + companion object { + /** Everything after it is cinterop's own command line. */ + private const val SEPARATOR = "--" + + fun parse(args: Array): ExporterInvocation { + val separator = args.indexOf(SEPARATOR) + require(separator >= 0) { "Expected '$SEPARATOR' before the cinterop command line: ${args.joinToString(" ")}" } + val own = args.copyOfRange(0, separator) + require(own.size % 2 == 0) { "Exporter options come in flag/value pairs: ${own.joinToString(" ")}" } + val values = own.toList().chunked(2).associate { (flag, value) -> + (Flag.entries.firstOrNull { it.spelling == flag } ?: error("Unknown exporter flag $flag")) to value + } + fun required(flag: Flag) = values[flag] ?: error("Missing ${flag.spelling}") + + val request = when (Kind.valueOf(required(Flag.GENERATOR))) { + Kind.DUMP -> GeneratorRequest.Dump + Kind.JNI -> GeneratorRequest.Jni( + stubLibrary = required(Flag.STUB_LIBRARY), + visibility = GeneratedVisibility.valueOf(required(Flag.VISIBILITY)), + ) + Kind.JEXTRACT -> GeneratorRequest.Jextract( + library = required(Flag.LIBRARY), + jextract = values[Flag.JEXTRACT]?.let(::File), + headerClass = values[Flag.HEADER_CLASS], + ) + } + return ExporterInvocation(request, File(required(Flag.OUTPUT)), args.copyOfRange(separator + 1, args.size).toList()) + } + } + + private enum class Kind { + DUMP, JNI, JEXTRACT; + + companion object { + fun of(request: GeneratorRequest) = when (request) { + GeneratorRequest.Dump -> DUMP + is GeneratorRequest.Jni -> JNI + is GeneratorRequest.Jextract -> JEXTRACT + } + } + } + + private enum class Flag(val spelling: String) { + GENERATOR("--generator"), + OUTPUT("--output"), + STUB_LIBRARY("--stub-library"), + VISIBILITY("--visibility"), + LIBRARY("--library"), + JEXTRACT("--jextract"), + HEADER_CLASS("--header-class"), + } +} diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt index be1c01c..7ae0666 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.index.exporter +import io.github.lemcoder.index.GeneratorRequest import org.jetbrains.kotlin.native.interop.indexer.NativeIndex import java.io.File @@ -9,12 +10,16 @@ import java.io.File * Runs in the exporter's process, next to the index, so it reads upstream's model directly: nothing about * the index crosses into the Gradle daemon, only what the generator writes. */ -interface BindingGenerator { - /** How the exporter's command line and the Gradle tasks name this generator. */ - val name: String +interface BindingGenerator { + /** Writes the output [request] asks for into [outputDirectory], which starts out empty. */ + fun generate(request: R, context: GenerationContext, outputDirectory: File) +} - /** Writes this generator's output into [outputDirectory], which starts out empty. */ - fun generate(context: GenerationContext, outputDirectory: File) +/** Runs the generator [request] is for. A new request type does not compile until it is handled here. */ +fun generate(request: GeneratorRequest, context: GenerationContext, outputDirectory: File) = when (request) { + GeneratorRequest.Dump -> DumpGenerator.generate(GeneratorRequest.Dump, context, outputDirectory) + is GeneratorRequest.Jni -> JniGenerator.generate(request, context, outputDirectory) + is GeneratorRequest.Jextract -> JextractGenerator.generate(request, context, outputDirectory) } /** @@ -28,8 +33,6 @@ class GenerationContext( val packageName: String, /** The `.def`'s lines after `---`: C code every translation unit of the library sees. */ val preambleLines: List, - /** `--option key=value` pairs from the exporter's command line, for the generator to interpret. */ - val options: Map, indexLibrary: () -> IndexedLibrary, ) { private val indexed by lazy(indexLibrary) @@ -49,6 +52,3 @@ class GenerationContext( /** cinterop's view of the library: its index, and what it was built from. */ class IndexedLibrary(val index: NativeIndex, val headers: List, val compilerArgs: List) -/** Every generator the exporter can run, by [BindingGenerator.name]. */ -val bindingGenerators: Map = - listOf(DumpGenerator, JniGenerator, JextractGenerator).associateBy { it.name } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt index bdc639d..0d78f29 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt @@ -1,6 +1,7 @@ package io.github.lemcoder.index.exporter import org.jetbrains.kotlin.native.interop.indexer.* +import io.github.lemcoder.index.GeneratorRequest import java.io.File /** @@ -9,10 +10,8 @@ import java.io.File * * Declarations referenced from a type are rendered by name, not expanded, so cycles terminate. */ -object DumpGenerator : BindingGenerator { - override val name = "dump" - - override fun generate(context: GenerationContext, outputDirectory: File) { +object DumpGenerator : BindingGenerator { + override fun generate(request: GeneratorRequest.Dump, context: GenerationContext, outputDirectory: File) { outputDirectory.resolve("index.txt").writeText(dumpNativeIndex(context.index)) } } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt index d5c105a..9975d7f 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.index.exporter +import io.github.lemcoder.index.GeneratorRequest import java.io.File import java.nio.file.Files @@ -14,17 +15,14 @@ import java.nio.file.Files * Only the user-level `-I`, `-D` and `-U` options reach jextract: it brings its own clang and builtin * headers, so the target flags cinterop parsed with do not apply. * - * Options: `library` (required) is the shared library the bindings load with `System.loadLibrary`, so - * from `java.library.path`; `jextract` is the jextract executable (default: `jextract` on the PATH); - * `headerClass` names the class holding the functions (default: the package's last segment). + * The bindings load the request's library with `System.loadLibrary`, so from `java.library.path`; see + * [GeneratorRequest.Jextract] for the rest. */ -object JextractGenerator : BindingGenerator { - override val name = "jextract" - - override fun generate(context: GenerationContext, outputDirectory: File) { - val library = requireNotNull(context.options["library"]) { "The jextract generator needs --option library=" } - val jextract = context.options["jextract"] ?: "jextract" - val headerClass = context.options["headerClass"] ?: context.packageName.substringAfterLast('.') +object JextractGenerator : BindingGenerator { + override fun generate(request: GeneratorRequest.Jextract, context: GenerationContext, outputDirectory: File) { + val library = request.library + val jextract = request.jextract?.absolutePath ?: "jextract" + val headerClass = request.headerClass ?: context.packageName.substringAfterLast('.') val scratch = Files.createTempDirectory("jextract").toFile() try { diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt index 87986be..63d98a5 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -1,5 +1,7 @@ package io.github.lemcoder.index.exporter +import io.github.lemcoder.index.GeneratedVisibility +import io.github.lemcoder.index.GeneratorRequest import org.jetbrains.kotlin.native.interop.indexer.* import java.io.File @@ -19,18 +21,13 @@ import java.io.File * Functions it cannot bind (variadic, `long double`, an anonymous struct by value, …) are listed with the * reason at the end of the Kotlin file instead. * - * Options: `stubLibrary` (required) is the library `System.loadLibrary` loads β€” the plugin owns that name; - * `visibility` is `internal` (default) or `public`. + * The request names the library `System.loadLibrary` loads β€” the plugin owns that name β€” and the + * bindings' visibility. */ -object JniGenerator : BindingGenerator { - override val name = "jni" - - override fun generate(context: GenerationContext, outputDirectory: File) { - val stubLibrary = requireNotNull(context.options["stubLibrary"]) { "The jni generator needs --option stubLibrary=" } - val visibility = when (val v = context.options["visibility"] ?: "internal") { - "internal", "public" -> v - else -> error("visibility must be internal or public, got '$v'") - } +object JniGenerator : BindingGenerator { + override fun generate(request: GeneratorRequest.Jni, context: GenerationContext, outputDirectory: File) { + val stubLibrary = request.stubLibrary + val visibility = request.visibility val packageName = context.packageName val facade = packageName.substringAfterLast('.').ifEmpty { "bindings" } @@ -59,12 +56,34 @@ object JniGenerator : BindingGenerator { private class Binding(val function: FunctionDecl, val params: List>, val result: Crossing) /** How one value crosses the JNI boundary. */ + /** The JVM primitives a C value can cross as, with how Kotlin and JNI spell them. */ + private enum class JvmPrimitive(val kotlinName: String, val jniName: String) { + BOOLEAN("Boolean", "jboolean"), + BYTE("Byte", "jbyte"), + SHORT("Short", "jshort"), + INT("Int", "jint"), + LONG("Long", "jlong"), + FLOAT("Float", "jfloat"), + DOUBLE("Double", "jdouble"), + ; + + companion object { + fun integer(size: Int): JvmPrimitive? = when (size) { + 1 -> BYTE; 2 -> SHORT; 4 -> INT; 8 -> LONG; else -> null + } + + fun floating(size: Int): JvmPrimitive? = when (size) { + 4 -> FLOAT; 8 -> DOUBLE; else -> null + } + } + } + private sealed class Crossing(val kotlinType: String, val jniType: String) { /** Passed as is; the C compiler converts between the JNI and C types of the same size. */ - class Primitive(kotlinType: String, jniType: String) : Crossing(kotlinType, jniType) + class Primitive(val primitive: JvmPrimitive) : Crossing(primitive.kotlinName, primitive.jniName) /** A pointer as an address; [cType] is how C spells the pointer, for the cast. */ - class Address(val cType: String) : Crossing("Long", "jlong") + class Address(val cType: String) : Crossing(JvmPrimitive.LONG.kotlinName, JvmPrimitive.LONG.jniName) /** `const char*` as a `String?`. */ object CString : Crossing("String?", "jstring") @@ -73,11 +92,12 @@ object JniGenerator : BindingGenerator { * A pointer to [element] values as a pinned primitive array; [cType] spells the pointer. A [readOnly] * one (`const` pointee) is not copied back. */ - class PrimitiveArray(val element: String, val cType: String, val readOnly: Boolean) : - Crossing("${element}Array?", "j${element.lowercase()}Array") + class PrimitiveArray(val element: JvmPrimitive, val cType: String, val readOnly: Boolean) : + Crossing("${element.kotlinName}Array?", "${element.jniName}Array") /** A struct by value as its bytes; [cType] spells the struct. */ - class StructBytes(val cType: String) : Crossing("ByteArray", "jbyteArray") + class StructBytes(val cType: String) : + Crossing("${JvmPrimitive.BYTE.kotlinName}Array", "${JvmPrimitive.BYTE.jniName}Array") object Void : Crossing("Unit", "void") } @@ -113,32 +133,23 @@ object JniGenerator : BindingGenerator { if (pointee == CharType && isConst) return Crossing.CString val cType = spelling(declared) val element = when (pointee) { - CharType -> "Byte" - is IntegerType -> integerKotlinType(pointee.size) - is FloatingType -> when (pointee.size) { 4 -> "Float"; 8 -> "Double"; else -> null } + CharType -> JvmPrimitive.BYTE + is IntegerType -> JvmPrimitive.integer(pointee.size) + is FloatingType -> JvmPrimitive.floating(pointee.size) else -> null } return if (element != null) Crossing.PrimitiveArray(element, cType, isConst) else Crossing.Address(cType) } private fun primitive(type: Type): Crossing.Primitive = when (type) { - CharType -> Crossing.Primitive("Byte", "jbyte") - is BoolType -> Crossing.Primitive("Boolean", "jboolean") - is IntegerType -> integerKotlinType(type.size)?.let { Crossing.Primitive(it, "j${it.lowercase()}") } - ?: throw Unsupported("${type.size * 8}-bit integer") - is FloatingType -> when (type.size) { - 4 -> Crossing.Primitive("Float", "jfloat") - 8 -> Crossing.Primitive("Double", "jdouble") - else -> throw Unsupported(type.spelling) - } + CharType -> Crossing.Primitive(JvmPrimitive.BYTE) + is BoolType -> Crossing.Primitive(JvmPrimitive.BOOLEAN) + is IntegerType -> Crossing.Primitive(JvmPrimitive.integer(type.size) ?: throw Unsupported("${type.size * 8}-bit integer")) + is FloatingType -> Crossing.Primitive(JvmPrimitive.floating(type.size) ?: throw Unsupported(type.spelling)) is EnumType -> primitive(resolved(type.def.baseType)) else -> throw Unsupported("type ${type::class.simpleName}") } - private fun integerKotlinType(size: Int): String? = when (size) { - 1 -> "Byte"; 2 -> "Short"; 4 -> "Int"; 8 -> "Long"; else -> null - } - /** [type] with typedefs looked through. */ private fun resolved(type: Type): Type = if (type is Typedef) resolved(type.def.aliased) else type @@ -162,7 +173,7 @@ object JniGenerator : BindingGenerator { packageName: String, facade: String, stubLibrary: String, - visibility: String, + visibility: GeneratedVisibility, bound: List, skipped: List>, ) = buildString { @@ -181,7 +192,7 @@ object JniGenerator : BindingGenerator { // JNI resolves the C symbol from the JVM method name; without @JvmName an internal function // could be mangled. appendLine("""@JvmName("${b.function.name}")""") - appendLine("$visibility external fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType}") + appendLine("${visibility.name.lowercase()} external fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType}") } if (skipped.isNotEmpty()) { appendLine() @@ -239,7 +250,7 @@ object JniGenerator : BindingGenerator { "s$i" } is Crossing.PrimitiveArray -> { - appendLine(" j${c.element.lowercase()}* a$i = p$i ? (*env)->Get${c.element}ArrayElements(env, p$i, 0) : 0;") + appendLine(" ${c.element.jniName}* a$i = p$i ? (*env)->Get${c.element.kotlinName}ArrayElements(env, p$i, 0) : 0;") "(${c.cType})a$i" } is Crossing.StructBytes -> { @@ -272,7 +283,7 @@ object JniGenerator : BindingGenerator { Crossing.CString -> appendLine(" if (p$i) (*env)->ReleaseStringUTFChars(env, p$i, s$i);") is Crossing.PrimitiveArray -> { val mode = if (c.readOnly) "JNI_ABORT" else "0" - appendLine(" if (p$i) (*env)->Release${c.element}ArrayElements(env, p$i, a$i, $mode);") + appendLine(" if (p$i) (*env)->Release${c.element.kotlinName}ArrayElements(env, p$i, a$i, $mode);") } is Crossing.StructBytes -> appendLine(" (*env)->ReleaseByteArrayElements(env, p$i, b$i, JNI_ABORT);") else -> {} diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt index 4eac024..cecf63c 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.index.exporter +import io.github.lemcoder.index.ExporterInvocation import kotlinx.cinterop.JvmCInteropCallbacks import org.jetbrains.kotlin.konan.util.DefFile import org.jetbrains.kotlin.native.interop.gen.ImportsImpl @@ -13,33 +14,16 @@ import org.jetbrains.kotlin.utils.NativeMemoryAllocator import java.io.File /** - * Entry point run in the Kotlin/Native distribution's JVM: hands cinterop's view of a library to a - * [BindingGenerator]. - * - * ``` - * --generator --output [--option =]... -- - * ``` + * Entry point run in the Kotlin/Native distribution's JVM: hands cinterop's view of a library to the + * generator an [ExporterInvocation] asks for. Its command line is that invocation's [ExporterInvocation.toArgs]. */ fun main(args: Array) { - val separator = args.indexOf("--") - require(separator >= 0) { "Expected '--' before the cinterop command line: ${args.joinToString(" ")}" } - val options = parseOptions(args.copyOfRange(0, separator)) - val cinteropArgs = args.copyOfRange(separator + 1, args.size) - - fun single(name: String) = options[name]?.singleOrNull() ?: error("Expected exactly one --$name") - val generatorName = single("generator") - val generator = bindingGenerators[generatorName] - ?: error("Unknown generator '$generatorName'. Known: ${bindingGenerators.keys.sorted().joinToString()}") - val output = File(single("output")) - val generatorOptions = options["option"].orEmpty().associate { - require('=' in it) { "Expected --option key=value, got '$it'" } - it.substringBefore('=') to it.substringAfter('=') - } - - val context = context(cinteropArgs, generatorOptions) + val invocation = ExporterInvocation.parse(args) + val context = context(invocation.cinteropArgs.toTypedArray()) + val output = invocation.outputDirectory output.deleteRecursively() output.mkdirs() - generator.generate(context, output) + generate(invocation.request, context, output) } /** @@ -51,7 +35,7 @@ fun main(args: Array) { * that jar as a friend module (see buildSrc's native-index plugin), so a signature change in a Kotlin * release fails compilation here, at the `kotlinNativeVersion` bump. */ -private fun context(cinteropArgs: Array, generatorOptions: Map): GenerationContext { +private fun context(cinteropArgs: Array): GenerationContext { val arguments = CInteropArguments() arguments.argParser.parse(cinteropArgs) require(arguments.library.isEmpty()) { "Dependency klibs (-library) are not supported by the index exporter yet." } @@ -73,7 +57,6 @@ private fun context(cinteropArgs: Array, generatorOptions: Map, generatorOptions: Map): Map> { - require(args.size % 2 == 0) { "Options come in --name value pairs: ${args.joinToString(" ")}" } - return args.toList().chunked(2).groupBy({ (name, _) -> name.removePrefix("--") }, { (_, value) -> value }) -} diff --git a/src/main/kotlin/io/github/lemcoder/JniToolchain.kt b/src/main/kotlin/io/github/lemcoder/JniToolchain.kt index f8da19d..7748248 100644 --- a/src/main/kotlin/io/github/lemcoder/JniToolchain.kt +++ b/src/main/kotlin/io/github/lemcoder/JniToolchain.kt @@ -1,6 +1,6 @@ package io.github.lemcoder -import io.github.lemcoder.jvm.JvmInteropSupport +import io.github.lemcoder.toolchain.NativeToolchain /** * A JDK home that ships `include/jni.h`, for a build that compiles the generated JNI stub itself. @@ -12,4 +12,4 @@ import io.github.lemcoder.jvm.JvmInteropSupport * * @throws IllegalStateException when no installed JDK ships the header. */ -fun jniHome(): String = JvmInteropSupport.detectJniHome().absolutePath +fun jniHome(): String = NativeToolchain.detectJniHome().absolutePath diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 6f18574..623d152 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -1,22 +1,11 @@ package io.github.lemcoder -import io.github.lemcoder.jvm.JvmInteropSupport -import io.github.lemcoder.interop.JvmInteropRegistry +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.toolchain.NativeToolchain +import io.github.lemcoder.wiring.registerBindings +import io.github.lemcoder.wiring.registerLibraries import org.gradle.api.Plugin import org.gradle.api.Project -import java.io.File - -/** - * Default clang arguments for [RunKonanClangTask]; `konanConfig.additionalCompilerArgs` is appended. - * - * No `-std=` here: sources are compiled in one invocation, so a C standard would also reach any `.cpp` - * in the source dir, which clang rejects outright ("invalid argument '-std=c99' not allowed with - * 'C++'"). Builds that need a specific standard set it via `additionalCompilerArgs`. - * - * `-fPIC` because the archive is routinely linked into a shared library β€” without it an x86_64 - * Android build fails with "relocation R_X86_64_PC32 cannot be used against symbol …". - */ -private val DEFAULT_COMPILER_ARGS = listOf("-fno-sanitize=undefined", "-fPIC") /** * The `clean` task as a lazy collection β€” empty when the base plugin isn't applied, so it is safe to @@ -26,53 +15,15 @@ internal fun Project.cleanTask() = tasks.matching { it.name == "clean" } abstract class KonanPlugin : Plugin { override fun apply(project: Project) { - // konanConfig owns compilation only, mirroring Kotlin/Native: C/C++ -> static .a per target. - val extension = project.extensions.create("konanConfig", KonanPluginExtension::class.java) - - project.applyConventions(extension) - project.registerRunKonanClang(extension) - // Holds the per-compilation `jvmInterops` containers; see JvmInterops.kt for the DSL. - project.extensions.create("jvmInteropRegistry", JvmInteropRegistry::class.java, project) - } -} - -/** Conventions for everything that can be inferred, so a build declares only what is project-specific. */ -private fun Project.applyConventions(ext: KonanPluginExtension) { - ext.sourceDir.convention("native") - ext.headerDir.convention(ext.sourceDir) - ext.libName.convention(name) - ext.outputDir.convention("build/native") - // Auto-detected: $KONAN_HOME, else the newest ~/.konan/kotlin-native-prebuilt-*. Absent rather - // than failing when there is none: the convention is queried whenever the configuration cache is - // stored, so throwing here breaks configuration of builds that never touch a konan task. The - // tasks that need it fail at execution instead, and say what to do. - ext.konanPath.convention(providers.provider { JvmInteropSupport.findKonanHome()?.absolutePath }) -} - -private fun Project.registerRunKonanClang(ext: KonanPluginExtension) { - tasks.register("runKonanClang", RunKonanClangTask::class.java) { - group = "interop" - description = "Cross-compile the C/C++ sources to a static library per target." - - // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives - // under build/tmp β€” so `gradle clean assembleDebug` can delete it mid-compile. - mustRunAfter(cleanTask()) - - // All wiring is lazy: the task can be registered even when konanConfig is left empty (e.g. a - // project that only consumes the JNI leg), and only fails if it is actually asked to run. - targets.set(ext.targets) - outputDir.set(ext.outputDir.map { layout.projectDirectory.dir(it) }) - sourceFiles.from(ext.sourceDir.map { layout.projectDirectory.dir(it).asFileTree }) - includeDirs.from(ext.headerDir.map { layout.projectDirectory.dir(it) }) - libName.set(ext.libName) - - arguments.addAll(DEFAULT_COMPILER_ARGS) - arguments.addAll(ext.additionalCompilerArgs) - - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val scriptPath = if (isWindows) "bin/run_konan.bat" else "bin/run_konan" - runKonan.fileProvider(ext.konanPath.map { File(it).resolve(scriptPath) }) - - onlyIf { targets.get().isNotEmpty() } + val konan = project.extensions.create("konan", KonanExtension::class.java) + // Auto-detected: $KONAN_HOME, else the newest ~/.konan/kotlin-native-prebuilt-*. Absent rather + // than failing when there is none: the convention is queried whenever the configuration cache is + // stored, so throwing here breaks configuration of builds that never touch a konan task. The + // tasks that need it fail at execution instead, and say what to do. + konan.konanHome.convention( + project.layout.dir(project.providers.provider { NativeToolchain.findKonanHome() }) + ) + project.registerLibraries(konan) + project.registerBindings(konan) } } diff --git a/src/main/kotlin/io/github/lemcoder/KonanPluginExtension.kt b/src/main/kotlin/io/github/lemcoder/KonanPluginExtension.kt deleted file mode 100644 index 45c52e5..0000000 --- a/src/main/kotlin/io/github/lemcoder/KonanPluginExtension.kt +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.lemcoder - -import org.jetbrains.kotlin.konan.target.KonanTarget - -import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import javax.inject.Inject - -/** - * Configuration for the `konanConfig { }` block: cross-compiles `sourceDir`'s C/C++ to a static - * `lib.a` per target using Kotlin/Native's `run_konan clang`. - * - * Everything except [libName] and [targets] has a convention, so a minimal build is: - * - * ```kotlin - * konanConfig { - * targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) - * libName.set("mymath") - * } - * ``` - * - * JNI bindings are declared separately, with `jvmInterops` on a Kotlin compilation; all of its - * inputs default to the matching value here, so it usually needs nothing but a package name. - */ -abstract class KonanPluginExtension @Inject constructor(objects: ObjectFactory) { - - /** Targets to cross-compile for. Required. */ - abstract val targets: ListProperty - - /** Directory holding the C/C++ sources, relative to the project. Defaults to `native`. */ - abstract val sourceDir: Property - - /** Include root passed as `-I`, relative to the project. Defaults to [sourceDir]. */ - abstract val headerDir: Property - - /** Static library base name, without `lib` prefix or `.a` suffix. Defaults to the project name. */ - abstract val libName: Property - - /** Root for the per-target `.a`s (`//lib.a`). Defaults to `build/native`. */ - abstract val outputDir: Property - - /** - * Root of the Kotlin/Native distribution. Defaults to `$KONAN_HOME`, else the newest - * `~/.konan/kotlin-native-prebuilt-*`. Set this only to override the auto-detected one. - */ - abstract val konanPath: Property - - /** - * Extra clang arguments, appended to the plugin's default (`-fno-sanitize=undefined`). A language - * standard belongs here rather than in the defaults: all sources compile in one invocation, so - * `-std=c99` would break a source dir that also holds C++. - */ - abstract val additionalCompilerArgs: ListProperty - - /** Convenience for `targets.set(listOf(...))`. */ - fun targets(vararg targets: KonanTarget) = this.targets.set(targets.toList()) - - /** Convenience for `targets.set(...)` from Kotlin/Native target names, e.g. `"android_arm64"`. */ - fun targets(vararg konanNames: String) = targets.set(konanNames.map(::konanTargetOf)) - - /** All four Android ABIs. */ - fun androidTargets() = targets.set(androidKonanTargets()) -} diff --git a/src/main/kotlin/io/github/lemcoder/KonanTargets.kt b/src/main/kotlin/io/github/lemcoder/KonanTargets.kt index 4f76246..9be3d0d 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanTargets.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanTargets.kt @@ -10,13 +10,6 @@ import org.jetbrains.kotlin.konan.target.KonanTarget * Kotlin version rather than with a plugin release. */ -/** Resolves a Kotlin/Native target name, e.g. `"android_arm64"`. */ -fun konanTargetOf(name: String): KonanTarget = KonanTarget.predefinedTargets[name] - ?: error( - "Unknown Kotlin/Native target '$name'. Known: " + - KonanTarget.predefinedTargets.keys.sorted().joinToString() - ) - /** The build host, as a target. */ fun hostKonanTarget(): KonanTarget = HostManager.host @@ -27,10 +20,10 @@ fun androidKonanTargets(): List = internal val KonanTarget.isAndroid: Boolean get() = family == Family.ANDROID /** Task-name segment, e.g. `Android_arm64` in `linkJvmInteropMymathAndroid_arm64`. */ -val KonanTarget.taskSuffix: String get() = name.replaceFirstChar { it.uppercase() } +internal val KonanTarget.taskSuffix: String get() = name.replaceFirstChar { it.uppercase() } /** `jniLibs/` subdirectory: Android ABI names, otherwise the target name. */ -val KonanTarget.abiDir: String +internal val KonanTarget.abiDir: String get() = when (name) { "android_arm64" -> "arm64-v8a" "android_arm32" -> "armeabi-v7a" diff --git a/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt similarity index 69% rename from src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt rename to src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt index 06c568d..de2d41d 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.interop +package io.github.lemcoder.dsl import org.gradle.api.Action import org.gradle.api.Named @@ -11,34 +11,18 @@ import org.gradle.api.provider.Property import javax.inject.Inject /** - * Declares that another build system compiles and links the generated JNI stub, the way AGP's - * `externalNativeBuild` declares that CMake owns a module's native code. - * - * The plugin then stops after generating, and drives that build instead of linking anything itself: - * it passes the stub's location and the library name the bindings expect, points `JAVA_HOME` at a JDK - * that ships `jni.h`, and orders generation before configuration before build. + * An external CMake build that compiles the generated JNI stub, the way AGP's `externalNativeBuild` + * declares that CMake owns a module's native code. The plugin then stops after generating and drives + * that build instead of linking anything itself: it passes the stub's location and the library name the + * bindings expect (`KONAN_JNI_STUB_DIR`, `KONAN_JNI_LIB_NAME`), points `JAVA_HOME` at a JDK that ships + * `jni.h`, and orders generation before configuration before build. * * This is the right shape whenever the native library is not the plugin's to build β€” a vendored CMake * project, say β€” because linking an archive from a foreign toolchain with konan's linker is what * produces mismatched C++ runtimes and missing compiler-rt. - */ -abstract class ExternalNativeBuildSettings @Inject constructor(objects: ObjectFactory) { - - /** CMake configuration; declaring it is what switches the interop to external-build mode. */ - val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) - - internal var isConfigured: Boolean = false - - /** Configures the CMake build that compiles the generated stub. */ - fun cmake(action: Action) { - isConfigured = true - action.execute(cmake) - } -} - -/** - * How to invoke CMake. Either name a [preset] or let the plugin configure [path] into [buildDirectory] - * directly; everything else has a default. + * + * Either name a [preset] or let the plugin configure [path] into [buildDirectory] directly; everything + * else has a default. * * Declaring [abi]s builds once per ABI instead of once for the host, which is what Android needs: the * libraries land in `jniLibs//` and are packaged into the AAR. @@ -80,8 +64,8 @@ abstract class CMakeSettings @Inject constructor(objects: ObjectFactory) { /** Extra arguments for the configure step, e.g. your own `-D` switches. */ abstract val arguments: ListProperty - /** The `cmake` binary. Defaults to `$CMAKE`, else a scan of the usual locations, else `cmake`. */ - abstract val executable: Property + /** The `cmake` binary. When absent, `$CMAKE`, else a scan of the usual locations, else `cmake`. */ + abstract val executable: RegularFileProperty } /** One ABI's build: everything not set here comes from the enclosing [CMakeSettings]. */ diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt new file mode 100644 index 0000000..20ad289 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -0,0 +1,252 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.androidKonanTargets +import io.github.lemcoder.library.LibraryProviderKind +import org.gradle.api.Action +import org.gradle.api.Named +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import io.github.lemcoder.library.NativeArtifacts +import org.jetbrains.kotlin.konan.target.KonanTarget +import javax.inject.Inject + +/** + * The `konan { }` block: the native libraries a project builds, and the bindings that reach them from + * Kotlin. + * + * ```kotlin + * konan { + * val mymath = libraries.create("mymath") { + * targets(KonanTarget.MACOS_ARM64, KonanTarget.ANDROID_ARM64) + * } + * bindings.create("mymath") { + * library(mymath) + * packageName.set("example") + * } + * } + * ``` + * + * A binding is consumed wherever the project's Kotlin targets need it: Kotlin/Native targets through + * KGP's own cinterop, JVM targets and Android through generated JNI bindings, and β€” when asked for with + * [BindingSpec.ffm] β€” the JVM through jextract's FFM bindings. + */ +abstract class KonanExtension @Inject constructor(objects: ObjectFactory) { + /** + * The Kotlin/Native distribution that compiles libraries and links JNI stubs. Defaults to + * `$KONAN_HOME`, else the newest `~/.konan/kotlin-native-prebuilt-*`, found when a task runs. + */ + abstract val konanHome: DirectoryProperty + + /** Native libraries this project builds from source, one archive per target. */ + val libraries: NamedDomainObjectContainer = + objects.domainObjectContainer(NativeLibrarySpec::class.java) + + /** C APIs to reach from Kotlin, each described by a cinterop `.def`. */ + val bindings: NamedDomainObjectContainer = + objects.domainObjectContainer(BindingSpec::class.java) + + fun libraries(action: Action>) = action.execute(libraries) + + fun bindings(action: Action>) = action.execute(bindings) +} + +/** + * A C/C++ library built into a static archive per target, `//lib.a`: by + * Kotlin/Native's clang from [sourceDirectory], or by the project's own build ([zig]). + */ +abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { + override fun getName(): String = name + + /** Targets to build for. Required. */ + abstract val targets: ListProperty + + /** The C/C++ sources. Defaults to `native/`. */ + abstract val sourceDirectory: DirectoryProperty + + /** Include roots beyond [sourceDirectory], which always is one. */ + abstract val includeDirs: ConfigurableFileCollection + + /** Extra clang options, appended to `-fno-sanitize=undefined -fPIC`. */ + abstract val compilerOptions: ListProperty + + /** Root of the per-target archives. Defaults to `build/konan/libraries/`. */ + abstract val outputDirectory: DirectoryProperty + + fun targets(vararg targets: KonanTarget) = this.targets.set(targets.toList()) + + /** All four Android ABIs. */ + fun androidTargets() = targets.set(androidKonanTargets()) + + /** The archive built for [target]. */ + fun archive(target: KonanTarget): Provider = outputDirectory.file(NativeArtifacts.archivePath(name, target)) + + /** The directory holding [target]'s archive. */ + fun archiveDirectory(target: KonanTarget): Provider = outputDirectory.dir(target.name) + + /** + * Builds the library with the project's own `build.zig` instead of compiling [sourceDirectory] with + * Kotlin/Native's clang. The archives land in the same place, so bindings do not change. + */ + val zig: ZigSettings = objects.newInstance(ZigSettings::class.java) + + fun zig(action: Action) { + selectedProviders += LibraryProviderKind.ZIG + action.execute(zig) + } + + /** The provider blocks this library's script called; at most one may be. */ + internal val selectedProviders = mutableSetOf() +} + +/** + * `zig build` for a library, once per target with `-Dtarget` set to the Zig triple that matches + * Kotlin/Native's sysroot. The `build.zig` must install a static library ([artifactName]). + */ +abstract class ZigSettings { + /** The directory holding `build.zig`. Defaults to the library's `sourceDirectory`. */ + abstract val projectDirectory: DirectoryProperty + + /** The `zig` executable. When absent, `zig` on the PATH. */ + abstract val executable: RegularFileProperty + + /** + * `-Doptimize`. Defaults to [ZigOptimize.RELEASE_FAST]: Zig's `DEBUG` and `RELEASE_SAFE` compile C with + * UBSan traps, whose runtime the Kotlin/Native and JNI links do not have. + */ + abstract val optimize: Property + + /** Further `zig build` arguments, e.g. your own `-D` options. */ + abstract val arguments: ListProperty + + /** The static library `build.zig` installs. Defaults to the library's name. */ + abstract val artifactName: Property +} + +/** Zig's optimisation modes, as `zig build -Doptimize` spells them. */ +enum class ZigOptimize(val zigName: String) { + DEBUG("Debug"), + RELEASE_SAFE("ReleaseSafe"), + RELEASE_FAST("ReleaseFast"), + RELEASE_SMALL("ReleaseSmall"), +} + +/** + * A C API, described by a cinterop `.def`, and how each kind of consumer reaches it. + */ +abstract class BindingSpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { + override fun getName(): String = name + + /** The `.def` to bind. Defaults to cinterop's own convention, `src/nativeInterop/cinterop/.def`. */ + abstract val defFile: RegularFileProperty + + /** + * The library the bindings call into, one of `konan.libraries`. The plugin then links its archive for + * every consumer β€” the `.def` needs no `staticLibraries` β€” and builds it first. + */ + abstract val library: Property + + /** Overrides the `.def`'s `package`. */ + abstract val packageName: Property + + /** Include roots beyond the `.def`'s own directory. */ + abstract val includeDirs: ConfigurableFileCollection + + /** Extra clang options for reading the headers. */ + abstract val compilerOptions: ListProperty + + fun library(library: NativeLibrarySpec) = this.library.set(library) + + /** JNI bindings for JVM targets and Android; generated whenever the project has such a target. */ + val jni: JniSpec = objects.newInstance(JniSpec::class.java) + + fun jni(action: Action) = action.execute(jni) + + /** + * FFM bindings, generated by jextract, for JDK 22+. Off unless [ffm] is called; in a `kotlin("jvm")` + * project they then replace the JNI bindings, which would define the same classes. + */ + val ffm: FfmSpec = objects.newInstance(FfmSpec::class.java) + + /** Turns on FFM bindings. */ + fun ffm(action: Action) { + ffm.isEnabled = true + action.execute(ffm) + } + + /** Turns on FFM bindings with every default. */ + fun ffm() = ffm(Action {}) +} + +/** How the JNI bindings are generated and linked. */ +abstract class JniSpec @Inject constructor(objects: ObjectFactory) { + /** + * Visibility of the generated bindings. They are an implementation detail β€” the idiomatic API is + * written on top of them β€” so they are `internal` by default. + */ + abstract val visibility: Property + + /** + * Targets to link the JNI library for. Defaults to the library's targets a JVM can load β€” the build + * host and Android β€” or the host when there is no library. + */ + abstract val targets: ListProperty + + /** An archive to link instead of the library's, e.g. one built elsewhere. */ + abstract val archive: RegularFileProperty + + /** A JDK home that ships `include/jni.h`. Defaults to a scan of installed JDKs. */ + abstract val jdkHome: DirectoryProperty + + /** Extra options for every target's link command. */ + abstract val linkerOptions: ListProperty + + /** Extra link options for one target, after [linkerOptions]. */ + abstract val targetLinkerOptions: MapProperty> + + /** Where the linked library ends up, whoever linked it: put it on `java.library.path`, or package it. */ + abstract val libraryDirectory: DirectoryProperty + + fun targets(vararg targets: KonanTarget) = this.targets.set(targets.toList()) + + /** Appends [options] to the link command of [target] only. */ + fun linkerOptionsFor(target: KonanTarget, vararg options: String) { + val existing = targetLinkerOptions.getOrElse(emptyMap())[target].orEmpty() + targetLinkerOptions.put(target, existing + options) + } + + /** An external CMake build that compiles the stub; declaring it replaces the plugin's own link. */ + val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) + + internal var usesCMake: Boolean = false + + fun cmake(action: Action) { + usesCMake = true + action.execute(cmake) + } +} + +/** How the FFM bindings are generated. The generated Java needs a JDK 22+ toolchain. */ +abstract class FfmSpec { + /** The jextract executable. Defaults to `jextract` on the PATH. */ + abstract val jextract: RegularFileProperty + + /** + * Where the shared library the bindings load ends up, for the build host: put it on + * `java.library.path`, together with `--enable-native-access`. + */ + abstract val libraryDirectory: DirectoryProperty + + internal var isEnabled: Boolean = false +} + +/** Whether generated bindings are visible outside the module that declares them. */ +enum class BindingVisibility { INTERNAL, PUBLIC } diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index a65a666..9722e2c 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -5,7 +5,6 @@ import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile @@ -22,7 +21,7 @@ import org.jetbrains.kotlin.konan.target.KonanTarget import javax.inject.Inject /** - * Indexes the headers a `.def` names with cinterop's own indexer and runs a [generator] over the + * Indexes the headers a `.def` names with cinterop's own indexer and runs the generator [request] asks for over the * resulting `NativeIndex`, in the exporter's process, writing into [outputDirectory]. See * [runIndexExporter] for where that process runs. */ @@ -33,7 +32,7 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) abstract val defFile: RegularFileProperty - /** Each directory becomes an `-I` for the indexer's clang. */ + /** Each directory becomes an `-I` for the indexer's clang, after the `.def`'s own directory. */ @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val includeDirs: ConfigurableFileCollection @@ -51,13 +50,9 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @get:Internal abstract val konanHome: DirectoryProperty - /** A generator the exporter knows by name: `dump`, `jni` or `jextract`. */ + /** What to generate, with that generator's settings, e.g. `GeneratorRequest.Dump`. */ @get:Input - abstract val generator: Property - - /** Passed to the generator as `key=value` options. */ - @get:Input @get:Optional - abstract val generatorOptions: MapProperty + abstract val request: Property /** Replaced on every run with the generator's output. */ @get:OutputDirectory @@ -66,16 +61,19 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @TaskAction fun generate() { exec.runIndexExporter( - generator = generator.get(), + request = request.get(), outputDirectory = outputDirectory.get().asFile, cinteropArgs = buildList { - add("-def"); add(defFile.get().asFile.absolutePath) + val def = defFile.get().asFile + add("-def"); add(def.absolutePath) + // The .def's own directory, as cinterop users expect; a path rather than an input, so a .def + // at the project root does not make the whole project one. + add("-compiler-option"); add("-I${def.parentFile.absolutePath}") add("-target"); add(target.get().name) includeDirs.files.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } addAll(extraCinteropArgs.getOrElse(emptyList())) }, - generatorOptions = generatorOptions.getOrElse(emptyMap()), konanHome = konanHome.orNull?.asFile, workDir = temporaryDir, ) diff --git a/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt b/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt index f264f8e..a700055 100644 --- a/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt +++ b/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt @@ -8,8 +8,8 @@ import java.nio.file.Files import java.nio.file.StandardCopyOption /** - * Runs the index exporter: cinterop's own indexer over [cinteropArgs], then [generator] over the index, - * writing into [outputDirectory]. + * Runs the index exporter: cinterop's own indexer over [cinteropArgs], then the generator [request] asks + * for, writing into [outputDirectory]. * * It runs in a JVM of its own whose classpath is only the distribution's compiler jar and the exporter. * Not in the daemon: a native library such as libclang can be loaded by one classloader per JVM, and KGP's @@ -20,10 +20,9 @@ import java.nio.file.StandardCopyOption * @param workDir where the exporter jar is extracted, e.g. the task's temporary directory. */ internal fun ExecOperations.runIndexExporter( - generator: String, + request: GeneratorRequest, outputDirectory: File, cinteropArgs: List, - generatorOptions: Map = emptyMap(), konanHome: File? = null, workDir: File, ) { @@ -41,24 +40,16 @@ internal fun ExecOperations.runIndexExporter( Files.copy(input, exporterJar.toPath(), StandardCopyOption.REPLACE_EXISTING) } - val arguments = buildList { - add("--generator"); add(generator) - add("--output"); add(outputDirectory.absolutePath) - generatorOptions.forEach { (key, value) -> add("--option"); add("$key=$value") } - // From here on, a cinterop command line. - add("--") - add("-Xkonan-home"); add(home.absolutePath) - addAll(cinteropArgs) - } + val invocation = ExporterInvocation(request, outputDirectory, listOf("-Xkonan-home", home.absolutePath) + cinteropArgs) val result = javaExecCapture { classpath(compilerJar, exporterJar) mainClass.set(EXPORTER_MAIN) - args(arguments) + args(invocation.toArgs()) systemProperty("konan.home", home.absolutePath) systemProperty("java.library.path", home.resolve("konan/nativelib").absolutePath) environment("LIBCLANG_DISABLE_CRASH_RECOVERY", "1") } - check(result is ExecCaptureResult.Success) { "Generating $generator bindings failed:\n${result.output}" } + check(result is ExecCaptureResult.Success) { "Generating bindings ($request) failed:\n${result.output}" } } private const val EXPORTER_JAR = "native-index-exporter.jar" diff --git a/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt b/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt index e0f684c..b5f1d53 100644 --- a/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt +++ b/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt @@ -7,7 +7,7 @@ import java.io.File * the exporter links against that version's compiler jar β€” its internal functions and the `NativeIndex` * model the generators read β€” so another version may not link. `kotlinNativeVersion` in the plugin build. */ -object PinnedKotlinNative { +internal object PinnedKotlinNative { val version: String by lazy { java.util.Properties().apply { PinnedKotlinNative::class.java.getResourceAsStream("kotlin-native.properties").use { load(checkNotNull(it)) } diff --git a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt deleted file mode 100644 index ba33200..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt +++ /dev/null @@ -1,396 +0,0 @@ -package io.github.lemcoder.interop - -import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension -import io.github.lemcoder.KonanPluginExtension -import io.github.lemcoder.abiDir -import io.github.lemcoder.hostKonanTarget -import io.github.lemcoder.isAndroid -import io.github.lemcoder.taskSuffix -import io.github.lemcoder.cleanTask -import io.github.lemcoder.jvm.GenerateJvmInteropTask -import io.github.lemcoder.jvm.JvmInteropSupport -import io.github.lemcoder.jvm.LinkJvmInteropTask -import org.gradle.api.Task -import org.jetbrains.kotlin.konan.target.KonanTarget -import org.gradle.api.Action -import org.gradle.api.NamedDomainObjectContainer -import org.gradle.api.NamedDomainObjectFactory -import org.gradle.api.Project -import org.gradle.api.model.ObjectFactory -import java.io.File -import javax.inject.Inject - -/** - * Holds one `jvmInterops` container per declaration site and turns each entry into tasks. - * - * A project-scoped registry rather than state on the compilation: `KotlinCompilation` is not - * `ExtensionAware`, so there is nowhere on it for a third-party plugin to hang a container. No - * Kotlin Gradle plugin type appears in a signature here either β€” Gradle decorates this class as soon - * as the plugin is applied, which would then fail in a project without KGP on the classpath. The - * caller passes [SourceWiring] instead. - */ -abstract class JvmInteropRegistry @Inject constructor( - private val project: Project, - private val objects: ObjectFactory, -) { - private val containers = mutableMapOf>() - - private companion object { - const val PROJECT_KEY = "" - - /** - * Cache entries the plugin sets on an external CMake build β€” its side of the contract, the - * way an AGP project reads ANDROID_ABI. A CMakeLists opts in by reading them. - */ - const val STUB_DIR_VARIABLE = "KONAN_JNI_STUB_DIR" - const val LIB_NAME_VARIABLE = "KONAN_JNI_LIB_NAME" - } - - /** How a declaration site takes the generated Kotlin; keeps KGP types out of this class. */ - internal fun interface SourceWiring { - fun addGeneratedSources(dir: org.gradle.api.provider.Provider) - } - - internal fun containerFor(key: String, wiring: SourceWiring): NamedDomainObjectContainer = - containers.getOrPut(key) { - objects.domainObjectContainer( - JvmInteropSettings::class.java, - NamedDomainObjectFactory { name -> objects.newInstance(JvmInteropSettings::class.java, name) }, - ).also { container -> - // An object expression, not a lambda: the kotlin-dsl plugin puts a `T.() -> Unit` - // overload of whenObjectAdded in scope, which makes the SAM form ambiguous. - container.whenObjectAdded(object : Action { - override fun execute(settings: JvmInteropSettings) = register(wiring, settings) - }) - } - } - - /** - * Container for a module with no Kotlin compilation to hang interops on β€” an AGP application or - * library, where the generated sources and `jniLibs` are wired into the variants instead. - */ - internal fun projectContainer(): NamedDomainObjectContainer = - containers.getOrPut(PROJECT_KEY) { - objects.domainObjectContainer( - JvmInteropSettings::class.java, - NamedDomainObjectFactory { name -> objects.newInstance(JvmInteropSettings::class.java, name) }, - ).also { container -> - container.whenObjectAdded(object : Action { - override fun execute(settings: JvmInteropSettings) = register(null, settings) - }) - } - } - - private fun register(wiring: SourceWiring?, settings: JvmInteropSettings) { - val konanConfig = project.extensions.getByType(KonanPluginExtension::class.java) - settings.targets.convention(defaultInteropTargets()) - settings.visibility.convention(BindingVisibility.INTERNAL) - // Same default location cinterop uses, so one def serves both legs with nothing declared. - settings.defFile.convention( - project.layout.projectDirectory.file("src/nativeInterop/cinterop/${settings.name}.def") - ) - - val suffix = settings.name.replaceFirstChar { it.uppercase() } - val generatedRoot = project.layout.buildDirectory.dir("generated/jvmInterop/${settings.name}") - // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, - // and a host library sitting in the root fails its merge with "… is not an ABI". - val jniLibsRoot = project.layout.buildDirectory.dir("jvmInterop/${settings.name}/jniLibs") - val hostLibRoot = project.layout.buildDirectory.dir("jvmInterop/${settings.name}/lib") - - // Falls back to the def's own `package`, so an interop that does not override it still has one. - val packageName = settings.packageName.orElse( - project.provider { - settings.defFile.orNull?.asFile?.takeIf { it.isFile }?.let { DefFile.parse(it).packageName } - } - ) - - val jniIncludeDirs = project.providers.provider { - settings.jniHome.orNull - ?.let { JvmInteropSupport.jniIncludeDirsOf(File(it)) ?: error("No include/jni.h under $it") } - ?: JvmInteropSupport.detectJniIncludeDirs() - } - - val generate = project.tasks.register( - "generateJvmInterop$suffix", - GenerateJvmInteropTask::class.java, - ) { - group = "interop" - description = "Generate JNI bindings for '${settings.name}' from its .def." - mustRunAfter(project.cleanTask()) - defFile.set(settings.defFile) - this.packageName.set(packageName) - includeDirs.from(settings.includeDirs) - hostTarget.set(hostKonanTarget()) - additionalCompilerArgs.set(settings.additionalCompilerArgs) - internalBindings.set(settings.visibility.map { it == BindingVisibility.INTERNAL }) - outputDirectory.set(generatedRoot) - } - - if (wiring != null) { - // Generated bindings are ordinary sources of whoever declared the interop; a srcDir - // carrying the task dependency is all the wiring needed. - wiring.addGeneratedSources(generate.flatMap { it.kotlinSourceDirectory }) - - // AGP looks for a `baselineProfiles` directory beside every source root, including this - // generated one, and reaches it by plain path β€” which carries no task dependency. Matched - // by name rather than behind plugins.withId: the KMP Android plugin does not apply - // com.android.base, and the matcher is inert when there is no AGP at all. - project.tasks.matching { it.name.contains("ArtProfile") } - .configureEach { dependsOn(generate) } - } else { - wireIntoAndroid(settings, generate.get().kotlinSourceDirectory) - } - - // One link task per target. Whether it does anything depends on the def naming a library; a - // def without staticLibraries produces bindings and a .c stub for another build to compile. - val linkAll = project.tasks.register("linkJvmInterop$suffix") { - group = "interop" - description = "Link the JNI library for '${settings.name}' for every configured target." - } - - // Eager: AGP's variant callbacks have already run by afterEvaluate, where the rest of the - // external build is registered. Wiring an empty directory costs nothing. - wireJniLibsIntoAndroid(jniLibsRoot, linkAll) - - // Deferred: the container fires whenObjectAdded before create()'s configure block runs, so - // `targets` and externalNativeBuild are not readable yet. Everything above only needs values - // Gradle resolves lazily. - project.afterEvaluate { - if (settings.externalNativeBuild.isConfigured) { - registerExternalBuild(settings, generate, linkAll) - return@afterEvaluate - } - - settings.targets.get().forEach { target -> - val link = project.tasks.register( - "linkJvmInterop$suffix${target.taskSuffix}", - LinkJvmInteropTask::class.java, - ) { - group = "interop" - description = "Link the ${settings.name} JNI library for ${target.name}." - dependsOn(generate) - // The archive may be the one konanConfig produces, and it will not exist before then. - dependsOn(project.tasks.matching { it.name == "runKonanClang" }) - mustRunAfter(project.cleanTask()) - konanPath.set(konanConfig.konanPath) - this.target.set(target) - this.stubBaseName.set(generate.flatMap { it.stubLibraryBaseName }) - stubCFile.set(generate.flatMap { it.stubSourceFile }) - includeDirs.from(settings.defFile.map { it.asFile.parentFile }, settings.includeDirs) - staticLibrary.set( - project.layout.file(project.provider { resolveLibrary(settings, konanConfig, target) }) - ) - if (target.isAndroid) { - ndkResourceDir.set( - konanConfig.konanPath.map { - JvmInteropSupport.ndkResourceDir(File(it))?.absolutePath ?: "" - } - ) - } else { - this.jniIncludeDirs.set(jniIncludeDirs.map { dirs -> dirs.map { it.absolutePath } }) - } - additionalLinkerArgs.set( - settings.additionalLinkerArgs.zip(settings.targetLinkerArgs) { common, perTarget -> - common + perTarget[target].orEmpty() - } - ) - outputDirectory.set( - if (target.isAndroid) jniLibsRoot.map { it.dir(target.abiDir) } - else hostLibRoot.map { it.dir(target.name) } - ) - - onlyIf { - resolveLibrary(settings, konanConfig, target) != null || run { - logger.lifecycle( - "jvmInterops '${settings.name}': no staticLibraries in the def and no library() set, " + - "so only the bindings and the .c stub were generated β€” link them from your own build." - ) - false - } - } - } - linkAll.configure { dependsOn(link) } - // Where a consumer looks for the library: the host's, since that is the one a JVM - // running this build could load. - if (target == hostKonanTarget()) { - settings.resolvedLibraryDirectory.set(hostLibRoot.map { it.dir(target.name) }) - } - } - } - } - - /** - * Drives the external build that compiles the stub, in place of the plugin's own link tasks. The - * plugin supplies what only it knows β€” where the stub is, the library name the bindings will - * load, and a JDK with jni.h β€” so the CMakeLists only reads two cache entries. - */ - private fun registerExternalBuild( - settings: JvmInteropSettings, - generate: org.gradle.api.tasks.TaskProvider, - linkAll: org.gradle.api.tasks.TaskProvider, - ) { - val cmake = settings.externalNativeBuild.cmake - val suffix = settings.name.replaceFirstChar { it.uppercase() } - val executable = cmake.executable.orElse(project.provider { CMakeSupport.detectExecutable() }) - val sourceDir = cmake.path.map { it.asFile.parentFile } - // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, - // and a host library sitting in the root fails its merge with "… is not an ABI". - val jniLibsRoot = project.layout.buildDirectory.dir("jvmInterop/${settings.name}/jniLibs") - val hostLibRoot = project.layout.buildDirectory.dir("jvmInterop/${settings.name}/lib") - - // No ABIs means one build for the host; with them, one per ABI, each into jniLibs//. - val abis = cmake.abis.toList() - val builds = if (abis.isEmpty()) listOf(null) else abis - - builds.forEach { abi -> - val abiSuffix = abi?.name.orEmpty() - .split('-', '_') - .joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } - val preset = abi?.preset?.orElse(cmake.preset) ?: cmake.preset - val outputDir = if (abi == null) hostLibRoot else jniLibsRoot.map { it.dir(abi.name) } - - // With a preset the binary directory is the preset's to choose, and `-B` cannot override - // it; the conventional layout is /build/, which stays overridable. - val buildDir = (abi?.buildDirectory ?: cmake.buildDirectory).orElse( - project.layout.dir( - preset.map { name -> sourceDir.get().resolve("build/$name") } - .orElse( - project.layout.buildDirectory - .dir("jvmInterop/${settings.name}/cmake${abiSuffix}") - .map { it.asFile } - ) - ) - ) - - val configure = project.tasks.register( - "cmakeConfigure$suffix$abiSuffix", - CMakeConfigureTask::class.java, - ) { - group = "interop" - description = "Configure the external CMake build for '${settings.name}'" + - (abi?.let { " (${it.name})" } ?: "") + "." - dependsOn(generate) - this.executable.set(executable) - this.preset.set(preset) - sourceDirectory.set(sourceDir.map { it.absolutePath }) - arguments.set(cmake.arguments.zip(abi?.arguments ?: cmake.arguments.map { emptyList() }) { a, b -> a + b }) - stubSourceDirectory.set(generate.flatMap { it.stubSourceDirectory }) - cacheEntries.put( - STUB_DIR_VARIABLE, - generate.flatMap { it.stubSourceDirectory }.map { it.asFile.absolutePath }, - ) - cacheEntries.put(LIB_NAME_VARIABLE, generate.flatMap { it.stubLibraryBaseName }) - // Land the library where the plugin says, so a consumer never guesses the build - // layout and Android gets the jniLibs// shape AGP packages. - cacheEntries.put( - "CMAKE_LIBRARY_OUTPUT_DIRECTORY", - outputDir.map { it.asFile.absolutePath }, - ) - javaHome.set(project.provider { JvmInteropSupport.detectJniHome().absolutePath }) - buildDirectory.set(buildDir) - } - - val build = project.tasks.register( - "cmakeBuild$suffix$abiSuffix", - CMakeBuildTask::class.java, - ) { - group = "interop" - description = "Build the '${settings.name}' JNI library" + - (abi?.let { " for ${it.name}" } ?: "") + "." - dependsOn(configure) - this.executable.set(executable) - targets.set(cmake.targets) - stubSourceDirectory.set(generate.flatMap { it.stubSourceDirectory }) - buildDirectory.set(buildDir) - cmakeCache.set(buildDir.map { it.file("CMakeCache.txt") }) - libraryDirectory.set(outputDir) - } - - // The umbrella link task stands for "make the JNI library exist", however it gets made. - linkAll.configure { dependsOn(build) } - } - - settings.resolvedLibraryDirectory.set( - cmake.libraryDirectory.orElse(if (abis.isEmpty()) hostLibRoot else jniLibsRoot) - ) - } - - /** - * Packages the per-ABI libraries into the AAR. The KMP Android plugin registers its own - * components extension rather than AGP's, and the directory has to exist at configuration time. - */ - private fun wireJniLibsIntoAndroid( - jniLibsRoot: org.gradle.api.provider.Provider, - linkAll: org.gradle.api.tasks.TaskProvider, - ) { - val dir = jniLibsRoot.get().asFile.apply { mkdirs() } - val relative = dir.relativeTo(project.projectDir).path - - // Behind withId so the AGP types are only resolved where AGP exists: this runs for every - // interop now, and a plain reference would be a NoClassDefFoundError in a plugin-only build. - project.plugins.withId("com.android.kotlin.multiplatform.library") { - project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java) - ?.onVariants { variant -> variant.sources.jniLibs?.addStaticSourceDirectory(relative) } - } - project.plugins.withId("com.android.base") { - project.extensions.findByType(AndroidComponentsExtension::class.java) - ?.onVariants { variant -> variant.sources.jniLibs?.addStaticSourceDirectory(relative) } - } - - // Nothing consumes the directory as a task output, so packaging needs the ordering spelled out. - project.tasks.matching { it.name.contains("JniLibFolders") || it.name.contains("MergeJniLib") } - .configureEach { dependsOn(linkAll) } - } - - /** - * AGP has no Kotlin compilation to attach to, so the generated sources go in through the variant - * API. Registered at configuration time β€” the variant API rejects callbacks added later β€” and the - * directory must exist by then. The per-ABI `jniLibs` are wired by [wireJniLibsIntoAndroid]; - * adding them here too fails the merge with "Duplicate resources". - */ - private fun wireIntoAndroid( - settings: JvmInteropSettings, - kotlinSources: org.gradle.api.provider.Provider, - ) { - project.plugins.withId("com.android.base") { - val kotlinDir = kotlinSources.get().asFile.apply { mkdirs() } - val kotlinRel = kotlinDir.relativeTo(project.projectDir).path - - project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { variant -> - variant.sources.kotlin?.addStaticSourceDirectory(kotlinRel) - } - - val suffix = settings.name.replaceFirstChar { it.uppercase() } - project.tasks.matching { it.name == "preBuild" }.configureEach { - dependsOn("generateJvmInterop$suffix", "linkJvmInterop$suffix") - } - project.tasks - .matching { (it.name.startsWith("compile") && it.name.contains("Kotlin")) || it.name == "compileJava" } - .configureEach { dependsOn("generateJvmInterop$suffix") } - } - } - - /** - * The archive to link: `library(...)` if set, else the def's `staticLibraries`, else whatever - * `konanConfig` compiled for this target. The last one keeps a def portable β€” naming the archive - * in it would hardcode a target directory, and the plugin already knows what it built. - */ - private fun resolveLibrary( - settings: JvmInteropSettings, - konanConfig: KonanPluginExtension, - target: KonanTarget, - ): File? { - settings.library.orNull?.asFile?.let { return it.takeIf { f -> f.isFile } } - - val def = settings.defFile.orNull?.asFile?.takeIf { it.isFile } - def?.let { DefFile.parse(it).resolveStaticLibraries(it.parentFile).firstOrNull() }?.let { return it } - - if (!konanConfig.targets.getOrElse(emptyList()).contains(target)) return null - val libName = konanConfig.libName.orNull ?: return null - val outputDir = konanConfig.outputDir.orNull ?: return null - return project.layout.projectDirectory - .file("$outputDir/${target.name}/lib$libName.a").asFile - .takeIf { it.isFile } - } -} diff --git a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropSettings.kt b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropSettings.kt deleted file mode 100644 index d44dcf3..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropSettings.kt +++ /dev/null @@ -1,117 +0,0 @@ -package io.github.lemcoder.interop - -import org.jetbrains.kotlin.konan.target.KonanTarget -import org.gradle.api.Action -import org.gradle.api.Named -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Property -import java.io.File -import javax.inject.Inject - -/** - * One JNI interop, declared the way a cinterop is: - * - * ```kotlin - * kotlin { - * jvm { - * compilations["main"].jvmInterops { - * create("koinference") { - * defFile(project.file("src/nativeInterop/koinference.def")) - * packageName.set("com.example.native") - * } - * } - * } - * } - * ``` - * - * The def decides how far the plugin goes. With `staticLibraries` (or [library]) it compiles the - * generated JNI stub and links a shared library per target; without, it stops after generating the - * Kotlin bindings and the `.c`, leaving the link to whoever owns the native build. - */ -abstract class JvmInteropSettings @Inject constructor( - private val name: String, - private val objects: ObjectFactory, -) : Named { - - override fun getName(): String = name - - /** - * The cinterop `.def` describing the headers to bind, shared with the native targets. Defaults to - * cinterop's own convention, `src/nativeInterop/cinterop/.def`. - */ - abstract val defFile: RegularFileProperty - - /** - * Visibility of the generated bridges. They are an implementation detail β€” the idiomatic API is - * written on top of them β€” so they are `internal` by default and stay out of the published API. - * Set [BindingVisibility.PUBLIC] when the wrapper lives in a different Gradle module. - */ - abstract val visibility: Property - - /** Kotlin package for the generated bindings. Overrides `package` in the def. */ - abstract val packageName: Property - - /** - * Targets to link the JNI library for. Defaults to the build host, which is the only one a JVM - * running this build could load; add Android ABIs to produce `jniLibs`. - */ - abstract val targets: ListProperty - - /** - * Archive to link into the JNI library, overriding the def's `staticLibraries`. Prefer a library - * this plugin built: linking one from another toolchain can fail on mismatched C++ runtimes. - */ - abstract val library: RegularFileProperty - - /** Extra include roots, on top of the def's own directory. */ - abstract val includeDirs: ConfigurableFileCollection - - /** A JDK home that ships `include/jni.h`. Defaults to a scan of installed JDKs. */ - abstract val jniHome: Property - - /** Extra `-compiler-option` values for the binding generator. */ - abstract val additionalCompilerArgs: ListProperty - - /** Extra arguments for every target's link command. */ - abstract val additionalLinkerArgs: ListProperty - - /** Extra link arguments for one target, appended after [additionalLinkerArgs]. */ - abstract val targetLinkerArgs: MapProperty> - - /** Sets [defFile]; mirrors cinterop's `defFile(project.file(...))`. */ - fun defFile(file: File) { - defFile.set(file) - } - - /** - * Declares that another build system compiles and links the stub β€” see [ExternalNativeBuildSettings]. - * Mutually exclusive with the plugin linking it: no link task is registered once this is declared. - */ - val externalNativeBuild: ExternalNativeBuildSettings = - objects.newInstance(ExternalNativeBuildSettings::class.java) - - /** Configures the external build that compiles the generated stub. */ - fun externalNativeBuild(action: Action) { - action.execute(externalNativeBuild) - } - - /** - * Where the linked JNI library ends up, whoever linked it. Use it to put the library on - * `java.library.path` for tests, or to package it. - */ - abstract val resolvedLibraryDirectory: DirectoryProperty - - /** Appends [args] to the link command of [target] only. */ - fun linkerArgsFor(target: KonanTarget, vararg args: String) { - val existing = targetLinkerArgs.getOrElse(emptyMap())[target].orEmpty() - targetLinkerArgs.put(target, existing + args) - } -} - -/** Whether the generated bridges are visible outside the module that declares the interop. */ -enum class BindingVisibility { INTERNAL, PUBLIC } diff --git a/src/main/kotlin/io/github/lemcoder/interop/JvmInterops.kt b/src/main/kotlin/io/github/lemcoder/interop/JvmInterops.kt deleted file mode 100644 index ea2218f..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/JvmInterops.kt +++ /dev/null @@ -1,62 +0,0 @@ -package io.github.lemcoder.interop - -import io.github.lemcoder.hostKonanTarget -import org.jetbrains.kotlin.konan.target.KonanTarget -import org.gradle.api.Action -import org.gradle.api.NamedDomainObjectContainer -import org.gradle.api.Project -import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation - -/** - * Declares JNI interops for this compilation, mirroring `cinterops` on a Kotlin/Native one: - * - * ```kotlin - * kotlin { - * jvm { - * compilations["main"].jvmInterops { - * create("koinference") { - * defFile(project.file("src/nativeInterop/koinference.def")) - * packageName.set("com.example.native") - * } - * } - * } - * } - * ``` - * - * `KotlinCompilation` is not `ExtensionAware`, so this is an extension function rather than a DSL - * block the Kotlin plugin owns β€” import it and it reads the same. Containers are held per compilation - * by [JvmInteropRegistry], which also registers the tasks and wires the generated sources in. - */ -fun KotlinCompilation<*>.jvmInterops(configure: Action>) { - configure.execute(jvmInteropsContainer()) -} - -/** - * The interops declared on this compilation, for reading back what the plugin resolved β€” where the - * linked library ended up, say. - * - * A function rather than a property named `jvmInterops`: with both, `compilations["main"] - * .jvmInterops { }` has two readings β€” the configuring function, or this property followed by - * Gradle's `invoke` on the container β€” and the IDE flags the call as a suspicious receiver. - */ -fun KotlinCompilation<*>.jvmInteropsContainer(): NamedDomainObjectContainer = - project.jvmInteropRegistry() - .containerFor("${target.name}/$name") { dir -> defaultSourceSet.kotlin.srcDir(dir) } - -/** - * Declares JNI interops for a module without Kotlin compilations β€” an AGP application or library. - * The generated bindings and per-ABI `jniLibs` are wired into the Android variants. - */ -fun Project.jvmInterops(configure: Action>) { - configure.execute(jvmInteropRegistry().projectContainer()) -} - -internal fun Project.jvmInteropRegistry(): JvmInteropRegistry = - extensions.findByType(JvmInteropRegistry::class.java) - ?: error( - "The Konan plugin is not applied to $path β€” apply id(\"io.github.lemcoder.konanplugin\") " + - "before declaring jvmInterops." - ) - -/** Default targets for an interop that does not name any: the host is the only JVM-loadable one. */ -internal fun defaultInteropTargets(): List = listOf(hostKonanTarget()) diff --git a/src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt b/src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt deleted file mode 100644 index acbd1f6..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/KgpCinteropAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.lemcoder.interop - -import io.github.lemcoder.KonanPluginExtension -import org.gradle.api.Project -import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget - -/** - * The KGP side of [kotlinNativeInterop]. Its own object so that KGP's types are referenced only from code - * that runs once the Kotlin Multiplatform plugin is applied. - */ -internal object KgpCinteropAdapter { - fun declare(project: Project, name: String, settings: KotlinNativeInteropSettings) { - val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) - val konanConfig = project.extensions.findByType(KonanPluginExtension::class.java) - - kotlin.targets.withType(KotlinNativeTarget::class.java).configureEach { - val target = this - val wanted = settings.targets.getOrElse(emptyList()) - if (wanted.isNotEmpty() && target.konanTarget !in wanted) return@configureEach - - target.compilations.getByName("main").cinterops.create(name) { - definitionFile.set(settings.defFile) - settings.packageName.orNull?.let { packageName = it } - includeDirs(settings.includeDirs) - compilerOpts(settings.compilerOptions.getOrElse(emptyList())) - - if (konanConfig != null) { - // Where runKonanClang puts this target's archive; cinterop ignores a missing directory. - val archives = project.layout.projectDirectory.dir(konanConfig.outputDir.get()).dir(target.konanTarget.name) - extraOpts("-libraryPath", archives.asFile.absolutePath) - project.tasks.named(interopProcessingTaskName).configure { - dependsOn(project.provider { - if (target.konanTarget in konanConfig.targets.getOrElse(emptyList())) listOf("runKonanClang") else emptyList() - }) - } - } - } - } - } -} diff --git a/src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt b/src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt deleted file mode 100644 index af39ca4..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/KotlinNativeInterop.kt +++ /dev/null @@ -1,52 +0,0 @@ -package io.github.lemcoder.interop - -import org.gradle.api.Action -import org.gradle.api.Project -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.jetbrains.kotlin.konan.target.KonanTarget - -/** - * Binds a cinterop `.def` for Kotlin/Native through KGP's own cinterop: an adapter that declares, on the - * main compilation of every Kotlin/Native target, the `cinterops {}` entry a build would otherwise write - * by hand. KGP then produces the interop klib with the project's own Kotlin version, per target, and wires - * it into compilation, commonization, the IDE and publication. - * - * Where `konanConfig` builds the library for a target, cinterop sees its archive: `-libraryPath` points at - * `/` and the cinterop task runs after `runKonanClang`, so a `.def` naming - * `staticLibraries = lib.a` embeds it. - * - * ```kotlin - * kotlinNativeInterop("mymath") { - * defFile.set(file("src/nativeInterop/cinterop/mymath.def")) - * includeDirs.from("native") - * } - * ``` - * - * Does nothing in a project without the Kotlin Multiplatform plugin. - */ -fun Project.kotlinNativeInterop(name: String, configure: Action) { - // Configured before KGP sees it: parts of KGP's cinterop settings are read eagerly. - val settings = objects.newInstance(KotlinNativeInteropSettings::class.java) - configure.execute(settings) - // Behind withId so KGP's types are only resolved where KGP exists. - plugins.withId("org.jetbrains.kotlin.multiplatform") { KgpCinteropAdapter.declare(this@kotlinNativeInterop, name, settings) } -} - -/** What [kotlinNativeInterop] binds: the same inputs the plugin's other binding generators take. */ -abstract class KotlinNativeInteropSettings { - abstract val defFile: RegularFileProperty - - /** Overrides the `.def`'s `package`. */ - abstract val packageName: Property - - /** Each directory becomes an `-I` for cinterop's clang. */ - abstract val includeDirs: ConfigurableFileCollection - - abstract val compilerOptions: ListProperty - - /** The Kotlin/Native targets to bind for; every one in the project when empty. */ - abstract val targets: ListProperty -} diff --git a/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt b/src/main/kotlin/io/github/lemcoder/jni/CMakeTasks.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt rename to src/main/kotlin/io/github/lemcoder/jni/CMakeTasks.kt index c8c2ba1..71d3dc0 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/CMakeTasks.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.interop +package io.github.lemcoder.jni import io.github.lemcoder.util.execCapture import org.gradle.api.DefaultTask @@ -23,7 +23,7 @@ import javax.inject.Inject /** Configures the external CMake build that compiles the generated JNI stub. */ @DisableCachingByDefault(because = "CMake configure writes machine-specific absolute paths") -abstract class CMakeConfigureTask @Inject constructor( +internal abstract class CMakeConfigureTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @get:Input abstract val executable: Property @@ -71,7 +71,7 @@ abstract class CMakeConfigureTask @Inject constructor( /** Runs the external CMake build for the generated JNI stub. */ @DisableCachingByDefault(because = "External build; CMake does its own incremental tracking") -abstract class CMakeBuildTask @Inject constructor( +internal abstract class CMakeBuildTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @get:Input abstract val executable: Property diff --git a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt similarity index 83% rename from src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt rename to src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt index d461baf..77388cf 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt @@ -1,6 +1,9 @@ -package io.github.lemcoder.jvm +package io.github.lemcoder.jni +import io.github.lemcoder.index.GeneratedVisibility +import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.index.runIndexExporter +import io.github.lemcoder.toolchain.NativeToolchain import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.Directory @@ -33,7 +36,7 @@ import javax.inject.Inject * overridden here, since the JVM bindings live in their own package. */ @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") -abstract class GenerateJvmInteropTask @Inject constructor( +internal abstract class GenerateJniBindingsTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) @@ -47,10 +50,10 @@ abstract class GenerateJvmInteropTask @Inject constructor( /** The headers are indexed once, for the host; the bridges are the same on every target. */ @get:Input abstract val hostTarget: Property - @get:Input @get:Optional abstract val additionalCompilerArgs: ListProperty + @get:Input @get:Optional abstract val compilerOptions: ListProperty - /** Emit the bindings as `internal`, keeping them out of the module's published API. */ - @get:Input abstract val internalBindings: Property + /** `INTERNAL` keeps the bindings out of the module's published API. */ + @get:Input abstract val visibility: Property @get:OutputDirectory abstract val outputDirectory: DirectoryProperty /** @@ -63,14 +66,14 @@ abstract class GenerateJvmInteropTask @Inject constructor( /** The generated stub itself, named after the binding package. */ @get:Internal val stubSourceFile: Provider - get() = outputDirectory.file(packageName.map { "$C_DIR/${JvmInteropSupport.stubBaseName(it)}.c" }) + get() = outputDirectory.file(packageName.map { "$C_DIR/${NativeToolchain.stubBaseName(it)}.c" }) /** * Base name of the JNI library the generated bindings will `System.loadLibrary`, derived from the * binding package. A build that links the stub itself has to produce exactly this name. */ @get:Internal - val stubLibraryBaseName: Provider get() = packageName.map { JvmInteropSupport.stubBaseName(it) } + val stubLibraryBaseName: Provider get() = packageName.map { NativeToolchain.stubBaseName(it) } /** Directory holding the generated Kotlin bindings. */ @get:Internal @@ -81,19 +84,15 @@ abstract class GenerateJvmInteropTask @Inject constructor( val def = defFile.get().asFile val includes = listOf(def.parentFile) + includeDirs.files exec.runIndexExporter( - generator = "jni", + request = GeneratorRequest.Jni(stubLibrary = stubLibraryBaseName.get(), visibility = visibility.get()), outputDirectory = outputDirectory.get().asFile, cinteropArgs = buildList { add("-def"); add(def.absolutePath) add("-pkg"); add(packageName.get()) add("-target"); add(hostTarget.get().name) includes.filter { it.exists() }.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } - additionalCompilerArgs.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } + compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } }, - generatorOptions = mapOf( - "stubLibrary" to stubLibraryBaseName.get(), - "visibility" to if (internalBindings.getOrElse(true)) "internal" else "public", - ), workDir = temporaryDir, ) } diff --git a/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt rename to src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt index 38cdc5b..33842e0 100644 --- a/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt +++ b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder +package io.github.lemcoder.library import org.jetbrains.kotlin.konan.target.KonanTarget @@ -32,7 +32,7 @@ import javax.inject.Inject * utility. */ @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native toolchain, which is not a tracked input") -abstract class RunKonanClangTask @Inject constructor( +internal abstract class CompileNativeLibraryTask @Inject constructor( private val exec: ExecOperations, private val fs: FileSystemOperations, private val objects: ObjectFactory, @@ -73,7 +73,7 @@ abstract class RunKonanClangTask @Inject constructor( abstract val libName: Property private val libFileName: Provider - get() = libName.map { "lib${it}.a" } + get() = libName.map { NativeArtifacts.archiveFileName(it) } @TaskAction fun compileAndLink() { diff --git a/src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt b/src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt new file mode 100644 index 0000000..8b7c365 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt @@ -0,0 +1,48 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.NativeLibrarySpec +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** + * The default provider: the sources in [NativeLibrarySpec.sourceDirectory], compiled with Kotlin/Native's + * clang and archived with its llvm-ar. + */ +internal object KonanClangProvider : LibraryProvider { + override val kind = LibraryProviderKind.KONAN_CLANG + + /** + * No `-std=` here: sources are compiled in one invocation, so a C standard would also reach any `.cpp` + * in the source dir, which clang rejects outright ("invalid argument '-std=c99' not allowed with + * 'C++'"). Builds that need a specific standard set it via `compilerOptions`. + * + * `-fPIC` because the archive is routinely linked into a shared library β€” without it an x86_64 + * Android build fails with "relocation R_X86_64_PC32 cannot be used against symbol …". + */ + private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC") + + override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = + project.tasks.register("konanClang${library.name.replaceFirstChar { it.uppercase() }}", CompileNativeLibraryTask::class.java) { + group = "konan" + description = "Compile the '${library.name}' C/C++ sources with Kotlin/Native's clang." + // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives + // under build/tmp β€” so `gradle clean assembleDebug` can delete it mid-compile. + mustRunAfter(project.cleanTask()) + + targets.set(library.targets) + outputDir.set(library.outputDirectory) + sourceFiles.from(library.sourceDirectory.map { it.asFileTree }) + includeDirs.from(library.sourceDirectory, library.includeDirs) + libName.set(library.name) + arguments.addAll(DEFAULT_COMPILER_OPTIONS) + arguments.addAll(library.compilerOptions) + + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val script = if (isWindows) "bin/run_konan.bat" else "bin/run_konan" + runKonan.set(konan.konanHome.file(script)) + + onlyIf { targets.get().isNotEmpty() } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt new file mode 100644 index 0000000..b60ef17 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt @@ -0,0 +1,44 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.NativeLibrarySpec +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** + * How a library's archives come to exist. An implementation owns its DSL block, its conventions and its + * tasks, and must leave one archive per target at [NativeArtifacts.archivePath] under the library's + * output directory β€” the only thing consumers know about it. + */ +internal interface LibraryProvider { + /** Which provider this is; a library selects it with that kind's DSL block. */ + val kind: LibraryProviderKind + + /** Sets this provider's conventions on [library]; called for every library, before its configure block. */ + fun conventions(project: Project, library: NativeLibrarySpec) {} + + /** Registers the work that builds [library]'s archives. Called once the build script has run. */ + fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> +} + +/** Every provider the plugin knows. A new build system is one more implementation here. */ +internal val libraryProviders: List = listOf(KonanClangProvider, ZigProvider) + +/** The library providers there are. Each has one implementation in [libraryProviders]. */ +internal enum class LibraryProviderKind { + /** The default: the library's sources, compiled with Kotlin/Native's clang. */ + KONAN_CLANG, + + /** `zig { }`: the project's own `build.zig`. */ + ZIG, +} + +/** The provider [library] selected: at most one block, else [LibraryProviderKind.KONAN_CLANG]. */ +internal fun providerFor(library: NativeLibrarySpec): LibraryProvider { + val selected = library.selectedProviders + check(selected.size <= 1) { + "konan.libraries.${library.name} selects ${selected.joinToString(" and ")}; a library is built one way." + } + val kind = selected.singleOrNull() ?: LibraryProviderKind.KONAN_CLANG + return libraryProviders.single { it.kind == kind } +} diff --git a/src/main/kotlin/io/github/lemcoder/library/NativeArtifacts.kt b/src/main/kotlin/io/github/lemcoder/library/NativeArtifacts.kt new file mode 100644 index 0000000..b5850c0 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/NativeArtifacts.kt @@ -0,0 +1,44 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.dsl.NativeLibrarySpec +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.konan.target.KonanTarget + +/** + * What a library provider guarantees and every consumer relies on: one static archive per target, at + * [archive], once [builtBy] has run. Consumers β€” cinterop, the JNI and FFM links β€” see only this, never + * which provider made the archives or how. + */ +internal class NativeArtifacts( + /** The library's name; the archives are `lib.a`. */ + val name: String, + private val outputDirectory: Provider, + val targets: Provider>, + /** Depend on this, not on a provider's task: it stands for "the archives exist". */ + val builtBy: TaskProvider<*>, +) { + fun archive(target: KonanTarget): Provider = outputDirectory.map { it.file(archivePath(name, target)) } + + fun directory(target: KonanTarget): Provider = outputDirectory.map { it.dir(target.name) } + + companion object { + /** The one place the layout is written: `/lib.a` under a library's output directory. */ + fun archivePath(name: String, target: KonanTarget): String = "${target.name}/${archiveFileName(name)}" + + fun archiveFileName(name: String): String = "lib$name.a" + + /** The lifecycle task every library has, whatever its provider. */ + fun taskName(name: String): String = "compileNativeLibrary${name.replaceFirstChar { it.uppercase() }}" + + fun of(project: Project, library: NativeLibrarySpec) = NativeArtifacts( + name = library.name, + outputDirectory = library.outputDirectory, + targets = library.targets, + builtBy = project.tasks.named(taskName(library.name)), + ) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt b/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt new file mode 100644 index 0000000..3f61de7 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt @@ -0,0 +1,84 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.dsl.ZigOptimize +import io.github.lemcoder.toolchain.zigTriple +import io.github.lemcoder.util.ExecCaptureResult +import io.github.lemcoder.util.execCapture +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import org.jetbrains.kotlin.konan.target.KonanTarget +import javax.inject.Inject + +/** + * Builds a library with the project's own `build.zig`, once per target, and puts each static archive + * where every other provider puts it: `//lib.a`. Consumers never + * learn which build system made it. + */ +@DisableCachingByDefault(because = "Output depends on the local Zig installation, which is not a tracked input") +internal abstract class ZigBuildTask @Inject constructor( + private val exec: ExecOperations, +) : DefaultTask() { + /** The directory holding `build.zig`; its `zig build` runs there. */ + @get:Internal abstract val projectDirectory: DirectoryProperty + + /** What the build reads: the project directory, less Zig's own cache and output. */ + @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sources: ConfigurableFileCollection + + @get:Input abstract val executable: Property + @get:Input abstract val targets: ListProperty + @get:Input abstract val optimize: Property + @get:Input abstract val arguments: ListProperty + + /** The static library `build.zig` installs, by its Zig name. */ + @get:Input abstract val artifactName: Property + + /** The name consumers know the library by: the archive becomes `lib.a`. */ + @get:Input abstract val libraryName: Property + + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun build() { + val artifact = artifactName.get() + // Read before exec's block: inside it, `executable` is the ExecSpec's own. + val zig = executable.get() + val directory = projectDirectory.get().asFile + val extra = arguments.get() + targets.get().forEach { target -> + val prefix = temporaryDir.resolve("install/${target.name}").apply { deleteRecursively() } + val result = exec.execCapture { + workingDir(directory) + commandLine( + listOf( + zig, "build", + "--prefix", prefix.absolutePath, + // Keep Zig's cache out of the project directory, which is this task's input. + "--cache-dir", temporaryDir.resolve("cache").absolutePath, + "-Dtarget=${target.zigTriple()}", + "-Doptimize=${optimize.get().zigName}", + ) + extra + ) + } + check(result is ExecCaptureResult.Success) { "zig build for ${target.name} failed:\n${result.output}" } + + // Zig names the archive after the target's convention: libfoo.a, or foo.lib on Windows. + val built = listOf("lib/lib$artifact.a", "lib/$artifact.lib").map(prefix::resolve).firstOrNull { it.isFile } + ?: error("zig build for ${target.name} installed no static library '$artifact' under $prefix/lib") + val archive = outputDirectory.get().asFile.resolve(NativeArtifacts.archivePath(libraryName.get(), target)) + archive.parentFile.mkdirs() + built.copyTo(archive, overwrite = true) + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt b/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt new file mode 100644 index 0000000..7ae5eff --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt @@ -0,0 +1,37 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.dsl.ZigOptimize +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** The project's own `build.zig`, run once per target; selected by a library's `zig { }` block. */ +internal object ZigProvider : LibraryProvider { + override val kind = LibraryProviderKind.ZIG + + override fun conventions(project: Project, library: NativeLibrarySpec) { + val zig = library.zig + zig.projectDirectory.convention(library.sourceDirectory) + zig.optimize.convention(ZigOptimize.RELEASE_FAST) + zig.artifactName.convention(library.name) + } + + override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = + project.tasks.register("zigBuild${library.name.replaceFirstChar { it.uppercase() }}", ZigBuildTask::class.java) { + group = "konan" + description = "Build the '${library.name}' library with zig build." + mustRunAfter(project.cleanTask()) + val zig = library.zig + projectDirectory.set(zig.projectDirectory) + sources.from(zig.projectDirectory.map { dir -> dir.asFileTree.matching { exclude(".zig-cache/**", "zig-out/**") } }) + executable.set(zig.executable.map { it.asFile.absolutePath }.orElse("zig")) + targets.set(library.targets) + optimize.set(zig.optimize) + arguments.set(zig.arguments) + artifactName.set(zig.artifactName) + libraryName.set(library.name) + outputDirectory.set(library.outputDirectory) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt similarity index 71% rename from src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt rename to src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt index e61bd3d..2dc9f13 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt +++ b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt @@ -1,9 +1,10 @@ -package io.github.lemcoder.jvm +package io.github.lemcoder.link import org.jetbrains.kotlin.konan.target.Family import org.jetbrains.kotlin.konan.target.KonanTarget import io.github.lemcoder.isAndroid import io.github.lemcoder.sharedLibraryName +import io.github.lemcoder.toolchain.NativeToolchain import io.github.lemcoder.util.execCapture import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection @@ -25,18 +26,24 @@ import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -/** Compiles the generated `.c` and statically links the target's `.a` into a shared JNI library. */ +/** + * Links a target's static archive into a shared library: with [stubCFile], the generated JNI stub + * compiled against it (JNI); without, the whole archive on its own, every symbol exported (FFM). + */ @DisableCachingByDefault(because = "Native link output depends on the local toolchain; cheap to rebuild") -abstract class LinkJvmInteropTask @Inject constructor( +internal abstract class LinkSharedLibraryTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { @get:Input @get:Optional abstract val konanPath: Property @get:Input abstract val target: Property - @get:Input abstract val stubBaseName: Property - @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) abstract val stubCFile: RegularFileProperty - /** Include roots for the stub compile: the def's directory plus whatever the interop declared. */ + /** Base name of the library, e.g. `examplestubs` for `libexamplestubs.so`. */ + @get:Input abstract val libraryBaseName: Property + @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.RELATIVE) abstract val stubCFile: RegularFileProperty + /** Include roots for the stub compile. */ @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val includeDirs: ConfigurableFileCollection + /** Further include roots, as paths: ones that must not be tracked as inputs, like the `.def`'s directory. */ + @get:Input @get:Optional abstract val includePaths: ListProperty @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val staticLibrary: RegularFileProperty @get:Input @get:Optional abstract val jniIncludeDirs: ListProperty @get:Input @get:Optional abstract val ndkResourceDir: Property @@ -49,8 +56,8 @@ abstract class LinkJvmInteropTask @Inject constructor( // configuration cache is stored, which is before the Kotlin plugin has downloaded the // distribution, and an absent value would then be cached for the run that needs it. val konanHome = konanPath.orNull?.let(::File) - ?: JvmInteropSupport.findKonanHome() - ?: error(JvmInteropSupport.MISSING_KONAN) + ?: NativeToolchain.findKonanHome() + ?: error(NativeToolchain.MISSING_KONAN) val tgt = target.get() val isWindows = System.getProperty("os.name").lowercase().contains("windows") val runKonan = konanHome.resolve(if (isWindows) "bin/run_konan.bat" else "bin/run_konan") @@ -58,12 +65,15 @@ abstract class LinkJvmInteropTask @Inject constructor( val outDir = outputDirectory.get().asFile outDir.deleteRecursively(); outDir.mkdirs() - val outLib = outDir.resolve(tgt.sharedLibraryName(stubBaseName.get())) + val outLib = outDir.resolve(tgt.sharedLibraryName(libraryBaseName.get())) + val archive = staticLibrary.get().asFile.absolutePath + val stub = stubCFile.orNull?.asFile val cmd = buildList { add(runKonan.absolutePath); add("clang"); add("clang"); add(tgt.name) add("-shared") includeDirs.files.filter { it.exists() }.forEach { add("-I${it.absolutePath}") } + includePaths.getOrElse(emptyList()).forEach { add("-I$it") } if (tgt.isAndroid) { // jni.h comes from the NDK sysroot; supply compiler-rt and skip the (absent) unwinder. ndkResourceDir.orNull?.takeIf { it.isNotEmpty() }?.let { add("-resource-dir=$it") } @@ -71,11 +81,17 @@ abstract class LinkJvmInteropTask @Inject constructor( } else { jniIncludeDirs.getOrElse(emptyList()).forEach { add("-I$it") } } - add(stubCFile.get().asFile.absolutePath) - add(staticLibrary.get().asFile.absolutePath) + if (stub != null) { + add(stub.absolutePath) + add(archive) + } else if (tgt.family.isAppleFamily) { + add("-Wl,-force_load,$archive") + } else { + add("-Wl,--whole-archive"); add(archive); add("-Wl,--no-whole-archive") + } if (tgt.family == Family.OSX) { // Konan's LLVM has no host compiler-rt; Apple-framework code needs its builtins. - JvmInteropSupport.appleCompilerRt(xcodeDeveloperDir())?.let { add(it.absolutePath) } + NativeToolchain.appleCompilerRt(xcodeDeveloperDir())?.let { add(it.absolutePath) } } add("-o"); add(outLib.absolutePath) // A C++ runtime, extra -L, frameworks: all caller-supplied. Adding konan's own NDK lib @@ -87,7 +103,7 @@ abstract class LinkJvmInteropTask @Inject constructor( val result = exec.execCapture { commandLine(cmd) } logger.lifecycle(result.output) result.assertNormalExitValue() - logger.lifecycle("JNI stub library: $outLib") + logger.lifecycle("Shared library: $outLib") } /** `xcode-select -p`, or null when there is no Xcode (then only the Command Line Tools are tried). */ diff --git a/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt b/src/main/kotlin/io/github/lemcoder/toolchain/NativeToolchain.kt similarity index 98% rename from src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt rename to src/main/kotlin/io/github/lemcoder/toolchain/NativeToolchain.kt index 779ee0c..cc3d624 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt +++ b/src/main/kotlin/io/github/lemcoder/toolchain/NativeToolchain.kt @@ -1,9 +1,9 @@ -package io.github.lemcoder.jvm +package io.github.lemcoder.toolchain import java.io.File /** Pure helpers for the JVM/JNI interop leg: toolchain discovery and naming. */ -internal object JvmInteropSupport { +internal object NativeToolchain { // --- toolchain discovery ------------------------------------------------- diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt b/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt new file mode 100644 index 0000000..3f0ed4c --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt @@ -0,0 +1,24 @@ +package io.github.lemcoder.toolchain + +import org.jetbrains.kotlin.konan.target.KonanTarget + +/** + * Zig's target triple for a Kotlin/Native target, pinned to what Kotlin/Native's own sysroots expect, + * so an archive Zig builds links wherever konan's would: glibc 2.19 on Linux, macOS 12.0 and iOS 14.0 + * on Apple. Android is mapped, but needs a libc file for the NDK's bionic, which Zig does not bundle. + */ +internal fun KonanTarget.zigTriple(): String = when (this) { + KonanTarget.MACOS_ARM64 -> "aarch64-macos.12.0" + KonanTarget.MACOS_X64 -> "x86_64-macos.12.0" + KonanTarget.IOS_ARM64 -> "aarch64-ios.14.0" + KonanTarget.IOS_SIMULATOR_ARM64 -> "aarch64-ios.14.0-simulator" + KonanTarget.IOS_X64 -> "x86_64-ios.14.0-simulator" + KonanTarget.LINUX_X64 -> "x86_64-linux-gnu.2.19" + KonanTarget.LINUX_ARM64 -> "aarch64-linux-gnu.2.19" + KonanTarget.MINGW_X64 -> "x86_64-windows-gnu" + KonanTarget.ANDROID_ARM64 -> "aarch64-linux-android" + KonanTarget.ANDROID_X64 -> "x86_64-linux-android" + KonanTarget.ANDROID_ARM32 -> "arm-linux-androideabi" + KonanTarget.ANDROID_X86 -> "x86-linux-android" + else -> error("No Zig target for Kotlin/Native target $name") +} diff --git a/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt b/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt index d529400..2d26607 100644 --- a/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt +++ b/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt @@ -6,7 +6,7 @@ import org.gradle.process.ExecSpec import org.gradle.process.JavaExecSpec import java.io.ByteArrayOutputStream -fun ExecOperations.execCapture( +internal fun ExecOperations.execCapture( configure: ExecSpec.() -> Unit, ): ExecCaptureResult = capture { os -> exec { @@ -17,7 +17,7 @@ fun ExecOperations.execCapture( } } -fun ExecOperations.javaExecCapture( +internal fun ExecOperations.javaExecCapture( configure: JavaExecSpec.() -> Unit, ): ExecCaptureResult = capture { os -> javaexec { @@ -39,7 +39,7 @@ private fun capture(run: (ByteArrayOutputStream) -> ExecResult): ExecCaptureResu } -sealed class ExecCaptureResult( +internal sealed class ExecCaptureResult( val output: String, private val result: ExecResult, ) : ExecResult by result { diff --git a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt new file mode 100644 index 0000000..e8d1029 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt @@ -0,0 +1,54 @@ +package io.github.lemcoder.wiring + +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider + +/** + * Everything that touches the Android Gradle plugin's types, behind `plugins.withId`. The KMP Android + * plugin does not apply `com.android.base`, so both ids need handling. + */ +internal object AgpWiring { + /** + * An Android module outside Kotlin Multiplatform has no Kotlin compilation to attach to, so the + * generated sources go in through the variant API β€” registered at configuration time, since the API + * rejects callbacks added later. A multiplatform module gets them through its Android target instead. + */ + fun addKotlinSources(project: Project, sources: Provider, generate: TaskProvider<*>, link: TaskProvider) { + project.withPlugin(GradlePluginId.ANDROID_BASE) { + val relative = sources.get().asFile.apply { mkdirs() }.relativeTo(project.projectDir).path + project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { variant -> + if (!project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM)) { + variant.sources.kotlin?.addStaticSourceDirectory(relative) + } + } + project.tasks.matching { it.name == "preBuild" }.configureEach { dependsOn(generate, link) } + project.tasks + .matching { (it.name.startsWith("compile") && it.name.contains("Kotlin")) || it.name == "compileJava" } + .configureEach { dependsOn(generate) } + } + } + + /** + * Packages the per-ABI libraries into the APK or AAR. The directory has to exist at configuration + * time; nothing consumes it as a task output, so packaging needs the ordering spelled out. Added once: + * a second registration fails the merge with "Duplicate resources". + */ + fun addJniLibs(project: Project, jniLibsRoot: Provider, link: TaskProvider) { + val relative = jniLibsRoot.get().asFile.apply { mkdirs() }.relativeTo(project.projectDir).path + project.withPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) { + project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java) + ?.onVariants { variant -> variant.sources.jniLibs?.addStaticSourceDirectory(relative) } + } + project.withPlugin(GradlePluginId.ANDROID_BASE) { + project.extensions.findByType(AndroidComponentsExtension::class.java) + ?.onVariants { variant -> variant.sources.jniLibs?.addStaticSourceDirectory(relative) } + } + project.tasks.matching { it.name.contains("JniLibFolders") || it.name.contains("MergeJniLib") } + .configureEach { dependsOn(link) } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt b/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt new file mode 100644 index 0000000..e3cca5f --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt @@ -0,0 +1,298 @@ +package io.github.lemcoder.wiring + +import io.github.lemcoder.abiDir +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.dsl.BindingVisibility +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.hostKonanTarget +import io.github.lemcoder.index.GenerateFromNativeIndexTask +import io.github.lemcoder.index.GeneratedVisibility +import io.github.lemcoder.index.GeneratorRequest +import io.github.lemcoder.isAndroid +import io.github.lemcoder.jni.CMakeBuildTask +import io.github.lemcoder.jni.CMakeConfigureTask +import io.github.lemcoder.jni.CMakeSupport +import io.github.lemcoder.jni.GenerateJniBindingsTask +import io.github.lemcoder.link.LinkSharedLibraryTask +import io.github.lemcoder.taskSuffix +import io.github.lemcoder.toolchain.NativeToolchain +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.konan.target.KonanTarget +import java.io.File + +/** + * Cache entries the plugin sets on an external CMake build β€” its side of the contract, the way an AGP + * project reads ANDROID_ABI. A CMakeLists opts in by reading them. + */ +private const val STUB_DIR_VARIABLE = "KONAN_JNI_STUB_DIR" +private const val LIB_NAME_VARIABLE = "KONAN_JNI_LIB_NAME" + + +/** Turns every binding into the tasks and wiring each of the project's consumers needs. */ +internal fun Project.registerBindings(konan: KonanExtension) { + konan.bindings.all(object : Action { + override fun execute(binding: BindingSpec) = BindingWiring(this@registerBindings, konan, binding).register() + }) +} + +private class BindingWiring( + private val project: Project, + private val konan: KonanExtension, + private val binding: BindingSpec, +) { + private val bindingName = binding.name + private val suffix = bindingName.capitalized() + private val build = project.layout.buildDirectory + + // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, and a + // host library sitting in the root fails its merge with "… is not an ABI". + private val jniLibsRoot = build.dir("konan/bindings/$bindingName/jni/jniLibs") + private val jniHostLibRoot = build.dir("konan/bindings/$bindingName/jni/lib") + + /** The archives of the library the binding names, once the build script has been evaluated. */ + private fun artifacts(): NativeArtifacts? = binding.library.orNull?.let { NativeArtifacts.of(project, it) } + + fun register() { + // The container fires before create()'s configure block runs: conventions and lazily configured + // tasks here; anything that needs the declared values waits for afterEvaluate. + binding.defFile.convention(project.layout.projectDirectory.file("src/nativeInterop/cinterop/$bindingName.def")) + binding.jni.visibility.convention(BindingVisibility.INTERNAL) + + val generateJni = registerJniGeneration() + val jniSources = generateJni.flatMap { it.kotlinSourceDirectory } + val linkJni = project.tasks.register("linkJni$suffix") { + group = "konan" + description = "Make the '$bindingName' JNI library exist for every target, however it is built." + } + + // Eager: the variant API must be wired at configuration time. + project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KgpWiring.addKmpJvmSources(project, jniSources) } + AgpWiring.addKotlinSources(project, jniSources, generateJni, linkJni) + AgpWiring.addJniLibs(project, jniLibsRoot, linkJni) + // AGP looks for a `baselineProfiles` directory beside every source root, including the generated + // one, by plain path β€” no task dependency. Matched by name: inert without AGP. + project.tasks.matching { it.name.contains("ArtProfile") }.configureEach { dependsOn(generateJni) } + + project.afterEvaluate { + // In a kotlin("jvm") project FFM replaces JNI: both would define the same facade class. + if (project.hasPlugin(GradlePluginId.KOTLIN_JVM) && !binding.ffm.isEnabled) KgpWiring.addJvmSources(project, jniSources) + if (hasJniConsumer()) { + if (binding.jni.usesCMake) registerCMakeBuild(generateJni, linkJni) else registerJniLinks(generateJni, linkJni) + } + if (project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM)) { + KgpWiring.declareCinterops(project, binding, artifacts()) + } + if (binding.ffm.isEnabled) registerFfm() + } + } + + private fun hasJniConsumer(): Boolean = + (project.hasPlugin(GradlePluginId.KOTLIN_JVM) && !binding.ffm.isEnabled) || + project.hasPlugin(GradlePluginId.ANDROID_BASE) || project.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || + (project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KgpWiring.hasKmpJvmTarget(project)) + + /** The package the bindings go in: the binding's, else the `.def`'s own. */ + private val packageName: Provider = binding.packageName.orElse( + project.provider { binding.defFile.orNull?.asFile?.takeIf { it.isFile }?.let { DefFile.parse(it).packageName } } + ) + + private fun registerJniGeneration(): TaskProvider = + project.tasks.register("generateJni$suffix", GenerateJniBindingsTask::class.java) { + group = "konan" + description = "Generate the '$bindingName' JNI bindings and stub from its .def." + mustRunAfter(project.cleanTask()) + defFile.set(binding.defFile) + packageName.set(this@BindingWiring.packageName) + includeDirs.from(binding.includeDirs) + hostTarget.set(hostKonanTarget()) + compilerOptions.set(binding.compilerOptions) + visibility.set( + binding.jni.visibility.map { + when (it) { + BindingVisibility.INTERNAL -> GeneratedVisibility.INTERNAL + BindingVisibility.PUBLIC -> GeneratedVisibility.PUBLIC + } + } + ) + outputDirectory.set(build.dir("generated/konan/$bindingName/jni")) + } + + /** Default JNI targets: the library's that a JVM can load β€” the host and Android β€” else the host. */ + private fun jniTargets(): List = binding.jni.targets.getOrElse(emptyList()).ifEmpty { + artifacts()?.targets?.getOrElse(emptyList())?.filter { it == hostKonanTarget() || it.isAndroid } + ?.ifEmpty { null } + ?: listOf(hostKonanTarget()) + } + + private fun registerJniLinks(generate: TaskProvider, linkAll: TaskProvider) { + val artifacts = artifacts() + val jniIncludeDirs = project.providers.provider { + binding.jni.jdkHome.orNull?.asFile + ?.let { NativeToolchain.jniIncludeDirsOf(it) ?: error("No include/jni.h under $it") } + ?: NativeToolchain.detectJniIncludeDirs() + } + jniTargets().forEach { target -> + val link = project.tasks.register("linkJni$suffix${target.taskSuffix}", LinkSharedLibraryTask::class.java) { + group = "konan" + description = "Link the '$bindingName' JNI library for ${target.name}." + dependsOn(generate) + artifacts?.let { dependsOn(it.builtBy) } + mustRunAfter(project.cleanTask()) + konanPath.set(konan.konanHome.map { it.asFile.absolutePath }) + this.target.set(target) + libraryBaseName.set(generate.flatMap { it.stubLibraryBaseName }) + stubCFile.set(generate.flatMap { it.stubSourceFile }) + includeDirs.from(binding.includeDirs) + // A plain path, not a tracked directory: a .def at the project root would make the whole + // project, build/ included, an input of this task. + includePaths.add(binding.defFile.map { it.asFile.parentFile.absolutePath }) + staticLibrary.set(project.layout.file(project.provider { archive(artifacts, target) })) + if (target.isAndroid) { + ndkResourceDir.set(konan.konanHome.map { NativeToolchain.ndkResourceDir(it.asFile)?.absolutePath ?: "" }) + } else { + this.jniIncludeDirs.set(jniIncludeDirs.map { dirs -> dirs.map { it.absolutePath } }) + } + additionalLinkerArgs.set( + binding.jni.linkerOptions.zip(binding.jni.targetLinkerOptions) { common, perTarget -> + common + perTarget[target].orEmpty() + } + ) + outputDirectory.set( + if (target.isAndroid) jniLibsRoot.map { it.dir(target.abiDir) } else jniHostLibRoot.map { it.dir(target.name) } + ) + onlyIf { + archive(artifacts, target) != null || run { + logger.lifecycle( + "konan.bindings.$bindingName: no library(), jni.archive or staticLibraries in the .def, so only " + + "the JNI bindings and the .c stub were generated β€” link them from your own build." + ) + false + } + } + } + linkAll.configure { dependsOn(link) } + // Where a consumer looks for the library: the host's, the one a JVM running this build loads. + if (target == hostKonanTarget()) binding.jni.libraryDirectory.convention(jniHostLibRoot.map { it.dir(target.name) }) + } + } + + /** + * The archive to link: `jni.archive` if set, else the library's for [target], else the `.def`'s + * `staticLibraries`. + */ + private fun archive(artifacts: NativeArtifacts?, target: KonanTarget): File? { + binding.jni.archive.orNull?.asFile?.let { return it.takeIf { f -> f.isFile } } + artifacts?.let { + return it.archive(target).get().asFile.takeIf { _ -> target in it.targets.getOrElse(emptyList()) } + } + val def = binding.defFile.orNull?.asFile?.takeIf { it.isFile } ?: return null + return DefFile.parse(def).resolveStaticLibraries(def.parentFile).firstOrNull() + } + + /** + * Drives the external build that compiles the stub, in place of the plugin's own link tasks. The + * plugin supplies what only it knows β€” where the stub is, the library name the bindings will load, + * and a JDK with jni.h β€” so the CMakeLists only reads two cache entries. + */ + private fun registerCMakeBuild(generate: TaskProvider, linkAll: TaskProvider) { + val cmake = binding.jni.cmake + val executable = cmake.executable.map { it.asFile.absolutePath }.orElse(project.provider { CMakeSupport.detectExecutable() }) + val sourceDir = cmake.path.map { it.asFile.parentFile } + // No ABIs means one build for the host; with them, one per ABI, each into jniLibs//. + val abis = cmake.abis.toList() + + (if (abis.isEmpty()) listOf(null) else abis).forEach { abi -> + val abiSuffix = abi?.name.orEmpty().split('-', '_').joinToString("") { it.capitalized() } + val preset = abi?.preset?.orElse(cmake.preset) ?: cmake.preset + val outputDir = if (abi == null) jniHostLibRoot else jniLibsRoot.map { it.dir(abi.name) } + // With a preset the binary directory is the preset's to choose, and `-B` cannot override it; + // the conventional layout is /build/, which stays overridable. + val buildDir = (abi?.buildDirectory ?: cmake.buildDirectory).orElse( + project.layout.dir( + preset.map { presetName -> sourceDir.get().resolve("build/$presetName") } + .orElse(build.dir("konan/bindings/$bindingName/jni/cmake$abiSuffix").map { it.asFile }) + ) + ) + val configure = project.tasks.register("cmakeConfigure$suffix$abiSuffix", CMakeConfigureTask::class.java) { + group = "konan" + description = "Configure the external CMake build for '$bindingName'" + (abi?.let { " (${it.name})" } ?: "") + "." + dependsOn(generate) + this.executable.set(executable) + this.preset.set(preset) + sourceDirectory.set(sourceDir.map { it.absolutePath }) + arguments.set(cmake.arguments.zip(abi?.arguments ?: cmake.arguments.map { emptyList() }) { a, b -> a + b }) + stubSourceDirectory.set(generate.flatMap { it.stubSourceDirectory }) + cacheEntries.put(STUB_DIR_VARIABLE, generate.flatMap { it.stubSourceDirectory }.map { it.asFile.absolutePath }) + cacheEntries.put(LIB_NAME_VARIABLE, generate.flatMap { it.stubLibraryBaseName }) + // Land the library where the plugin says, so a consumer never guesses the build layout and + // Android gets the jniLibs// shape AGP packages. + cacheEntries.put("CMAKE_LIBRARY_OUTPUT_DIRECTORY", outputDir.map { it.asFile.absolutePath }) + javaHome.set(project.provider { NativeToolchain.detectJniHome().absolutePath }) + buildDirectory.set(buildDir) + } + val cmakeBuild = project.tasks.register("cmakeBuild$suffix$abiSuffix", CMakeBuildTask::class.java) { + group = "konan" + description = "Build the '$bindingName' JNI library" + (abi?.let { " for ${it.name}" } ?: "") + "." + dependsOn(configure) + this.executable.set(executable) + targets.set(cmake.targets) + stubSourceDirectory.set(generate.flatMap { it.stubSourceDirectory }) + buildDirectory.set(buildDir) + cmakeCache.set(buildDir.map { it.file("CMakeCache.txt") }) + libraryDirectory.set(outputDir) + } + linkAll.configure { dependsOn(cmakeBuild) } + } + binding.jni.libraryDirectory.convention(cmake.libraryDirectory.orElse(if (abis.isEmpty()) jniHostLibRoot else jniLibsRoot)) + } + + /** + * FFM bindings: jextract over the same `.def`, and the library itself as a shared library for the + * host β€” FFM loads it directly, with no stub. + */ + private fun registerFfm() { + val artifacts = artifacts() + val libraryName = artifacts?.name ?: bindingName + val host = hostKonanTarget() + val generate = project.tasks.register("generateFfm$suffix", GenerateFromNativeIndexTask::class.java) { + group = "konan" + description = "Generate the '$bindingName' FFM bindings with jextract." + defFile.set(binding.defFile) + includeDirs.from(binding.includeDirs) + compilerOptions.set(binding.compilerOptions) + target.set(host) + request.set(project.provider { GeneratorRequest.Jextract(library = libraryName, jextract = binding.ffm.jextract.orNull?.asFile) }) + packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } + outputDirectory.set(build.dir("generated/konan/$bindingName/ffm")) + } + val ffmLib = build.dir("konan/bindings/$bindingName/ffm/lib/${host.name}") + binding.ffm.libraryDirectory.convention(ffmLib) + if (artifacts != null && host in artifacts.targets.getOrElse(emptyList())) { + project.tasks.register("linkFfm$suffix", LinkSharedLibraryTask::class.java) { + group = "konan" + description = "Link the '$libraryName' library as a shared library for the FFM bindings." + dependsOn(artifacts.builtBy) + konanPath.set(konan.konanHome.map { it.asFile.absolutePath }) + target.set(host) + libraryBaseName.set(libraryName) + staticLibrary.set(artifacts.archive(host)) + additionalLinkerArgs.set(binding.jni.linkerOptions) + outputDirectory.set(ffmLib) + } + } + if (project.hasPlugin(GradlePluginId.KOTLIN_JVM)) { + // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. + project.extensions.getByType(SourceSetContainer::class.java).getByName("main") + .java.srcDir(generate.map { it.outputDirectory.dir("java") }) + } else { + project.logger.info("konan.bindings.$bindingName: FFM bindings are only wired into kotlin(\"jvm\") projects; add build/generated/konan/$bindingName/ffm/java yourself.") + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt b/src/main/kotlin/io/github/lemcoder/wiring/DefFile.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/interop/DefFile.kt rename to src/main/kotlin/io/github/lemcoder/wiring/DefFile.kt index 338365c..cc10fc9 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/DefFile.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.interop +package io.github.lemcoder.wiring import java.io.File @@ -11,7 +11,7 @@ import java.io.File * these into the klib; here they decide whether there is anything to link at all β€” a def without * them yields bindings and a `.c` stub for someone else's build to compile. */ -data class DefFile( +internal data class DefFile( val packageName: String?, val staticLibraries: List, val libraryPaths: List, diff --git a/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt b/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt new file mode 100644 index 0000000..7bccfc2 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt @@ -0,0 +1,19 @@ +package io.github.lemcoder.wiring + +import org.gradle.api.Project + +/** The other plugins whose presence decides how bindings are consumed. */ +internal enum class GradlePluginId(val id: String) { + KOTLIN_JVM("org.jetbrains.kotlin.jvm"), + KOTLIN_MULTIPLATFORM("org.jetbrains.kotlin.multiplatform"), + /** Every AGP application and library plugin; the KMP Android plugin does not apply it. */ + ANDROID_BASE("com.android.base"), + ANDROID_KMP_LIBRARY("com.android.kotlin.multiplatform.library"), +} + +internal fun Project.hasPlugin(plugin: GradlePluginId): Boolean = plugins.hasPlugin(plugin.id) + +/** Runs [action] once [plugin] is applied, now if it already is. */ +internal fun Project.withPlugin(plugin: GradlePluginId, action: () -> Unit) { + plugins.withId(plugin.id) { action() } +} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/KgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/KgpWiring.kt new file mode 100644 index 0000000..e3c07ea --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/KgpWiring.kt @@ -0,0 +1,63 @@ +package io.github.lemcoder.wiring + +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.library.NativeArtifacts +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget + +/** + * Everything that touches the Kotlin Gradle plugin's types. Called only from `plugins.withId` callbacks + * or once the plugin is known to be applied: in a project without KGP, a reference elsewhere would be a + * NoClassDefFoundError. + */ +internal object KgpWiring { + private val JVM_PLATFORMS = setOf(KotlinPlatformType.jvm, KotlinPlatformType.androidJvm) + + /** Generated bindings are sources of a `kotlin("jvm")` project's main source set. */ + fun addJvmSources(project: Project, sources: Provider) { + project.extensions.getByType(KotlinJvmProjectExtension::class.java).sourceSets.getByName("main").kotlin.srcDir(sources) + } + + /** …and of the main compilation of every JVM and Android target of a multiplatform project. */ + fun addKmpJvmSources(project: Project, sources: Provider) { + project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets + .matching { it.platformType in JVM_PLATFORMS } + .configureEach { compilations.matching { it.name == "main" }.configureEach { defaultSourceSet.kotlin.srcDir(sources) } } + } + + fun hasKmpJvmTarget(project: Project): Boolean = + project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets.any { it.platformType in JVM_PLATFORMS } + + /** + * Kotlin/Native goes through KGP's own cinterop, not the plugin's generators: a klib is a compiler + * artifact and must come from the project's Kotlin version. Declares the `cinterops {}` entry on the + * main compilation of every Kotlin/Native target β€” those the library is built for, when there is one, + * whose archive cinterop then embeds. + */ + fun declareCinterops(project: Project, binding: BindingSpec, artifacts: NativeArtifacts?) { + val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) + kotlin.targets.withType(KotlinNativeTarget::class.java).configureEach { + val target = this + if (artifacts != null && target.konanTarget !in artifacts.targets.getOrElse(emptyList())) return@configureEach + + target.compilations.getByName("main").cinterops.create(binding.name) { + definitionFile.set(binding.defFile) + binding.packageName.orNull?.let { packageName = it } + includeDirs(binding.includeDirs) + compilerOpts(binding.compilerOptions.getOrElse(emptyList())) + if (artifacts != null) { + extraOpts( + "-staticLibrary", NativeArtifacts.archiveFileName(artifacts.name), + "-libraryPath", artifacts.directory(target.konanTarget).get().asFile.absolutePath, + ) + project.tasks.named(interopProcessingTaskName).configure { dependsOn(artifacts.builtBy) } + } + } + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt b/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt new file mode 100644 index 0000000..28ff905 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt @@ -0,0 +1,38 @@ +package io.github.lemcoder.wiring + +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.library.libraryProviders +import io.github.lemcoder.library.providerFor +import org.gradle.api.Action +import org.gradle.api.Project + +internal fun String.capitalized() = replaceFirstChar { it.uppercase() } + +/** + * Every library gets its lifecycle task now β€” the name bindings depend on β€” and its provider's tasks once + * the build script has said which provider it uses. + */ +internal fun Project.registerLibraries(konan: KonanExtension) { + // An object expression, not a lambda: the kotlin-dsl plugin puts a `T.() -> Unit` overload of + // all/whenObjectAdded in scope, which makes the SAM form ambiguous. + konan.libraries.all(object : Action { + override fun execute(library: NativeLibrarySpec) = registerLibrary(konan, library) + }) +} + +private fun Project.registerLibrary(konan: KonanExtension, library: NativeLibrarySpec) { + library.sourceDirectory.convention(layout.projectDirectory.dir("native")) + library.outputDirectory.convention(layout.buildDirectory.dir("konan/libraries/${library.name}")) + libraryProviders.forEach { it.conventions(this, library) } + + val lifecycle = tasks.register(NativeArtifacts.taskName(library.name)) { + group = "konan" + description = "Build the '${library.name}' library: a static archive per target." + } + afterEvaluate { + val work = providerFor(library).register(this, konan, library) + lifecycle.configure { dependsOn(work) } + } +} diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 84a2f93..24df916 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -1,172 +1,163 @@ package io.github.lemcoder +import io.github.lemcoder.index.PinnedKotlinNative import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File import kotlin.io.path.createTempDirectory import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * Black-box functional tests: apply the plugin to a throwaway build via TestKit and drive its tasks - * with `--dry-run`, so the task graph is configured and wired but no real Kotlin/Native toolchain is - * needed. `withPluginClasspath()` injects the plugin-under-test (auto-generated by java-gradle-plugin). + * Functional tests of the `konan { libraries; bindings }` DSL with Gradle TestKit. Most only configure + * the task graph (`--dry-run`), so no real toolchain is needed; the multiplatform one runs cinterop. */ class KonanPluginFunctionalTest { private val projectDir = createTempDirectory("konan-testkit").toFile() - private val settingsFile get() = File(projectDir, "settings.gradle.kts") - private val buildFile get() = File(projectDir, "build.gradle.kts") + private val host = hostKonanTarget() + private val hostSuffix = host.name.replaceFirstChar { it.uppercase() } private fun runner(vararg args: String) = GradleRunner.create() .withProjectDir(projectDir) .withPluginClasspath() .withArguments(*args) - // A dummy KONAN_HOME so configuration that reads konanPath resolves without a real distribution. - private fun konanStub(): String { - val home = File(projectDir, "konan-stub").apply { mkdirs() } - return home.absolutePath.replace("\\", "/") - } + /** A dummy Kotlin/Native home, so configuration that reads it resolves without a distribution. */ + private fun konanStub(): String = File(projectDir, "konan-stub").apply { mkdirs() }.absolutePath.replace("\\", "/") - private fun writeBuild(name: String, konanConfig: String) { - settingsFile.writeText("""rootProject.name = "$name"""") - buildFile.writeText( - """ - import org.jetbrains.kotlin.konan.target.KonanTarget - import io.github.lemcoder.interop.jvmInterops + private fun write(plugins: String, konan: String) { + File(projectDir, "settings.gradle.kts").writeText("""rootProject.name = "fixture"""") + File(projectDir, "mymath.def").writeText("headers = mymath.h\npackage = example\n") + File(projectDir, "mymath.h").writeText("int my_add(int a, int b);\n") + File(projectDir, "build.gradle.kts").writeText( + "import org.jetbrains.kotlin.konan.target.KonanTarget\n\n" + + "plugins {\n${plugins.trimIndent().prependIndent(" ")}\n}\n\n" + + "konan {\n konanHome.set(file(\"${konanStub()}\"))\n${konan.trimIndent().prependIndent(" ")}\n}\n" + ) + } - plugins { - id("io.github.lemcoder.konanplugin") + private val mymath = """ + val mymath = libraries.create("mymath") { targets(KonanTarget.${host.name.uppercase()}) } + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + library(mymath) } + } + """.trimIndent() - konanConfig { - konanPath.set("${konanStub()}") - ${konanConfig.trimIndent().prependIndent(" ")} - } - """.trimIndent() - ) - } + @Test + fun `a library compiles from conventions alone`() { + write("""id("io.github.lemcoder.konanplugin")""", """libraries { create("mymath") { targets(KonanTarget.MACOS_ARM64) } }""") + val result = runner("compileNativeLibraryMymath", "--dry-run").build() - /** As [writeBuild], plus a `jvmInterops { }` block and the def it points at. */ - private fun writeInteropBuild(name: String, konanConfig: String, interops: String) { - writeBuild(name, konanConfig) - File(projectDir, "mymath.def").writeText("headers = mymath.h\npackage = example\n") - File(projectDir, "mymath.h").writeText("int my_add(int a, int b);\n") - buildFile.appendText( - "\n\njvmInterops {\n" + interops.trimIndent().prependIndent(" ") + "\n}\n" + assertTrue(":compileNativeLibraryMymath SKIPPED" in result.output, result.output) + } + + @Test + fun `a library built with zig sits behind the same task, with no konan clang`() { + write( + """id("io.github.lemcoder.konanplugin")""", + """libraries { create("mymath") { targets(KonanTarget.MACOS_ARM64); zig { } } }""", ) + + val result = runner("compileNativeLibraryMymath", "--dry-run").build() + + assertTrue(":zigBuildMymath SKIPPED" in result.output && ":compileNativeLibraryMymath SKIPPED" in result.output, result.output) + assertFalse(":konanClangMymath" in result.output, result.output) } @Test - fun `registers runKonanClang and configures it without a real toolchain`() { - writeBuild( - "konan-fixture", + fun `zig builds each target's archive where every provider puts it`() { + val zig = System.getenv("ZIG") + assumeTrue(zig != null && File(zig).canExecute(), "Set ZIG to a zig executable") + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") + File(projectDir, "native/build.zig").writeText( """ - targets(KonanTarget.MACOS_ARM64) - libName.set("mymath") + const std = @import("std"); + pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const mod = b.createModule(.{ .target = target, .optimize = optimize, .link_libc = true }); + mod.addCSourceFiles(.{ .files = &.{"mymath.c"} }); + b.installArtifact(b.addLibrary(.{ .name = "mymath", .linkage = .static, .root_module = mod })); + } + """.trimIndent() + ) + write( + """id("io.github.lemcoder.konanplugin")""", """ + libraries { + create("mymath") { + targets(KonanTarget.MACOS_ARM64, KonanTarget.LINUX_X64, KonanTarget.MINGW_X64) + zig { executable.set(file("${zig.replace("\\", "/")}")) } + } + } + """, ) - val result = runner("runKonanClang", "--dry-run").build() + runner("compileNativeLibraryMymath").build() - // Dry-run realizes + configures the task graph but skips the @TaskAction. Dry-run tasks are - // not "executed", so they show up in the output rather than in result.task(...). - assertTrue( - result.output.contains(":runKonanClang SKIPPED"), - "expected runKonanClang in the configured dry-run graph, got:\n${result.output}" - ) + listOf("macos_arm64", "linux_x64", "mingw_x64").forEach { target -> + // Windows' mymath.lib too: consumers only ever look for lib.a. + assertTrue(File(projectDir, "build/konan/libraries/mymath/$target/libmymath.a").isFile, "archive for $target") + } } @Test - fun `sourceDir, headerDir, libName and outputDir have conventions`() { - // Only the targets are declared: everything else must come from a convention, and the build - // must still configure. libName defaults to the project name. - writeBuild("convention-fixture", """targets(KonanTarget.MACOS_ARM64)""") + fun `without a Kotlin or Android plugin a binding links nothing and loads no KGP type`() { + write("""id("io.github.lemcoder.konanplugin")""", mymath) - val result = runner("runKonanClang", "--dry-run").build() + val result = runner("tasks", "--all").build() - assertTrue( - result.output.contains(":runKonanClang SKIPPED"), - "expected runKonanClang to configure from conventions alone, got:\n${result.output}" - ) + assertFalse("linkJniMymath$hostSuffix" in result.output, "no JVM consumer, so no JNI link:\n${result.output}") + assertFalse("cinterop" in result.output, "no multiplatform project, so no cinterop:\n${result.output}") } @Test - fun `an interop declared on the project registers generate and per-target link tasks`() { - writeInteropBuild( - "interop-fixture", - konanConfig = """ - targets(KonanTarget.MACOS_ARM64) - libName.set("mymath") - """, - interops = """ - create("mymath") { - defFile(project.file("mymath.def")) - } - """, - ) + fun `a JVM project links the JNI library for the host, after compiling the library`() { + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) - val result = runner("linkJvmInteropMymath", "--dry-run").build() + val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() - assertTrue( - result.output.contains(":generateJvmInteropMymath SKIPPED"), - "expected the generate task in the dry-run graph, got:\n${result.output}" - ) - assertTrue( - result.output.contains(":linkJvmInteropMymathMacos_arm64 SKIPPED"), - "expected a per-target link task in the dry-run graph, got:\n${result.output}" - ) - // The per-target link task must depend on the .a it links. - assertTrue( - result.output.contains(":runKonanClang SKIPPED"), - "expected runKonanClang to be a dependency of the link task, got:\n${result.output}" - ) + listOf(":compileNativeLibraryMymath", ":generateJniMymath", ":linkJniMymath$hostSuffix").forEach { + assertTrue("$it SKIPPED" in result.output, "expected $it in the graph:\n${result.output}") + } } @Test - fun `interop targets are independent of the konanConfig ones`() { - writeInteropBuild( - "android-fixture", - konanConfig = """ - targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) - libName.set("mymath") - """, - interops = """ - create("mymath") { - defFile(project.file("mymath.def")) - targets.set(listOf(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64)) - } - """, + fun `JNI targets default to the library's that a JVM can load`() { + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + mymath.replace("targets(KonanTarget.${host.name.uppercase()})", "targets(KonanTarget.${host.name.uppercase()}, KonanTarget.ANDROID_ARM64, KonanTarget.LINUX_ARM64)"), ) - val result = runner("linkJvmInteropMymath", "--dry-run").build() + val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() - assertTrue( - result.output.contains(":linkJvmInteropMymathAndroid_arm64 SKIPPED"), - "expected an arm64 link task, got:\n${result.output}" - ) - assertTrue( - result.output.contains(":linkJvmInteropMymathAndroid_x64 SKIPPED"), - "expected an x64 link task, got:\n${result.output}" - ) + assertTrue(":linkJniMymath$hostSuffix SKIPPED" in result.output, result.output) + assertTrue(":linkJniMymathAndroid_arm64 SKIPPED" in result.output, result.output) + if (host.name != "linux_arm64") assertFalse("linkJniMymathLinux_arm64" in result.output, "not loadable here:\n${result.output}") } @Test - fun `an external build replaces the plugin's link tasks`() { - writeInteropBuild( - "external-fixture", - konanConfig = """ - libName.set("mymath") - """, - interops = """ - create("mymath") { - defFile(project.file("mymath.def")) - externalNativeBuild { - cmake { - path.set(project.file("CMakeLists.txt")) - targets.add("mymath-jni") + fun `an external CMake build replaces the plugin's JNI link`() { + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + """ + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + jni { + cmake { + path.set(file("CMakeLists.txt")) + targets.add("mymath-jni") + } } } } @@ -174,34 +165,57 @@ class KonanPluginFunctionalTest { ) File(projectDir, "CMakeLists.txt").writeText("cmake_minimum_required(VERSION 3.16)\n") - val result = runner("linkJvmInteropMymath", "--dry-run").build() + val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() - assertTrue( - result.output.contains(":cmakeConfigureMymath SKIPPED") && - result.output.contains(":cmakeBuildMymath SKIPPED"), - "expected the CMake tasks in the dry-run graph, got:\n${result.output}" - ) - assertFalse( - result.output.contains(":linkJvmInteropMymathMacos_arm64"), - "expected no plugin link task when an external build owns the link, got:\n${result.output}" - ) + assertTrue(":cmakeConfigureMymath SKIPPED" in result.output && ":cmakeBuildMymath SKIPPED" in result.output, result.output) + assertFalse(":linkJniMymath$hostSuffix" in result.output, "no plugin link with an external build:\n${result.output}") } @Test - fun `konanConfig alone registers no interop tasks`() { - writeBuild( - "no-interop-fixture", - """ - targets(KonanTarget.MACOS_ARM64) - libName.set("mymath") - """ + fun `ffm() adds jextract generation and a shared library for the host`() { + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + mymath.replace("library(mymath)", "library(mymath)\n ffm()"), ) - val result = runner("tasks", "--group=interop").build() + val result = runner("compileJava", "linkFfmMymath", "--dry-run").withPluginClasspathAndKgp().build() - assertFalse( - result.output.contains("generateJvmInterop"), - "expected no interop tasks without a jvmInterops block, got:\n${result.output}" + assertTrue(":generateFfmMymath SKIPPED" in result.output, "the Java sources come from jextract:\n${result.output}") + assertTrue(":linkFfmMymath SKIPPED" in result.output, result.output) + } + + @Test + fun `a multiplatform project binds through KGP's cinterop, after compiling the library`() { + assumeTrue(PinnedKotlinNative.home() != null, "Kotlin/Native ${PinnedKotlinNative.version} is not installed") + // KMP's DSL names targets in camelCase: macos_arm64 -> macosArm64. + val dslName = host.name.split('_').mapIndexed { i, part -> if (i == 0) part else part.replaceFirstChar { it.uppercase() } }.joinToString("") + val targetSuffix = dslName.replaceFirstChar { it.uppercase() } + + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.h").writeText("int my_add(int a, int b);\n") + File(projectDir, "native/mymath.c").writeText("#include \"mymath.h\"\nint my_add(int a, int b) { return a + b; }\n") + File(projectDir, "src/${dslName}Main/kotlin").mkdirs() + File(projectDir, "src/${dslName}Main/kotlin/Use.kt").writeText( + "@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)\n\nfun sum(): Int = example.my_add(2, 3)\n" + ) + write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) + // The real distribution, not the stub: this one compiles. + File(projectDir, "build.gradle.kts").appendText( + "\nkonan.konanHome.set(file(\"${PinnedKotlinNative.home()!!.absolutePath.replace("\\", "/")}\"))\n" + + "kotlin { $dslName() }\nrepositories { mavenCentral() }\n" + ) + File(projectDir, "mymath.def").writeText("headers = mymath.h\npackage = example\n") + File(projectDir, "build.gradle.kts").writeText( + File(projectDir, "build.gradle.kts").readText() + .replace("defFile.set(file(\"mymath.def\"))", "defFile.set(file(\"mymath.def\"))\n includeDirs.from(\"native\")") ) + + val result = runner("compileKotlin$targetSuffix", "--stacktrace").withPluginClasspathAndKgp().build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":compileNativeLibraryMymath")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":cinteropMymath$targetSuffix")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":compileKotlin$targetSuffix")?.outcome) + val order = result.tasks.map { it.path } + assertTrue(order.indexOf(":compileNativeLibraryMymath") < order.indexOf(":cinteropMymath$targetSuffix"), "$order") } } diff --git a/src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt b/src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt deleted file mode 100644 index ad4f164..0000000 --- a/src/test/kotlin/io/github/lemcoder/KotlinNativeInteropTest.kt +++ /dev/null @@ -1,106 +0,0 @@ -package io.github.lemcoder - -import io.github.lemcoder.index.PinnedKotlinNative -import org.gradle.testkit.runner.GradleRunner -import org.gradle.testkit.runner.TaskOutcome -import org.junit.jupiter.api.Assumptions.assumeTrue -import java.io.File -import kotlin.io.path.createTempDirectory -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** [io.github.lemcoder.interop.kotlinNativeInterop]: the adapter over KGP's own cinterop. */ -class KotlinNativeInteropTest { - private val projectDir = createTempDirectory("kotlin-native-interop").toFile() - - private fun runner(vararg args: String) = GradleRunner.create() - .withProjectDir(projectDir) - .withPluginClasspath() - .withArguments(*args, "--stacktrace") - - /** - * As [runner], with KGP on the plugin-under-test classpath: TestKit loads the plugin under test where - * a plugin the test build applies on its own cannot be seen, which is not how a real build loads them. - */ - private fun runnerWithKgp(vararg args: String): GradleRunner { - val metadata = java.util.Properties().apply { - KotlinNativeInteropTest::class.java.classLoader.getResourceAsStream("plugin-under-test-metadata.properties").use { load(it) } - } - val pluginClasspath = metadata.getProperty("implementation-classpath").split(File.pathSeparator) - val kgp = System.getProperty("kgp.classpath").split(File.pathSeparator) - return runner(*args).withPluginClasspath((pluginClasspath + kgp).filter { it.isNotEmpty() }.map(::File)) - } - - @Test - fun `without the Kotlin plugin it declares nothing and loads no KGP type`() { - projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "no-kgp"""") - projectDir.resolve("build.gradle.kts").writeText( - """ - import io.github.lemcoder.interop.kotlinNativeInterop - - plugins { id("io.github.lemcoder.konanplugin") } - - kotlinNativeInterop("mymath") { - defFile.set(file("mymath.def")) - } - """.trimIndent() - ) - val result = runner("tasks", "--all").build() - assertTrue("cinterop" !in result.output, "no cinterop tasks without KGP:\n${result.output}") - } - - @Test - fun `KGP binds the def for the host target, after konanConfig built its archive`() { - assumeTrue(PinnedKotlinNative.home() != null, "Kotlin/Native ${PinnedKotlinNative.version} is not installed") - val host = hostKonanTarget() - // KMP's DSL names targets in camelCase: macos_arm64 -> macosArm64. - val dslName = host.name.split('_').mapIndexed { i, part -> if (i == 0) part else part.replaceFirstChar { it.uppercase() } }.joinToString("") - - projectDir.resolve("native").mkdirs() - projectDir.resolve("native/mymath.h").writeText("int my_add(int a, int b);\n") - projectDir.resolve("native/mymath.c").writeText("#include \"mymath.h\"\nint my_add(int a, int b) { return a + b; }\n") - projectDir.resolve("mymath.def").writeText( - "headers = mymath.h\nheaderFilter = mymath.h\npackage = mymath\nstaticLibraries = libmymath.a\n" - ) - projectDir.resolve("src/${dslName}Main/kotlin").mkdirs() - projectDir.resolve("src/${dslName}Main/kotlin/Use.kt").writeText( - "@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)\n\nfun sum(): Int = mymath.my_add(2, 3)\n" - ) - projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "kmp"""") - projectDir.resolve("build.gradle.kts").writeText( - """ - import io.github.lemcoder.hostKonanTarget - import io.github.lemcoder.interop.kotlinNativeInterop - - plugins { - kotlin("multiplatform") - id("io.github.lemcoder.konanplugin") - } - - repositories { mavenCentral() } - - konanConfig { - targets(hostKonanTarget()) - libName.set("mymath") - } - - kotlin { $dslName() } - - kotlinNativeInterop("mymath") { - defFile.set(file("mymath.def")) - includeDirs.from("native") - } - """.trimIndent() - ) - - val targetSuffix = dslName.replaceFirstChar { it.uppercase() } - val result = runnerWithKgp("compileKotlin$targetSuffix").build() - - assertEquals(TaskOutcome.SUCCESS, result.task(":runKonanClang")?.outcome) - assertEquals(TaskOutcome.SUCCESS, result.task(":cinteropMymath$targetSuffix")?.outcome) - assertEquals(TaskOutcome.SUCCESS, result.task(":compileKotlin$targetSuffix")?.outcome) - val order = result.tasks.map { it.path } - assertTrue(order.indexOf(":runKonanClang") < order.indexOf(":cinteropMymath$targetSuffix"), "archive before cinterop: $order") - } -} diff --git a/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt new file mode 100644 index 0000000..2e3360f --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt @@ -0,0 +1,21 @@ +package io.github.lemcoder + +import org.gradle.testkit.runner.GradleRunner +import java.io.File + +/** + * The plugin-under-test classpath with KGP on it. TestKit loads the plugin under test where a plugin the + * test build applies on its own cannot see it β€” not how a real build loads them β€” so a fixture that + * applies a Kotlin plugin gets KGP from here and requests it without a version. Fixtures that must prove + * the plugin loads without KGP use plain `withPluginClasspath()`. + */ +internal fun GradleRunner.withPluginClasspathAndKgp(): GradleRunner { + val metadata = java.util.Properties().apply { + TestKitSupport::class.java.classLoader.getResourceAsStream("plugin-under-test-metadata.properties").use { load(it) } + } + val plugin = metadata.getProperty("implementation-classpath").split(File.pathSeparator) + val kgp = System.getProperty("kgp.classpath").split(File.pathSeparator) + return withPluginClasspath((plugin + kgp).filter { it.isNotEmpty() }.map(::File)) +} + +private object TestKitSupport diff --git a/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt new file mode 100644 index 0000000..186423b --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt @@ -0,0 +1,24 @@ +package io.github.lemcoder.index + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The command line between the plugin and the index exporter carries every request intact. */ +class ExporterProtocolTest { + private val requests = listOf( + GeneratorRequest.Dump, + GeneratorRequest.Jni(stubLibrary = "examplestubs"), + GeneratorRequest.Jni(stubLibrary = "examplestubs", visibility = GeneratedVisibility.PUBLIC), + GeneratorRequest.Jextract(library = "mymath"), + GeneratorRequest.Jextract(library = "mymath", jextract = File("/opt/jextract/bin/jextract"), headerClass = "MyMath"), + ) + + @Test + fun `every request survives the round trip`() { + requests.forEach { request -> + val invocation = ExporterInvocation(request, File("/tmp/out"), listOf("-def", "mymath.def", "-target", "macos_arm64")) + assertEquals(invocation, ExporterInvocation.parse(invocation.toArgs().toTypedArray())) + } + } +} diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index d5abb83..24528f7 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -71,7 +71,7 @@ class GenerateFromNativeIndexTest { assumeTrue(jextract != null && File(jextract).canExecute(), "Set JEXTRACT to a jextract executable") assumeInstalled() val projectDir = project( - exportTask("jextract", "ffm_sample", sampleDef, sampleDef.parentFile, "library" to "sample", "jextract" to path(File(jextract))), + exportTask(GeneratorRequest.Jextract(library = "sample", jextract = File(jextract)), "ffm_sample", sampleDef, sampleDef.parentFile), ) runner(projectDir, "generate_ffm_sample").build() @@ -83,14 +83,6 @@ class GenerateFromNativeIndexTest { .forEach { assertContains(header, it) } } - @Test - fun `an unknown generator fails with the ones that exist`() { - assumeInstalled() - val projectDir = project(exportTask("bogus", "mymath", mymathDef, mymathHeaders)) - val result = runner(projectDir, "generate_mymath").buildAndFail() - assertContains(result.output, "Unknown generator 'bogus'. Known: dump, jextract, jni") - } - private fun dump(name: String): String { assumeInstalled() return generated.getValue(name).resolve("index.txt").readText() @@ -112,26 +104,34 @@ class GenerateFromNativeIndexTest { /** One build runs every generator the tests read: output directory by task name. */ val generated: Map by lazy { val projectDir = project( - exportTask("dump", "mymath", mymathDef, mymathHeaders), - exportTask("dump", "sample", sampleDef, sampleDef.parentFile), - exportTask("jni", "jni_sample", sampleDef, sampleDef.parentFile, "stubLibrary" to "samplestubs"), + exportTask(GeneratorRequest.Dump, "mymath", mymathDef, mymathHeaders), + exportTask(GeneratorRequest.Dump, "sample", sampleDef, sampleDef.parentFile), + exportTask(GeneratorRequest.Jni(stubLibrary = "samplestubs"), "jni_sample", sampleDef, sampleDef.parentFile), ) val names = listOf("mymath", "sample", "jni_sample") runner(projectDir, *names.map { "generate_$it" }.toTypedArray()).build() names.associateWith { projectDir.resolve("build/generated/$it") } } - fun exportTask(generator: String, name: String, def: File, includeDir: File, vararg options: Pair) = """ + fun exportTask(request: GeneratorRequest, name: String, def: File, includeDir: File) = """ tasks.register("generate_$name") { defFile.set(file("${path(def)}")) includeDirs.from("${path(includeDir)}") target.set(hostKonanTarget()) - this.generator.set("$generator") - generatorOptions.set(mapOf(${options.joinToString { (k, v) -> "\"$k\" to \"$v\"" }})) + this.request.set(${kotlinSource(request)}) outputDirectory.set(layout.buildDirectory.dir("generated/$name")) } """.trimIndent() + /** [request] as build-script source. */ + fun kotlinSource(request: GeneratorRequest): String = when (request) { + GeneratorRequest.Dump -> "GeneratorRequest.Dump" + is GeneratorRequest.Jni -> + "GeneratorRequest.Jni(stubLibrary = \"${request.stubLibrary}\", visibility = GeneratedVisibility.${request.visibility})" + is GeneratorRequest.Jextract -> + "GeneratorRequest.Jextract(library = \"${request.library}\", jextract = file(\"${request.jextract?.let(::path)}\"))" + } + fun project(vararg tasks: String): File { val projectDir = createTempDirectory("native-index").toFile() projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "native-index"""") @@ -139,6 +139,8 @@ class GenerateFromNativeIndexTest { (listOf( "import io.github.lemcoder.hostKonanTarget", "import io.github.lemcoder.index.GenerateFromNativeIndexTask", + "import io.github.lemcoder.index.GeneratedVisibility", + "import io.github.lemcoder.index.GeneratorRequest", "plugins { id(\"io.github.lemcoder.konanplugin\") }", ) + tasks).joinToString("\n") ) From de93a8e31715153f2c8d6c72f83e5f87dc2139e8 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 01:42:09 +0200 Subject: [PATCH 15/57] feat!: binding consumers and external builds for the JNI stub Consumers: each way a project uses a binding is a BindingConsumer (KotlinNativeConsumer, JniConsumer, FfmConsumer), prepared eagerly and registered after evaluation when the project's plugins call for it, replacing the branching in wiring/Bindings.kt. JNI stub builds: a sealed JniStubBuild, either the plugin's own KonanLink or an External build. External builds share one task type, ExternalBuildTask, which runs a list of commands per target with the plugin's contract (KONAN_JNI_STUB_DIR, KONAN_JNI_LIB_NAME, KONAN_JNI_OUTPUT_DIR, KONAN_TARGET, KONAN_JNI_ABI, JAVA_HOME) as environment, and can copy a library the build leaves elsewhere into place. - jni { external { step { } } } runs any build system. - jni { cmake { } } is now a preset over it: configure and build in one task, per-target settings keyed by KonanTarget, `buildTargets` for CMake's --target. - Programs are a sealed Executable (File, OnPath), resolved when the task runs; CMake and Zig use it. - The contract is also on JniSpec as providers for arguments. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 20 +- README.md | 103 ++++-- doc/interop-architecture.md | 10 +- .../kotlin/io/github/lemcoder/KonanTargets.kt | 10 +- .../lemcoder/consumer/BindingConsumer.kt | 51 +++ .../github/lemcoder/consumer/FfmConsumer.kt | 60 ++++ .../github/lemcoder/consumer/JniConsumer.kt | 98 ++++++ .../lemcoder/consumer/KotlinNativeConsumer.kt | 14 + .../io/github/lemcoder/dsl/CMakeSettings.kt | 51 ++- .../io/github/lemcoder/dsl/Executable.kt | 15 + .../lemcoder/dsl/ExternalBuildSettings.kt | 68 ++++ .../io/github/lemcoder/dsl/KonanExtension.kt | 40 ++- .../lemcoder/external/ExternalBuildTask.kt | 103 ++++++ .../io/github/lemcoder/jni/CMakeStubBuild.kt | 54 ++++ .../io/github/lemcoder/jni/CMakeTasks.kt | 122 -------- .../github/lemcoder/jni/GenericStubBuild.kt | 21 ++ .../io/github/lemcoder/jni/JniStubBuild.kt | 169 ++++++++++ .../github/lemcoder/jni/KonanLinkStubBuild.kt | 93 ++++++ .../github/lemcoder/library/ZigBuildTask.kt | 6 +- .../io/github/lemcoder/library/ZigProvider.kt | 4 +- .../github/lemcoder/toolchain/Executables.kt | 24 ++ .../io/github/lemcoder/wiring/Bindings.kt | 294 +----------------- .../lemcoder/KonanPluginFunctionalTest.kt | 75 ++++- 23 files changed, 1026 insertions(+), 479 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt create mode 100644 src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt create mode 100644 src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt create mode 100644 src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/Executable.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt create mode 100644 src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt create mode 100644 src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/jni/CMakeTasks.kt create mode 100644 src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt create mode 100644 src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt create mode 100644 src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt create mode 100644 src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt diff --git a/CLAUDE.md b/CLAUDE.md index 51ba9fb..43a9d8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,13 +30,20 @@ resolved the moment the plugin is applied, in *every* project that applies it. - **`all`/`whenObjectAdded` fires before `create(name) { … }` runs its configure block.** Reading a binding's `library` or a library's `targets` there gives the default, not what the build declared β€” per-target tasks, consumer detection and KGP's cinterops are deferred to `afterEvaluate` (KGP - accepts cinterops declared there). + accepts cinterops declared there). Both sides follow this split: a `LibraryProvider` sets + conventions eagerly and registers later; a `BindingConsumer` (`consumer/`) has `prepare()` for the + eager part, then `isUsed()` and `register()` after evaluation. - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with *"It is too late to add actions as the callbacks already executed"*, so the jniLibs wiring runs at binding-registration time and tolerates an empty directory. -- **A CMake build task needs `CMakeCache.txt` as an input.** Otherwise changing a configure argument - re-runs configure, leaves the build `UP-TO-DATE`, and silently keeps the previous library β€” a - linker flag appears to do nothing. +- **An external build's configure and build steps are one task.** As two tasks, changing a configure + argument re-ran configure, left the build `UP-TO-DATE`, and silently kept the previous library β€” a + linker flag appeared to do nothing. `ExternalBuildTask` runs every step, and without declared + `sources` it always runs, leaving incrementality to the build system. +- **External-build steps stay lazy.** The stub directory comes from the generator's + `@OutputDirectory`, so steps are `@Nested` `ExternalCommand` beans whose arguments are composed from + providers (`add(provider.map { … })`); never `provider { x.get() }`. Executables are resolved in the + task action (`Executable.command()`), not at configuration. ## Layout and naming contracts @@ -48,7 +55,10 @@ resolved the moment the plugin is applied, in *every* project that applies it. - **The stub library name is derived from the binding package** and baked into the generated `System.loadLibrary`. It, and the stub directory, are reported by the task (`stubLibraryBaseName`, `stubSourceDirectory`, `kotlinSourceDirectory`) β€” consumers must never - reconstruct them, and `KONAN_JNI_STUB_DIR` / `KONAN_JNI_LIB_NAME` are the external-build contract. + reconstruct them. The external-build contract is `ExternalStubVariable` (`KONAN_JNI_STUB_DIR`, + `KONAN_JNI_LIB_NAME`, `KONAN_JNI_OUTPUT_DIR`, `KONAN_TARGET`, `KONAN_JNI_ABI`, `JAVA_HOME`): every + `JniStubBuild.External` gets it as environment in `External.register`; a subclass only adds commands + (CMake also passes it as cache entries). The same values are on `JniSpec` as providers. - **`cmake --preset` resolves `CMakePresets.json` from the working directory**, so configure runs from the CMakeLists directory rather than the project root. diff --git a/README.md b/README.md index ba6df91..b12203c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and - Speaks Kotlin/Native's own `KonanTarget`: pass `kotlinNativeTarget.konanTarget` straight in, and new targets arrive with the Kotlin version. - Wires generated sources, `jniLibs` and task order into Kotlin and Android builds; can hand the JNI - stub to an external CMake build instead. + stub to the project's own build β€” CMake, or any build system as a list of commands β€” instead. - Auto-detects the Kotlin/Native distribution, the JDK that supplies `jni.h`, and the compiler-rt builtins each target needs (NDK on Android, Xcode on macOS). @@ -89,7 +89,7 @@ library (`b.addLibrary(.{ .linkage = .static, … })`). | Property | Type | Default | Description | |--------------------|----------------|---------------------|-------------| | `projectDirectory` | `Directory` | `sourceDirectory` | Where `build.zig` is. | -| `executable` | `File` | `zig` on the PATH | The `zig` executable. | +| `executable` | `Executable` | `OnPath("zig")` | The `zig` program: `Executable.File(file(…))`, or `Executable.OnPath(name)`. | | `optimize` | `ZigOptimize` | `RELEASE_FAST` | `-Doptimize`. Zig's `DEBUG` and `RELEASE_SAFE` compile C with UBSan traps, whose runtime the Kotlin/Native and JNI links do not have. | | `arguments` | `List` | β€” | Further `zig build` arguments. | | `artifactName` | `String` | the library's name | The static library `build.zig` installs. | @@ -121,16 +121,38 @@ A C API described by a cinterop `.def`. | `linkerOptionsFor(target, …)` | | β€” | Extra options for one target's link; `-framework` belongs here, since the Android linker rejects it. | | `libraryDirectory` | `Directory` | output | Where the host's JNI library ends up: put it on `java.library.path`. | | `cmake { }` | | β€” | An external CMake build that compiles the stub instead; below. | +| `external { }` | | β€” | Any other build system that compiles the stub, as commands; below. | +| `stubDirectory`, `stubLibraryName`, `libraryDirectoryFor(target)` | read-only | β€” | The external-build contract as providers, for arguments. | Without a library, `jni.archive` or `staticLibraries` in the `.def`, the plugin stops after the Kotlin bindings and the `.c` stub, for another build system to compile. That is the mode for a native library owned by CMake, Bazel or a vendor: linking an archive built by a foreign toolchain with konan's linker is what produces mismatched C++ runtimes and missing compiler-rt. -#### Letting CMake link it β€” `jni { cmake { } }` -When the native library belongs to a CMake project, the plugin drives that build instead of linking -anything itself, the way AGP's `externalNativeBuild` hands a module's native code to CMake: +#### Letting your own build link it +When the native library belongs to another build system, the plugin drives that build instead of +linking anything itself, the way AGP's `externalNativeBuild` hands a module's native code to CMake. No +link task is registered: one build task per target takes its place, ordered after generation, and +`linkJni` depends on them, so "make the JNI library exist" means the same thing either way. A +binding selects at most one of `cmake { }` and `external { }`. +Every run gets the plugin's contract β€” what only it knows β€” as environment variables: + +| variable | meaning | +|---|---| +| `KONAN_JNI_STUB_DIR` | directory holding the generated `.c` stub | +| `KONAN_JNI_LIB_NAME` | the name the generated bindings will `System.loadLibrary` | +| `KONAN_JNI_OUTPUT_DIR` | where this target's library must land: `jniLibs//` on Android, which the plugin packages | +| `KONAN_TARGET` | the Kotlin/Native target, e.g. `android_arm64` | +| `KONAN_JNI_ABI` | its Android ABI, e.g. `arm64-v8a`; the target's name elsewhere | +| `JAVA_HOME` | `jni.jdkHome`, else a JDK that ships `include/jni.h` β€” IDE-bundled JBRs strip the headers | + +The same values are on `jni` as providers β€” `stubDirectory`, `stubLibraryName`, +`libraryDirectoryFor(target)` β€” for building arguments. Programs are an `Executable`: `File(file)`, or +`OnPath(name)`, looked up when the build runs on the `PATH` and in the usual install locations, because +the Gradle daemon does not inherit a login shell's `PATH`. + +##### CMake β€” `jni { cmake { } }` ```kotlin create("koinference") { packageName.set("com.example.native") @@ -138,30 +160,18 @@ create("koinference") { cmake { path.set(file("native/CMakeLists.txt")) preset.set("macosArm64") // or buildDirectory, for preset-less projects - targets.add("koinference-jni") + buildTargets.add("koinference-jni") arguments.add("-DMY_OWN_SWITCH=ON") } } } ``` -No link task is registered; `cmakeConfigure` and `cmakeBuild` take its place, ordered -after generation, and `linkJni` depends on the build so "make the JNI library exist" means the -same thing either way. Android needs a library per ABI, so declare them: -`abi("arm64-v8a") { preset.set("androidNativeArm64") }`. Each ABI gets its own configure/build pair and -lands in `jniLibs//`, which the plugin packages; it sets `CMAKE_LIBRARY_OUTPUT_DIRECTORY`, so the -CMakeLists needs no knowledge of the layout. - -The plugin supplies what only it knows, as cache entries your `CMakeLists.txt` reads: - -| variable | meaning | -|---|---| -| `KONAN_JNI_STUB_DIR` | directory holding the generated `.c` stub | -| `KONAN_JNI_LIB_NAME` | the name the generated bindings will `System.loadLibrary` | - -It also points `JAVA_HOME` at a JDK that ships `include/jni.h` β€” the one running Gradle often does -not, since IDE-bundled JBRs strip the headers β€” and locates `cmake` itself, because the Gradle daemon -does not inherit a login shell's PATH. +`cmakeBuild` configures, then builds. It always runs and leaves incrementality to CMake, +so a changed configure argument always reaches the build. Android needs a library per ABI, so declare +targets: `target(KonanTarget.ANDROID_ARM64) { preset.set("androidNativeArm64") }`; without any, it +builds once for the host. The contract also arrives as cache entries, and `CMAKE_LIBRARY_OUTPUT_DIRECTORY` +is set to `KONAN_JNI_OUTPUT_DIR`, so the CMakeLists needs no knowledge of the layout: ```cmake set(KONAN_JNI_STUB_DIR "" CACHE PATH "") @@ -173,6 +183,47 @@ set_target_properties(mylib-jni PROPERTIES OUTPUT_NAME "${KONAN_JNI_LIB_NAME}") target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arrive transitively ``` +| Property | Type | Default | Description | +|------------------|------------------|--------------------|-------------| +| `path` | `File` | β€” | The `CMakeLists.txt`. Required unless `preset` is set. | +| `preset` | `String` | β€” | A configure preset from `CMakePresets.json`. | +| `buildDirectory` | `Directory` | `/build/`, else under `build/` | Where CMake builds. | +| `buildTargets` | `List` | the default target | `--target`s to build. | +| `arguments` | `List` | β€” | Extra configure arguments. | +| `libraryDirectory` | `Directory` | `KONAN_JNI_OUTPUT_DIR` | Where the library is, if the build ignores `CMAKE_LIBRARY_OUTPUT_DIRECTORY`. | +| `executable` | `Executable` | `OnPath("cmake")` | The `cmake` program. | +| `target(t) { }` | | the host | A per-target build with its own `preset`, `buildDirectory`, `arguments`. | + +##### Any build system β€” `jni { external { } }` +A list of commands, run in order once per JNI target (`jni.targets`): + +```kotlin +jni { + external { + step { + executable.set(Executable.OnPath("make")) + arguments.addAll("-C", "native/jni") + argumentsFor(KonanTarget.ANDROID_ARM64, "ARCH=arm64") + } + sources.from("native") // what it reads; omit to always run + producedLibrary(KonanTarget.MACOS_ARM64, file("native/jni/out/libstub.dylib")) + } +} +``` + +`externalBuild` runs the steps. The build writes the library into `KONAN_JNI_OUTPUT_DIR`, +or names where it leaves it with `producedLibrary(target, file)`, and the plugin copies it into place +under the name the bindings load. With `sources` declared the task is up to date while they and the +steps are unchanged; without, the plugin cannot know what the build reads, so it always runs. + +| Step property | Type | Description | +|--------------------|---------------------|-------------| +| `executable` | `Executable` | The program. Required. | +| `arguments` | `List` | Arguments for every target; `add(provider)` composes the contract providers. | +| `argumentsFor(target, …)` | | Arguments for one target, after `arguments`. | +| `environment` | `Map` | Variables beyond the contract; these win over it. | +| `workingDirectory` | `Directory` | Where it runs. | + ### The generated JNI bindings One `external fun` per C function, named after it and carrying its C signature as a doc comment. cinterop's own indexer reads the headers β€” the same `.def` the Kotlin/Native targets use β€” and the @@ -215,7 +266,7 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | | `linkJni` | Links it for one target. | -| `cmakeConfigure`, `cmakeBuild` | The external CMake build, in place of the link. | +| `cmakeBuild`, `externalBuild` | The project's own build, in place of the link. | | `generateFfm`, `linkFfm` | jextract's bindings, and the shared library they load. | | `cinterop` | KGP's own cinterop, declared by the plugin. | @@ -238,7 +289,9 @@ The DSL is new and there is no compatibility shim. - `jvmInterops { create(…) { … } }` β€” on a compilation or on the project β€” becomes `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` β†’ `jdkHome`, `library` β†’ `archive`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ - `linkerOptionsFor`, `externalNativeBuild { cmake { } }` β†’ `cmake { }`, `resolvedLibraryDirectory` β†’ + `linkerOptionsFor`, `externalNativeBuild { cmake { } }` β†’ `cmake { }` (`targets` β†’ `buildTargets`, + `abi("arm64-v8a")` β†’ `target(KonanTarget.ANDROID_ARM64)`, `executable` β†’ an `Executable`, no `$CMAKE`), + `resolvedLibraryDirectory` β†’ `libraryDirectory`. `additionalCompilerArgs` becomes `compilerOptions`. Bindings are wired into the project's JVM and Android targets automatically. - Refer to the library instead of naming the archive: `library(mymath)`, where diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index ddbab64..55896d3 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -110,8 +110,8 @@ flowchart TD | 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`: **KonanClangProvider** (default, `konanClang`) and **ZigProvider** (`zig { }`, `zigBuild`), both behind `compileNativeLibrary`; CMake only for the JNI stub; **Prebuilt** not yet | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KgpWiring.declareCinterops`) | -| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `LinkJvmInteropTask`, or an external build through the `KONAN_JNI_STUB_DIR` contract; a no-glue shared library for FFM is missing (section D) | -| 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | JVM and Android for JNI; Kotlin/Native through the KGP adapter; FFM and publications not yet (section D) | +| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); FFM links the library itself, no glue | +| 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | `BindingConsumer`: **KotlinNativeConsumer** (the KGP adapter), **JniConsumer** (JVM, multiplatform JVM, Android; own link or CMake), **FfmConsumer** (`kotlin("jvm")` only); publications not yet (section D) | How the two paths use them: @@ -303,7 +303,11 @@ Partly settled by what exists: ## D. Toolchains, prebuilt binaries and packaging What the generators showed each consumer needs. The plugin wires every row today, FFM only in a -`kotlin("jvm")` project: +`kotlin("jvm")` project. Each row is a `BindingConsumer`, the binding-side counterpart of +`LibraryProvider`: `prepare()` runs when the binding is added, for wiring that must be eager (AGP's +variant callbacks, the multiplatform source directory); `isUsed()` decides from the project's plugins +and the binding's settings once the build script has run; `register()` then adds the tasks. Consumers +take the library only as `NativeArtifacts`. A new consumer is one more entry in `bindingConsumers`. | Consumer | Binary | Glue | Wiring | | --- | --- | --- | --- | diff --git a/src/main/kotlin/io/github/lemcoder/KonanTargets.kt b/src/main/kotlin/io/github/lemcoder/KonanTargets.kt index 9be3d0d..d9b84be 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanTargets.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanTargets.kt @@ -24,11 +24,11 @@ internal val KonanTarget.taskSuffix: String get() = name.replaceFirstChar { it.u /** `jniLibs/` subdirectory: Android ABI names, otherwise the target name. */ internal val KonanTarget.abiDir: String - get() = when (name) { - "android_arm64" -> "arm64-v8a" - "android_arm32" -> "armeabi-v7a" - "android_x64" -> "x86_64" - "android_x86" -> "x86" + get() = when (this) { + KonanTarget.ANDROID_ARM64 -> "arm64-v8a" + KonanTarget.ANDROID_ARM32 -> "armeabi-v7a" + KonanTarget.ANDROID_X64 -> "x86_64" + KonanTarget.ANDROID_X86 -> "x86" else -> name } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt new file mode 100644 index 0000000..463e038 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt @@ -0,0 +1,51 @@ +package io.github.lemcoder.consumer + +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.wiring.DefFile +import io.github.lemcoder.wiring.GradlePluginId +import io.github.lemcoder.wiring.capitalized +import io.github.lemcoder.wiring.hasPlugin +import org.gradle.api.Project +import org.gradle.api.provider.Provider + +/** + * One way a project uses a binding β€” Kotlin/Native, JNI, FFM. A consumer is found from the project's + * plugins and the binding's settings, never declared, and takes the library only as [NativeArtifacts]: + * it never learns which provider built it. + */ +internal interface BindingConsumer { + /** + * Runs when the binding is added, before its configure block: conventions, and wiring Gradle or AGP + * only accept at configuration time. Must not read the binding's declared values. + */ + fun prepare() {} + + /** Whether the project consumes the binding this way. Called once the build script has run. */ + fun isUsed(): Boolean + + /** Registers this consumer's tasks and wiring. Called once the build script has run, if [isUsed]. */ + fun register() +} + +/** Every consumer the plugin knows, for one binding. A new kind of consumer is one more entry here. */ +internal fun bindingConsumers(context: BindingContext): List = + listOf(KotlinNativeConsumer(context), JniConsumer(context), FfmConsumer(context)) + +/** What every consumer of one binding shares. */ +internal class BindingContext(val project: Project, val konan: KonanExtension, val binding: BindingSpec) { + val bindingName: String = binding.name + val suffix: String = bindingName.capitalized() + val buildDirectory = project.layout.buildDirectory + + /** The archives of the library the binding names, once the build script has been evaluated. */ + fun artifacts(): NativeArtifacts? = binding.library.orNull?.let { NativeArtifacts.of(project, it) } + + /** The package the bindings go in: the binding's, else the `.def`'s own. */ + val packageName: Provider = binding.packageName.orElse( + project.provider { binding.defFile.orNull?.asFile?.takeIf { it.isFile }?.let { DefFile.parse(it).packageName } } + ) + + fun hasPlugin(id: GradlePluginId): Boolean = project.hasPlugin(id) +} diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt new file mode 100644 index 0000000..a5f3732 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt @@ -0,0 +1,60 @@ +package io.github.lemcoder.consumer + +import io.github.lemcoder.hostKonanTarget +import io.github.lemcoder.index.GenerateFromNativeIndexTask +import io.github.lemcoder.index.GeneratorRequest +import io.github.lemcoder.link.LinkSharedLibraryTask +import io.github.lemcoder.wiring.GradlePluginId +import org.gradle.api.tasks.SourceSetContainer + +/** + * FFM bindings: jextract over the same `.def`, and the library itself as a shared library for the host β€” + * FFM loads it directly, with no stub. Opted into with `ffm { }`; in a `kotlin("jvm")` project it + * replaces JNI. + */ +internal class FfmConsumer(private val context: BindingContext) : BindingConsumer { + override fun isUsed(): Boolean = context.binding.ffm.isEnabled + + override fun register() { + val project = context.project + val binding = context.binding + val bindingName = context.bindingName + val artifacts = context.artifacts() + val libraryName = artifacts?.name ?: bindingName + val host = hostKonanTarget() + val packageName = context.packageName + val generate = project.tasks.register("generateFfm${context.suffix}", GenerateFromNativeIndexTask::class.java) { + group = "konan" + description = "Generate the '$bindingName' FFM bindings with jextract." + defFile.set(binding.defFile) + includeDirs.from(binding.includeDirs) + compilerOptions.set(binding.compilerOptions) + target.set(host) + request.set(project.provider { GeneratorRequest.Jextract(library = libraryName, jextract = binding.ffm.jextract.orNull?.asFile) }) + packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } + outputDirectory.set(context.buildDirectory.dir("generated/konan/$bindingName/ffm")) + } + val ffmLib = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib/${host.name}") + binding.ffm.libraryDirectory.convention(ffmLib) + if (artifacts != null && host in artifacts.targets.getOrElse(emptyList())) { + project.tasks.register("linkFfm${context.suffix}", LinkSharedLibraryTask::class.java) { + group = "konan" + description = "Link the '$libraryName' library as a shared library for the FFM bindings." + dependsOn(artifacts.builtBy) + konanPath.set(context.konan.konanHome.map { it.asFile.absolutePath }) + target.set(host) + libraryBaseName.set(libraryName) + staticLibrary.set(artifacts.archive(host)) + additionalLinkerArgs.set(binding.jni.linkerOptions) + outputDirectory.set(ffmLib) + } + } + if (context.hasPlugin(GradlePluginId.KOTLIN_JVM)) { + // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. + project.extensions.getByType(SourceSetContainer::class.java).getByName("main") + .java.srcDir(generate.map { it.outputDirectory.dir("java") }) + } else { + project.logger.info("konan.bindings.$bindingName: FFM bindings are only wired into kotlin(\"jvm\") projects; add build/generated/konan/$bindingName/ffm/java yourself.") + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt new file mode 100644 index 0000000..277d65b --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt @@ -0,0 +1,98 @@ +package io.github.lemcoder.consumer + +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.BindingVisibility +import io.github.lemcoder.hostKonanTarget +import io.github.lemcoder.index.GeneratedVisibility +import io.github.lemcoder.isAndroid +import io.github.lemcoder.jni.GenerateJniBindingsTask +import io.github.lemcoder.jni.JniStubInputs +import io.github.lemcoder.jni.stubBuildFor +import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.wiring.AgpWiring +import io.github.lemcoder.wiring.GradlePluginId +import io.github.lemcoder.wiring.KgpWiring +import io.github.lemcoder.wiring.withPlugin +import org.gradle.api.Task +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.konan.target.KonanTarget + +/** + * JVM and Android targets, through the JNI generator: Kotlin `external fun`s in the main sources, and a + * JNI library per target from the binding's [io.github.lemcoder.jni.JniStubBuild]. + */ +internal class JniConsumer(private val context: BindingContext) : BindingConsumer { + private val project = context.project + private val binding = context.binding + private val bindingName = context.bindingName + private val suffix = context.suffix + + private lateinit var generate: TaskProvider + private lateinit var linkAll: TaskProvider + + override fun prepare() { + binding.jni.visibility.convention(BindingVisibility.INTERNAL) + generate = registerGeneration() + binding.jni.stubDirectoryProperty.set(generate.flatMap { it.stubSourceDirectory }) + binding.jni.stubLibraryNameProperty.set(generate.flatMap { it.stubLibraryBaseName }) + binding.jni.jniLibsRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/jni/jniLibs")) + binding.jni.hostLibRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/jni/lib")) + linkAll = project.tasks.register("linkJni$suffix") { + group = "konan" + description = "Make the '$bindingName' JNI library exist for every target, however it is built." + } + val sources = generate.flatMap { it.kotlinSourceDirectory } + + // Eager: the variant API must be wired at configuration time. + project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KgpWiring.addKmpJvmSources(project, sources) } + AgpWiring.addKotlinSources(project, sources, generate, linkAll) + AgpWiring.addJniLibs(project, binding.jni.jniLibsRoot, linkAll) + // AGP looks for a `baselineProfiles` directory beside every source root, including the generated + // one, by plain path β€” no task dependency. Matched by name: inert without AGP. + project.tasks.matching { it.name.contains("ArtProfile") }.configureEach { dependsOn(generate) } + } + + /** In a `kotlin("jvm")` project FFM replaces JNI: both would define the same facade class. */ + private val isPlainJvmConsumer get() = context.hasPlugin(GradlePluginId.KOTLIN_JVM) && !binding.ffm.isEnabled + + override fun isUsed(): Boolean = + isPlainJvmConsumer || + context.hasPlugin(GradlePluginId.ANDROID_BASE) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || + (context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KgpWiring.hasKmpJvmTarget(project)) + + override fun register() { + if (isPlainJvmConsumer) KgpWiring.addJvmSources(project, generate.flatMap { it.kotlinSourceDirectory }) + val artifacts = context.artifacts() + val inputs = JniStubInputs(generate, artifacts, targets(artifacts)) + val builds = stubBuildFor(bindingName, binding.jni).register(project, context.konan, binding, inputs) + linkAll.configure { dependsOn(builds) } + } + + /** `jni.targets`, else the library's targets a JVM can load β€” the host and Android β€” else the host. */ + private fun targets(artifacts: NativeArtifacts?): List = binding.jni.targets.getOrElse(emptyList()).ifEmpty { + artifacts?.targets?.getOrElse(emptyList())?.filter { it == hostKonanTarget() || it.isAndroid } + ?.ifEmpty { null } + ?: listOf(hostKonanTarget()) + } + + private fun registerGeneration(): TaskProvider = + project.tasks.register("generateJni$suffix", GenerateJniBindingsTask::class.java) { + group = "konan" + description = "Generate the '$bindingName' JNI bindings and stub from its .def." + mustRunAfter(project.cleanTask()) + defFile.set(binding.defFile) + packageName.set(context.packageName) + includeDirs.from(binding.includeDirs) + hostTarget.set(hostKonanTarget()) + compilerOptions.set(binding.compilerOptions) + visibility.set( + binding.jni.visibility.map { + when (it) { + BindingVisibility.INTERNAL -> GeneratedVisibility.INTERNAL + BindingVisibility.PUBLIC -> GeneratedVisibility.PUBLIC + } + } + ) + outputDirectory.set(context.buildDirectory.dir("generated/konan/$bindingName/jni")) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt new file mode 100644 index 0000000..ec58a62 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt @@ -0,0 +1,14 @@ +package io.github.lemcoder.consumer + +import io.github.lemcoder.wiring.GradlePluginId +import io.github.lemcoder.wiring.KgpWiring + +/** + * Kotlin/Native targets, through KGP's own cinterop: the adapter declares the binding on each target's + * main compilation and embeds the library's archive in the klib. + */ +internal class KotlinNativeConsumer(private val context: BindingContext) : BindingConsumer { + override fun isUsed(): Boolean = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) + + override fun register() = KgpWiring.declareCinterops(context.project, context.binding, context.artifacts()) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt index de2d41d..3ca242d 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt @@ -1,13 +1,12 @@ package io.github.lemcoder.dsl import org.gradle.api.Action -import org.gradle.api.Named -import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.model.ObjectFactory import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property +import org.jetbrains.kotlin.konan.target.KonanTarget import javax.inject.Inject /** @@ -24,22 +23,23 @@ import javax.inject.Inject * Either name a [preset] or let the plugin configure [path] into [buildDirectory] directly; everything * else has a default. * - * Declaring [abi]s builds once per ABI instead of once for the host, which is what Android needs: the - * libraries land in `jniLibs//` and are packaged into the AAR. + * Declaring [target]s builds once per target instead of once for the host, which is what Android needs: + * the libraries land in `jniLibs//` and are packaged into the AAR. */ -abstract class CMakeSettings @Inject constructor(objects: ObjectFactory) { +abstract class CMakeSettings @Inject constructor(private val objects: ObjectFactory) { - /** Per-ABI builds. Empty means a single build for the host. */ - val abis: NamedDomainObjectContainer = - objects.domainObjectContainer(CMakeAbiSettings::class.java) { name -> - objects.newInstance(CMakeAbiSettings::class.java, name) - } + init { + executable.convention(Executable.OnPath("cmake")) + } - /** Adds a build for one Android ABI, e.g. `abi("arm64-v8a") { preset.set("androidNativeArm64") }`. */ - fun abi(name: String, action: Action) { - action.execute(abis.maybeCreate(name)) + /** Builds for [target] β€” an Android ABI, say β€” with its own settings, instead of once for the host. */ + fun target(target: KonanTarget, action: Action) { + action.execute(targetSettings.getOrPut(target) { objects.newInstance(CMakeTargetSettings::class.java) }) } + /** The per-target builds declared; empty means a single build for the host. */ + internal val targetSettings = linkedMapOf() + /** The `CMakeLists.txt` to configure. Required unless [preset] is set. */ abstract val path: RegularFileProperty @@ -53,32 +53,29 @@ abstract class CMakeSettings @Inject constructor(objects: ObjectFactory) { abstract val buildDirectory: DirectoryProperty /** - * Directory holding the linked JNI library, if the build does not leave it in [buildDirectory] - * (`CMAKE_LIBRARY_OUTPUT_DIRECTORY`). + * Directory holding the linked JNI library, if the build does not leave it where + * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` says. */ abstract val libraryDirectory: DirectoryProperty - /** Targets to build. Defaults to whatever the build's default target is. */ - abstract val targets: ListProperty + /** CMake targets to build (`--target`). Defaults to the build's default target. */ + abstract val buildTargets: ListProperty /** Extra arguments for the configure step, e.g. your own `-D` switches. */ abstract val arguments: ListProperty - /** The `cmake` binary. When absent, `$CMAKE`, else a scan of the usual locations, else `cmake`. */ - abstract val executable: RegularFileProperty + /** The `cmake` program. Defaults to `cmake` on the PATH or in the usual install locations. */ + abstract val executable: Property } -/** One ABI's build: everything not set here comes from the enclosing [CMakeSettings]. */ -abstract class CMakeAbiSettings @Inject constructor(private val abiName: String) : Named { - - override fun getName(): String = abiName - - /** Configure preset for this ABI. */ +/** One target's build: everything not set here comes from the enclosing [CMakeSettings]. */ +abstract class CMakeTargetSettings { + /** Configure preset for this target. */ abstract val preset: Property - /** Build directory for this ABI; defaults the same way as the enclosing block's. */ + /** Build directory for this target; defaults the same way as the enclosing block's. */ abstract val buildDirectory: DirectoryProperty - /** Extra configure arguments for this ABI only. */ + /** Extra configure arguments for this target only. */ abstract val arguments: ListProperty } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/Executable.kt b/src/main/kotlin/io/github/lemcoder/dsl/Executable.kt new file mode 100644 index 0000000..000b4c0 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/Executable.kt @@ -0,0 +1,15 @@ +package io.github.lemcoder.dsl + +import java.io.Serializable + +/** A program an external build runs: a file, or a name looked up when the build runs. */ +sealed class Executable : Serializable { + /** This exact file. */ + data class File(val file: java.io.File) : Executable() + + /** + * Found on the `PATH` when the build runs, then in the usual install locations: the Gradle daemon + * does not inherit a login shell's `PATH`, so a Homebrew or MacPorts install is otherwise invisible. + */ + data class OnPath(val name: String) : Executable() +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt new file mode 100644 index 0000000..83159d0 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt @@ -0,0 +1,68 @@ +package io.github.lemcoder.dsl + +import org.gradle.api.Action +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.jetbrains.kotlin.konan.target.KonanTarget +import java.io.File +import javax.inject.Inject + +/** + * Any build system, as a list of commands run once per JNI target. Each run gets the plugin's contract + * as environment variables β€” `KONAN_JNI_STUB_DIR`, `KONAN_JNI_LIB_NAME`, `KONAN_JNI_OUTPUT_DIR`, + * `KONAN_TARGET`, `KONAN_JNI_ABI`, `JAVA_HOME` β€” and the same values are on `jni` as providers, for + * arguments. The build writes the library into `KONAN_JNI_OUTPUT_DIR`, or names where it leaves it with + * [producedLibrary]. + */ +abstract class ExternalBuildSettings @Inject constructor(private val objects: ObjectFactory) { + /** + * What the build reads. With none, it runs every time and its own incrementality decides; with them, + * it is up to date while they and its commands are unchanged. + */ + abstract val sources: ConfigurableFileCollection + + /** Where the build leaves each target's library, when it does not write it to `KONAN_JNI_OUTPUT_DIR`. */ + abstract val producedLibraries: MapProperty + + /** The library the build leaves at [file] for [target]; the plugin copies and renames it into place. */ + fun producedLibrary(target: KonanTarget, file: File) = producedLibraries.put(target, file) + + /** Appends a command; commands run in declaration order, each only after the previous succeeded. */ + fun step(action: Action) { + steps += objects.newInstance(ExternalStepSpec::class.java).also { action.execute(it) } + } + + internal val steps = mutableListOf() +} + +/** One command of an [ExternalBuildSettings] build. */ +abstract class ExternalStepSpec { + /** The program to run. Required. */ + abstract val executable: Property + + /** Arguments for every target. */ + abstract val arguments: ListProperty + + /** Arguments for one target, after [arguments]. */ + abstract val targetArguments: MapProperty> + + /** Variables beyond the plugin's contract; these win over it. */ + abstract val environment: MapProperty + + /** Where the command runs. Defaults to the Gradle daemon's own working directory. */ + abstract val workingDirectory: DirectoryProperty + + /** Appends [arguments] for [target] only. */ + fun argumentsFor(target: KonanTarget, vararg arguments: String) { + val existing = targetArguments.getOrElse(emptyMap())[target].orEmpty() + targetArguments.put(target, existing + arguments) + } + + /** Sets [target]'s arguments from a provider, e.g. one built from `jni.libraryDirectoryFor(target)`. */ + fun argumentsFor(target: KonanTarget, arguments: Provider>) = targetArguments.put(target, arguments) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt index 20ad289..a2d450e 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -1,6 +1,9 @@ package io.github.lemcoder.dsl +import io.github.lemcoder.abiDir import io.github.lemcoder.androidKonanTargets +import io.github.lemcoder.isAndroid +import io.github.lemcoder.jni.JniStubBuild import io.github.lemcoder.library.LibraryProviderKind import org.gradle.api.Action import org.gradle.api.Named @@ -115,8 +118,8 @@ abstract class ZigSettings { /** The directory holding `build.zig`. Defaults to the library's `sourceDirectory`. */ abstract val projectDirectory: DirectoryProperty - /** The `zig` executable. When absent, `zig` on the PATH. */ - abstract val executable: RegularFileProperty + /** The `zig` program. Defaults to `zig` on the PATH or in the usual install locations. */ + abstract val executable: Property /** * `-Doptimize`. Defaults to [ZigOptimize.RELEASE_FAST]: Zig's `DEBUG` and `RELEASE_SAFE` compile C with @@ -226,12 +229,39 @@ abstract class JniSpec @Inject constructor(objects: ObjectFactory) { /** An external CMake build that compiles the stub; declaring it replaces the plugin's own link. */ val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) - internal var usesCMake: Boolean = false - fun cmake(action: Action) { - usesCMake = true + selectedStubBuilds += JniStubBuild.External.CMake action.execute(cmake) } + + /** Any other build system that compiles the stub, as a list of commands; replaces the plugin's own link. */ + val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) + + fun external(action: Action) { + selectedStubBuilds += JniStubBuild.External.Generic + action.execute(external) + } + + /** The directory holding the generated `.c` stub β€” what an external build compiles. */ + val stubDirectory: Provider get() = stubDirectoryProperty + + /** The library name the generated bindings load: an external build must produce exactly that. */ + val stubLibraryName: Provider get() = stubLibraryNameProperty + + /** Where [target]'s JNI library must land: `jniLibs//` for Android, a per-target directory otherwise. */ + fun libraryDirectoryFor(target: KonanTarget): Provider = + if (target.isAndroid) jniLibsRoot.dir(target.abiDir) else hostLibRoot.dir(target.name) + + internal val stubDirectoryProperty: DirectoryProperty = objects.directoryProperty() + internal val stubLibraryNameProperty: Property = objects.property(String::class.java) + + // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, and a + // host library sitting in the root fails its merge with "… is not an ABI". + internal val jniLibsRoot: DirectoryProperty = objects.directoryProperty() + internal val hostLibRoot: DirectoryProperty = objects.directoryProperty() + + /** The stub build blocks this binding's script called; at most one may be. */ + internal val selectedStubBuilds = mutableSetOf() } /** How the FFM bindings are generated. The generated Java needs a JDK 22+ toolchain. */ diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt new file mode 100644 index 0000000..06c5271 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt @@ -0,0 +1,103 @@ +package io.github.lemcoder.external + +import io.github.lemcoder.dsl.Executable +import io.github.lemcoder.toolchain.command +import io.github.lemcoder.util.ExecCaptureResult +import io.github.lemcoder.util.execCapture +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +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.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import java.io.File +import javax.inject.Inject + +/** + * Runs a build system the plugin does not model, for one target: its [commands] in order, with the + * plugin's contract in [environment], then β€” if the build leaves its library somewhere of its own β€” + * copies [producedLibrary] into [outputDirectory] as [libraryFileName]. + * + * Up-to-date only when [sources] are declared: without them the plugin cannot know what the build + * reads, so it always runs and the build's own incrementality decides. + */ +@DisableCachingByDefault(because = "Runs a build system whose inputs the plugin does not model") +internal abstract class ExternalBuildTask @Inject constructor( + private val exec: ExecOperations, + private val objects: ObjectFactory, +) : DefaultTask() { + @get:Nested val commands: MutableList = mutableListOf() + + /** Every command's environment: the plugin's contract. A command's own entries win. */ + @get:Input abstract val environment: MapProperty + + /** What the build reads, as declared by the build script. */ + @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sources: ConfigurableFileCollection + + /** What the build reads that the plugin generated, such as the JNI stub. */ + @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val generatedSources: ConfigurableFileCollection + + /** Where the build leaves its library, when that is not [outputDirectory]. */ + @get:Internal abstract val producedLibrary: RegularFileProperty + + /** The name the library must have in [outputDirectory]. */ + @get:Input abstract val libraryFileName: Property + + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + init { + outputs.upToDateWhen { !(it as ExternalBuildTask).sources.isEmpty } + } + + fun command(configure: ExternalCommand.() -> Unit) { + commands += objects.newInstance(ExternalCommand::class.java).apply(configure) + } + + @TaskAction + fun run() { + outputDirectory.get().asFile.mkdirs() + // Read before exec's block: inside it, `environment` is the ExecSpec's own. + val contract = environment.get() + commands.forEachIndexed { index, command -> + val line = listOf(command.executable.get().command()) + command.arguments.get() + val directory = command.workingDirectory.orNull + val variables = contract + command.environment.get() + val result = exec.execCapture { + commandLine(line) + directory?.let { workingDir = File(it) } + environment(variables) + } + logger.info(result.output) + check(result is ExecCaptureResult.Success) { + "Command ${index + 1} of $path failed: ${line.joinToString(" ")}\n${result.output}" + } + } + producedLibrary.orNull?.asFile?.let { produced -> + check(produced.isFile) { "$path: the build left no library at $produced" } + produced.copyTo(outputDirectory.get().asFile.resolve(libraryFileName.get()), overwrite = true) + } + } +} + +/** One command of an external build. */ +internal abstract class ExternalCommand { + @get:Input abstract val executable: Property + @get:Input abstract val arguments: ListProperty + @get:Input abstract val environment: MapProperty + + /** A path, not a tracked directory: the build's own directory is rarely only its input. */ + @get:Input @get:Optional abstract val workingDirectory: Property +} diff --git a/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt new file mode 100644 index 0000000..d88a682 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt @@ -0,0 +1,54 @@ +package io.github.lemcoder.jni + +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.external.ExternalBuildTask +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider + +/** + * [JniStubBuild.External.CMake]: configure, then build, the project's CMakeLists for one target. The + * contract also goes in as cache entries β€” what a CMakeLists conventionally reads β€” and + * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` lands the library where the plugin says, so the CMakeLists needs no + * knowledge of the layout. + * + * Both commands are one task, which always runs: CMake tracks its own sources, and a changed configure + * argument then always reaches the build. + */ +internal fun addCMakeCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) { + val cmake = binding.jni.cmake + val perTarget = cmake.targetSettings[contract.target] + val preset = perTarget?.preset?.orElse(cmake.preset) ?: cmake.preset + check(cmake.path.isPresent || preset.isPresent) { "konan.bindings.${binding.name}.jni.cmake needs a path or a preset." } + val sourceDirectory = cmake.path.map { it.asFile.parentFile } + // With a preset the binary directory is the preset's to choose, and `-B` cannot override it; the + // conventional layout is /build/, which stays overridable. + val buildDirectory: Provider = (perTarget?.buildDirectory ?: cmake.buildDirectory).orElse( + project.layout.dir( + preset.map { presetName -> sourceDirectory.get().resolve("build/$presetName") } + .orElse(project.layout.buildDirectory.dir("konan/bindings/${binding.name}/jni/cmake/${contract.target.name}").map { it.asFile }) + ) + ) + + task.command { + executable.set(cmake.executable) + arguments.addAll( + preset.map { listOf("--preset", it) }.orElse( + sourceDirectory.zip(buildDirectory) { source, build -> listOf("-S", source.absolutePath, "-B", build.asFile.absolutePath) } + ) + ) + arguments.add(contract.stubDirectory.map { "-D${ExternalStubVariable.STUB_DIR.variableName}=${it.asFile.absolutePath}" }) + arguments.add(contract.libraryName.map { "-D${ExternalStubVariable.LIB_NAME.variableName}=$it" }) + arguments.add(contract.outputDirectory.map { "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=${it.asFile.absolutePath}" }) + arguments.addAll(cmake.arguments) + perTarget?.let { arguments.addAll(it.arguments) } + // `--preset` resolves CMakePresets.json from the working directory. + workingDirectory.set(sourceDirectory.map { it.absolutePath }) + } + task.command { + executable.set(cmake.executable) + arguments.addAll(buildDirectory.map { listOf("--build", it.asFile.absolutePath) }) + arguments.addAll(cmake.buildTargets.map { targets -> targets.flatMap { listOf("--target", it) } }) + arguments.add("--parallel") + } +} diff --git a/src/main/kotlin/io/github/lemcoder/jni/CMakeTasks.kt b/src/main/kotlin/io/github/lemcoder/jni/CMakeTasks.kt deleted file mode 100644 index 71d3dc0..0000000 --- a/src/main/kotlin/io/github/lemcoder/jni/CMakeTasks.kt +++ /dev/null @@ -1,122 +0,0 @@ -package io.github.lemcoder.jni - -import io.github.lemcoder.util.execCapture -import org.gradle.api.DefaultTask -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations -import org.gradle.work.DisableCachingByDefault -import java.io.File -import javax.inject.Inject - -/** Configures the external CMake build that compiles the generated JNI stub. */ -@DisableCachingByDefault(because = "CMake configure writes machine-specific absolute paths") -internal abstract class CMakeConfigureTask @Inject constructor( - private val exec: ExecOperations, -) : DefaultTask() { - @get:Input abstract val executable: Property - @get:Input @get:Optional abstract val preset: Property - /** Also the working directory: `--preset` resolves CMakePresets.json relative to the cwd. */ - @get:Input abstract val sourceDirectory: Property - @get:Input abstract val arguments: ListProperty - - /** `-D` entries the plugin supplies: where the stub is, and what the library must be called. */ - @get:Input abstract val cacheEntries: MapProperty - - /** A JDK that ships `include/jni.h`; CMake's FindJNI wants a full JDK, the stub only the header. */ - @get:Input abstract val javaHome: Property - - @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val stubSourceDirectory: DirectoryProperty - - @get:OutputDirectory abstract val buildDirectory: DirectoryProperty - - @TaskAction - fun run() { - val build = buildDirectory.get().asFile.apply { mkdirs() } - val cmd = buildList { - add(executable.get()) - preset.orNull?.let { - // A preset carries its own binaryDir, which -B may not override. - add("--preset"); add(it) - } ?: run { - add("-S"); add(sourceDirectory.get()) - add("-B"); add(build.absolutePath) - } - cacheEntries.get().forEach { (key, value) -> add("-D$key=$value") } - addAll(arguments.get()) - } - - val result = exec.execCapture { - commandLine(cmd) - workingDir = File(sourceDirectory.get()) - environment("JAVA_HOME", javaHome.get()) - } - logger.lifecycle(result.output) - result.assertNormalExitValue() - } -} - -/** Runs the external CMake build for the generated JNI stub. */ -@DisableCachingByDefault(because = "External build; CMake does its own incremental tracking") -internal abstract class CMakeBuildTask @Inject constructor( - private val exec: ExecOperations, -) : DefaultTask() { - @get:Input abstract val executable: Property - @get:Input abstract val targets: ListProperty - @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val stubSourceDirectory: DirectoryProperty - @get:Internal abstract val buildDirectory: DirectoryProperty - - /** - * The cache the configure step wrote. Declared as an input so changing a cache entry β€” a linker - * flag, say β€” re-runs the build instead of leaving the previous library in place. - */ - @get:InputFile @get:PathSensitive(PathSensitivity.NONE) - abstract val cmakeCache: RegularFileProperty - - /** Where CMake was told to leave the linked library. */ - @get:OutputDirectory abstract val libraryDirectory: DirectoryProperty - - @TaskAction - fun run() { - val cmd = buildList { - add(executable.get()) - add("--build"); add(buildDirectory.get().asFile.absolutePath) - targets.get().forEach { add("--target"); add(it) } - add("-j"); add(Runtime.getRuntime().availableProcessors().toString()) - } - - val result = exec.execCapture { commandLine(cmd) } - logger.lifecycle(result.output) - result.assertNormalExitValue() - } -} - -internal object CMakeSupport { - /** - * The `cmake` binary. The Gradle daemon does not inherit a login shell's PATH, so a Homebrew or - * MacPorts install is invisible to it and a bare `cmake` fails with "A problem occurred starting - * process". - */ - fun detectExecutable(): String = System.getenv("CMAKE") - ?: sequenceOf( - "/opt/homebrew/bin/cmake", - "/usr/local/bin/cmake", - "/usr/bin/cmake", - "/opt/local/bin/cmake", - ).firstOrNull { File(it).canExecute() } - ?: "cmake" -} diff --git a/src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt new file mode 100644 index 0000000..3c854ad --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt @@ -0,0 +1,21 @@ +package io.github.lemcoder.jni + +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.external.ExternalBuildTask + +/** [JniStubBuild.External.Generic]: the build script's own steps, as they are, for one target. */ +internal fun addGenericCommands(task: ExternalBuildTask, binding: BindingSpec, contract: ExternalContract) { + val external = binding.jni.external + check(external.steps.isNotEmpty()) { "konan.bindings.${binding.name}.jni.external declares no step." } + external.steps.forEach { step -> + task.command { + executable.set(step.executable) + arguments.addAll(step.arguments) + arguments.addAll(step.targetArguments.getting(contract.target).orElse(emptyList())) + environment.putAll(step.environment) + workingDirectory.set(step.workingDirectory.map { it.asFile.absolutePath }) + } + } + task.sources.from(external.sources) + task.producedLibrary.fileProvider(external.producedLibraries.getting(contract.target)) +} diff --git a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt new file mode 100644 index 0000000..ead5956 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt @@ -0,0 +1,169 @@ +package io.github.lemcoder.jni + +import io.github.lemcoder.abiDir +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.dsl.JniSpec +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.hostKonanTarget +import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.sharedLibraryName +import io.github.lemcoder.taskSuffix +import io.github.lemcoder.toolchain.NativeToolchain +import io.github.lemcoder.wiring.capitalized +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.konan.target.KonanTarget + +/** + * How a binding's JNI library β€” the generated stub, linked with the C library β€” comes to exist: linked + * by the plugin, or built by the project's own build system. Every build leaves the library in + * `jni.libraryDirectoryFor(target)`, so consumers never learn which one ran. + */ +internal sealed interface JniStubBuild { + /** + * Registers the work that builds the JNI library and sets `jni.libraryDirectory`'s convention. + * Called once the build script has run; returns the tasks `linkJni` depends on. + */ + fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs): List> + + /** The default: the stub and the library's archive, linked per target with Kotlin/Native's toolchain. */ + data object KonanLink : JniStubBuild { + override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs) = + registerKonanLink(project, konan, binding, inputs) + } + + /** + * The project's own build compiles the stub: one [ExternalBuildTask] per target, run with the + * [ExternalContract] as its environment. A build system says only which targets it builds and which + * commands build one. + */ + sealed class External : JniStubBuild { + /** Task-name prefix, e.g. `cmakeBuild` in `cmakeBuildMymathMacos_arm64`. */ + protected abstract val taskPrefix: String + + /** The build system, for task descriptions. */ + protected abstract val toolName: String + + /** The targets this build runs for. */ + protected abstract fun targets(binding: BindingSpec, inputs: JniStubInputs): List + + /** Adds the commands, and any sources or produced library, that build [contract]'s target. */ + protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) + + /** `jni.libraryDirectory`'s convention, given the plugin's [default]. */ + protected open fun libraryDirectory(binding: BindingSpec, default: Provider): Provider = default + + final override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs): List> { + val bindingName = binding.name + val jni = binding.jni + val javaHome = jni.jdkHome.map { it.asFile.absolutePath } + .orElse(project.provider { NativeToolchain.detectJniHome().absolutePath }) + val targets = targets(binding, inputs) + val tasks = targets.map { target -> + val contract = ExternalContract(target, jni.stubDirectory, jni.stubLibraryName, jni.libraryDirectoryFor(target), javaHome, project.providers) + project.tasks.register("$taskPrefix${bindingName.capitalized()}${target.taskSuffix}", ExternalBuildTask::class.java) { + group = "konan" + description = "Build the '$bindingName' JNI library for ${target.name} with $toolName." + dependsOn(inputs.generate) + mustRunAfter(project.cleanTask()) + contract.environment().forEach { (variable, value) -> environment.put(variable.variableName, value) } + generatedSources.from(contract.stubDirectory) + libraryFileName.set(contract.libraryName.map { target.sharedLibraryName(it) }) + outputDirectory.set(contract.outputDirectory) + addCommands(this, project, binding, contract) + } + } + val host = hostKonanTarget() + jni.libraryDirectory.convention(libraryDirectory(binding, if (host in targets) jni.libraryDirectoryFor(host) else jni.jniLibsRoot)) + return tasks + } + + /** `jni { external { } }`: any build system, as the commands the build script lists. */ + data object Generic : External() { + override val taskPrefix = "externalBuild" + override val toolName = "its external build" + override fun targets(binding: BindingSpec, inputs: JniStubInputs) = inputs.targets + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) = + addGenericCommands(task, binding, contract) + } + + /** `jni { cmake { } }`: a CMakeLists, configured with the contract as cache entries, then built. */ + data object CMake : External() { + override val taskPrefix = "cmakeBuild" + override val toolName = "CMake" + override fun targets(binding: BindingSpec, inputs: JniStubInputs) = + binding.jni.cmake.targetSettings.keys.toList().ifEmpty { listOf(hostKonanTarget()) } + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) = + addCMakeCommands(task, project, binding, contract) + override fun libraryDirectory(binding: BindingSpec, default: Provider) = + binding.jni.cmake.libraryDirectory.orElse(default) + } + } +} + +/** What the plugin tells an external build about one target. */ +internal class ExternalContract( + val target: KonanTarget, + val stubDirectory: Provider, + val libraryName: Provider, + val outputDirectory: Provider, + val javaHome: Provider, + private val providers: ProviderFactory, +) { + fun environment(): Map> = mapOf( + ExternalStubVariable.STUB_DIR to stubDirectory.map { it.asFile.absolutePath }, + ExternalStubVariable.LIB_NAME to libraryName, + ExternalStubVariable.OUTPUT_DIR to outputDirectory.map { it.asFile.absolutePath }, + ExternalStubVariable.TARGET to providers.provider { target.name }, + ExternalStubVariable.ABI to providers.provider { target.abiDir }, + ExternalStubVariable.JAVA_HOME to javaHome, + ) +} + +/** + * The environment of every external build β€” the names are the public contract, read the way an AGP + * project reads `ANDROID_ABI`. + */ +internal enum class ExternalStubVariable(val variableName: String) { + /** The directory holding the generated `.c` stub. */ + STUB_DIR("KONAN_JNI_STUB_DIR"), + + /** The library name the generated bindings pass to `System.loadLibrary`. */ + LIB_NAME("KONAN_JNI_LIB_NAME"), + + /** Where the target's library must land, unless the build names a produced library. */ + OUTPUT_DIR("KONAN_JNI_OUTPUT_DIR"), + + /** The Kotlin/Native target, e.g. `android_arm64`. */ + TARGET("KONAN_TARGET"), + + /** The target's Android ABI, e.g. `arm64-v8a`; the target's name elsewhere. */ + ABI("KONAN_JNI_ABI"), + + /** A JDK that ships `include/jni.h`. */ + JAVA_HOME("JAVA_HOME"), +} + +/** The build [jni] selected: at most one block, else [JniStubBuild.KonanLink]. */ +internal fun stubBuildFor(bindingName: String, jni: JniSpec): JniStubBuild { + val selected = jni.selectedStubBuilds + check(selected.size <= 1) { + "konan.bindings.$bindingName.jni selects ${selected.joinToString(" and ")}; a JNI library is built one way." + } + return selected.singleOrNull() ?: JniStubBuild.KonanLink +} + +/** What every stub build gets from the JNI consumer. */ +internal class JniStubInputs( + /** The generator, which the build must run after. */ + val generate: TaskProvider, + /** The archives of the binding's library, if it names one. */ + val artifacts: NativeArtifacts?, + /** The JNI targets: `jni.targets`, else the library's that a JVM can load, else the host. */ + val targets: List, +) diff --git a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt new file mode 100644 index 0000000..9762998 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt @@ -0,0 +1,93 @@ +package io.github.lemcoder.jni + +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.hostKonanTarget +import io.github.lemcoder.isAndroid +import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.link.LinkSharedLibraryTask +import io.github.lemcoder.taskSuffix +import io.github.lemcoder.toolchain.NativeToolchain +import io.github.lemcoder.wiring.DefFile +import io.github.lemcoder.wiring.capitalized +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.konan.target.KonanTarget +import java.io.File + +/** + * [JniStubBuild.KonanLink]: the stub and the library's archive into one shared library per target, with + * Kotlin/Native's toolchain. + */ +internal fun registerKonanLink( + project: Project, + konan: KonanExtension, + binding: BindingSpec, + inputs: JniStubInputs, +): List> { + val bindingName = binding.name + val suffix = bindingName.capitalized() + val generate = inputs.generate + val artifacts = inputs.artifacts + val konanHome = konan.konanHome + val jniIncludeDirs = project.providers.provider { + binding.jni.jdkHome.orNull?.asFile + ?.let { NativeToolchain.jniIncludeDirsOf(it) ?: error("No include/jni.h under $it") } + ?: NativeToolchain.detectJniIncludeDirs() + } + return inputs.targets.map { target -> + // Where a consumer looks for the library: the host's, the one a JVM running this build loads. + if (target == hostKonanTarget()) binding.jni.libraryDirectory.convention(binding.jni.libraryDirectoryFor(target)) + + project.tasks.register("linkJni$suffix${target.taskSuffix}", LinkSharedLibraryTask::class.java) { + group = "konan" + description = "Link the '$bindingName' JNI library for ${target.name}." + dependsOn(generate) + artifacts?.let { dependsOn(it.builtBy) } + mustRunAfter(project.cleanTask()) + konanPath.set(konanHome.map { it.asFile.absolutePath }) + this.target.set(target) + libraryBaseName.set(generate.flatMap { it.stubLibraryBaseName }) + stubCFile.set(generate.flatMap { it.stubSourceFile }) + includeDirs.from(binding.includeDirs) + // A plain path, not a tracked directory: a .def at the project root would make the whole + // project, build/ included, an input of this task. + includePaths.add(binding.defFile.map { it.asFile.parentFile.absolutePath }) + staticLibrary.set(project.layout.file(project.provider { archive(binding, artifacts, target) })) + if (target.isAndroid) { + ndkResourceDir.set(konanHome.map { NativeToolchain.ndkResourceDir(it.asFile)?.absolutePath ?: "" }) + } else { + this.jniIncludeDirs.set(jniIncludeDirs.map { dirs -> dirs.map { it.absolutePath } }) + } + additionalLinkerArgs.set( + binding.jni.linkerOptions.zip(binding.jni.targetLinkerOptions) { common, perTarget -> + common + perTarget[target].orEmpty() + } + ) + outputDirectory.set(binding.jni.libraryDirectoryFor(target)) + onlyIf { + archive(binding, artifacts, target) != null || run { + logger.lifecycle( + "konan.bindings.$bindingName: no library(), jni.archive or staticLibraries in the .def, so only " + + "the JNI bindings and the .c stub were generated β€” link them from your own build." + ) + false + } + } + } + } +} + +/** + * The archive to link: `jni.archive` if set, else the library's for [target], else the `.def`'s + * `staticLibraries`. + */ +private fun archive(binding: BindingSpec, artifacts: NativeArtifacts?, target: KonanTarget): File? { + binding.jni.archive.orNull?.asFile?.let { return it.takeIf { f -> f.isFile } } + artifacts?.let { + return it.archive(target).get().asFile.takeIf { _ -> target in it.targets.getOrElse(emptyList()) } + } + val def = binding.defFile.orNull?.asFile?.takeIf { it.isFile } ?: return null + return DefFile.parse(def).resolveStaticLibraries(def.parentFile).firstOrNull() +} diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt b/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt index 3f61de7..c98f1d6 100644 --- a/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt +++ b/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt @@ -1,6 +1,8 @@ package io.github.lemcoder.library +import io.github.lemcoder.dsl.Executable import io.github.lemcoder.dsl.ZigOptimize +import io.github.lemcoder.toolchain.command import io.github.lemcoder.toolchain.zigTriple import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.execCapture @@ -36,7 +38,7 @@ internal abstract class ZigBuildTask @Inject constructor( /** What the build reads: the project directory, less Zig's own cache and output. */ @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sources: ConfigurableFileCollection - @get:Input abstract val executable: Property + @get:Input abstract val executable: Property @get:Input abstract val targets: ListProperty @get:Input abstract val optimize: Property @get:Input abstract val arguments: ListProperty @@ -53,7 +55,7 @@ internal abstract class ZigBuildTask @Inject constructor( fun build() { val artifact = artifactName.get() // Read before exec's block: inside it, `executable` is the ExecSpec's own. - val zig = executable.get() + val zig = executable.get().command() val directory = projectDirectory.get().asFile val extra = arguments.get() targets.get().forEach { target -> diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt b/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt index 7ae5eff..c57d16b 100644 --- a/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt +++ b/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt @@ -1,6 +1,7 @@ package io.github.lemcoder.library import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.Executable import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec import io.github.lemcoder.dsl.ZigOptimize @@ -14,6 +15,7 @@ internal object ZigProvider : LibraryProvider { override fun conventions(project: Project, library: NativeLibrarySpec) { val zig = library.zig zig.projectDirectory.convention(library.sourceDirectory) + zig.executable.convention(Executable.OnPath("zig")) zig.optimize.convention(ZigOptimize.RELEASE_FAST) zig.artifactName.convention(library.name) } @@ -26,7 +28,7 @@ internal object ZigProvider : LibraryProvider { val zig = library.zig projectDirectory.set(zig.projectDirectory) sources.from(zig.projectDirectory.map { dir -> dir.asFileTree.matching { exclude(".zig-cache/**", "zig-out/**") } }) - executable.set(zig.executable.map { it.asFile.absolutePath }.orElse("zig")) + executable.set(zig.executable) targets.set(library.targets) optimize.set(zig.optimize) arguments.set(zig.arguments) diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt b/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt new file mode 100644 index 0000000..3a2d75b --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt @@ -0,0 +1,24 @@ +package io.github.lemcoder.toolchain + +import io.github.lemcoder.dsl.Executable +import java.io.File + +/** Where installs live that a daemon's `PATH` misses. */ +private val FALLBACK_DIRECTORIES = listOf("/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", "/usr/bin") + +private val WINDOWS_EXTENSIONS = listOf("", ".exe", ".cmd", ".bat") + +/** The command line's first element for this executable. Call it when the build runs, not before. */ +internal fun Executable.command(): String = when (this) { + is Executable.File -> file.absolutePath + // Unresolved, the bare name still runs if the process's own PATH has it, and fails naming it if not. + is Executable.OnPath -> find(name)?.absolutePath ?: name +} + +private fun find(name: String): File? { + val directories = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter { it.isNotEmpty() } + FALLBACK_DIRECTORIES + val extensions = if (File.separatorChar == '\\') WINDOWS_EXTENSIONS else listOf("") + return directories.asSequence() + .flatMap { directory -> extensions.asSequence().map { File(directory, name + it) } } + .firstOrNull { it.isFile && it.canExecute() } +} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt b/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt index e3cca5f..290cebc 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt @@ -1,298 +1,30 @@ package io.github.lemcoder.wiring -import io.github.lemcoder.abiDir -import io.github.lemcoder.cleanTask +import io.github.lemcoder.consumer.BindingContext +import io.github.lemcoder.consumer.bindingConsumers import io.github.lemcoder.dsl.BindingSpec -import io.github.lemcoder.dsl.BindingVisibility import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.library.NativeArtifacts -import io.github.lemcoder.hostKonanTarget -import io.github.lemcoder.index.GenerateFromNativeIndexTask -import io.github.lemcoder.index.GeneratedVisibility -import io.github.lemcoder.index.GeneratorRequest -import io.github.lemcoder.isAndroid -import io.github.lemcoder.jni.CMakeBuildTask -import io.github.lemcoder.jni.CMakeConfigureTask -import io.github.lemcoder.jni.CMakeSupport -import io.github.lemcoder.jni.GenerateJniBindingsTask -import io.github.lemcoder.link.LinkSharedLibraryTask -import io.github.lemcoder.taskSuffix -import io.github.lemcoder.toolchain.NativeToolchain import org.gradle.api.Action import org.gradle.api.Project -import org.gradle.api.Task -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.SourceSetContainer -import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.konan.target.KonanTarget -import java.io.File /** - * Cache entries the plugin sets on an external CMake build β€” its side of the contract, the way an AGP - * project reads ANDROID_ABI. A CMakeLists opts in by reading them. + * Every binding is prepared for every consumer now β€” conventions and eager wiring β€” and registered with + * the consumers the project turns out to have once the build script has run. */ -private const val STUB_DIR_VARIABLE = "KONAN_JNI_STUB_DIR" -private const val LIB_NAME_VARIABLE = "KONAN_JNI_LIB_NAME" - - -/** Turns every binding into the tasks and wiring each of the project's consumers needs. */ internal fun Project.registerBindings(konan: KonanExtension) { konan.bindings.all(object : Action { - override fun execute(binding: BindingSpec) = BindingWiring(this@registerBindings, konan, binding).register() + override fun execute(binding: BindingSpec) = registerBinding(konan, binding) }) } -private class BindingWiring( - private val project: Project, - private val konan: KonanExtension, - private val binding: BindingSpec, -) { - private val bindingName = binding.name - private val suffix = bindingName.capitalized() - private val build = project.layout.buildDirectory - - // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, and a - // host library sitting in the root fails its merge with "… is not an ABI". - private val jniLibsRoot = build.dir("konan/bindings/$bindingName/jni/jniLibs") - private val jniHostLibRoot = build.dir("konan/bindings/$bindingName/jni/lib") - - /** The archives of the library the binding names, once the build script has been evaluated. */ - private fun artifacts(): NativeArtifacts? = binding.library.orNull?.let { NativeArtifacts.of(project, it) } - - fun register() { - // The container fires before create()'s configure block runs: conventions and lazily configured - // tasks here; anything that needs the declared values waits for afterEvaluate. - binding.defFile.convention(project.layout.projectDirectory.file("src/nativeInterop/cinterop/$bindingName.def")) - binding.jni.visibility.convention(BindingVisibility.INTERNAL) - - val generateJni = registerJniGeneration() - val jniSources = generateJni.flatMap { it.kotlinSourceDirectory } - val linkJni = project.tasks.register("linkJni$suffix") { - group = "konan" - description = "Make the '$bindingName' JNI library exist for every target, however it is built." - } - - // Eager: the variant API must be wired at configuration time. - project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KgpWiring.addKmpJvmSources(project, jniSources) } - AgpWiring.addKotlinSources(project, jniSources, generateJni, linkJni) - AgpWiring.addJniLibs(project, jniLibsRoot, linkJni) - // AGP looks for a `baselineProfiles` directory beside every source root, including the generated - // one, by plain path β€” no task dependency. Matched by name: inert without AGP. - project.tasks.matching { it.name.contains("ArtProfile") }.configureEach { dependsOn(generateJni) } - - project.afterEvaluate { - // In a kotlin("jvm") project FFM replaces JNI: both would define the same facade class. - if (project.hasPlugin(GradlePluginId.KOTLIN_JVM) && !binding.ffm.isEnabled) KgpWiring.addJvmSources(project, jniSources) - if (hasJniConsumer()) { - if (binding.jni.usesCMake) registerCMakeBuild(generateJni, linkJni) else registerJniLinks(generateJni, linkJni) - } - if (project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM)) { - KgpWiring.declareCinterops(project, binding, artifacts()) - } - if (binding.ffm.isEnabled) registerFfm() - } - } - - private fun hasJniConsumer(): Boolean = - (project.hasPlugin(GradlePluginId.KOTLIN_JVM) && !binding.ffm.isEnabled) || - project.hasPlugin(GradlePluginId.ANDROID_BASE) || project.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || - (project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KgpWiring.hasKmpJvmTarget(project)) - - /** The package the bindings go in: the binding's, else the `.def`'s own. */ - private val packageName: Provider = binding.packageName.orElse( - project.provider { binding.defFile.orNull?.asFile?.takeIf { it.isFile }?.let { DefFile.parse(it).packageName } } - ) - - private fun registerJniGeneration(): TaskProvider = - project.tasks.register("generateJni$suffix", GenerateJniBindingsTask::class.java) { - group = "konan" - description = "Generate the '$bindingName' JNI bindings and stub from its .def." - mustRunAfter(project.cleanTask()) - defFile.set(binding.defFile) - packageName.set(this@BindingWiring.packageName) - includeDirs.from(binding.includeDirs) - hostTarget.set(hostKonanTarget()) - compilerOptions.set(binding.compilerOptions) - visibility.set( - binding.jni.visibility.map { - when (it) { - BindingVisibility.INTERNAL -> GeneratedVisibility.INTERNAL - BindingVisibility.PUBLIC -> GeneratedVisibility.PUBLIC - } - } - ) - outputDirectory.set(build.dir("generated/konan/$bindingName/jni")) - } - - /** Default JNI targets: the library's that a JVM can load β€” the host and Android β€” else the host. */ - private fun jniTargets(): List = binding.jni.targets.getOrElse(emptyList()).ifEmpty { - artifacts()?.targets?.getOrElse(emptyList())?.filter { it == hostKonanTarget() || it.isAndroid } - ?.ifEmpty { null } - ?: listOf(hostKonanTarget()) - } - - private fun registerJniLinks(generate: TaskProvider, linkAll: TaskProvider) { - val artifacts = artifacts() - val jniIncludeDirs = project.providers.provider { - binding.jni.jdkHome.orNull?.asFile - ?.let { NativeToolchain.jniIncludeDirsOf(it) ?: error("No include/jni.h under $it") } - ?: NativeToolchain.detectJniIncludeDirs() - } - jniTargets().forEach { target -> - val link = project.tasks.register("linkJni$suffix${target.taskSuffix}", LinkSharedLibraryTask::class.java) { - group = "konan" - description = "Link the '$bindingName' JNI library for ${target.name}." - dependsOn(generate) - artifacts?.let { dependsOn(it.builtBy) } - mustRunAfter(project.cleanTask()) - konanPath.set(konan.konanHome.map { it.asFile.absolutePath }) - this.target.set(target) - libraryBaseName.set(generate.flatMap { it.stubLibraryBaseName }) - stubCFile.set(generate.flatMap { it.stubSourceFile }) - includeDirs.from(binding.includeDirs) - // A plain path, not a tracked directory: a .def at the project root would make the whole - // project, build/ included, an input of this task. - includePaths.add(binding.defFile.map { it.asFile.parentFile.absolutePath }) - staticLibrary.set(project.layout.file(project.provider { archive(artifacts, target) })) - if (target.isAndroid) { - ndkResourceDir.set(konan.konanHome.map { NativeToolchain.ndkResourceDir(it.asFile)?.absolutePath ?: "" }) - } else { - this.jniIncludeDirs.set(jniIncludeDirs.map { dirs -> dirs.map { it.absolutePath } }) - } - additionalLinkerArgs.set( - binding.jni.linkerOptions.zip(binding.jni.targetLinkerOptions) { common, perTarget -> - common + perTarget[target].orEmpty() - } - ) - outputDirectory.set( - if (target.isAndroid) jniLibsRoot.map { it.dir(target.abiDir) } else jniHostLibRoot.map { it.dir(target.name) } - ) - onlyIf { - archive(artifacts, target) != null || run { - logger.lifecycle( - "konan.bindings.$bindingName: no library(), jni.archive or staticLibraries in the .def, so only " + - "the JNI bindings and the .c stub were generated β€” link them from your own build." - ) - false - } - } - } - linkAll.configure { dependsOn(link) } - // Where a consumer looks for the library: the host's, the one a JVM running this build loads. - if (target == hostKonanTarget()) binding.jni.libraryDirectory.convention(jniHostLibRoot.map { it.dir(target.name) }) - } - } - - /** - * The archive to link: `jni.archive` if set, else the library's for [target], else the `.def`'s - * `staticLibraries`. - */ - private fun archive(artifacts: NativeArtifacts?, target: KonanTarget): File? { - binding.jni.archive.orNull?.asFile?.let { return it.takeIf { f -> f.isFile } } - artifacts?.let { - return it.archive(target).get().asFile.takeIf { _ -> target in it.targets.getOrElse(emptyList()) } - } - val def = binding.defFile.orNull?.asFile?.takeIf { it.isFile } ?: return null - return DefFile.parse(def).resolveStaticLibraries(def.parentFile).firstOrNull() - } - - /** - * Drives the external build that compiles the stub, in place of the plugin's own link tasks. The - * plugin supplies what only it knows β€” where the stub is, the library name the bindings will load, - * and a JDK with jni.h β€” so the CMakeLists only reads two cache entries. - */ - private fun registerCMakeBuild(generate: TaskProvider, linkAll: TaskProvider) { - val cmake = binding.jni.cmake - val executable = cmake.executable.map { it.asFile.absolutePath }.orElse(project.provider { CMakeSupport.detectExecutable() }) - val sourceDir = cmake.path.map { it.asFile.parentFile } - // No ABIs means one build for the host; with them, one per ABI, each into jniLibs//. - val abis = cmake.abis.toList() - - (if (abis.isEmpty()) listOf(null) else abis).forEach { abi -> - val abiSuffix = abi?.name.orEmpty().split('-', '_').joinToString("") { it.capitalized() } - val preset = abi?.preset?.orElse(cmake.preset) ?: cmake.preset - val outputDir = if (abi == null) jniHostLibRoot else jniLibsRoot.map { it.dir(abi.name) } - // With a preset the binary directory is the preset's to choose, and `-B` cannot override it; - // the conventional layout is /build/, which stays overridable. - val buildDir = (abi?.buildDirectory ?: cmake.buildDirectory).orElse( - project.layout.dir( - preset.map { presetName -> sourceDir.get().resolve("build/$presetName") } - .orElse(build.dir("konan/bindings/$bindingName/jni/cmake$abiSuffix").map { it.asFile }) - ) - ) - val configure = project.tasks.register("cmakeConfigure$suffix$abiSuffix", CMakeConfigureTask::class.java) { - group = "konan" - description = "Configure the external CMake build for '$bindingName'" + (abi?.let { " (${it.name})" } ?: "") + "." - dependsOn(generate) - this.executable.set(executable) - this.preset.set(preset) - sourceDirectory.set(sourceDir.map { it.absolutePath }) - arguments.set(cmake.arguments.zip(abi?.arguments ?: cmake.arguments.map { emptyList() }) { a, b -> a + b }) - stubSourceDirectory.set(generate.flatMap { it.stubSourceDirectory }) - cacheEntries.put(STUB_DIR_VARIABLE, generate.flatMap { it.stubSourceDirectory }.map { it.asFile.absolutePath }) - cacheEntries.put(LIB_NAME_VARIABLE, generate.flatMap { it.stubLibraryBaseName }) - // Land the library where the plugin says, so a consumer never guesses the build layout and - // Android gets the jniLibs// shape AGP packages. - cacheEntries.put("CMAKE_LIBRARY_OUTPUT_DIRECTORY", outputDir.map { it.asFile.absolutePath }) - javaHome.set(project.provider { NativeToolchain.detectJniHome().absolutePath }) - buildDirectory.set(buildDir) - } - val cmakeBuild = project.tasks.register("cmakeBuild$suffix$abiSuffix", CMakeBuildTask::class.java) { - group = "konan" - description = "Build the '$bindingName' JNI library" + (abi?.let { " for ${it.name}" } ?: "") + "." - dependsOn(configure) - this.executable.set(executable) - targets.set(cmake.targets) - stubSourceDirectory.set(generate.flatMap { it.stubSourceDirectory }) - buildDirectory.set(buildDir) - cmakeCache.set(buildDir.map { it.file("CMakeCache.txt") }) - libraryDirectory.set(outputDir) - } - linkAll.configure { dependsOn(cmakeBuild) } - } - binding.jni.libraryDirectory.convention(cmake.libraryDirectory.orElse(if (abis.isEmpty()) jniHostLibRoot else jniLibsRoot)) - } +private fun Project.registerBinding(konan: KonanExtension, binding: BindingSpec) { + // The container fires before create()'s configure block runs: anything that needs the declared values + // waits for afterEvaluate. + binding.defFile.convention(layout.projectDirectory.file("src/nativeInterop/cinterop/${binding.name}.def")) - /** - * FFM bindings: jextract over the same `.def`, and the library itself as a shared library for the - * host β€” FFM loads it directly, with no stub. - */ - private fun registerFfm() { - val artifacts = artifacts() - val libraryName = artifacts?.name ?: bindingName - val host = hostKonanTarget() - val generate = project.tasks.register("generateFfm$suffix", GenerateFromNativeIndexTask::class.java) { - group = "konan" - description = "Generate the '$bindingName' FFM bindings with jextract." - defFile.set(binding.defFile) - includeDirs.from(binding.includeDirs) - compilerOptions.set(binding.compilerOptions) - target.set(host) - request.set(project.provider { GeneratorRequest.Jextract(library = libraryName, jextract = binding.ffm.jextract.orNull?.asFile) }) - packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } - outputDirectory.set(build.dir("generated/konan/$bindingName/ffm")) - } - val ffmLib = build.dir("konan/bindings/$bindingName/ffm/lib/${host.name}") - binding.ffm.libraryDirectory.convention(ffmLib) - if (artifacts != null && host in artifacts.targets.getOrElse(emptyList())) { - project.tasks.register("linkFfm$suffix", LinkSharedLibraryTask::class.java) { - group = "konan" - description = "Link the '$libraryName' library as a shared library for the FFM bindings." - dependsOn(artifacts.builtBy) - konanPath.set(konan.konanHome.map { it.asFile.absolutePath }) - target.set(host) - libraryBaseName.set(libraryName) - staticLibrary.set(artifacts.archive(host)) - additionalLinkerArgs.set(binding.jni.linkerOptions) - outputDirectory.set(ffmLib) - } - } - if (project.hasPlugin(GradlePluginId.KOTLIN_JVM)) { - // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. - project.extensions.getByType(SourceSetContainer::class.java).getByName("main") - .java.srcDir(generate.map { it.outputDirectory.dir("java") }) - } else { - project.logger.info("konan.bindings.$bindingName: FFM bindings are only wired into kotlin(\"jvm\") projects; add build/generated/konan/$bindingName/ffm/java yourself.") - } + val consumers = bindingConsumers(BindingContext(this, konan, binding)) + consumers.forEach { it.prepare() } + afterEvaluate { + consumers.filter { it.isUsed() }.forEach { it.register() } } } diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 24df916..a07fd43 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -96,7 +96,7 @@ class KonanPluginFunctionalTest { libraries { create("mymath") { targets(KonanTarget.MACOS_ARM64, KonanTarget.LINUX_X64, KonanTarget.MINGW_X64) - zig { executable.set(file("${zig.replace("\\", "/")}")) } + zig { executable.set(io.github.lemcoder.dsl.Executable.File(file("${zig.replace("\\", "/")}"))) } } } """, @@ -156,7 +156,7 @@ class KonanPluginFunctionalTest { jni { cmake { path.set(file("CMakeLists.txt")) - targets.add("mymath-jni") + buildTargets.add("mymath-jni") } } } @@ -167,10 +167,79 @@ class KonanPluginFunctionalTest { val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() - assertTrue(":cmakeConfigureMymath SKIPPED" in result.output && ":cmakeBuildMymath SKIPPED" in result.output, result.output) + assertTrue(":cmakeBuildMymath$hostSuffix SKIPPED" in result.output, result.output) assertFalse(":linkJniMymath$hostSuffix" in result.output, "no plugin link with an external build:\n${result.output}") } + @Test + fun `CMake receives the contract as cache entries and builds into the plugin's directory`() { + val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } + assumeTrue(cmake != null, "needs cmake") + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + """ + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + jni { cmake { path.set(file("CMakeLists.txt")) } } + } + } + """, + ) + File(projectDir, "CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.16) + project(contract NONE) + file(WRITE "${'$'}{CMAKE_LIBRARY_OUTPUT_DIRECTORY}/contract.txt" "${'$'}{KONAN_JNI_LIB_NAME}|${'$'}{KONAN_JNI_STUB_DIR}") + """.trimIndent() + ) + + runner("linkJniMymath").withPluginClasspathAndKgp().build() + + val contract = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.name}/contract.txt").readText().split('|') + assertTrue(contract[0].isNotEmpty(), "library name: $contract") + assertTrue(File(contract[1]).isDirectory, "stub directory: $contract") + } + + @Test + fun `a generic external build runs its steps with the contract and its library is copied into place`() { + assumeTrue(File("/bin/sh").canExecute(), "needs /bin/sh") + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + """ + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + jni { + external { + step { + executable.set(io.github.lemcoder.dsl.Executable.File(file("/bin/sh"))) + arguments.add("build.sh") + workingDirectory.set(layout.projectDirectory) + } + producedLibrary(KonanTarget.${host.name.uppercase()}, file("out/built")) + } + } + } + } + """, + ) + // Leaves its library somewhere of its own, as most build systems do. + File(projectDir, "build.sh").writeText( + "mkdir -p out && printf '%s|%s|%s' \"\$KONAN_TARGET\" \"\$KONAN_JNI_LIB_NAME\" \"\$KONAN_JNI_STUB_DIR\" > out/built\n" + ) + + val result = runner("linkJniMymath").withPluginClasspathAndKgp().build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":externalBuildMymath$hostSuffix")?.outcome, result.output) + val libraries = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.name}").listFiles().orEmpty() + val library = libraries.singleOrNull() ?: error("expected one library, got ${libraries.toList()}") + val (target, name, stubDirectory) = library.readText().split('|') + assertEquals(host.name, target) + assertTrue(library.name.contains(name), "${library.name} is named after $name") + assertTrue(File(stubDirectory).isDirectory, "stub directory: $stubDirectory") + } + @Test fun `ffm() adds jextract generation and a shared library for the host`() { write( From 7109f589f6b203b7691034b452564f047377c5b2 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 01:48:40 +0200 Subject: [PATCH 16/57] feat!: sealed library providers over the shared external build LibraryProvider becomes a sealed hierarchy matching JniStubBuild: KonanClang, or External with Generic (`external { step { } }`, any build system) and Zig as a preset. External library builds share ExternalBuildTask with the JNI stub builds: one task per target, the ExternalLibraryVariable contract (KONAN_LIBRARY_NAME, KONAN_LIBRARY_OUTPUT_DIR, KONAN_LIBRARY_SOURCE_DIR, KONAN_TARGET) as environment, the produced archive copied to lib.a. - ZigBuildTask is gone; Zig runs per target as zigBuild and is up to date while its project is unchanged. - The provider kind enum, registry and conventions hook are replaced by the selected sealed value and DSL-constructor conventions. - The plugin's JNI link decides onlyIf from its own staticLibrary, so the configuration cache no longer captures the library's TaskProvider. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 +- README.md | 35 +++++- doc/interop-architecture.md | 14 ++- .../lemcoder/dsl/ExternalBuildSettings.kt | 9 +- .../io/github/lemcoder/dsl/KonanExtension.kt | 31 ++++- .../lemcoder/external/ExternalBuilds.kt | 59 +++++++++ .../github/lemcoder/jni/GenericStubBuild.kt | 21 ---- .../io/github/lemcoder/jni/JniStubBuild.kt | 22 ++-- .../github/lemcoder/jni/KonanLinkStubBuild.kt | 4 +- .../lemcoder/library/KonanClangBuild.kt | 44 +++++++ .../lemcoder/library/KonanClangProvider.kt | 48 ------- .../lemcoder/library/LibraryProvider.kt | 118 ++++++++++++++---- .../io/github/lemcoder/library/ZigBuild.kt | 37 ++++++ .../github/lemcoder/library/ZigBuildTask.kt | 86 ------------- .../io/github/lemcoder/library/ZigProvider.kt | 39 ------ .../io/github/lemcoder/wiring/Libraries.kt | 2 - .../lemcoder/KonanPluginFunctionalTest.kt | 38 +++++- 17 files changed, 367 insertions(+), 250 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt create mode 100644 src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt create mode 100644 src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt diff --git a/CLAUDE.md b/CLAUDE.md index 43a9d8c..6da0f1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,9 +30,13 @@ resolved the moment the plugin is applied, in *every* project that applies it. - **`all`/`whenObjectAdded` fires before `create(name) { … }` runs its configure block.** Reading a binding's `library` or a library's `targets` there gives the default, not what the build declared β€” per-target tasks, consumer detection and KGP's cinterops are deferred to `afterEvaluate` (KGP - accepts cinterops declared there). Both sides follow this split: a `LibraryProvider` sets - conventions eagerly and registers later; a `BindingConsumer` (`consumer/`) has `prepare()` for the - eager part, then `isUsed()` and `register()` after evaluation. + accepts cinterops declared there). Both sides follow this split: a `LibraryProvider` registers + after evaluation (its block's conventions are set in the DSL constructors); a `BindingConsumer` + (`consumer/`) has `prepare()` for the eager part, then `isUsed()` and `register()` after evaluation. +- **Build systems are sealed hierarchies with a shared `External` branch** β€” `LibraryProvider` for + libraries, `JniStubBuild` for the JNI stub. Every external one is `ExternalBuildTask` per target + (`external/ExternalBuilds.kt`); a preset (Zig, CMake) only adds commands. Prefer a preset over a new + task type. - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with *"It is too late to add actions as the callbacks already executed"*, so the jniLibs wiring runs at binding-registration time and tolerates an empty directory. diff --git a/README.md b/README.md index b12203c..caddce0 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ A binding is consumed wherever the project's Kotlin targets need it, with nothin ### `libraries { }` A C/C++ library built to `//lib.a` for each target: by Kotlin/Native's -clang from `sourceDirectory`, or by Zig. Bindings see the same archives either way. +clang from `sourceDirectory`, by Zig, or by any other build system. Bindings see the same archives +either way. | Property | Type | Default | Description | |-------------------|---------------------|----------------------------------|-------------| @@ -72,6 +73,7 @@ clang from `sourceDirectory`, or by Zig. Bindings see the same archives either w | `compilerOptions` | `List` | β€” | Appended to `-fno-sanitize=undefined -fPIC`. Put `-std=…` here β€” C and C++ sources share one invocation. | | `outputDirectory` | `Directory` | `build/konan/libraries/` | Root of the per-target archives; `archive(target)` gives one. | | `zig { }` | | β€” | Build with the project's `build.zig` instead; below. | +| `external { }` | | β€” | Build with any other build system, as commands; below. | #### Building with Zig β€” `zig { }` ```kotlin @@ -81,7 +83,7 @@ create("mymath") { } ``` -`zig build` runs once per target, with `-Dtarget` set to the Zig triple matching Kotlin/Native's +`zigBuild` runs `zig build` once per target, with `-Dtarget` set to the Zig triple matching Kotlin/Native's sysroot (glibc 2.19 on Linux, macOS 12.0, iOS 14.0), and its static library is copied to where the plugin's own build would put it β€” Windows' `mymath.lib` included. The `build.zig` must install a static library (`b.addLibrary(.{ .linkage = .static, … })`). @@ -96,6 +98,33 @@ library (`b.addLibrary(.{ .linkage = .static, … })`). Android targets need a libc file for the NDK's bionic, which Zig does not bundle. +#### Building with anything else β€” `external { }` +The same block as `jni { external { } }`, below: a list of `step { }`s run in order once per target by +`externalBuild`, with `sources` for up-to-date checks and `producedLibrary(target, file)` +when the build leaves its archive somewhere of its own. Each run gets: + +| variable | meaning | +|---|---| +| `KONAN_LIBRARY_NAME` | the library's name: the archive becomes `lib.a` | +| `KONAN_LIBRARY_OUTPUT_DIR` | where this target's archive must land | +| `KONAN_LIBRARY_SOURCE_DIR` | the library's `sourceDirectory` | +| `KONAN_TARGET` | the Kotlin/Native target, e.g. `linux_x64` | + +```kotlin +create("mymath") { + targets(KonanTarget.LINUX_X64) + external { + step { + executable.set(Executable.OnPath("cargo")) + arguments.addAll("build", "--release", "--target", "x86_64-unknown-linux-gnu") + workingDirectory.set(layout.projectDirectory.dir("native")) + } + sources.from("native/src", "native/Cargo.toml") + producedLibrary(KonanTarget.LINUX_X64, file("native/target/x86_64-unknown-linux-gnu/release/libmymath.a")) + } +} +``` + ### `bindings { }` A C API described by a cinterop `.def`. @@ -262,7 +291,7 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. | Task | Description | |-------------------------------|-------------| | `compileNativeLibrary` | Builds a library to `lib.a` per target, with whichever provider it uses. | -| `konanClang`, `zigBuild` | The provider doing it: Kotlin/Native's clang, or `zig build`. | +| `konanClang`, `zigBuild`, `externalBuild` | The provider doing it: Kotlin/Native's clang, `zig build`, or the library's own steps. | | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | | `linkJni` | Links it for one target. | diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index 55896d3..e72aabf 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -107,7 +107,7 @@ flowchart TD | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `KonanTarget` today, mapped to Android ABI and JVM library paths | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots) today; Xcode, Android NDK, system clang/GCC/MSVC, Zig later | -| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`: **KonanClangProvider** (default, `konanClang`) and **ZigProvider** (`zig { }`, `zigBuild`), both behind `compileNativeLibrary`; CMake only for the JNI stub; **Prebuilt** not yet | +| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); all behind `compileNativeLibrary`; **Prebuilt** not yet | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KgpWiring.declareCinterops`) | | 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); FFM links the library itself, no glue | @@ -175,7 +175,7 @@ Choices made: - **No compatibility shim** for `konanConfig`/`jvmInterops`/`kotlinNativeInterop`: a major version. **A second library provider: Zig.** `libraries { create(…) { zig { } } }` runs the project's own -`build.zig` once per target. Adding it touched only the library side β€” a `ZigBuildTask`, a `zig { }` +`build.zig` once per target. Adding it touched only the library side β€” a build task, a `zig { }` block on the library, a Kotlin/Native β†’ Zig triple mapping β€” and left bindings, generators and consumer wiring unchanged. One Zig-built archive was then consumed by all three consumers: JNI and KGP's cinterop in one multiplatform build (`jvm()` + `macosArm64()`), FFM in a `kotlin("jvm")` build, and a @@ -186,10 +186,12 @@ cinterop in one multiplatform build (`jvm()` + `macosArm64()`), FFM in a `kotlin and `builtBy`, the lifecycle task `compileNativeLibrary`. Bindings and consumers take a `NativeArtifacts` and never learn which provider ran. Zig names Windows archives `mymath.lib`; its provider renames on copy. -- **A provider is an implementation of `LibraryProvider`**: its DSL block name, its conventions, and - the tasks that leave the archives in place. A library selects one by calling its block β€” two is an - error, none means `KonanClangProvider`. A new build system is a settings class with its block on the - library, a `LibraryProvider`, and an entry in `libraryProviders`; nothing on the consumer side. +- **A provider is a case of the sealed `LibraryProvider`**, and the selection is the provider itself: + a library's block adds it, two is an error, none means `KonanClang`. Every build system other than + konan's clang is an `External` β€” the generic `external { step { } }` one, or a preset such as Zig β€” + sharing `ExternalBuildTask` with the JNI stub builds: commands per target, the contract as + environment, the produced archive copied to `lib.a`. A new preset is a nested object that adds + its commands; nothing on the consumer side. - **The target model has to map to each toolchain's triple.** Kotlin/Native targets become Zig triples pinned to konan's sysroots (glibc 2.19, macOS 12.0, iOS 14.0). The glibc pin is defensive: in this test, unpinned Zig also produced plain `strtol`. Android needs bionic, which Zig does not bundle. diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt index 83159d0..8ae2992 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt @@ -13,11 +13,10 @@ import java.io.File import javax.inject.Inject /** - * Any build system, as a list of commands run once per JNI target. Each run gets the plugin's contract - * as environment variables β€” `KONAN_JNI_STUB_DIR`, `KONAN_JNI_LIB_NAME`, `KONAN_JNI_OUTPUT_DIR`, - * `KONAN_TARGET`, `KONAN_JNI_ABI`, `JAVA_HOME` β€” and the same values are on `jni` as providers, for - * arguments. The build writes the library into `KONAN_JNI_OUTPUT_DIR`, or names where it leaves it with - * [producedLibrary]. + * Any build system, as a list of commands run once per target β€” for a library (`libraries { external + * { } }`) or a JNI stub (`jni { external { } }`). Each run gets the plugin's contract as environment + * variables, documented on the block that owns this one. The build writes its library where the contract + * says, or names where it leaves it with [producedLibrary], and the plugin copies it into place. */ abstract class ExternalBuildSettings @Inject constructor(private val objects: ObjectFactory) { /** diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt index a2d450e..ca5879e 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -4,7 +4,7 @@ import io.github.lemcoder.abiDir import io.github.lemcoder.androidKonanTargets import io.github.lemcoder.isAndroid import io.github.lemcoder.jni.JniStubBuild -import io.github.lemcoder.library.LibraryProviderKind +import io.github.lemcoder.library.LibraryProvider import org.gradle.api.Action import org.gradle.api.Named import org.gradle.api.NamedDomainObjectContainer @@ -64,7 +64,7 @@ abstract class KonanExtension @Inject constructor(objects: ObjectFactory) { /** * A C/C++ library built into a static archive per target, `//lib.a`: by - * Kotlin/Native's clang from [sourceDirectory], or by the project's own build ([zig]). + * Kotlin/Native's clang from [sourceDirectory], or by the project's own build ([zig], [external]). */ abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { override fun getName(): String = name @@ -102,12 +102,30 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o val zig: ZigSettings = objects.newInstance(ZigSettings::class.java) fun zig(action: Action) { - selectedProviders += LibraryProviderKind.ZIG + selectedProviders += LibraryProvider.External.Zig action.execute(zig) } + /** + * Builds the library with any other build system, as a list of commands run once per target. Each run + * gets `KONAN_LIBRARY_NAME`, `KONAN_LIBRARY_OUTPUT_DIR`, `KONAN_LIBRARY_SOURCE_DIR` and `KONAN_TARGET`; + * the build writes `lib.a` into the output directory, or names where it leaves its archive with + * `producedLibrary`. + */ + val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) + + fun external(action: Action) { + selectedProviders += LibraryProvider.External.Generic + action.execute(external) + } + /** The provider blocks this library's script called; at most one may be. */ - internal val selectedProviders = mutableSetOf() + internal val selectedProviders = mutableSetOf() + + init { + zig.projectDirectory.convention(sourceDirectory) + zig.artifactName.convention(name) + } } /** @@ -115,6 +133,11 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o * Kotlin/Native's sysroot. The `build.zig` must install a static library ([artifactName]). */ abstract class ZigSettings { + init { + executable.convention(Executable.OnPath("zig")) + optimize.convention(ZigOptimize.RELEASE_FAST) + } + /** The directory holding `build.zig`. Defaults to the library's `sourceDirectory`. */ abstract val projectDirectory: DirectoryProperty diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt new file mode 100644 index 0000000..316d15c --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt @@ -0,0 +1,59 @@ +package io.github.lemcoder.external + +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.ExternalBuildSettings +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.konan.target.KonanTarget + +/** A variable of an external build's contract: what the plugin tells the build, by environment. */ +internal interface ExternalBuildVariable { + /** The environment variable's name β€” public contract, read by build scripts. */ + val variableName: String +} + +/** + * Registers one target's [ExternalBuildTask]: [environment] as the contract, the library expected in + * [outputDirectory] as [libraryFileName]. [configure] adds the build system's commands. + */ +internal fun Project.registerExternalBuild( + taskName: String, + taskDescription: String, + environment: Map>, + outputDirectory: Provider, + libraryFileName: Provider, + configure: ExternalBuildTask.() -> Unit, +): TaskProvider { + val clean = cleanTask() + // Read into locals: inside the block, `environment` and `outputDirectory` are the task's own. + val contract = environment + val output = outputDirectory + val fileName = libraryFileName + return tasks.register(taskName, ExternalBuildTask::class.java) { + group = "konan" + description = taskDescription + mustRunAfter(clean) + contract.forEach { (variable, value) -> this.environment.put(variable.variableName, value) } + this.libraryFileName.set(fileName) + this.outputDirectory.set(output) + configure() + } +} + +/** The build script's own steps, as they are, for [target]: what a generic `external { }` block runs. */ +internal fun ExternalBuildTask.addSteps(settings: ExternalBuildSettings, target: KonanTarget, owner: String) { + check(settings.steps.isNotEmpty()) { "$owner.external declares no step." } + settings.steps.forEach { step -> + command { + executable.set(step.executable) + arguments.addAll(step.arguments) + arguments.addAll(step.targetArguments.getting(target).orElse(emptyList())) + environment.putAll(step.environment) + workingDirectory.set(step.workingDirectory.map { it.asFile.absolutePath }) + } + } + sources.from(settings.sources) + producedLibrary.fileProvider(settings.producedLibraries.getting(target)) +} diff --git a/src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt deleted file mode 100644 index 3c854ad..0000000 --- a/src/main/kotlin/io/github/lemcoder/jni/GenericStubBuild.kt +++ /dev/null @@ -1,21 +0,0 @@ -package io.github.lemcoder.jni - -import io.github.lemcoder.dsl.BindingSpec -import io.github.lemcoder.external.ExternalBuildTask - -/** [JniStubBuild.External.Generic]: the build script's own steps, as they are, for one target. */ -internal fun addGenericCommands(task: ExternalBuildTask, binding: BindingSpec, contract: ExternalContract) { - val external = binding.jni.external - check(external.steps.isNotEmpty()) { "konan.bindings.${binding.name}.jni.external declares no step." } - external.steps.forEach { step -> - task.command { - executable.set(step.executable) - arguments.addAll(step.arguments) - arguments.addAll(step.targetArguments.getting(contract.target).orElse(emptyList())) - environment.putAll(step.environment) - workingDirectory.set(step.workingDirectory.map { it.asFile.absolutePath }) - } - } - task.sources.from(external.sources) - task.producedLibrary.fileProvider(external.producedLibraries.getting(contract.target)) -} diff --git a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt index ead5956..58f866a 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt @@ -1,11 +1,13 @@ package io.github.lemcoder.jni import io.github.lemcoder.abiDir -import io.github.lemcoder.cleanTask import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.dsl.JniSpec import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.external.ExternalBuildVariable +import io.github.lemcoder.external.addSteps +import io.github.lemcoder.external.registerExternalBuild import io.github.lemcoder.hostKonanTarget import io.github.lemcoder.library.NativeArtifacts import io.github.lemcoder.sharedLibraryName @@ -66,15 +68,15 @@ internal sealed interface JniStubBuild { val targets = targets(binding, inputs) val tasks = targets.map { target -> val contract = ExternalContract(target, jni.stubDirectory, jni.stubLibraryName, jni.libraryDirectoryFor(target), javaHome, project.providers) - project.tasks.register("$taskPrefix${bindingName.capitalized()}${target.taskSuffix}", ExternalBuildTask::class.java) { - group = "konan" - description = "Build the '$bindingName' JNI library for ${target.name} with $toolName." + project.registerExternalBuild( + taskName = "$taskPrefix${bindingName.capitalized()}${target.taskSuffix}", + taskDescription = "Build the '$bindingName' JNI library for ${target.name} with $toolName.", + environment = contract.environment(), + outputDirectory = contract.outputDirectory, + libraryFileName = contract.libraryName.map { target.sharedLibraryName(it) }, + ) { dependsOn(inputs.generate) - mustRunAfter(project.cleanTask()) - contract.environment().forEach { (variable, value) -> environment.put(variable.variableName, value) } generatedSources.from(contract.stubDirectory) - libraryFileName.set(contract.libraryName.map { target.sharedLibraryName(it) }) - outputDirectory.set(contract.outputDirectory) addCommands(this, project, binding, contract) } } @@ -89,7 +91,7 @@ internal sealed interface JniStubBuild { override val toolName = "its external build" override fun targets(binding: BindingSpec, inputs: JniStubInputs) = inputs.targets override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) = - addGenericCommands(task, binding, contract) + task.addSteps(binding.jni.external, contract.target, "konan.bindings.${binding.name}.jni") } /** `jni { cmake { } }`: a CMakeLists, configured with the contract as cache entries, then built. */ @@ -129,7 +131,7 @@ internal class ExternalContract( * The environment of every external build β€” the names are the public contract, read the way an AGP * project reads `ANDROID_ABI`. */ -internal enum class ExternalStubVariable(val variableName: String) { +internal enum class ExternalStubVariable(override val variableName: String) : ExternalBuildVariable { /** The directory holding the generated `.c` stub. */ STUB_DIR("KONAN_JNI_STUB_DIR"), diff --git a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt index 9762998..63fdbcf 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt @@ -66,8 +66,10 @@ internal fun registerKonanLink( } ) outputDirectory.set(binding.jni.libraryDirectoryFor(target)) + // From the task's own property: a closure over the artifacts would drag their TaskProvider into + // the configuration cache. onlyIf { - archive(binding, artifacts, target) != null || run { + staticLibrary.isPresent || run { logger.lifecycle( "konan.bindings.$bindingName: no library(), jni.archive or staticLibraries in the .def, so only " + "the JNI bindings and the .c stub were generated β€” link them from your own build." diff --git a/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt new file mode 100644 index 0000000..cd9422e --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt @@ -0,0 +1,44 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.cleanTask +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.NativeLibrarySpec +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** + * No `-std=` here: sources are compiled in one invocation, so a C standard would also reach any `.cpp` + * in the source dir, which clang rejects outright ("invalid argument '-std=c99' not allowed with + * 'C++'"). Builds that need a specific standard set it via `compilerOptions`. + * + * `-fPIC` because the archive is routinely linked into a shared library β€” without it an x86_64 + * Android build fails with "relocation R_X86_64_PC32 cannot be used against symbol …". + */ +private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC") + +/** + * [LibraryProvider.KonanClang]: the sources in [NativeLibrarySpec.sourceDirectory], compiled with + * Kotlin/Native's clang and archived with its llvm-ar. + */ +internal fun registerKonanClang(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = + project.tasks.register("konanClang${library.name.replaceFirstChar { it.uppercase() }}", CompileNativeLibraryTask::class.java) { + group = "konan" + description = "Compile the '${library.name}' C/C++ sources with Kotlin/Native's clang." + // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives + // under build/tmp β€” so `gradle clean assembleDebug` can delete it mid-compile. + mustRunAfter(project.cleanTask()) + + targets.set(library.targets) + outputDir.set(library.outputDirectory) + sourceFiles.from(library.sourceDirectory.map { it.asFileTree }) + includeDirs.from(library.sourceDirectory, library.includeDirs) + libName.set(library.name) + arguments.addAll(DEFAULT_COMPILER_OPTIONS) + arguments.addAll(library.compilerOptions) + + val isWindows = System.getProperty("os.name").lowercase().contains("windows") + val script = if (isWindows) "bin/run_konan.bat" else "bin/run_konan" + runKonan.set(konan.konanHome.file(script)) + + onlyIf { targets.get().isNotEmpty() } + } diff --git a/src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt b/src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt deleted file mode 100644 index 8b7c365..0000000 --- a/src/main/kotlin/io/github/lemcoder/library/KonanClangProvider.kt +++ /dev/null @@ -1,48 +0,0 @@ -package io.github.lemcoder.library - -import io.github.lemcoder.cleanTask -import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.NativeLibrarySpec -import org.gradle.api.Project -import org.gradle.api.tasks.TaskProvider - -/** - * The default provider: the sources in [NativeLibrarySpec.sourceDirectory], compiled with Kotlin/Native's - * clang and archived with its llvm-ar. - */ -internal object KonanClangProvider : LibraryProvider { - override val kind = LibraryProviderKind.KONAN_CLANG - - /** - * No `-std=` here: sources are compiled in one invocation, so a C standard would also reach any `.cpp` - * in the source dir, which clang rejects outright ("invalid argument '-std=c99' not allowed with - * 'C++'"). Builds that need a specific standard set it via `compilerOptions`. - * - * `-fPIC` because the archive is routinely linked into a shared library β€” without it an x86_64 - * Android build fails with "relocation R_X86_64_PC32 cannot be used against symbol …". - */ - private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC") - - override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = - project.tasks.register("konanClang${library.name.replaceFirstChar { it.uppercase() }}", CompileNativeLibraryTask::class.java) { - group = "konan" - description = "Compile the '${library.name}' C/C++ sources with Kotlin/Native's clang." - // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives - // under build/tmp β€” so `gradle clean assembleDebug` can delete it mid-compile. - mustRunAfter(project.cleanTask()) - - targets.set(library.targets) - outputDir.set(library.outputDirectory) - sourceFiles.from(library.sourceDirectory.map { it.asFileTree }) - includeDirs.from(library.sourceDirectory, library.includeDirs) - libName.set(library.name) - arguments.addAll(DEFAULT_COMPILER_OPTIONS) - arguments.addAll(library.compilerOptions) - - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val script = if (isWindows) "bin/run_konan.bat" else "bin/run_konan" - runKonan.set(konan.konanHome.file(script)) - - onlyIf { targets.get().isNotEmpty() } - } -} diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt index b60ef17..733d986 100644 --- a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt +++ b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt @@ -2,43 +2,119 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.external.ExternalBuildVariable +import io.github.lemcoder.external.addSteps +import io.github.lemcoder.external.registerExternalBuild +import io.github.lemcoder.taskSuffix +import io.github.lemcoder.wiring.capitalized import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory import org.gradle.api.tasks.TaskProvider +import org.jetbrains.kotlin.konan.target.KonanTarget /** - * How a library's archives come to exist. An implementation owns its DSL block, its conventions and its - * tasks, and must leave one archive per target at [NativeArtifacts.archivePath] under the library's - * output directory β€” the only thing consumers know about it. + * How a library's archives come to exist: compiled by the plugin, or built by the project's own build + * system. Every provider must leave one archive per target at [NativeArtifacts.archivePath] under the + * library's output directory β€” the only thing consumers know about it. */ -internal interface LibraryProvider { - /** Which provider this is; a library selects it with that kind's DSL block. */ - val kind: LibraryProviderKind +internal sealed interface LibraryProvider { + /** Registers the work that builds [library]'s archives. Called once the build script has run. */ + fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): List> - /** Sets this provider's conventions on [library]; called for every library, before its configure block. */ - fun conventions(project: Project, library: NativeLibrarySpec) {} + /** The default: the library's sources, compiled with Kotlin/Native's clang. */ + data object KonanClang : LibraryProvider { + override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec) = + listOf(registerKonanClang(project, konan, library)) + } - /** Registers the work that builds [library]'s archives. Called once the build script has run. */ - fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> + /** + * The project's own build makes the archive: one [ExternalBuildTask] per target, run with the + * [LibraryContract] as its environment, its archive copied to `lib.a`. A build system only + * adds the commands that build one target. + */ + sealed class External : LibraryProvider { + /** Task-name prefix, e.g. `zigBuild` in `zigBuildMymathMacos_arm64`. */ + protected abstract val taskPrefix: String + + /** The build system, for task descriptions. */ + protected abstract val toolName: String + + /** Adds the commands, sources and produced archive that build [contract]'s target. */ + protected abstract fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) + + final override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): List> { + val libraryName = library.name + return library.targets.getOrElse(emptyList()).map { target -> + val contract = LibraryContract(target, libraryName, library.archiveDirectory(target), library.sourceDirectory, project.providers) + project.registerExternalBuild( + taskName = "$taskPrefix${libraryName.capitalized()}${target.taskSuffix}", + taskDescription = "Build the '$libraryName' library for ${target.name} with $toolName.", + environment = contract.environment(), + outputDirectory = contract.outputDirectory, + libraryFileName = project.providers.provider { NativeArtifacts.archiveFileName(libraryName) }, + ) { + addCommands(this, project, library, contract) + } + } + } + + /** `external { }`: any build system, as the commands the build script lists. */ + data object Generic : External() { + override val taskPrefix = "externalBuild" + override val toolName = "its external build" + override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) = + task.addSteps(library.external, contract.target, "konan.libraries.${library.name}") + } + + /** `zig { }`: the project's own `build.zig`, once per target. */ + data object Zig : External() { + override val taskPrefix = "zigBuild" + override val toolName = "zig build" + override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) = + addZigCommands(task, project, library, contract) + } + } } -/** Every provider the plugin knows. A new build system is one more implementation here. */ -internal val libraryProviders: List = listOf(KonanClangProvider, ZigProvider) +/** What the plugin tells an external library build about one target. */ +internal class LibraryContract( + val target: KonanTarget, + private val libraryName: String, + val outputDirectory: Provider, + private val sourceDirectory: Provider, + private val providers: ProviderFactory, +) { + fun environment(): Map> = mapOf( + ExternalLibraryVariable.LIB_NAME to providers.provider { libraryName }, + ExternalLibraryVariable.OUTPUT_DIR to outputDirectory.map { it.asFile.absolutePath }, + ExternalLibraryVariable.SOURCE_DIR to sourceDirectory.map { it.asFile.absolutePath }, + ExternalLibraryVariable.TARGET to providers.provider { target.name }, + ) +} -/** The library providers there are. Each has one implementation in [libraryProviders]. */ -internal enum class LibraryProviderKind { - /** The default: the library's sources, compiled with Kotlin/Native's clang. */ - KONAN_CLANG, +/** The environment of every external library build β€” the names are public contract. */ +internal enum class ExternalLibraryVariable(override val variableName: String) : ExternalBuildVariable { + /** The library's name: its archive must become `lib.a`. */ + LIB_NAME("KONAN_LIBRARY_NAME"), + + /** Where this target's archive must land, unless the build names a produced library. */ + OUTPUT_DIR("KONAN_LIBRARY_OUTPUT_DIR"), + + /** The library's `sourceDirectory`. */ + SOURCE_DIR("KONAN_LIBRARY_SOURCE_DIR"), - /** `zig { }`: the project's own `build.zig`. */ - ZIG, + /** The Kotlin/Native target, e.g. `linux_x64`. */ + TARGET("KONAN_TARGET"), } -/** The provider [library] selected: at most one block, else [LibraryProviderKind.KONAN_CLANG]. */ +/** The provider [library] selected: at most one block, else [LibraryProvider.KonanClang]. */ internal fun providerFor(library: NativeLibrarySpec): LibraryProvider { val selected = library.selectedProviders check(selected.size <= 1) { "konan.libraries.${library.name} selects ${selected.joinToString(" and ")}; a library is built one way." } - val kind = selected.singleOrNull() ?: LibraryProviderKind.KONAN_CLANG - return libraryProviders.single { it.kind == kind } + return selected.singleOrNull() ?: LibraryProvider.KonanClang } diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt b/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt new file mode 100644 index 0000000..c8e1c5d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt @@ -0,0 +1,37 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.toolchain.zigTriple +import org.gradle.api.Project +import org.jetbrains.kotlin.konan.target.Family + +/** + * [LibraryProvider.External.Zig]: `zig build` for one target, with `-Dtarget` set to the Zig triple that + * matches Kotlin/Native's sysroot, installed into a prefix of its own; its static library is then copied + * to where every other provider puts the archive. + */ +internal fun addZigCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) { + val zig = library.zig + val target = contract.target + // Zig's prefix and cache stay out of the project directory, which is the task's input. + val work = project.layout.buildDirectory.dir("konan/libraries/${library.name}/zig") + val prefix = work.map { it.dir(target.name) } + + task.command { + executable.set(zig.executable) + arguments.addAll(prefix.map { listOf("build", "--prefix", it.asFile.absolutePath) }) + arguments.addAll(work.map { listOf("--cache-dir", it.dir("cache").asFile.absolutePath) }) + arguments.add("-Dtarget=${target.zigTriple()}") + arguments.add(zig.optimize.map { "-Doptimize=${it.zigName}" }) + arguments.addAll(zig.arguments) + workingDirectory.set(zig.projectDirectory.map { it.asFile.absolutePath }) + } + task.sources.from(zig.projectDirectory.map { directory -> directory.asFileTree.matching { exclude(".zig-cache/**", "zig-out/**") } }) + // Zig names the archive after the target's convention: libfoo.a, or foo.lib on Windows. + task.producedLibrary.set( + prefix.zip(zig.artifactName) { directory, artifact -> + directory.file(if (target.family == Family.MINGW) "lib/$artifact.lib" else "lib/lib$artifact.a") + } + ) +} diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt b/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt deleted file mode 100644 index c98f1d6..0000000 --- a/src/main/kotlin/io/github/lemcoder/library/ZigBuildTask.kt +++ /dev/null @@ -1,86 +0,0 @@ -package io.github.lemcoder.library - -import io.github.lemcoder.dsl.Executable -import io.github.lemcoder.dsl.ZigOptimize -import io.github.lemcoder.toolchain.command -import io.github.lemcoder.toolchain.zigTriple -import io.github.lemcoder.util.ExecCaptureResult -import io.github.lemcoder.util.execCapture -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations -import org.gradle.work.DisableCachingByDefault -import org.jetbrains.kotlin.konan.target.KonanTarget -import javax.inject.Inject - -/** - * Builds a library with the project's own `build.zig`, once per target, and puts each static archive - * where every other provider puts it: `//lib.a`. Consumers never - * learn which build system made it. - */ -@DisableCachingByDefault(because = "Output depends on the local Zig installation, which is not a tracked input") -internal abstract class ZigBuildTask @Inject constructor( - private val exec: ExecOperations, -) : DefaultTask() { - /** The directory holding `build.zig`; its `zig build` runs there. */ - @get:Internal abstract val projectDirectory: DirectoryProperty - - /** What the build reads: the project directory, less Zig's own cache and output. */ - @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val sources: ConfigurableFileCollection - - @get:Input abstract val executable: Property - @get:Input abstract val targets: ListProperty - @get:Input abstract val optimize: Property - @get:Input abstract val arguments: ListProperty - - /** The static library `build.zig` installs, by its Zig name. */ - @get:Input abstract val artifactName: Property - - /** The name consumers know the library by: the archive becomes `lib.a`. */ - @get:Input abstract val libraryName: Property - - @get:OutputDirectory abstract val outputDirectory: DirectoryProperty - - @TaskAction - fun build() { - val artifact = artifactName.get() - // Read before exec's block: inside it, `executable` is the ExecSpec's own. - val zig = executable.get().command() - val directory = projectDirectory.get().asFile - val extra = arguments.get() - targets.get().forEach { target -> - val prefix = temporaryDir.resolve("install/${target.name}").apply { deleteRecursively() } - val result = exec.execCapture { - workingDir(directory) - commandLine( - listOf( - zig, "build", - "--prefix", prefix.absolutePath, - // Keep Zig's cache out of the project directory, which is this task's input. - "--cache-dir", temporaryDir.resolve("cache").absolutePath, - "-Dtarget=${target.zigTriple()}", - "-Doptimize=${optimize.get().zigName}", - ) + extra - ) - } - check(result is ExecCaptureResult.Success) { "zig build for ${target.name} failed:\n${result.output}" } - - // Zig names the archive after the target's convention: libfoo.a, or foo.lib on Windows. - val built = listOf("lib/lib$artifact.a", "lib/$artifact.lib").map(prefix::resolve).firstOrNull { it.isFile } - ?: error("zig build for ${target.name} installed no static library '$artifact' under $prefix/lib") - val archive = outputDirectory.get().asFile.resolve(NativeArtifacts.archivePath(libraryName.get(), target)) - archive.parentFile.mkdirs() - built.copyTo(archive, overwrite = true) - } - } -} diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt b/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt deleted file mode 100644 index c57d16b..0000000 --- a/src/main/kotlin/io/github/lemcoder/library/ZigProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.lemcoder.library - -import io.github.lemcoder.cleanTask -import io.github.lemcoder.dsl.Executable -import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.NativeLibrarySpec -import io.github.lemcoder.dsl.ZigOptimize -import org.gradle.api.Project -import org.gradle.api.tasks.TaskProvider - -/** The project's own `build.zig`, run once per target; selected by a library's `zig { }` block. */ -internal object ZigProvider : LibraryProvider { - override val kind = LibraryProviderKind.ZIG - - override fun conventions(project: Project, library: NativeLibrarySpec) { - val zig = library.zig - zig.projectDirectory.convention(library.sourceDirectory) - zig.executable.convention(Executable.OnPath("zig")) - zig.optimize.convention(ZigOptimize.RELEASE_FAST) - zig.artifactName.convention(library.name) - } - - override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = - project.tasks.register("zigBuild${library.name.replaceFirstChar { it.uppercase() }}", ZigBuildTask::class.java) { - group = "konan" - description = "Build the '${library.name}' library with zig build." - mustRunAfter(project.cleanTask()) - val zig = library.zig - projectDirectory.set(zig.projectDirectory) - sources.from(zig.projectDirectory.map { dir -> dir.asFileTree.matching { exclude(".zig-cache/**", "zig-out/**") } }) - executable.set(zig.executable) - targets.set(library.targets) - optimize.set(zig.optimize) - arguments.set(zig.arguments) - artifactName.set(zig.artifactName) - libraryName.set(library.name) - outputDirectory.set(library.outputDirectory) - } -} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt b/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt index 28ff905..e589457 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt @@ -3,7 +3,6 @@ package io.github.lemcoder.wiring import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec import io.github.lemcoder.library.NativeArtifacts -import io.github.lemcoder.library.libraryProviders import io.github.lemcoder.library.providerFor import org.gradle.api.Action import org.gradle.api.Project @@ -25,7 +24,6 @@ internal fun Project.registerLibraries(konan: KonanExtension) { private fun Project.registerLibrary(konan: KonanExtension, library: NativeLibrarySpec) { library.sourceDirectory.convention(layout.projectDirectory.dir("native")) library.outputDirectory.convention(layout.buildDirectory.dir("konan/libraries/${library.name}")) - libraryProviders.forEach { it.conventions(this, library) } val lifecycle = tasks.register(NativeArtifacts.taskName(library.name)) { group = "konan" diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index a07fd43..ba7857a 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -68,10 +68,46 @@ class KonanPluginFunctionalTest { val result = runner("compileNativeLibraryMymath", "--dry-run").build() - assertTrue(":zigBuildMymath SKIPPED" in result.output && ":compileNativeLibraryMymath SKIPPED" in result.output, result.output) + assertTrue(":zigBuildMymathMacos_arm64 SKIPPED" in result.output && ":compileNativeLibraryMymath SKIPPED" in result.output, result.output) assertFalse(":konanClangMymath" in result.output, result.output) } + @Test + fun `a library built by any build system lands where every provider puts it`() { + assumeTrue(File("/bin/sh").canExecute(), "needs /bin/sh") + write( + """id("io.github.lemcoder.konanplugin")""", + """ + libraries { + create("mymath") { + targets(KonanTarget.LINUX_X64, KonanTarget.MINGW_X64) + external { + step { + executable.set(io.github.lemcoder.dsl.Executable.OnPath("sh")) + arguments.add("build.sh") + workingDirectory.set(layout.projectDirectory) + } + sources.from("build.sh") + } + } + } + """, + ) + // Writes straight into the output directory the contract names. + File(projectDir, "build.sh").writeText( + "printf '%s' \"${'$'}KONAN_TARGET\" > \"${'$'}KONAN_LIBRARY_OUTPUT_DIR/lib${'$'}KONAN_LIBRARY_NAME.a\"\n" + ) + + val first = runner("compileNativeLibraryMymath").build() + val second = runner("compileNativeLibraryMymath").build() + + listOf("linux_x64", "mingw_x64").forEach { target -> + assertEquals(target, File(projectDir, "build/konan/libraries/mymath/$target/libmymath.a").readText()) + assertEquals(TaskOutcome.UP_TO_DATE, second.task(":externalBuildMymath${target.replaceFirstChar { it.uppercase() }}")?.outcome, second.output) + } + assertFalse(":konanClangMymath" in first.output, first.output) + } + @Test fun `zig builds each target's archive where every provider puts it`() { val zig = System.getenv("ZIG") From 56cbd1039dd880b129d6dbfcf253a2a4d49e1be9 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 01:51:45 +0200 Subject: [PATCH 17/57] feat: prebuilt libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `libraries { create(…) { prebuilt { archive(target, file) } } }` takes an archive per target built elsewhere and places it as /lib.a, so consumers cannot tell it from a built one and a Windows name.lib works unchanged. Targets default to the ones with an archive; a declared target without one fails configuration, and a missing file fails prebuilt naming it (a Copy task would skip it as NO-SOURCE). Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++++- doc/interop-architecture.md | 14 ++-- .../io/github/lemcoder/dsl/KonanExtension.kt | 25 ++++++- .../lemcoder/library/LibraryProvider.kt | 6 ++ .../lemcoder/library/PrebuiltLibrary.kt | 71 +++++++++++++++++++ .../lemcoder/KonanPluginFunctionalTest.kt | 45 ++++++++++++ 6 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt diff --git a/README.md b/README.md index caddce0..6601231 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ either way. | `outputDirectory` | `Directory` | `build/konan/libraries/` | Root of the per-target archives; `archive(target)` gives one. | | `zig { }` | | β€” | Build with the project's `build.zig` instead; below. | | `external { }` | | β€” | Build with any other build system, as commands; below. | +| `prebuilt { }` | | β€” | Use archives built elsewhere; below. | #### Building with Zig β€” `zig { }` ```kotlin @@ -98,6 +99,20 @@ library (`b.addLibrary(.{ .linkage = .static, … })`). Android targets need a libc file for the NDK's bionic, which Zig does not bundle. +#### Archives built elsewhere β€” `prebuilt { }` +```kotlin +create("mymath") { + prebuilt { + archive(KonanTarget.MACOS_ARM64, file("vendor/macos/libmymath.a")) + archive(KonanTarget.MINGW_X64, file("vendor/windows/mymath.lib")) + } +} +``` + +`prebuilt` places each archive as `/lib.a`, whatever it was called, and fails naming +any that does not exist. `targets` default to the ones with an archive; declaring a target without one +fails configuration. The headers stay the binding's business: point its `includeDirs` at them. + #### Building with anything else β€” `external { }` The same block as `jni { external { } }`, below: a list of `step { }`s run in order once per target by `externalBuild`, with `sources` for up-to-date checks and `producedLibrary(target, file)` @@ -291,7 +306,7 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. | Task | Description | |-------------------------------|-------------| | `compileNativeLibrary` | Builds a library to `lib.a` per target, with whichever provider it uses. | -| `konanClang`, `zigBuild`, `externalBuild` | The provider doing it: Kotlin/Native's clang, `zig build`, or the library's own steps. | +| `konanClang`, `zigBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, the library's own steps, or placing prebuilt archives. | | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | | `linkJni` | Links it for one target. | diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index e72aabf..d03a9fc 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -107,7 +107,7 @@ flowchart TD | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `KonanTarget` today, mapped to Android ABI and JVM library paths | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots) today; Xcode, Android NDK, system clang/GCC/MSVC, Zig later | -| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); all behind `compileNativeLibrary`; **Prebuilt** not yet | +| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KgpWiring.declareCinterops`) | | 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); FFM links the library itself, no glue | @@ -205,8 +205,12 @@ Bazel would slot in the same way: `bazel build //target --platforms=…` per Kot platform per target and the output copied to the archive path. Its cost is the platforms and C toolchains a Bazel workspace must declare to cross-compile, which Zig brings built in. -Still open: a **Prebuilt** library provider (binaries and headers per target from paths or a Maven -artifact), FFM in multiplatform projects, and publishing a library's bindings and binaries. +A **Prebuilt** provider takes an archive per target from local paths β€” a vendor drop β€” and places +each as `lib.a`, so a Windows `name.lib` works unchanged; the library's targets default to the +ones it has archives for. It is not an `External`: there is nothing to run. + +Still open: prebuilt archives from a Maven artifact, FFM in multiplatform projects, and publishing a +library's bindings and binaries. ## B. Generator model @@ -330,8 +334,8 @@ Still undecided: - How a toolchain is chosen per target: a default per host and target (Konan where available), overridable per library. -- How prebuilt binaries arrive: local paths per target, a Maven artifact with a classifier per - target, or an archive to unpack; and how their headers travel with them. +- How prebuilt binaries arrive beyond local paths (done, `prebuilt { }`): a Maven artifact with a + classifier per target, or an archive to unpack; and how their headers travel with them. - What a published library carries for its own consumers. ## Open questions diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt index ca5879e..380c4d5 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -20,6 +20,7 @@ import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import io.github.lemcoder.library.NativeArtifacts import org.jetbrains.kotlin.konan.target.KonanTarget +import java.io.File import javax.inject.Inject /** @@ -64,7 +65,8 @@ abstract class KonanExtension @Inject constructor(objects: ObjectFactory) { /** * A C/C++ library built into a static archive per target, `//lib.a`: by - * Kotlin/Native's clang from [sourceDirectory], or by the project's own build ([zig], [external]). + * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [external]), or built + * elsewhere ([prebuilt]). */ abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { override fun getName(): String = name @@ -119,15 +121,36 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o action.execute(external) } + /** Archives built elsewhere, one per target; [targets] default to the targets they are declared for. */ + val prebuilt: PrebuiltSettings = objects.newInstance(PrebuiltSettings::class.java) + + fun prebuilt(action: Action) { + selectedProviders += LibraryProvider.Prebuilt + action.execute(prebuilt) + } + /** The provider blocks this library's script called; at most one may be. */ internal val selectedProviders = mutableSetOf() init { zig.projectDirectory.convention(sourceDirectory) zig.artifactName.convention(name) + targets.convention(prebuilt.archives.keySet().map { it.toList() }) } } +/** + * A library's archives built elsewhere β€” a vendor drop, another repository's CI. Each is copied to + * `/lib.a`, so a Windows `name.lib` works as it is. + */ +abstract class PrebuiltSettings { + /** The archive for each target. */ + abstract val archives: MapProperty + + /** The static archive built for [target]. */ + fun archive(target: KonanTarget, file: File) = archives.put(target, file) +} + /** * `zig build` for a library, once per target with `-Dtarget` set to the Zig triple that matches * Kotlin/Native's sysroot. The `build.zig` must install a static library ([artifactName]). diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt index 733d986..5f88e56 100644 --- a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt +++ b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt @@ -30,6 +30,12 @@ internal sealed interface LibraryProvider { listOf(registerKonanClang(project, konan, library)) } + /** `prebuilt { }`: archives built elsewhere, one per target, copied into place. */ + data object Prebuilt : LibraryProvider { + override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec) = + listOf(registerPrebuilt(project, library)) + } + /** * The project's own build makes the archive: one [ExternalBuildTask] per target, run with the * [LibraryContract] as its environment, its archive copied to `lib.a`. A build system only diff --git a/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt new file mode 100644 index 0000000..95c22ae --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt @@ -0,0 +1,71 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.wiring.capitalized +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider +import org.gradle.work.DisableCachingByDefault +import org.jetbrains.kotlin.konan.target.KonanTarget +import java.io.File + +/** + * [LibraryProvider.Prebuilt]: archives built elsewhere, placed in the layout every provider leaves β€” + * `/lib.a`, whatever the file was called β€” so consumers cannot tell them apart. + */ +internal fun registerPrebuilt(project: Project, library: NativeLibrarySpec): TaskProvider<*> { + val libraryName = library.name + val archives = library.prebuilt.archives.getOrElse(emptyMap()) + val targets = library.targets.getOrElse(emptyList()) + val missing = targets.filter { it !in archives } + check(missing.isEmpty()) { + "konan.libraries.$libraryName.prebuilt has no archive for ${missing.joinToString { it.name }}." + } + val placements = targets.associateWith { archives.getValue(it) } + + return project.tasks.register("prebuilt${libraryName.capitalized()}", PlacePrebuiltArchivesTask::class.java) { + group = "konan" + description = "Place the prebuilt '$libraryName' archives where consumers look for them." + archiveFiles.from(placements.values) + this.placements.set(placements) + libraryFileName.set(NativeArtifacts.archiveFileName(libraryName)) + outputDirectory.set(library.outputDirectory) + } +} + +/** + * Copies each target's archive to `//`. Not a `Copy`: that + * skips a missing source as NO-SOURCE, and a consumer would then fail far from the cause. + */ +@DisableCachingByDefault(because = "Copying files is not worth caching") +internal abstract class PlacePrebuiltArchivesTask : DefaultTask() { + @get:InputFiles @get:PathSensitive(PathSensitivity.NONE) abstract val archiveFiles: ConfigurableFileCollection + + /** Which archive each target gets. */ + @get:Input abstract val placements: MapProperty + + @get:Input abstract val libraryFileName: Property + + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun place() { + val archives = placements.get() + val absent = archives.values.filterNot { it.isFile } + check(absent.isEmpty()) { "$path: no such archive: ${absent.joinToString()}" } + val output = outputDirectory.get().asFile + archives.forEach { (target, archive) -> + archive.copyTo(output.resolve(target.name).resolve(libraryFileName.get()), overwrite = true) + } + } +} diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index ba7857a..4c00eb0 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -108,6 +108,51 @@ class KonanPluginFunctionalTest { assertFalse(":konanClangMymath" in first.output, first.output) } + @Test + fun `prebuilt archives are placed where every provider puts them`() { + File(projectDir, "libs").mkdirs() + File(projectDir, "libs/libmymath-linux.a").writeText("linux") + File(projectDir, "libs/mymath.lib").writeText("windows") + write( + """id("io.github.lemcoder.konanplugin")""", + """ + libraries { + create("mymath") { + prebuilt { + archive(KonanTarget.LINUX_X64, file("libs/libmymath-linux.a")) + archive(KonanTarget.MINGW_X64, file("libs/mymath.lib")) + } + } + } + """, + ) + + val result = runner("compileNativeLibraryMymath").build() + + assertEquals("linux", File(projectDir, "build/konan/libraries/mymath/linux_x64/libmymath.a").readText()) + assertEquals("windows", File(projectDir, "build/konan/libraries/mymath/mingw_x64/libmymath.a").readText()) + assertFalse(":konanClangMymath" in result.output, result.output) + } + + @Test + fun `a prebuilt library names the targets it has no archive for, and archives that do not exist`() { + write( + """id("io.github.lemcoder.konanplugin")""", + """ + libraries { + create("mymath") { + targets(KonanTarget.LINUX_X64, KonanTarget.MACOS_ARM64) + prebuilt { archive(KonanTarget.LINUX_X64, file("libs/absent.a")) } + } + } + """, + ) + assertTrue("has no archive for macos_arm64" in runner("compileNativeLibraryMymath").buildAndFail().output) + + File(projectDir, "build.gradle.kts").writeText(File(projectDir, "build.gradle.kts").readText().replace(", KonanTarget.MACOS_ARM64", "")) + assertTrue("no such archive" in runner("compileNativeLibraryMymath").buildAndFail().output) + } + @Test fun `zig builds each target's archive where every provider puts it`() { val zig = System.getenv("ZIG") From 3a6b1dc4c6d15fea79fac4fdda3cadd10be506d1 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 02:40:43 +0200 Subject: [PATCH 18/57] refactor!: model package, Kotlin touchpoints behind kotlin/, split DSL - model/: the domain without tasks or KGP. NativeTarget is the plugin's own target type, one entry per Kotlin/Native target, with family, architecture, Android ABI and library naming; plus HostOs, task naming, NativeArtifacts, the external-build contract, Executable, DefFile and selectOne. - kotlin/: the only package importing org.jetbrains.kotlin. NativeTarget.of(konanTarget), KonanDistribution (home, pinned version, run_konan clang/llvm-ar, NDK), KotlinGradlePlugin (the KGP adapter) and the index exporter client. kotlin-native-utils becomes compileOnly; NativeTargetTest fails when a Kotlin version adds or retires a target. - toolchain/: Jdk, Xcode, executable lookup and Zig triples, replacing NativeToolchain. - dsl/: KonanExtension split into one file per spec. FFM gets its own linkerOptions, and jextract is an Executable. - Duplicates merged: one BindingVisibility shared with the exporter protocol, one ExternalBuildContext, one registerExternalBuilds, one taskName/capitalized, one selectOne, one HostOs. BREAKING: targets are NativeTarget (KonanTarget.X -> NativeTarget.X, hostKonanTarget() -> NativeTarget.host), Executable moved to model, and jniHome() is removed. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 20 +- README.md | 38 ++- build.gradle.kts | 9 +- doc/interop-architecture.md | 28 +- examples/android/README.md | 2 +- examples/android/build.gradle.kts | 4 +- examples/jvm/build.gradle.kts | 4 +- examples/native/build.gradle.kts | 4 +- .../github/lemcoder/index/ExporterProtocol.kt | 8 +- .../lemcoder/model/BindingVisibility.kt | 7 + .../lemcoder/index/exporter/JniGenerator.kt | 4 +- .../kotlin/io/github/lemcoder/JniToolchain.kt | 15 - .../kotlin/io/github/lemcoder/KonanPlugin.kt | 12 +- .../kotlin/io/github/lemcoder/KonanTargets.kt | 38 --- .../lemcoder/consumer/BindingConsumer.kt | 6 +- .../github/lemcoder/consumer/FfmConsumer.kt | 23 +- .../github/lemcoder/consumer/JniConsumer.kt | 42 +-- .../lemcoder/consumer/KotlinNativeConsumer.kt | 4 +- .../io/github/lemcoder/dsl/BindingSpec.kt | 57 ++++ .../io/github/lemcoder/dsl/CMakeSettings.kt | 9 +- .../lemcoder/dsl/ExternalBuildSettings.kt | 13 +- .../kotlin/io/github/lemcoder/dsl/FfmSpec.kt | 27 ++ .../kotlin/io/github/lemcoder/dsl/JniSpec.kt | 94 ++++++ .../io/github/lemcoder/dsl/KonanExtension.kt | 285 +----------------- .../github/lemcoder/dsl/NativeLibrarySpec.kt | 91 ++++++ .../github/lemcoder/dsl/PrebuiltSettings.kt | 17 ++ .../io/github/lemcoder/dsl/ZigSettings.kt | 43 +++ .../lemcoder/external/ExternalBuildTask.kt | 2 +- .../lemcoder/external/ExternalBuilds.kt | 63 ++-- .../index/GenerateFromNativeIndexTask.kt | 10 +- .../lemcoder/index/PinnedKotlinNative.kt | 22 -- .../io/github/lemcoder/jni/CMakeStubBuild.kt | 15 +- .../lemcoder/jni/GenerateJniBindingsTask.kt | 18 +- .../io/github/lemcoder/jni/JniStubBuild.kt | 136 +++------ .../github/lemcoder/jni/KonanLinkStubBuild.kt | 37 +-- .../{index => kotlin}/IndexExporter.kt | 22 +- .../lemcoder/kotlin/KonanDistribution.kt | 74 +++++ .../io/github/lemcoder/kotlin/KonanTargets.kt | 13 + .../KotlinGradlePlugin.kt} | 12 +- .../library/CompileNativeLibraryTask.kt | 29 +- .../lemcoder/library/KonanClangBuild.kt | 10 +- .../lemcoder/library/LibraryProvider.kt | 112 +++---- .../lemcoder/library/PrebuiltLibrary.kt | 13 +- .../io/github/lemcoder/library/ZigBuild.kt | 11 +- .../lemcoder/link/LinkSharedLibraryTask.kt | 76 ++--- .../lemcoder/{wiring => model}/DefFile.kt | 2 +- .../lemcoder/{dsl => model}/Executable.kt | 2 +- .../lemcoder/model/ExternalBuildContract.kt | 58 ++++ .../kotlin/io/github/lemcoder/model/HostOs.kt | 26 ++ .../kotlin/io/github/lemcoder/model/Naming.kt | 14 + .../{library => model}/NativeArtifacts.kt | 13 +- .../io/github/lemcoder/model/NativeTarget.kt | 86 ++++++ .../io/github/lemcoder/model/Selection.kt | 10 + .../github/lemcoder/toolchain/Executables.kt | 5 +- .../io/github/lemcoder/toolchain/Jdk.kt | 43 +++ .../lemcoder/toolchain/NativeToolchain.kt | 92 ------ .../io/github/lemcoder/toolchain/Xcode.kt | 32 ++ .../github/lemcoder/toolchain/ZigTargets.kt | 30 +- .../kotlin/io/github/lemcoder/util/Gradle.kt | 9 + .../io/github/lemcoder/wiring/Libraries.kt | 4 +- .../lemcoder/KonanPluginFunctionalTest.kt | 53 ++-- .../lemcoder/index/ExporterProtocolTest.kt | 3 +- .../index/GenerateFromNativeIndexTest.kt | 13 +- .../github/lemcoder/model/NativeTargetTest.kt | 41 +++ 64 files changed, 1163 insertions(+), 952 deletions(-) create mode 100644 src/exporterProtocol/kotlin/io/github/lemcoder/model/BindingVisibility.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/JniToolchain.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/KonanTargets.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/BindingSpec.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt rename src/main/kotlin/io/github/lemcoder/{index => kotlin}/IndexExporter.kt (68%) create mode 100644 src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt create mode 100644 src/main/kotlin/io/github/lemcoder/kotlin/KonanTargets.kt rename src/main/kotlin/io/github/lemcoder/{wiring/KgpWiring.kt => kotlin/KotlinGradlePlugin.kt} (87%) rename src/main/kotlin/io/github/lemcoder/{wiring => model}/DefFile.kt (98%) rename src/main/kotlin/io/github/lemcoder/{dsl => model}/Executable.kt (94%) create mode 100644 src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt create mode 100644 src/main/kotlin/io/github/lemcoder/model/HostOs.kt create mode 100644 src/main/kotlin/io/github/lemcoder/model/Naming.kt rename src/main/kotlin/io/github/lemcoder/{library => model}/NativeArtifacts.kt (70%) create mode 100644 src/main/kotlin/io/github/lemcoder/model/NativeTarget.kt create mode 100644 src/main/kotlin/io/github/lemcoder/model/Selection.kt create mode 100644 src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/toolchain/NativeToolchain.kt create mode 100644 src/main/kotlin/io/github/lemcoder/toolchain/Xcode.kt create mode 100644 src/main/kotlin/io/github/lemcoder/util/Gradle.kt create mode 100644 src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index 6da0f1a..d6fc218 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,11 +12,19 @@ resolved the moment the plugin is applied, in *every* project that applies it. blocks, which is why the whole DSL is one project extension, `konan`, and consumers are found from the project's plugins and targets. No KGP or AGP type may appear in the DSL classes: putting one in a decorated signature fails with *"Could not generate a decorated class … KotlinCompilation"* in any - project without KGP. All KGP and AGP code lives in `wiring/KgpWiring` and `wiring/AgpWiring`, reached - only from `plugins.withId` callbacks or once the plugin is known to be applied. -- `kotlin-native-utils` is an `implementation` dependency, not the compile-only it arrives as - transitively: `KonanTarget` appears in decorated DSL signatures, and the published POM previously - declared no dependencies at all. + project without KGP. All KGP code lives in `kotlin/KotlinGradlePlugin` and AGP code in + `wiring/AgpWiring`, reached only from `plugins.withId` callbacks or once the plugin is known to be + applied. +- **The DSL speaks `model.NativeTarget`, never `KonanTarget`.** Only the `kotlin/` package imports + `org.jetbrains.kotlin.*`: `NativeTarget.of(konanTarget)`, the KGP adapter, the distribution + (`KonanDistribution`) and the index exporter client. So `kotlin-native-utils` is `compileOnly` β€” + everything that touches it runs where KGP, which brings it, is applied. `NativeTargetTest` fails when + a Kotlin bump adds or retires a target; add the `NativeTarget` entry (and its Zig triple, if any). +- **One home per concept.** `model/` holds the domain (targets, naming, the artifact layout, the + external-build contract, `selectOne`), `kotlin/` the touchpoints with Kotlin, `toolchain/` the + others (JDK, Xcode, executables, Zig). Task names come from `taskName(...)`, OS checks from + `HostOs`, the distribution's layout from `KonanDistribution` β€” add to those, don't re-derive. + `BindingVisibility` lives in `src/exporterProtocol` so the DSL and the generator share one enum. - AGP types are only touched inside `plugins.withId(...)` blocks. A plain reference in an eagerly executed method is `NoClassDefFoundError: KotlinMultiplatformAndroidComponentsExtension` in a plugin-only build. The KMP Android plugin does **not** apply `com.android.base`, so both ids need @@ -107,7 +115,7 @@ holds a `NativeIndex`. the compiler jar as a friend module (`friendPaths` in the buildSrc plugin). Java is not a way around `internal` here: `native` is a Java keyword, so Java cannot import any `org.jetbrains.kotlin.native.…` class. And Kotlin cannot see the jar's types through Java sources in the same source set. -- **Kotlin/Native bindings are not an exporter generator.** `KgpWiring.declareCinterops` adapts a +- **Kotlin/Native bindings are not an exporter generator.** `KotlinGradlePlugin.declareCinterops` adapts a binding to KGP's own `cinterops {}`, so the klib comes from the consumer's Kotlin version β€” a klib is a compiler artifact, unlike the sources the exporter writes. Running cinterop's pipeline in the exporter works (`Interop().interop("native", …)`), but would pin the klib to `kotlinNativeVersion`; and its native diff --git a/README.md b/README.md index 6601231..540676b 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and - **JVM** targets and **Android** through generated JNI bindings and a stub library per target, with strings and primitive buffers marshalled; - the **JVM 22+** through jextract's FFM bindings, on request. -- Speaks Kotlin/Native's own `KonanTarget`: pass `kotlinNativeTarget.konanTarget` straight in, and new - targets arrive with the Kotlin version. +- Has its own target type, `NativeTarget`, one entry per Kotlin/Native target; a KGP target converts + with `NativeTarget.of(kotlinNativeTarget.konanTarget)`. The plugin's API does not depend on the + Kotlin compiler's. - Wires generated sources, `jniLibs` and task order into Kotlin and Android builds; can hand the JNI stub to the project's own build β€” CMake, or any build system as a list of commands β€” instead. - Auto-detects the Kotlin/Native distribution, the JDK that supplies `jni.h`, and the compiler-rt @@ -37,11 +38,11 @@ Everything lives in one block: the **libraries** the project builds, and the **b them from Kotlin. ```kotlin -import org.jetbrains.kotlin.konan.target.KonanTarget +import io.github.lemcoder.model.NativeTarget konan { val mymath = libraries.create("mymath") { - targets(KonanTarget.MACOS_ARM64, KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) + targets(NativeTarget.MACOS_ARM64, NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } bindings { create("mymath") { @@ -67,7 +68,7 @@ either way. | Property | Type | Default | Description | |-------------------|---------------------|----------------------------------|-------------| -| `targets` | `List` | β€” | Targets to build for. `targets(...)` takes `KonanTarget`s; `androidTargets()` is every Android ABI. | +| `targets` | `List`| β€” | Targets to build for. `targets(...)` takes `NativeTarget`s; `NativeTarget.host` is the build host, `androidTargets()` every Android ABI. | | `sourceDirectory` | `Directory` | `native/` | The C/C++ sources; also an include root. | | `includeDirs` | `FileCollection` | β€” | Further include roots. | | `compilerOptions` | `List` | β€” | Appended to `-fno-sanitize=undefined -fPIC`. Put `-std=…` here β€” C and C++ sources share one invocation. | @@ -79,7 +80,7 @@ either way. #### Building with Zig β€” `zig { }` ```kotlin create("mymath") { - targets(KonanTarget.MACOS_ARM64, KonanTarget.LINUX_X64, KonanTarget.MINGW_X64) + targets(NativeTarget.MACOS_ARM64, NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) zig { } // runs native/build.zig once per target } ``` @@ -103,8 +104,8 @@ Android targets need a libc file for the NDK's bionic, which Zig does not bundle ```kotlin create("mymath") { prebuilt { - archive(KonanTarget.MACOS_ARM64, file("vendor/macos/libmymath.a")) - archive(KonanTarget.MINGW_X64, file("vendor/windows/mymath.lib")) + archive(NativeTarget.MACOS_ARM64, file("vendor/macos/libmymath.a")) + archive(NativeTarget.MINGW_X64, file("vendor/windows/mymath.lib")) } } ``` @@ -127,7 +128,7 @@ when the build leaves its archive somewhere of its own. Each run gets: ```kotlin create("mymath") { - targets(KonanTarget.LINUX_X64) + targets(NativeTarget.LINUX_X64) external { step { executable.set(Executable.OnPath("cargo")) @@ -135,7 +136,7 @@ create("mymath") { workingDirectory.set(layout.projectDirectory.dir("native")) } sources.from("native/src", "native/Cargo.toml") - producedLibrary(KonanTarget.LINUX_X64, file("native/target/x86_64-unknown-linux-gnu/release/libmymath.a")) + producedLibrary(NativeTarget.LINUX_X64, file("native/target/x86_64-unknown-linux-gnu/release/libmymath.a")) } } ``` @@ -158,7 +159,7 @@ A C API described by a cinterop `.def`. | Property | Type | Default | Description | |----------------------|--------------------------|---------|-------------| | `visibility` | `BindingVisibility` | `INTERNAL` | `PUBLIC` when the idiomatic wrapper lives in another module. | -| `targets` | `List` | the library's targets a JVM can load β€” the host and Android β€” else the host | Targets to link the JNI library for. | +| `targets` | `List` | the library's targets a JVM can load β€” the host and Android β€” else the host | Targets to link the JNI library for. | | `archive` | `File` | the library's | An archive to link instead, e.g. one built elsewhere. | | `jdkHome` | `Directory` | auto-detected | A JDK shipping `include/jni.h`. | | `linkerOptions` | `List` | β€” | Extra options for every target's link. | @@ -213,7 +214,7 @@ create("koinference") { `cmakeBuild` configures, then builds. It always runs and leaves incrementality to CMake, so a changed configure argument always reaches the build. Android needs a library per ABI, so declare -targets: `target(KonanTarget.ANDROID_ARM64) { preset.set("androidNativeArm64") }`; without any, it +targets: `target(NativeTarget.ANDROID_ARM64) { preset.set("androidNativeArm64") }`; without any, it builds once for the host. The contract also arrives as cache entries, and `CMAKE_LIBRARY_OUTPUT_DIRECTORY` is set to `KONAN_JNI_OUTPUT_DIR`, so the CMakeLists needs no knowledge of the layout: @@ -247,10 +248,10 @@ jni { step { executable.set(Executable.OnPath("make")) arguments.addAll("-C", "native/jni") - argumentsFor(KonanTarget.ANDROID_ARM64, "ARCH=arm64") + argumentsFor(NativeTarget.ANDROID_ARM64, "ARCH=arm64") } sources.from("native") // what it reads; omit to always run - producedLibrary(KonanTarget.MACOS_ARM64, file("native/jni/out/libstub.dylib")) + producedLibrary(NativeTarget.MACOS_ARM64, file("native/jni/out/libstub.dylib")) } } ``` @@ -299,7 +300,8 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. | Property | Type | Default | Description | |--------------------|-------------|----------------------|-------------| -| `jextract` | `File` | `jextract` on the PATH | The jextract executable. | +| `jextract` | `Executable`| `OnPath("jextract")` | The jextract program. | +| `linkerOptions` | `List` | β€” | Extra options for linking the library the bindings load. | | `libraryDirectory` | `Directory` | output | Where the shared library ends up. | ## Tasks @@ -334,7 +336,7 @@ The DSL is new and there is no compatibility shim. `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` β†’ `jdkHome`, `library` β†’ `archive`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ `linkerOptionsFor`, `externalNativeBuild { cmake { } }` β†’ `cmake { }` (`targets` β†’ `buildTargets`, - `abi("arm64-v8a")` β†’ `target(KonanTarget.ANDROID_ARM64)`, `executable` β†’ an `Executable`, no `$CMAKE`), + `abi("arm64-v8a")` β†’ `target(NativeTarget.ANDROID_ARM64)`, `executable` β†’ an `Executable`, no `$CMAKE`), `resolvedLibraryDirectory` β†’ `libraryDirectory`. `additionalCompilerArgs` becomes `compilerOptions`. Bindings are wired into the project's JVM and Android targets automatically. @@ -342,6 +344,10 @@ The DSL is new and there is no compatibility shim. `val mymath = libraries.create("mymath") { … }`. Drop `staticLibraries` for it from the `.def`, or Kotlin/Native would embed it twice. - `kotlinNativeInterop` is gone: a binding is declared for every Kotlin/Native target by itself. +- Targets are `NativeTarget`s (`io.github.lemcoder.model`), not `KonanTarget`s: `KonanTarget.X` β†’ + `NativeTarget.X`, `hostKonanTarget()` β†’ `NativeTarget.host`, a KGP target's `konanTarget` β†’ + `NativeTarget.of(konanTarget)` (`import io.github.lemcoder.kotlin.of`). `jniHome()` is gone: external + builds get `JAVA_HOME`. Programs (`zig`, `cmake`, `jextract`) are `Executable`s. - JNI bindings are named after the C functions (`my_add`), not `kniBridge0`; tasks are `generateJni…` and `linkJni…`. diff --git a/build.gradle.kts b/build.gradle.kts index d906139..72090ca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,10 +38,9 @@ val kgpForTests: Configuration = configurations.create("kgpForTests") dependencies { "kgpForTests"(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-gradle-plugin:$it" }) - // KonanTarget & HostManager: the plugin speaks Kotlin/Native's own target type, so a build can pass - // `kotlinNativeTarget.konanTarget` straight in. Not compileOnly β€” it appears in decorated DSL - // signatures, which Gradle resolves in projects that have no Kotlin plugin on the classpath. - implementation(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-native-utils:$it" }) + // KonanTarget, for NativeTarget.of(konanTarget) and the KGP adapter β€” both reached only where KGP, which + // brings it, is applied. The DSL speaks the plugin's own NativeTarget, so nothing decorated needs it. + compileOnly(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-native-utils:$it" }) // Used only by the KGP cinterop adapter, inside plugins.withId: the consuming project supplies KGP. compileOnly(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-gradle-plugin:$it" }) // Used only to auto-wire generated sources/jniLibs into Android projects via the AGP variant API. @@ -53,6 +52,8 @@ dependencies { // plugin-under-test classpath into withPluginClasspath(). testImplementation(gradleTestKit()) testImplementation(kotlin("test")) + // NativeTargetTest compares the plugin's targets with the Kotlin/Native version it is built against. + testImplementation(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-native-utils:$it" }) } tasks.test { diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index d03a9fc..ee7027b 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -105,11 +105,11 @@ flowchart TD | # | Component | Responsibility | Consumes β†’ produces | Implementations | | --- | --- | --- | --- | --- | | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | -| 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `KonanTarget` today, mapped to Android ABI and JVM library paths | +| 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `model.NativeTarget`: one entry per Kotlin/Native target (checked by a test against the pinned version), with family, architecture, Android ABI, library naming and Zig triple; `NativeTarget.of(konanTarget)` is the only conversion | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots) today; Xcode, Android NDK, system clang/GCC/MSVC, Zig later | | 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | -| 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KgpWiring.declareCinterops`) | +| 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | | 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); FFM links the library itself, no glue | | 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | `BindingConsumer`: **KotlinNativeConsumer** (the KGP adapter), **JniConsumer** (JVM, multiplatform JVM, Android; own link or CMake), **FfmConsumer** (`kotlin("jvm")` only); publications not yet (section D) | @@ -141,7 +141,7 @@ Kotlin targets, so a build declares what it has, not how each consumer is wired. ```kotlin konan { val mymath = libraries.create("mymath") { - targets(KonanTarget.MACOS_ARM64, KonanTarget.ANDROID_ARM64) // sourceDirectory = native/ + targets(NativeTarget.MACOS_ARM64, NativeTarget.ANDROID_ARM64) // sourceDirectory = native/ } bindings { create("mymath") { @@ -292,12 +292,22 @@ Not yet: Partly settled by what exists: - **Plugin source**: `src/indexExporter` is the exporter and every generator (its own source set: it - compiles against the compiler jar, which `main` must not); `src/main/.../index` holds the launcher - (`runIndexExporter`), the generic `GenerateFromNativeIndexTask` and `PinnedKotlinNative`; - `buildSrc/.../native-index.gradle.kts` wires the source set, the friend path and the nested exporter - jar. The other components have a package each: `dsl` (everything a build script touches), `library`, - `jni`, `link`, `toolchain`, and `wiring` (which turns the DSL into tasks; `KgpWiring` and `AgpWiring` - are the only files touching KGP and AGP types). + compiles against the compiler jar, which `main` must not); `src/exporterProtocol` is compiled into + both sides (`GeneratorRequest`, `ExporterInvocation`, `BindingVisibility`); + `buildSrc/.../native-index.gradle.kts` wires the source sets, the friend path and the nested exporter + jar. `src/main` has a package per concern: + + | Package | Holds | + | --- | --- | + | `model` | The domain, without tasks or KGP: `NativeTarget` (+ `TargetFamily`, `Architecture`, `HostOs`), `NativeArtifacts` (the archive layout), the external-build contract, `Executable`, `DefFile`, task naming, `selectOne` | + | `kotlin` | Every touchpoint with Kotlin, and the only package importing it: `NativeTarget.of(konanTarget)`, `KonanDistribution` (home, pinned version, `run_konan` clang and llvm-ar, NDK), `KotlinGradlePlugin` (KGP), the index exporter client | + | `toolchain` | The other toolchains: `Jdk` (`jni.h`), `Xcode` (compiler-rt), executable lookup, Zig triples | + | `dsl` | Everything a build script touches, one file per spec | + | `library` | `LibraryProvider` and its builds: Kotlin/Native's clang, prebuilt, Zig | + | `jni`, `link`, `index` | `JniStubBuild` and its builds, the generate tasks, the shared-library link | + | `external` | `ExternalBuildTask` and the registration every external build shares | + | `consumer` | `BindingConsumer`: Kotlin/Native, JNI, FFM | + | `wiring` | Registration of libraries and bindings, AGP, plugin ids | - **Generator output**: each exporter generator owns its layout under the task's output directory β€” `jni`: `kotlin//.kt` and `c/.c`; `jextract`: `java//`; `dump`: `index.txt`. Kotlin/Native klibs stay where KGP puts them. diff --git a/examples/android/README.md b/examples/android/README.md index 70081cd..f882726 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -57,7 +57,7 @@ One config block β€” the plugin does the wiring: ```kotlin konan { - val mymath = libraries.create("mymath") { targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) } // device + emulator + val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } // device + emulator bindings { create("mymath") { defFile.set(file("src/main/nativeInterop/mymath.def")) diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts index 7a336b8..4556848 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -2,7 +2,7 @@ // Both plugins are applied to this one module: the konan plugin produces the .a, the JNI stub .so and the // Kotlin bindings; AGP packages the .so and compiles the app. -import org.jetbrains.kotlin.konan.target.KonanTarget +import io.github.lemcoder.model.NativeTarget plugins { id("com.android.application") // AGP 9 has built-in Kotlin support @@ -11,7 +11,7 @@ plugins { konan { // native/*.c -> one static archive per ABI: device + emulator. - val mymath = libraries.create("mymath") { targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) } + val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } // An Android app gets JNI bindings, wired into its variants, and a JNI library per ABI the library // is built for, packaged as jniLibs. bindings { diff --git a/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts index edb0014..ea375c6 100644 --- a/examples/jvm/build.gradle.kts +++ b/examples/jvm/build.gradle.kts @@ -1,4 +1,4 @@ -import io.github.lemcoder.hostKonanTarget +import io.github.lemcoder.model.NativeTarget plugins { kotlin("jvm") version "2.4.20" @@ -10,7 +10,7 @@ repositories { mavenCentral() } konan { // native/*.c -> one static archive per target. - val mymath = libraries.create("mymath") { targets(hostKonanTarget()) } + val mymath = libraries.create("mymath") { targets(NativeTarget.host) } // The C API in the .def, linked against that library. A kotlin("jvm") project gets JNI bindings, // added to its main source set, and a JNI library per target a JVM can load. bindings { diff --git a/examples/native/build.gradle.kts b/examples/native/build.gradle.kts index 43fcf9f..8f3055b 100644 --- a/examples/native/build.gradle.kts +++ b/examples/native/build.gradle.kts @@ -1,6 +1,6 @@ // Kotlin/Native consumer of the same C library, through KGP's own cinterop (no JNI). -import org.jetbrains.kotlin.konan.target.KonanTarget +import io.github.lemcoder.model.NativeTarget plugins { kotlin("multiplatform") version "2.4.20" @@ -17,7 +17,7 @@ kotlin { konan { // native/*.c -> build/konan/libraries/mymath/macos_arm64/libmymath.a. - val mymath = libraries.create("mymath") { targets(KonanTarget.MACOS_ARM64) } + val mymath = libraries.create("mymath") { targets(NativeTarget.MACOS_ARM64) } // Every Kotlin/Native target the library is built for gets KGP's cinterop for the .def, with the // library's archive embedded in the klib. bindings { diff --git a/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt index f4b9815..1117ef0 100644 --- a/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt +++ b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.index +import io.github.lemcoder.model.BindingVisibility import java.io.File import java.io.Serializable @@ -18,7 +19,7 @@ sealed class GeneratorRequest : Serializable { * * @property stubLibrary the library the bindings `System.loadLibrary`; the plugin owns that name. */ - data class Jni(val stubLibrary: String, val visibility: GeneratedVisibility = GeneratedVisibility.INTERNAL) : + data class Jni(val stubLibrary: String, val visibility: BindingVisibility = BindingVisibility.INTERNAL) : GeneratorRequest() /** @@ -32,9 +33,6 @@ sealed class GeneratorRequest : Serializable { GeneratorRequest() } -/** Visibility of generated declarations. */ -enum class GeneratedVisibility { INTERNAL, PUBLIC } - /** * One run of the exporter, as its command line: the [request], where its output goes, and the cinterop * command line that describes the library. [toArgs] and [parse] are the only places the flags are spelled. @@ -82,7 +80,7 @@ data class ExporterInvocation( Kind.DUMP -> GeneratorRequest.Dump Kind.JNI -> GeneratorRequest.Jni( stubLibrary = required(Flag.STUB_LIBRARY), - visibility = GeneratedVisibility.valueOf(required(Flag.VISIBILITY)), + visibility = BindingVisibility.valueOf(required(Flag.VISIBILITY)), ) Kind.JEXTRACT -> GeneratorRequest.Jextract( library = required(Flag.LIBRARY), diff --git a/src/exporterProtocol/kotlin/io/github/lemcoder/model/BindingVisibility.kt b/src/exporterProtocol/kotlin/io/github/lemcoder/model/BindingVisibility.kt new file mode 100644 index 0000000..e65cdf0 --- /dev/null +++ b/src/exporterProtocol/kotlin/io/github/lemcoder/model/BindingVisibility.kt @@ -0,0 +1,7 @@ +package io.github.lemcoder.model + +/** + * Whether generated bindings are visible outside the module that declares them. One type for the DSL and + * the generator: it lives with the exporter protocol, which both the plugin and the exporter compile. + */ +enum class BindingVisibility { INTERNAL, PUBLIC } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt index 63d98a5..64d23af 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -1,6 +1,6 @@ package io.github.lemcoder.index.exporter -import io.github.lemcoder.index.GeneratedVisibility +import io.github.lemcoder.model.BindingVisibility import io.github.lemcoder.index.GeneratorRequest import org.jetbrains.kotlin.native.interop.indexer.* import java.io.File @@ -173,7 +173,7 @@ object JniGenerator : BindingGenerator { packageName: String, facade: String, stubLibrary: String, - visibility: GeneratedVisibility, + visibility: BindingVisibility, bound: List, skipped: List>, ) = buildString { diff --git a/src/main/kotlin/io/github/lemcoder/JniToolchain.kt b/src/main/kotlin/io/github/lemcoder/JniToolchain.kt deleted file mode 100644 index 7748248..0000000 --- a/src/main/kotlin/io/github/lemcoder/JniToolchain.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.lemcoder - -import io.github.lemcoder.toolchain.NativeToolchain - -/** - * A JDK home that ships `include/jni.h`, for a build that compiles the generated JNI stub itself. - * - * In generate-only mode the plugin hands over the `.c` and another build system β€” CMake, Bazel β€” - * compiles it, and that build needs the header. The JVM running Gradle is not a reliable source: - * IDE-bundled JBRs strip `include/`, and CMake's `FindJNI` insists on a full JDK when only the - * header is wanted. Pass this as `JAVA_HOME` to the external build. - * - * @throws IllegalStateException when no installed JDK ships the header. - */ -fun jniHome(): String = NativeToolchain.detectJniHome().absolutePath diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 623d152..9ff6265 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -1,27 +1,21 @@ package io.github.lemcoder import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.toolchain.NativeToolchain +import io.github.lemcoder.kotlin.KonanDistribution import io.github.lemcoder.wiring.registerBindings import io.github.lemcoder.wiring.registerLibraries import org.gradle.api.Plugin import org.gradle.api.Project -/** - * The `clean` task as a lazy collection β€” empty when the base plugin isn't applied, so it is safe to - * pass to `mustRunAfter` unconditionally. - */ -internal fun Project.cleanTask() = tasks.matching { it.name == "clean" } - abstract class KonanPlugin : Plugin { override fun apply(project: Project) { val konan = project.extensions.create("konan", KonanExtension::class.java) // Auto-detected: $KONAN_HOME, else the newest ~/.konan/kotlin-native-prebuilt-*. Absent rather // than failing when there is none: the convention is queried whenever the configuration cache is // stored, so throwing here breaks configuration of builds that never touch a konan task. The - // tasks that need it fail at execution instead, and say what to do. + // tasks that need it resolve it again when they run, and say what to do. konan.konanHome.convention( - project.layout.dir(project.providers.provider { NativeToolchain.findKonanHome() }) + project.layout.dir(project.providers.provider { KonanDistribution.findHome() }) ) project.registerLibraries(konan) project.registerBindings(konan) diff --git a/src/main/kotlin/io/github/lemcoder/KonanTargets.kt b/src/main/kotlin/io/github/lemcoder/KonanTargets.kt deleted file mode 100644 index d9b84be..0000000 --- a/src/main/kotlin/io/github/lemcoder/KonanTargets.kt +++ /dev/null @@ -1,38 +0,0 @@ -package io.github.lemcoder - -import org.jetbrains.kotlin.konan.target.Family -import org.jetbrains.kotlin.konan.target.HostManager -import org.jetbrains.kotlin.konan.target.KonanTarget - -/** - * Helpers over Kotlin/Native's own [KonanTarget], which this plugin uses as its target type so a - * build can hand over `kotlinNativeTarget.konanTarget` directly and new targets arrive with the - * Kotlin version rather than with a plugin release. - */ - -/** The build host, as a target. */ -fun hostKonanTarget(): KonanTarget = HostManager.host - -/** Every Android ABI. */ -fun androidKonanTargets(): List = - KonanTarget.predefinedTargets.values.filter { it.family == Family.ANDROID } - -internal val KonanTarget.isAndroid: Boolean get() = family == Family.ANDROID - -/** Task-name segment, e.g. `Android_arm64` in `linkJvmInteropMymathAndroid_arm64`. */ -internal val KonanTarget.taskSuffix: String get() = name.replaceFirstChar { it.uppercase() } - -/** `jniLibs/` subdirectory: Android ABI names, otherwise the target name. */ -internal val KonanTarget.abiDir: String - get() = when (this) { - KonanTarget.ANDROID_ARM64 -> "arm64-v8a" - KonanTarget.ANDROID_ARM32 -> "armeabi-v7a" - KonanTarget.ANDROID_X64 -> "x86_64" - KonanTarget.ANDROID_X86 -> "x86" - else -> name - } - -/** Shared-library file name for [baseName], e.g. `libfoo.dylib` / `foo.dll`. */ -internal fun KonanTarget.sharedLibraryName(baseName: String): String = - // Family carries the prefix and suffix, but the suffix has no leading dot. - "${family.dynamicPrefix}$baseName.${family.dynamicSuffix}" diff --git a/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt index 463e038..718f12c 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt @@ -2,10 +2,9 @@ package io.github.lemcoder.consumer import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.library.NativeArtifacts -import io.github.lemcoder.wiring.DefFile +import io.github.lemcoder.model.DefFile +import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.wiring.GradlePluginId -import io.github.lemcoder.wiring.capitalized import io.github.lemcoder.wiring.hasPlugin import org.gradle.api.Project import org.gradle.api.provider.Provider @@ -36,7 +35,6 @@ internal fun bindingConsumers(context: BindingContext): List = /** What every consumer of one binding shares. */ internal class BindingContext(val project: Project, val konan: KonanExtension, val binding: BindingSpec) { val bindingName: String = binding.name - val suffix: String = bindingName.capitalized() val buildDirectory = project.layout.buildDirectory /** The archives of the library the binding names, once the build script has been evaluated. */ diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt index a5f3732..f52687e 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt @@ -1,11 +1,14 @@ package io.github.lemcoder.consumer -import io.github.lemcoder.hostKonanTarget import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.link.LinkSharedLibraryTask +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName +import io.github.lemcoder.toolchain.command import io.github.lemcoder.wiring.GradlePluginId import org.gradle.api.tasks.SourceSetContainer +import java.io.File /** * FFM bindings: jextract over the same `.def`, and the library itself as a shared library for the host β€” @@ -21,31 +24,35 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume val bindingName = context.bindingName val artifacts = context.artifacts() val libraryName = artifacts?.name ?: bindingName - val host = hostKonanTarget() + val host = NativeTarget.host val packageName = context.packageName - val generate = project.tasks.register("generateFfm${context.suffix}", GenerateFromNativeIndexTask::class.java) { + val generate = project.tasks.register(taskName("generateFfm", bindingName), GenerateFromNativeIndexTask::class.java) { group = "konan" description = "Generate the '$bindingName' FFM bindings with jextract." defFile.set(binding.defFile) includeDirs.from(binding.includeDirs) compilerOptions.set(binding.compilerOptions) target.set(host) - request.set(project.provider { GeneratorRequest.Jextract(library = libraryName, jextract = binding.ffm.jextract.orNull?.asFile) }) + // Resolved here, in the daemon, which knows the usual install locations; unresolved, the exporter + // runs `jextract` from its own PATH. + request.set(binding.ffm.jextract.map { jextract -> + GeneratorRequest.Jextract(library = libraryName, jextract = File(jextract.command()).takeIf { it.isAbsolute }) + }) packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } outputDirectory.set(context.buildDirectory.dir("generated/konan/$bindingName/ffm")) } - val ffmLib = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib/${host.name}") + val ffmLib = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib/${host.konanName}") binding.ffm.libraryDirectory.convention(ffmLib) if (artifacts != null && host in artifacts.targets.getOrElse(emptyList())) { - project.tasks.register("linkFfm${context.suffix}", LinkSharedLibraryTask::class.java) { + project.tasks.register(taskName("linkFfm", bindingName), LinkSharedLibraryTask::class.java) { group = "konan" description = "Link the '$libraryName' library as a shared library for the FFM bindings." dependsOn(artifacts.builtBy) - konanPath.set(context.konan.konanHome.map { it.asFile.absolutePath }) + konanHome.set(context.konan.konanHome) target.set(host) libraryBaseName.set(libraryName) staticLibrary.set(artifacts.archive(host)) - additionalLinkerArgs.set(binding.jni.linkerOptions) + additionalLinkerArgs.set(binding.ffm.linkerOptions) outputDirectory.set(ffmLib) } } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt index 277d65b..ced5543 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt @@ -1,21 +1,18 @@ package io.github.lemcoder.consumer -import io.github.lemcoder.cleanTask -import io.github.lemcoder.dsl.BindingVisibility -import io.github.lemcoder.hostKonanTarget -import io.github.lemcoder.index.GeneratedVisibility -import io.github.lemcoder.isAndroid import io.github.lemcoder.jni.GenerateJniBindingsTask import io.github.lemcoder.jni.JniStubInputs import io.github.lemcoder.jni.stubBuildFor -import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.kotlin.KotlinGradlePlugin +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName +import io.github.lemcoder.util.cleanTask import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId -import io.github.lemcoder.wiring.KgpWiring import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Task import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.konan.target.KonanTarget /** * JVM and Android targets, through the JNI generator: Kotlin `external fun`s in the main sources, and a @@ -25,26 +22,24 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume private val project = context.project private val binding = context.binding private val bindingName = context.bindingName - private val suffix = context.suffix private lateinit var generate: TaskProvider private lateinit var linkAll: TaskProvider override fun prepare() { - binding.jni.visibility.convention(BindingVisibility.INTERNAL) generate = registerGeneration() binding.jni.stubDirectoryProperty.set(generate.flatMap { it.stubSourceDirectory }) binding.jni.stubLibraryNameProperty.set(generate.flatMap { it.stubLibraryBaseName }) binding.jni.jniLibsRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/jni/jniLibs")) binding.jni.hostLibRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/jni/lib")) - linkAll = project.tasks.register("linkJni$suffix") { + linkAll = project.tasks.register(taskName("linkJni", bindingName)) { group = "konan" description = "Make the '$bindingName' JNI library exist for every target, however it is built." } val sources = generate.flatMap { it.kotlinSourceDirectory } // Eager: the variant API must be wired at configuration time. - project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KgpWiring.addKmpJvmSources(project, sources) } + project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.addKmpJvmSources(project, sources) } AgpWiring.addKotlinSources(project, sources, generate, linkAll) AgpWiring.addJniLibs(project, binding.jni.jniLibsRoot, linkAll) // AGP looks for a `baselineProfiles` directory beside every source root, including the generated @@ -58,10 +53,10 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume override fun isUsed(): Boolean = isPlainJvmConsumer || context.hasPlugin(GradlePluginId.ANDROID_BASE) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || - (context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KgpWiring.hasKmpJvmTarget(project)) + (context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project)) override fun register() { - if (isPlainJvmConsumer) KgpWiring.addJvmSources(project, generate.flatMap { it.kotlinSourceDirectory }) + if (isPlainJvmConsumer) KotlinGradlePlugin.addJvmSources(project, generate.flatMap { it.kotlinSourceDirectory }) val artifacts = context.artifacts() val inputs = JniStubInputs(generate, artifacts, targets(artifacts)) val builds = stubBuildFor(bindingName, binding.jni).register(project, context.konan, binding, inputs) @@ -69,30 +64,23 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume } /** `jni.targets`, else the library's targets a JVM can load β€” the host and Android β€” else the host. */ - private fun targets(artifacts: NativeArtifacts?): List = binding.jni.targets.getOrElse(emptyList()).ifEmpty { - artifacts?.targets?.getOrElse(emptyList())?.filter { it == hostKonanTarget() || it.isAndroid } + private fun targets(artifacts: NativeArtifacts?): List = binding.jni.targets.getOrElse(emptyList()).ifEmpty { + artifacts?.targets?.getOrElse(emptyList())?.filter { it == NativeTarget.host || it.isAndroid } ?.ifEmpty { null } - ?: listOf(hostKonanTarget()) + ?: listOf(NativeTarget.host) } private fun registerGeneration(): TaskProvider = - project.tasks.register("generateJni$suffix", GenerateJniBindingsTask::class.java) { + project.tasks.register(taskName("generateJni", bindingName), GenerateJniBindingsTask::class.java) { group = "konan" description = "Generate the '$bindingName' JNI bindings and stub from its .def." mustRunAfter(project.cleanTask()) defFile.set(binding.defFile) packageName.set(context.packageName) includeDirs.from(binding.includeDirs) - hostTarget.set(hostKonanTarget()) + hostTarget.set(NativeTarget.host) compilerOptions.set(binding.compilerOptions) - visibility.set( - binding.jni.visibility.map { - when (it) { - BindingVisibility.INTERNAL -> GeneratedVisibility.INTERNAL - BindingVisibility.PUBLIC -> GeneratedVisibility.PUBLIC - } - } - ) + visibility.set(binding.jni.visibility) outputDirectory.set(context.buildDirectory.dir("generated/konan/$bindingName/jni")) } } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt index ec58a62..2b844fe 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt @@ -1,7 +1,7 @@ package io.github.lemcoder.consumer +import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.wiring.GradlePluginId -import io.github.lemcoder.wiring.KgpWiring /** * Kotlin/Native targets, through KGP's own cinterop: the adapter declares the binding on each target's @@ -10,5 +10,5 @@ import io.github.lemcoder.wiring.KgpWiring internal class KotlinNativeConsumer(private val context: BindingContext) : BindingConsumer { override fun isUsed(): Boolean = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) - override fun register() = KgpWiring.declareCinterops(context.project, context.binding, context.artifacts()) + override fun register() = KotlinGradlePlugin.declareCinterops(context.project, context.binding, context.artifacts()) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/BindingSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/BindingSpec.kt new file mode 100644 index 0000000..e83e53a --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/BindingSpec.kt @@ -0,0 +1,57 @@ +package io.github.lemcoder.dsl + +import org.gradle.api.Action +import org.gradle.api.Named +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import javax.inject.Inject + +/** + * A C API, described by a cinterop `.def`, and how each kind of consumer reaches it. + */ +abstract class BindingSpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { + override fun getName(): String = name + + /** The `.def` to bind. Defaults to cinterop's own convention, `src/nativeInterop/cinterop/.def`. */ + abstract val defFile: RegularFileProperty + + /** + * The library the bindings call into, one of `konan.libraries`. The plugin then links its archive for + * every consumer β€” the `.def` needs no `staticLibraries` β€” and builds it first. + */ + abstract val library: Property + + /** Overrides the `.def`'s `package`. */ + abstract val packageName: Property + + /** Include roots beyond the `.def`'s own directory. */ + abstract val includeDirs: ConfigurableFileCollection + + /** Extra clang options for reading the headers. */ + abstract val compilerOptions: ListProperty + + fun library(library: NativeLibrarySpec) = this.library.set(library) + + /** JNI bindings for JVM targets and Android; generated whenever the project has such a target. */ + val jni: JniSpec = objects.newInstance(JniSpec::class.java) + + fun jni(action: Action) = action.execute(jni) + + /** + * FFM bindings, generated by jextract, for JDK 22+. Off unless [ffm] is called; in a `kotlin("jvm")` + * project they then replace the JNI bindings, which would define the same classes. + */ + val ffm: FfmSpec = objects.newInstance(FfmSpec::class.java) + + /** Turns on FFM bindings. */ + fun ffm(action: Action) { + ffm.isEnabled = true + action.execute(ffm) + } + + /** Turns on FFM bindings with every default. */ + fun ffm() = ffm(Action {}) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt index 3ca242d..78f16d9 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt @@ -1,12 +1,13 @@ package io.github.lemcoder.dsl +import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action -import org.gradle.api.model.ObjectFactory import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property -import org.jetbrains.kotlin.konan.target.KonanTarget import javax.inject.Inject /** @@ -33,12 +34,12 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact } /** Builds for [target] β€” an Android ABI, say β€” with its own settings, instead of once for the host. */ - fun target(target: KonanTarget, action: Action) { + fun target(target: NativeTarget, action: Action) { action.execute(targetSettings.getOrPut(target) { objects.newInstance(CMakeTargetSettings::class.java) }) } /** The per-target builds declared; empty means a single build for the host. */ - internal val targetSettings = linkedMapOf() + internal val targetSettings = linkedMapOf() /** The `CMakeLists.txt` to configure. Required unless [preset] is set. */ abstract val path: RegularFileProperty diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt index 8ae2992..edecf86 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt @@ -1,5 +1,7 @@ package io.github.lemcoder.dsl +import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty @@ -8,7 +10,6 @@ import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider -import org.jetbrains.kotlin.konan.target.KonanTarget import java.io.File import javax.inject.Inject @@ -26,10 +27,10 @@ abstract class ExternalBuildSettings @Inject constructor(private val objects: Ob abstract val sources: ConfigurableFileCollection /** Where the build leaves each target's library, when it does not write it to `KONAN_JNI_OUTPUT_DIR`. */ - abstract val producedLibraries: MapProperty + abstract val producedLibraries: MapProperty /** The library the build leaves at [file] for [target]; the plugin copies and renames it into place. */ - fun producedLibrary(target: KonanTarget, file: File) = producedLibraries.put(target, file) + fun producedLibrary(target: NativeTarget, file: File) = producedLibraries.put(target, file) /** Appends a command; commands run in declaration order, each only after the previous succeeded. */ fun step(action: Action) { @@ -48,7 +49,7 @@ abstract class ExternalStepSpec { abstract val arguments: ListProperty /** Arguments for one target, after [arguments]. */ - abstract val targetArguments: MapProperty> + abstract val targetArguments: MapProperty> /** Variables beyond the plugin's contract; these win over it. */ abstract val environment: MapProperty @@ -57,11 +58,11 @@ abstract class ExternalStepSpec { abstract val workingDirectory: DirectoryProperty /** Appends [arguments] for [target] only. */ - fun argumentsFor(target: KonanTarget, vararg arguments: String) { + fun argumentsFor(target: NativeTarget, vararg arguments: String) { val existing = targetArguments.getOrElse(emptyMap())[target].orEmpty() targetArguments.put(target, existing + arguments) } /** Sets [target]'s arguments from a provider, e.g. one built from `jni.libraryDirectoryFor(target)`. */ - fun argumentsFor(target: KonanTarget, arguments: Provider>) = targetArguments.put(target, arguments) + fun argumentsFor(target: NativeTarget, arguments: Provider>) = targetArguments.put(target, arguments) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt new file mode 100644 index 0000000..ec7bd8d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt @@ -0,0 +1,27 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.model.Executable +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** How the FFM bindings are generated. The generated Java needs a JDK 22+ toolchain. */ +abstract class FfmSpec { + init { + jextract.convention(Executable.OnPath("jextract")) + } + + /** The jextract program. Defaults to `jextract` on the PATH or in the usual install locations. */ + abstract val jextract: Property + + /** Extra options for linking the library the bindings load. */ + abstract val linkerOptions: ListProperty + + /** + * Where the shared library the bindings load ends up, for the build host: put it on + * `java.library.path`, together with `--enable-native-access`. + */ + abstract val libraryDirectory: DirectoryProperty + + internal var isEnabled: Boolean = false +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt new file mode 100644 index 0000000..a2944e7 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt @@ -0,0 +1,94 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.jni.JniStubBuild +import io.github.lemcoder.model.BindingVisibility +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.Action +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import javax.inject.Inject + +/** How the JNI bindings are generated and linked. */ +abstract class JniSpec @Inject constructor(objects: ObjectFactory) { + init { + visibility.convention(BindingVisibility.INTERNAL) + } + + /** + * Visibility of the generated bindings. They are an implementation detail β€” the idiomatic API is + * written on top of them β€” so they are `internal` by default. + */ + abstract val visibility: Property + + /** + * Targets to link the JNI library for. Defaults to the library's targets a JVM can load β€” the build + * host and Android β€” or the host when there is no library. + */ + abstract val targets: ListProperty + + /** An archive to link instead of the library's, e.g. one built elsewhere. */ + abstract val archive: RegularFileProperty + + /** A JDK home that ships `include/jni.h`. Defaults to a scan of installed JDKs. */ + abstract val jdkHome: DirectoryProperty + + /** Extra options for every target's link command. */ + abstract val linkerOptions: ListProperty + + /** Extra link options for one target, after [linkerOptions]. */ + abstract val targetLinkerOptions: MapProperty> + + /** Where the linked library ends up, whoever linked it: put it on `java.library.path`, or package it. */ + abstract val libraryDirectory: DirectoryProperty + + fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) + + /** Appends [options] to the link command of [target] only. */ + fun linkerOptionsFor(target: NativeTarget, vararg options: String) { + val existing = targetLinkerOptions.getOrElse(emptyMap())[target].orEmpty() + targetLinkerOptions.put(target, existing + options) + } + + /** An external CMake build that compiles the stub; declaring it replaces the plugin's own link. */ + val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) + + fun cmake(action: Action) { + selectedStubBuilds += JniStubBuild.External.CMake + action.execute(cmake) + } + + /** Any other build system that compiles the stub, as a list of commands; replaces the plugin's own link. */ + val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) + + fun external(action: Action) { + selectedStubBuilds += JniStubBuild.External.Generic + action.execute(external) + } + + /** The directory holding the generated `.c` stub β€” what an external build compiles. */ + val stubDirectory: Provider get() = stubDirectoryProperty + + /** The library name the generated bindings load: an external build must produce exactly that. */ + val stubLibraryName: Provider get() = stubLibraryNameProperty + + /** Where [target]'s JNI library must land: `jniLibs//` for Android, a per-target directory otherwise. */ + fun libraryDirectoryFor(target: NativeTarget): Provider = + (if (target.isAndroid) jniLibsRoot else hostLibRoot).dir(target.libraryDirectoryName) + + internal val stubDirectoryProperty: DirectoryProperty = objects.directoryProperty() + internal val stubLibraryNameProperty: Property = objects.property(String::class.java) + + // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, and a + // host library sitting in the root fails its merge with "… is not an ABI". + internal val jniLibsRoot: DirectoryProperty = objects.directoryProperty() + internal val hostLibRoot: DirectoryProperty = objects.directoryProperty() + + /** The stub build blocks this binding's script called; at most one may be. */ + internal val selectedStubBuilds = mutableSetOf() +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt index 380c4d5..003c618 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -1,26 +1,9 @@ package io.github.lemcoder.dsl -import io.github.lemcoder.abiDir -import io.github.lemcoder.androidKonanTargets -import io.github.lemcoder.isAndroid -import io.github.lemcoder.jni.JniStubBuild -import io.github.lemcoder.library.LibraryProvider import org.gradle.api.Action -import org.gradle.api.Named import org.gradle.api.NamedDomainObjectContainer -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.Directory import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFile -import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider -import io.github.lemcoder.library.NativeArtifacts -import org.jetbrains.kotlin.konan.target.KonanTarget -import java.io.File import javax.inject.Inject /** @@ -30,7 +13,7 @@ import javax.inject.Inject * ```kotlin * konan { * val mymath = libraries.create("mymath") { - * targets(KonanTarget.MACOS_ARM64, KonanTarget.ANDROID_ARM64) + * targets(NativeTarget.MACOS_ARM64, NativeTarget.ANDROID_ARM64) * } * bindings.create("mymath") { * library(mymath) @@ -50,7 +33,7 @@ abstract class KonanExtension @Inject constructor(objects: ObjectFactory) { */ abstract val konanHome: DirectoryProperty - /** Native libraries this project builds from source, one archive per target. */ + /** Native libraries this project builds or brings in, one archive per target. */ val libraries: NamedDomainObjectContainer = objects.domainObjectContainer(NativeLibrarySpec::class.java) @@ -62,267 +45,3 @@ abstract class KonanExtension @Inject constructor(objects: ObjectFactory) { fun bindings(action: Action>) = action.execute(bindings) } - -/** - * A C/C++ library built into a static archive per target, `//lib.a`: by - * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [external]), or built - * elsewhere ([prebuilt]). - */ -abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { - override fun getName(): String = name - - /** Targets to build for. Required. */ - abstract val targets: ListProperty - - /** The C/C++ sources. Defaults to `native/`. */ - abstract val sourceDirectory: DirectoryProperty - - /** Include roots beyond [sourceDirectory], which always is one. */ - abstract val includeDirs: ConfigurableFileCollection - - /** Extra clang options, appended to `-fno-sanitize=undefined -fPIC`. */ - abstract val compilerOptions: ListProperty - - /** Root of the per-target archives. Defaults to `build/konan/libraries/`. */ - abstract val outputDirectory: DirectoryProperty - - fun targets(vararg targets: KonanTarget) = this.targets.set(targets.toList()) - - /** All four Android ABIs. */ - fun androidTargets() = targets.set(androidKonanTargets()) - - /** The archive built for [target]. */ - fun archive(target: KonanTarget): Provider = outputDirectory.file(NativeArtifacts.archivePath(name, target)) - - /** The directory holding [target]'s archive. */ - fun archiveDirectory(target: KonanTarget): Provider = outputDirectory.dir(target.name) - - /** - * Builds the library with the project's own `build.zig` instead of compiling [sourceDirectory] with - * Kotlin/Native's clang. The archives land in the same place, so bindings do not change. - */ - val zig: ZigSettings = objects.newInstance(ZigSettings::class.java) - - fun zig(action: Action) { - selectedProviders += LibraryProvider.External.Zig - action.execute(zig) - } - - /** - * Builds the library with any other build system, as a list of commands run once per target. Each run - * gets `KONAN_LIBRARY_NAME`, `KONAN_LIBRARY_OUTPUT_DIR`, `KONAN_LIBRARY_SOURCE_DIR` and `KONAN_TARGET`; - * the build writes `lib.a` into the output directory, or names where it leaves its archive with - * `producedLibrary`. - */ - val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) - - fun external(action: Action) { - selectedProviders += LibraryProvider.External.Generic - action.execute(external) - } - - /** Archives built elsewhere, one per target; [targets] default to the targets they are declared for. */ - val prebuilt: PrebuiltSettings = objects.newInstance(PrebuiltSettings::class.java) - - fun prebuilt(action: Action) { - selectedProviders += LibraryProvider.Prebuilt - action.execute(prebuilt) - } - - /** The provider blocks this library's script called; at most one may be. */ - internal val selectedProviders = mutableSetOf() - - init { - zig.projectDirectory.convention(sourceDirectory) - zig.artifactName.convention(name) - targets.convention(prebuilt.archives.keySet().map { it.toList() }) - } -} - -/** - * A library's archives built elsewhere β€” a vendor drop, another repository's CI. Each is copied to - * `/lib.a`, so a Windows `name.lib` works as it is. - */ -abstract class PrebuiltSettings { - /** The archive for each target. */ - abstract val archives: MapProperty - - /** The static archive built for [target]. */ - fun archive(target: KonanTarget, file: File) = archives.put(target, file) -} - -/** - * `zig build` for a library, once per target with `-Dtarget` set to the Zig triple that matches - * Kotlin/Native's sysroot. The `build.zig` must install a static library ([artifactName]). - */ -abstract class ZigSettings { - init { - executable.convention(Executable.OnPath("zig")) - optimize.convention(ZigOptimize.RELEASE_FAST) - } - - /** The directory holding `build.zig`. Defaults to the library's `sourceDirectory`. */ - abstract val projectDirectory: DirectoryProperty - - /** The `zig` program. Defaults to `zig` on the PATH or in the usual install locations. */ - abstract val executable: Property - - /** - * `-Doptimize`. Defaults to [ZigOptimize.RELEASE_FAST]: Zig's `DEBUG` and `RELEASE_SAFE` compile C with - * UBSan traps, whose runtime the Kotlin/Native and JNI links do not have. - */ - abstract val optimize: Property - - /** Further `zig build` arguments, e.g. your own `-D` options. */ - abstract val arguments: ListProperty - - /** The static library `build.zig` installs. Defaults to the library's name. */ - abstract val artifactName: Property -} - -/** Zig's optimisation modes, as `zig build -Doptimize` spells them. */ -enum class ZigOptimize(val zigName: String) { - DEBUG("Debug"), - RELEASE_SAFE("ReleaseSafe"), - RELEASE_FAST("ReleaseFast"), - RELEASE_SMALL("ReleaseSmall"), -} - -/** - * A C API, described by a cinterop `.def`, and how each kind of consumer reaches it. - */ -abstract class BindingSpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { - override fun getName(): String = name - - /** The `.def` to bind. Defaults to cinterop's own convention, `src/nativeInterop/cinterop/.def`. */ - abstract val defFile: RegularFileProperty - - /** - * The library the bindings call into, one of `konan.libraries`. The plugin then links its archive for - * every consumer β€” the `.def` needs no `staticLibraries` β€” and builds it first. - */ - abstract val library: Property - - /** Overrides the `.def`'s `package`. */ - abstract val packageName: Property - - /** Include roots beyond the `.def`'s own directory. */ - abstract val includeDirs: ConfigurableFileCollection - - /** Extra clang options for reading the headers. */ - abstract val compilerOptions: ListProperty - - fun library(library: NativeLibrarySpec) = this.library.set(library) - - /** JNI bindings for JVM targets and Android; generated whenever the project has such a target. */ - val jni: JniSpec = objects.newInstance(JniSpec::class.java) - - fun jni(action: Action) = action.execute(jni) - - /** - * FFM bindings, generated by jextract, for JDK 22+. Off unless [ffm] is called; in a `kotlin("jvm")` - * project they then replace the JNI bindings, which would define the same classes. - */ - val ffm: FfmSpec = objects.newInstance(FfmSpec::class.java) - - /** Turns on FFM bindings. */ - fun ffm(action: Action) { - ffm.isEnabled = true - action.execute(ffm) - } - - /** Turns on FFM bindings with every default. */ - fun ffm() = ffm(Action {}) -} - -/** How the JNI bindings are generated and linked. */ -abstract class JniSpec @Inject constructor(objects: ObjectFactory) { - /** - * Visibility of the generated bindings. They are an implementation detail β€” the idiomatic API is - * written on top of them β€” so they are `internal` by default. - */ - abstract val visibility: Property - - /** - * Targets to link the JNI library for. Defaults to the library's targets a JVM can load β€” the build - * host and Android β€” or the host when there is no library. - */ - abstract val targets: ListProperty - - /** An archive to link instead of the library's, e.g. one built elsewhere. */ - abstract val archive: RegularFileProperty - - /** A JDK home that ships `include/jni.h`. Defaults to a scan of installed JDKs. */ - abstract val jdkHome: DirectoryProperty - - /** Extra options for every target's link command. */ - abstract val linkerOptions: ListProperty - - /** Extra link options for one target, after [linkerOptions]. */ - abstract val targetLinkerOptions: MapProperty> - - /** Where the linked library ends up, whoever linked it: put it on `java.library.path`, or package it. */ - abstract val libraryDirectory: DirectoryProperty - - fun targets(vararg targets: KonanTarget) = this.targets.set(targets.toList()) - - /** Appends [options] to the link command of [target] only. */ - fun linkerOptionsFor(target: KonanTarget, vararg options: String) { - val existing = targetLinkerOptions.getOrElse(emptyMap())[target].orEmpty() - targetLinkerOptions.put(target, existing + options) - } - - /** An external CMake build that compiles the stub; declaring it replaces the plugin's own link. */ - val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) - - fun cmake(action: Action) { - selectedStubBuilds += JniStubBuild.External.CMake - action.execute(cmake) - } - - /** Any other build system that compiles the stub, as a list of commands; replaces the plugin's own link. */ - val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) - - fun external(action: Action) { - selectedStubBuilds += JniStubBuild.External.Generic - action.execute(external) - } - - /** The directory holding the generated `.c` stub β€” what an external build compiles. */ - val stubDirectory: Provider get() = stubDirectoryProperty - - /** The library name the generated bindings load: an external build must produce exactly that. */ - val stubLibraryName: Provider get() = stubLibraryNameProperty - - /** Where [target]'s JNI library must land: `jniLibs//` for Android, a per-target directory otherwise. */ - fun libraryDirectoryFor(target: KonanTarget): Provider = - if (target.isAndroid) jniLibsRoot.dir(target.abiDir) else hostLibRoot.dir(target.name) - - internal val stubDirectoryProperty: DirectoryProperty = objects.directoryProperty() - internal val stubLibraryNameProperty: Property = objects.property(String::class.java) - - // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, and a - // host library sitting in the root fails its merge with "… is not an ABI". - internal val jniLibsRoot: DirectoryProperty = objects.directoryProperty() - internal val hostLibRoot: DirectoryProperty = objects.directoryProperty() - - /** The stub build blocks this binding's script called; at most one may be. */ - internal val selectedStubBuilds = mutableSetOf() -} - -/** How the FFM bindings are generated. The generated Java needs a JDK 22+ toolchain. */ -abstract class FfmSpec { - /** The jextract executable. Defaults to `jextract` on the PATH. */ - abstract val jextract: RegularFileProperty - - /** - * Where the shared library the bindings load ends up, for the build host: put it on - * `java.library.path`, together with `--enable-native-access`. - */ - abstract val libraryDirectory: DirectoryProperty - - internal var isEnabled: Boolean = false -} - -/** Whether generated bindings are visible outside the module that declares them. */ -enum class BindingVisibility { INTERNAL, PUBLIC } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt new file mode 100644 index 0000000..b15d137 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt @@ -0,0 +1,91 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.library.LibraryProvider +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.Action +import org.gradle.api.Named +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFile +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Provider +import javax.inject.Inject + +/** + * A C/C++ library built into a static archive per target, `//lib.a`: by + * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [external]), or built + * elsewhere ([prebuilt]). + */ +abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { + override fun getName(): String = name + + /** Targets to build for. Required, unless [prebuilt] declares them. */ + abstract val targets: ListProperty + + /** The C/C++ sources. Defaults to `native/`. */ + abstract val sourceDirectory: DirectoryProperty + + /** Include roots beyond [sourceDirectory], which always is one. */ + abstract val includeDirs: ConfigurableFileCollection + + /** Extra clang options, appended to `-fno-sanitize=undefined -fPIC`. */ + abstract val compilerOptions: ListProperty + + /** Root of the per-target archives. Defaults to `build/konan/libraries/`. */ + abstract val outputDirectory: DirectoryProperty + + fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) + + /** All four Android ABIs. */ + fun androidTargets() = targets.set(NativeTarget.android) + + /** The archive built for [target]. */ + fun archive(target: NativeTarget): Provider = outputDirectory.file(NativeArtifacts.archivePath(name, target)) + + /** The directory holding [target]'s archive. */ + fun archiveDirectory(target: NativeTarget): Provider = outputDirectory.dir(target.konanName) + + /** + * Builds the library with the project's own `build.zig` instead of compiling [sourceDirectory] with + * Kotlin/Native's clang. The archives land in the same place, so bindings do not change. + */ + val zig: ZigSettings = objects.newInstance(ZigSettings::class.java) + + fun zig(action: Action) { + selectedProviders += LibraryProvider.External.Zig + action.execute(zig) + } + + /** + * Builds the library with any other build system, as a list of commands run once per target. Each run + * gets `KONAN_LIBRARY_NAME`, `KONAN_LIBRARY_OUTPUT_DIR`, `KONAN_LIBRARY_SOURCE_DIR` and `KONAN_TARGET`; + * the build writes `lib.a` into the output directory, or names where it leaves its archive with + * `producedLibrary`. + */ + val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) + + fun external(action: Action) { + selectedProviders += LibraryProvider.External.Generic + action.execute(external) + } + + /** Archives built elsewhere, one per target; [targets] default to the targets they are declared for. */ + val prebuilt: PrebuiltSettings = objects.newInstance(PrebuiltSettings::class.java) + + fun prebuilt(action: Action) { + selectedProviders += LibraryProvider.Prebuilt + action.execute(prebuilt) + } + + /** The provider blocks this library's script called; at most one may be. */ + internal val selectedProviders = mutableSetOf() + + init { + zig.projectDirectory.convention(sourceDirectory) + zig.artifactName.convention(name) + targets.convention(prebuilt.archives.keySet().map { it.toList() }) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt new file mode 100644 index 0000000..aecf0f3 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt @@ -0,0 +1,17 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.provider.MapProperty +import java.io.File + +/** + * A library's archives built elsewhere β€” a vendor drop, another repository's CI. Each is copied to + * `/lib.a`, so a Windows `name.lib` works as it is. + */ +abstract class PrebuiltSettings { + /** The archive for each target. */ + abstract val archives: MapProperty + + /** The static archive built for [target]. */ + fun archive(target: NativeTarget, file: File) = archives.put(target, file) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt new file mode 100644 index 0000000..0e308e4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt @@ -0,0 +1,43 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.model.Executable +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * `zig build` for a library, once per target with `-Dtarget` set to the Zig triple that matches + * Kotlin/Native's sysroot. The `build.zig` must install a static library ([artifactName]). + */ +abstract class ZigSettings { + init { + executable.convention(Executable.OnPath("zig")) + optimize.convention(ZigOptimize.RELEASE_FAST) + } + + /** The directory holding `build.zig`. Defaults to the library's `sourceDirectory`. */ + abstract val projectDirectory: DirectoryProperty + + /** The `zig` program. Defaults to `zig` on the PATH or in the usual install locations. */ + abstract val executable: Property + + /** + * `-Doptimize`. Defaults to [ZigOptimize.RELEASE_FAST]: Zig's `DEBUG` and `RELEASE_SAFE` compile C with + * UBSan traps, whose runtime the Kotlin/Native and JNI links do not have. + */ + abstract val optimize: Property + + /** Further `zig build` arguments, e.g. your own `-D` options. */ + abstract val arguments: ListProperty + + /** The static library `build.zig` installs. Defaults to the library's name. */ + abstract val artifactName: Property +} + +/** Zig's optimisation modes, as `zig build -Doptimize` spells them. */ +enum class ZigOptimize(val zigName: String) { + DEBUG("Debug"), + RELEASE_SAFE("ReleaseSafe"), + RELEASE_FAST("ReleaseFast"), + RELEASE_SMALL("ReleaseSmall"), +} diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt index 06c5271..fe1f068 100644 --- a/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt +++ b/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt @@ -1,6 +1,6 @@ package io.github.lemcoder.external -import io.github.lemcoder.dsl.Executable +import io.github.lemcoder.model.Executable import io.github.lemcoder.toolchain.command import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.execCapture diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt index 316d15c..7b76776 100644 --- a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt @@ -1,49 +1,48 @@ package io.github.lemcoder.external -import io.github.lemcoder.cleanTask import io.github.lemcoder.dsl.ExternalBuildSettings +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName +import io.github.lemcoder.util.cleanTask import org.gradle.api.Project -import org.gradle.api.file.Directory import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.konan.target.KonanTarget - -/** A variable of an external build's contract: what the plugin tells the build, by environment. */ -internal interface ExternalBuildVariable { - /** The environment variable's name β€” public contract, read by build scripts. */ - val variableName: String -} /** - * Registers one target's [ExternalBuildTask]: [environment] as the contract, the library expected in - * [outputDirectory] as [libraryFileName]. [configure] adds the build system's commands. + * An external build for each of [targets]: one [ExternalBuildTask] per target, named + * ``, run with [contextFor]'s contract as environment and expected to leave + * [libraryFileName] in the context's output directory. [configure] adds the build system's commands β€” + * the only part that differs between build systems and between a library and a JNI stub. */ -internal fun Project.registerExternalBuild( - taskName: String, - taskDescription: String, - environment: Map>, - outputDirectory: Provider, - libraryFileName: Provider, - configure: ExternalBuildTask.() -> Unit, -): TaskProvider { +internal fun Project.registerExternalBuilds( + taskPrefix: String, + owner: String, + description: (NativeTarget) -> String, + targets: List, + contextFor: (NativeTarget) -> ExternalBuildContext, + libraryFileName: (NativeTarget) -> Provider, + configure: ExternalBuildTask.(ExternalBuildContext) -> Unit, +): List> { val clean = cleanTask() - // Read into locals: inside the block, `environment` and `outputDirectory` are the task's own. - val contract = environment - val output = outputDirectory - val fileName = libraryFileName - return tasks.register(taskName, ExternalBuildTask::class.java) { - group = "konan" - description = taskDescription - mustRunAfter(clean) - contract.forEach { (variable, value) -> this.environment.put(variable.variableName, value) } - this.libraryFileName.set(fileName) - this.outputDirectory.set(output) - configure() + return targets.map { target -> + val context = contextFor(target) + val taskDescription = description(target) + val fileName = libraryFileName(target) + tasks.register(taskName(taskPrefix, owner, target), ExternalBuildTask::class.java) { + group = "konan" + this.description = taskDescription + mustRunAfter(clean) + context.environment.forEach { (variable, value) -> environment.put(variable.variableName, value) } + this.libraryFileName.set(fileName) + outputDirectory.set(context.outputDirectory) + configure(context) + } } } /** The build script's own steps, as they are, for [target]: what a generic `external { }` block runs. */ -internal fun ExternalBuildTask.addSteps(settings: ExternalBuildSettings, target: KonanTarget, owner: String) { +internal fun ExternalBuildTask.addSteps(settings: ExternalBuildSettings, target: NativeTarget, owner: String) { check(settings.steps.isNotEmpty()) { "$owner.external declares no step." } settings.steps.forEach { step -> command { diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index 9722e2c..4ac1a24 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -1,5 +1,8 @@ package io.github.lemcoder.index +import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.kotlin.runIndexExporter +import io.github.lemcoder.model.NativeTarget import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty @@ -17,7 +20,6 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -import org.jetbrains.kotlin.konan.target.KonanTarget import javax.inject.Inject /** @@ -44,9 +46,9 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( abstract val extraCinteropArgs: ListProperty @get:Input - abstract val target: Property + abstract val target: Property - /** Defaults to the installed distribution of [PinnedKotlinNative.version], found at execution. */ + /** Defaults to the installed distribution of [KonanDistribution.pinnedVersion], found at execution. */ @get:Internal abstract val konanHome: DirectoryProperty @@ -69,7 +71,7 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( // The .def's own directory, as cinterop users expect; a path rather than an input, so a .def // at the project root does not make the whole project one. add("-compiler-option"); add("-I${def.parentFile.absolutePath}") - add("-target"); add(target.get().name) + add("-target"); add(target.get().konanName) includeDirs.files.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } addAll(extraCinteropArgs.getOrElse(emptyList())) diff --git a/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt b/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt deleted file mode 100644 index b5f1d53..0000000 --- a/src/main/kotlin/io/github/lemcoder/index/PinnedKotlinNative.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.github.lemcoder.index - -import java.io.File - -/** - * The Kotlin/Native distribution the index exporter was compiled against, and so the one it must run in: - * the exporter links against that version's compiler jar β€” its internal functions and the `NativeIndex` - * model the generators read β€” so another version may not link. `kotlinNativeVersion` in the plugin build. - */ -internal object PinnedKotlinNative { - val version: String by lazy { - java.util.Properties().apply { - PinnedKotlinNative::class.java.getResourceAsStream("kotlin-native.properties").use { load(checkNotNull(it)) } - }.getProperty("version") - } - - /** The installed distribution of [version] under `~/.konan`, if any. */ - fun home(): File? = - File(System.getProperty("user.home"), ".konan") - .listFiles { f -> f.isDirectory && f.name.startsWith("kotlin-native-prebuilt-") && f.name.endsWith("-$version") } - ?.singleOrNull() -} diff --git a/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt index d88a682..959206b 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt @@ -2,6 +2,8 @@ package io.github.lemcoder.jni import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ExternalStubVariable import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider @@ -15,9 +17,9 @@ import org.gradle.api.provider.Provider * Both commands are one task, which always runs: CMake tracks its own sources, and a changed configure * argument then always reaches the build. */ -internal fun addCMakeCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) { +internal fun addCMakeCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) { val cmake = binding.jni.cmake - val perTarget = cmake.targetSettings[contract.target] + val perTarget = cmake.targetSettings[context.target] val preset = perTarget?.preset?.orElse(cmake.preset) ?: cmake.preset check(cmake.path.isPresent || preset.isPresent) { "konan.bindings.${binding.name}.jni.cmake needs a path or a preset." } val sourceDirectory = cmake.path.map { it.asFile.parentFile } @@ -26,7 +28,7 @@ internal fun addCMakeCommands(task: ExternalBuildTask, project: Project, binding val buildDirectory: Provider = (perTarget?.buildDirectory ?: cmake.buildDirectory).orElse( project.layout.dir( preset.map { presetName -> sourceDirectory.get().resolve("build/$presetName") } - .orElse(project.layout.buildDirectory.dir("konan/bindings/${binding.name}/jni/cmake/${contract.target.name}").map { it.asFile }) + .orElse(project.layout.buildDirectory.dir("konan/bindings/${binding.name}/jni/cmake/${context.target.konanName}").map { it.asFile }) ) ) @@ -37,9 +39,10 @@ internal fun addCMakeCommands(task: ExternalBuildTask, project: Project, binding sourceDirectory.zip(buildDirectory) { source, build -> listOf("-S", source.absolutePath, "-B", build.asFile.absolutePath) } ) ) - arguments.add(contract.stubDirectory.map { "-D${ExternalStubVariable.STUB_DIR.variableName}=${it.asFile.absolutePath}" }) - arguments.add(contract.libraryName.map { "-D${ExternalStubVariable.LIB_NAME.variableName}=$it" }) - arguments.add(contract.outputDirectory.map { "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=${it.asFile.absolutePath}" }) + listOf(ExternalStubVariable.STUB_DIR, ExternalStubVariable.LIB_NAME).forEach { variable -> + arguments.add(context[variable].map { "-D${variable.variableName}=$it" }) + } + arguments.add(context[ExternalStubVariable.OUTPUT_DIR].map { "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$it" }) arguments.addAll(cmake.arguments) perTarget?.let { arguments.addAll(it.arguments) } // `--preset` resolves CMakePresets.json from the working directory. diff --git a/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt b/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt index 77388cf..721f6e4 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt @@ -1,9 +1,10 @@ package io.github.lemcoder.jni -import io.github.lemcoder.index.GeneratedVisibility import io.github.lemcoder.index.GeneratorRequest -import io.github.lemcoder.index.runIndexExporter -import io.github.lemcoder.toolchain.NativeToolchain +import io.github.lemcoder.kotlin.runIndexExporter +import io.github.lemcoder.model.BindingVisibility +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.stubLibraryBaseName import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.Directory @@ -24,7 +25,6 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -import org.jetbrains.kotlin.konan.target.KonanTarget import javax.inject.Inject /** @@ -49,11 +49,11 @@ internal abstract class GenerateJniBindingsTask @Inject constructor( abstract val includeDirs: ConfigurableFileCollection /** The headers are indexed once, for the host; the bridges are the same on every target. */ - @get:Input abstract val hostTarget: Property + @get:Input abstract val hostTarget: Property @get:Input @get:Optional abstract val compilerOptions: ListProperty /** `INTERNAL` keeps the bindings out of the module's published API. */ - @get:Input abstract val visibility: Property + @get:Input abstract val visibility: Property @get:OutputDirectory abstract val outputDirectory: DirectoryProperty /** @@ -66,14 +66,14 @@ internal abstract class GenerateJniBindingsTask @Inject constructor( /** The generated stub itself, named after the binding package. */ @get:Internal val stubSourceFile: Provider - get() = outputDirectory.file(packageName.map { "$C_DIR/${NativeToolchain.stubBaseName(it)}.c" }) + get() = outputDirectory.file(packageName.map { "$C_DIR/${stubLibraryBaseName(it)}.c" }) /** * Base name of the JNI library the generated bindings will `System.loadLibrary`, derived from the * binding package. A build that links the stub itself has to produce exactly this name. */ @get:Internal - val stubLibraryBaseName: Provider get() = packageName.map { NativeToolchain.stubBaseName(it) } + val stubLibraryBaseName: Provider get() = packageName.map { stubLibraryBaseName(it) } /** Directory holding the generated Kotlin bindings. */ @get:Internal @@ -89,7 +89,7 @@ internal abstract class GenerateJniBindingsTask @Inject constructor( cinteropArgs = buildList { add("-def"); add(def.absolutePath) add("-pkg"); add(packageName.get()) - add("-target"); add(hostTarget.get().name) + add("-target"); add(hostTarget.get().konanName) includes.filter { it.exists() }.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } }, diff --git a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt index 58f866a..76135e6 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt @@ -1,25 +1,21 @@ package io.github.lemcoder.jni -import io.github.lemcoder.abiDir import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.dsl.JniSpec import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.external.ExternalBuildTask -import io.github.lemcoder.external.ExternalBuildVariable import io.github.lemcoder.external.addSteps -import io.github.lemcoder.external.registerExternalBuild -import io.github.lemcoder.hostKonanTarget -import io.github.lemcoder.library.NativeArtifacts -import io.github.lemcoder.sharedLibraryName -import io.github.lemcoder.taskSuffix -import io.github.lemcoder.toolchain.NativeToolchain -import io.github.lemcoder.wiring.capitalized +import io.github.lemcoder.external.registerExternalBuilds +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ExternalStubVariable +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.selectOne +import io.github.lemcoder.toolchain.Jdk import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider -import org.gradle.api.provider.ProviderFactory import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.konan.target.KonanTarget /** * How a binding's JNI library β€” the generated stub, linked with the C library β€” comes to exist: linked @@ -40,22 +36,18 @@ internal sealed interface JniStubBuild { } /** - * The project's own build compiles the stub: one [ExternalBuildTask] per target, run with the - * [ExternalContract] as its environment. A build system says only which targets it builds and which - * commands build one. + * The project's own build compiles the stub, once per target, told the [ExternalStubVariable] + * contract. A build system says which targets it builds and adds its commands. */ sealed class External : JniStubBuild { - /** Task-name prefix, e.g. `cmakeBuild` in `cmakeBuildMymathMacos_arm64`. */ protected abstract val taskPrefix: String /** The build system, for task descriptions. */ protected abstract val toolName: String - /** The targets this build runs for. */ - protected abstract fun targets(binding: BindingSpec, inputs: JniStubInputs): List + protected abstract fun targets(binding: BindingSpec, inputs: JniStubInputs): List - /** Adds the commands, and any sources or produced library, that build [contract]'s target. */ - protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) + protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) /** `jni.libraryDirectory`'s convention, given the plugin's [default]. */ protected open fun libraryDirectory(binding: BindingSpec, default: Provider): Provider = default @@ -63,24 +55,34 @@ internal sealed interface JniStubBuild { final override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs): List> { val bindingName = binding.name val jni = binding.jni - val javaHome = jni.jdkHome.map { it.asFile.absolutePath } - .orElse(project.provider { NativeToolchain.detectJniHome().absolutePath }) + val javaHome = jni.jdkHome.map { it.asFile.absolutePath }.orElse(project.provider { Jdk.detectHome().absolutePath }) val targets = targets(binding, inputs) - val tasks = targets.map { target -> - val contract = ExternalContract(target, jni.stubDirectory, jni.stubLibraryName, jni.libraryDirectoryFor(target), javaHome, project.providers) - project.registerExternalBuild( - taskName = "$taskPrefix${bindingName.capitalized()}${target.taskSuffix}", - taskDescription = "Build the '$bindingName' JNI library for ${target.name} with $toolName.", - environment = contract.environment(), - outputDirectory = contract.outputDirectory, - libraryFileName = contract.libraryName.map { target.sharedLibraryName(it) }, - ) { - dependsOn(inputs.generate) - generatedSources.from(contract.stubDirectory) - addCommands(this, project, binding, contract) - } + val tasks = project.registerExternalBuilds( + taskPrefix = taskPrefix, + owner = bindingName, + description = { target -> "Build the '$bindingName' JNI library for ${target.konanName} with $toolName." }, + targets = targets, + contextFor = { target -> + val output = jni.libraryDirectoryFor(target) + ExternalBuildContext( + target, output, + mapOf( + ExternalStubVariable.STUB_DIR to jni.stubDirectory.map { it.asFile.absolutePath }, + ExternalStubVariable.LIB_NAME to jni.stubLibraryName, + ExternalStubVariable.OUTPUT_DIR to output.map { it.asFile.absolutePath }, + ExternalStubVariable.TARGET to project.provider { target.konanName }, + ExternalStubVariable.ABI to project.provider { target.libraryDirectoryName }, + ExternalStubVariable.JAVA_HOME to javaHome, + ), + ) + }, + libraryFileName = { target -> jni.stubLibraryName.map { target.sharedLibraryName(it) } }, + ) { context -> + dependsOn(inputs.generate) + generatedSources.from(jni.stubDirectory) + addCommands(this, project, binding, context) } - val host = hostKonanTarget() + val host = NativeTarget.host jni.libraryDirectory.convention(libraryDirectory(binding, if (host in targets) jni.libraryDirectoryFor(host) else jni.jniLibsRoot)) return tasks } @@ -90,8 +92,8 @@ internal sealed interface JniStubBuild { override val taskPrefix = "externalBuild" override val toolName = "its external build" override fun targets(binding: BindingSpec, inputs: JniStubInputs) = inputs.targets - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) = - task.addSteps(binding.jni.external, contract.target, "konan.bindings.${binding.name}.jni") + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = + task.addSteps(binding.jni.external, context.target, "konan.bindings.${binding.name}.jni") } /** `jni { cmake { } }`: a CMakeLists, configured with the contract as cache entries, then built. */ @@ -99,66 +101,18 @@ internal sealed interface JniStubBuild { override val taskPrefix = "cmakeBuild" override val toolName = "CMake" override fun targets(binding: BindingSpec, inputs: JniStubInputs) = - binding.jni.cmake.targetSettings.keys.toList().ifEmpty { listOf(hostKonanTarget()) } - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, contract: ExternalContract) = - addCMakeCommands(task, project, binding, contract) + binding.jni.cmake.targetSettings.keys.toList().ifEmpty { listOf(NativeTarget.host) } + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = + addCMakeCommands(task, project, binding, context) override fun libraryDirectory(binding: BindingSpec, default: Provider) = binding.jni.cmake.libraryDirectory.orElse(default) } } } -/** What the plugin tells an external build about one target. */ -internal class ExternalContract( - val target: KonanTarget, - val stubDirectory: Provider, - val libraryName: Provider, - val outputDirectory: Provider, - val javaHome: Provider, - private val providers: ProviderFactory, -) { - fun environment(): Map> = mapOf( - ExternalStubVariable.STUB_DIR to stubDirectory.map { it.asFile.absolutePath }, - ExternalStubVariable.LIB_NAME to libraryName, - ExternalStubVariable.OUTPUT_DIR to outputDirectory.map { it.asFile.absolutePath }, - ExternalStubVariable.TARGET to providers.provider { target.name }, - ExternalStubVariable.ABI to providers.provider { target.abiDir }, - ExternalStubVariable.JAVA_HOME to javaHome, - ) -} - -/** - * The environment of every external build β€” the names are the public contract, read the way an AGP - * project reads `ANDROID_ABI`. - */ -internal enum class ExternalStubVariable(override val variableName: String) : ExternalBuildVariable { - /** The directory holding the generated `.c` stub. */ - STUB_DIR("KONAN_JNI_STUB_DIR"), - - /** The library name the generated bindings pass to `System.loadLibrary`. */ - LIB_NAME("KONAN_JNI_LIB_NAME"), - - /** Where the target's library must land, unless the build names a produced library. */ - OUTPUT_DIR("KONAN_JNI_OUTPUT_DIR"), - - /** The Kotlin/Native target, e.g. `android_arm64`. */ - TARGET("KONAN_TARGET"), - - /** The target's Android ABI, e.g. `arm64-v8a`; the target's name elsewhere. */ - ABI("KONAN_JNI_ABI"), - - /** A JDK that ships `include/jni.h`. */ - JAVA_HOME("JAVA_HOME"), -} - /** The build [jni] selected: at most one block, else [JniStubBuild.KonanLink]. */ -internal fun stubBuildFor(bindingName: String, jni: JniSpec): JniStubBuild { - val selected = jni.selectedStubBuilds - check(selected.size <= 1) { - "konan.bindings.$bindingName.jni selects ${selected.joinToString(" and ")}; a JNI library is built one way." - } - return selected.singleOrNull() ?: JniStubBuild.KonanLink -} +internal fun stubBuildFor(bindingName: String, jni: JniSpec): JniStubBuild = + selectOne("konan.bindings.$bindingName.jni", jni.selectedStubBuilds, JniStubBuild.KonanLink) /** What every stub build gets from the JNI consumer. */ internal class JniStubInputs( @@ -167,5 +121,5 @@ internal class JniStubInputs( /** The archives of the binding's library, if it names one. */ val artifacts: NativeArtifacts?, /** The JNI targets: `jni.targets`, else the library's that a JVM can load, else the host. */ - val targets: List, + val targets: List, ) diff --git a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt index 63fdbcf..49655d5 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt @@ -1,19 +1,16 @@ package io.github.lemcoder.jni -import io.github.lemcoder.cleanTask import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.hostKonanTarget -import io.github.lemcoder.isAndroid -import io.github.lemcoder.library.NativeArtifacts import io.github.lemcoder.link.LinkSharedLibraryTask -import io.github.lemcoder.taskSuffix -import io.github.lemcoder.toolchain.NativeToolchain -import io.github.lemcoder.wiring.DefFile -import io.github.lemcoder.wiring.capitalized +import io.github.lemcoder.model.DefFile +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName +import io.github.lemcoder.toolchain.Jdk +import io.github.lemcoder.util.cleanTask import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.konan.target.KonanTarget import java.io.File /** @@ -27,26 +24,24 @@ internal fun registerKonanLink( inputs: JniStubInputs, ): List> { val bindingName = binding.name - val suffix = bindingName.capitalized() val generate = inputs.generate val artifacts = inputs.artifacts - val konanHome = konan.konanHome val jniIncludeDirs = project.providers.provider { binding.jni.jdkHome.orNull?.asFile - ?.let { NativeToolchain.jniIncludeDirsOf(it) ?: error("No include/jni.h under $it") } - ?: NativeToolchain.detectJniIncludeDirs() + ?.let { Jdk.jniIncludeDirectoriesOf(it) ?: error("No include/jni.h under $it") } + ?: Jdk.detectJniIncludeDirectories() } return inputs.targets.map { target -> // Where a consumer looks for the library: the host's, the one a JVM running this build loads. - if (target == hostKonanTarget()) binding.jni.libraryDirectory.convention(binding.jni.libraryDirectoryFor(target)) + if (target == NativeTarget.host) binding.jni.libraryDirectory.convention(binding.jni.libraryDirectoryFor(target)) - project.tasks.register("linkJni$suffix${target.taskSuffix}", LinkSharedLibraryTask::class.java) { + project.tasks.register(taskName("linkJni", bindingName, target), LinkSharedLibraryTask::class.java) { group = "konan" - description = "Link the '$bindingName' JNI library for ${target.name}." + description = "Link the '$bindingName' JNI library for ${target.konanName}." dependsOn(generate) artifacts?.let { dependsOn(it.builtBy) } mustRunAfter(project.cleanTask()) - konanPath.set(konanHome.map { it.asFile.absolutePath }) + konanHome.set(konan.konanHome) this.target.set(target) libraryBaseName.set(generate.flatMap { it.stubLibraryBaseName }) stubCFile.set(generate.flatMap { it.stubSourceFile }) @@ -55,11 +50,7 @@ internal fun registerKonanLink( // project, build/ included, an input of this task. includePaths.add(binding.defFile.map { it.asFile.parentFile.absolutePath }) staticLibrary.set(project.layout.file(project.provider { archive(binding, artifacts, target) })) - if (target.isAndroid) { - ndkResourceDir.set(konanHome.map { NativeToolchain.ndkResourceDir(it.asFile)?.absolutePath ?: "" }) - } else { - this.jniIncludeDirs.set(jniIncludeDirs.map { dirs -> dirs.map { it.absolutePath } }) - } + if (!target.isAndroid) this.jniIncludeDirs.from(jniIncludeDirs) additionalLinkerArgs.set( binding.jni.linkerOptions.zip(binding.jni.targetLinkerOptions) { common, perTarget -> common + perTarget[target].orEmpty() @@ -85,7 +76,7 @@ internal fun registerKonanLink( * The archive to link: `jni.archive` if set, else the library's for [target], else the `.def`'s * `staticLibraries`. */ -private fun archive(binding: BindingSpec, artifacts: NativeArtifacts?, target: KonanTarget): File? { +private fun archive(binding: BindingSpec, artifacts: NativeArtifacts?, target: NativeTarget): File? { binding.jni.archive.orNull?.asFile?.let { return it.takeIf { f -> f.isFile } } artifacts?.let { return it.archive(target).get().asFile.takeIf { _ -> target in it.targets.getOrElse(emptyList()) } diff --git a/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt b/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt similarity index 68% rename from src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt rename to src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt index a700055..7d28598 100644 --- a/src/main/kotlin/io/github/lemcoder/index/IndexExporter.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt @@ -1,5 +1,7 @@ -package io.github.lemcoder.index +package io.github.lemcoder.kotlin +import io.github.lemcoder.index.ExporterInvocation +import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.javaExecCapture import org.gradle.process.ExecOperations @@ -16,7 +18,7 @@ import java.nio.file.StandardCopyOption * cinterop may already have loaded it there. Not in a Gradle worker either: that process carries Gradle's * Kotlin stdlib, which would meet the one bundled in the compiler jar. * - * @param konanHome the distribution to run in; by default the installed one of [PinnedKotlinNative.version]. + * @param konanHome the distribution to run in; by default the installed one of [KonanDistribution.pinnedVersion]. * @param workDir where the exporter jar is extracted, e.g. the task's temporary directory. */ internal fun ExecOperations.runIndexExporter( @@ -26,27 +28,27 @@ internal fun ExecOperations.runIndexExporter( konanHome: File? = null, workDir: File, ) { - val home = konanHome ?: PinnedKotlinNative.home() ?: error( - "Kotlin/Native ${PinnedKotlinNative.version} is not installed; generating bindings needs exactly that " + - "version. A Kotlin Multiplatform build of ${PinnedKotlinNative.version} with a native target downloads it." + val distribution = konanHome?.let(::KonanDistribution) ?: KonanDistribution.pinned() ?: error( + "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed; generating bindings needs exactly that " + + "version. A Kotlin Multiplatform build of ${KonanDistribution.pinnedVersion} with a native target downloads it." ) - val compilerJar = home.resolve("konan/lib/kotlin-native-compiler-embeddable.jar") - check(compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } + val home = distribution.home + check(distribution.compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } val exporterJar = workDir.resolve(EXPORTER_JAR) workDir.mkdirs() - PinnedKotlinNative::class.java.getResourceAsStream("/io/github/lemcoder/index/$EXPORTER_JAR").use { input -> + KonanDistribution::class.java.getResourceAsStream("/io/github/lemcoder/index/$EXPORTER_JAR").use { input -> checkNotNull(input) { "Index exporter missing from the plugin jar: $EXPORTER_JAR" } Files.copy(input, exporterJar.toPath(), StandardCopyOption.REPLACE_EXISTING) } val invocation = ExporterInvocation(request, outputDirectory, listOf("-Xkonan-home", home.absolutePath) + cinteropArgs) val result = javaExecCapture { - classpath(compilerJar, exporterJar) + classpath(distribution.compilerJar, exporterJar) mainClass.set(EXPORTER_MAIN) args(invocation.toArgs()) systemProperty("konan.home", home.absolutePath) - systemProperty("java.library.path", home.resolve("konan/nativelib").absolutePath) + systemProperty("java.library.path", distribution.nativeLibraryDirectory.absolutePath) environment("LIBCLANG_DISABLE_CRASH_RECOVERY", "1") } check(result is ExecCaptureResult.Success) { "Generating bindings ($request) failed:\n${result.output}" } diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt new file mode 100644 index 0000000..205defc --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -0,0 +1,74 @@ +package io.github.lemcoder.kotlin + +import io.github.lemcoder.model.HostOs +import io.github.lemcoder.model.NativeTarget +import java.io.File + +/** + * A Kotlin/Native distribution β€” `kotlin-native-prebuilt-*` β€” and the tools in it the plugin runs: clang + * and llvm-ar through `run_konan`, with the target's sysroot, and cinterop's compiler jar. The one + * place the distribution's layout is known. + */ +internal class KonanDistribution(val home: File) { + /** `run_konan`, which runs the distribution's LLVM tools the way the Kotlin/Native compiler does. */ + val runKonan: File get() = home.resolve(if (HostOs.current == HostOs.WINDOWS) "bin/run_konan.bat" else "bin/run_konan") + + /** The compiler, cinterop included, as one jar: what the index exporter runs against. */ + val compilerJar: File get() = home.resolve("konan/lib/kotlin-native-compiler-embeddable.jar") + + /** Native libraries the compiler jar loads, libclang among them. */ + val nativeLibraryDirectory: File get() = home.resolve("konan/nativelib") + + /** clang for [target], with Kotlin/Native's sysroot and flags for it. */ + fun clang(target: NativeTarget, arguments: List): List = + listOf(runKonan.absolutePath, "clang", "clang", target.konanName) + arguments + + /** llvm-ar, for archiving. */ + fun llvmAr(arguments: List): List = listOf(runKonan.absolutePath, "llvm", "llvm-ar") + arguments + + /** The Android NDK clang resource directory, which holds compiler-rt, from the distribution's dependencies. */ + fun ndkResourceDirectory(): File? { + val dependencies = home.parentFile.resolve("dependencies") + val ndk = dependencies.listFiles { f -> f.isDirectory && f.name.contains("android_ndk") }?.firstOrNull() ?: return null + return ndk.resolve("lib64/clang").listFiles { f -> f.isDirectory }?.maxByOrNull { it.name } + } + + companion object { + private const val PREBUILT_PREFIX = "kotlin-native-prebuilt-" + + private val konanDirectory: File get() = File(System.getProperty("user.home"), ".konan") + + const val MISSING: String = + "No Kotlin/Native distribution found. The Kotlin plugin downloads one when it first compiles " + + "a native target, so build one of those first, or set konan.konanHome or KONAN_HOME." + + /** `$KONAN_HOME`, else the newest distribution under `~/.konan`; null when none is installed yet. */ + fun findHome(): File? { + System.getenv("KONAN_HOME")?.let { return File(it) } + return konanDirectory.listFiles { f -> f.isDirectory && f.name.startsWith(PREBUILT_PREFIX) }?.maxByOrNull { it.name } + } + + /** + * [configured], else [findHome]. Called when a task runs: a convention resolved earlier β€” when the + * configuration cache stores β€” may predate the Kotlin plugin's download of the distribution. + */ + fun resolve(configured: File?): KonanDistribution = KonanDistribution(configured ?: findHome() ?: error(MISSING)) + + /** + * The version the index exporter was compiled against, and so the one it must run in: it links + * against that version's compiler jar. `kotlinNativeVersion` in the plugin build. + */ + val pinnedVersion: String by lazy { + java.util.Properties().apply { + KonanDistribution::class.java.getResourceAsStream("/io/github/lemcoder/index/kotlin-native.properties") + .use { load(checkNotNull(it)) } + }.getProperty("version") + } + + /** The installed distribution of [pinnedVersion], if any. */ + fun pinned(): KonanDistribution? = + konanDirectory.listFiles { f -> f.isDirectory && f.name.startsWith(PREBUILT_PREFIX) && f.name.endsWith("-$pinnedVersion") } + ?.singleOrNull() + ?.let(::KonanDistribution) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanTargets.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanTargets.kt new file mode 100644 index 0000000..3c09332 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanTargets.kt @@ -0,0 +1,13 @@ +package io.github.lemcoder.kotlin + +import io.github.lemcoder.model.NativeTarget +import org.jetbrains.kotlin.konan.target.KonanTarget + +/** + * Kotlin/Native's own target as the plugin's β€” the only conversion between the two, e.g. + * `targets(NativeTarget.of(kotlinNativeTarget.konanTarget))`. Fails for a target the plugin does not + * know yet: [NativeTarget] then needs the new entry. + */ +fun NativeTarget.Companion.of(konanTarget: KonanTarget): NativeTarget = + byKonanName(konanTarget.name) + ?: error("Kotlin/Native target ${konanTarget.name} is not known to this version of the Konan plugin.") diff --git a/src/main/kotlin/io/github/lemcoder/wiring/KgpWiring.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt similarity index 87% rename from src/main/kotlin/io/github/lemcoder/wiring/KgpWiring.kt rename to src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index e3c07ea..a294a22 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/KgpWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -1,7 +1,8 @@ -package io.github.lemcoder.wiring +package io.github.lemcoder.kotlin import io.github.lemcoder.dsl.BindingSpec -import io.github.lemcoder.library.NativeArtifacts +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider @@ -15,7 +16,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget * or once the plugin is known to be applied: in a project without KGP, a reference elsewhere would be a * NoClassDefFoundError. */ -internal object KgpWiring { +internal object KotlinGradlePlugin { private val JVM_PLATFORMS = setOf(KotlinPlatformType.jvm, KotlinPlatformType.androidJvm) /** Generated bindings are sources of a `kotlin("jvm")` project's main source set. */ @@ -43,7 +44,8 @@ internal object KgpWiring { val kotlin = project.extensions.getByType(KotlinMultiplatformExtension::class.java) kotlin.targets.withType(KotlinNativeTarget::class.java).configureEach { val target = this - if (artifacts != null && target.konanTarget !in artifacts.targets.getOrElse(emptyList())) return@configureEach + val nativeTarget = NativeTarget.of(target.konanTarget) + if (artifacts != null && nativeTarget !in artifacts.targets.getOrElse(emptyList())) return@configureEach target.compilations.getByName("main").cinterops.create(binding.name) { definitionFile.set(binding.defFile) @@ -53,7 +55,7 @@ internal object KgpWiring { if (artifacts != null) { extraOpts( "-staticLibrary", NativeArtifacts.archiveFileName(artifacts.name), - "-libraryPath", artifacts.directory(target.konanTarget).get().asFile.absolutePath, + "-libraryPath", artifacts.directory(nativeTarget).get().asFile.absolutePath, ) project.tasks.named(interopProcessingTaskName).configure { dependsOn(artifacts.builtBy) } } diff --git a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt index 33842e0..0d5306a 100644 --- a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt @@ -1,25 +1,22 @@ package io.github.lemcoder.library -import org.jetbrains.kotlin.konan.target.KonanTarget - - +import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.FileSystemOperations -import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity.NAME_ONLY import org.gradle.api.tasks.PathSensitivity.RELATIVE import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations @@ -53,14 +50,13 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( @get:PathSensitive(RELATIVE) abstract val includeDirs: ConfigurableFileCollection - /** Path to the (platform specific) `run_konan` utility */ - @get:InputFile - @get:PathSensitive(NAME_ONLY) - abstract val runKonan: RegularFileProperty + /** The Kotlin/Native distribution whose clang compiles; resolved when the task runs. */ + @get:Internal + abstract val konanHome: DirectoryProperty - /** Kotlin/Native target platforms to compile for */ + /** Targets to compile for */ @get:Input - abstract val targets: ListProperty + abstract val targets: ListProperty @get:Input @get:Optional @@ -78,6 +74,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( @TaskAction fun compileAndLink() { val workingDir = workingDir.asFile.getOrElse(temporaryDir) + val distribution = KonanDistribution.resolve(konanHome.orNull?.asFile) targets.get().forEach { target -> // prepare output dirs val sourcesDir = workingDir.resolve("sources") @@ -120,8 +117,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( // compile files val compileResult = exec.execCapture { - executable(runKonan.asFile.get()) - args("clang", "clang", target.name, "@args") + commandLine(distribution.clang(target, listOf("@args"))) workingDir(compileDir) } @@ -130,7 +126,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( compileResult.assertNormalExitValue() // move compiled files to output directory - val outDir = outputDir.get().dir(target.name) + val outDir = outputDir.get().dir(target.konanName) fs.delete { delete(outDir) } fs.sync { from(compileDir) { @@ -158,8 +154,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( // link files val linkResult = exec.execCapture { - executable(runKonan.asFile.get()) - args("llvm", "llvm-ar", "@args") + commandLine(distribution.llvmAr(listOf("@args"))) workingDir(linkDir) } diff --git a/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt index cd9422e..cbcc772 100644 --- a/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt @@ -1,8 +1,9 @@ package io.github.lemcoder.library -import io.github.lemcoder.cleanTask import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.model.taskName +import io.github.lemcoder.util.cleanTask import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider @@ -21,7 +22,7 @@ private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC" * Kotlin/Native's clang and archived with its llvm-ar. */ internal fun registerKonanClang(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = - project.tasks.register("konanClang${library.name.replaceFirstChar { it.uppercase() }}", CompileNativeLibraryTask::class.java) { + project.tasks.register(taskName("konanClang", library.name), CompileNativeLibraryTask::class.java) { group = "konan" description = "Compile the '${library.name}' C/C++ sources with Kotlin/Native's clang." // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives @@ -35,10 +36,7 @@ internal fun registerKonanClang(project: Project, konan: KonanExtension, library libName.set(library.name) arguments.addAll(DEFAULT_COMPILER_OPTIONS) arguments.addAll(library.compilerOptions) - - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val script = if (isWindows) "bin/run_konan.bat" else "bin/run_konan" - runKonan.set(konan.konanHome.file(script)) + konanHome.set(konan.konanHome) onlyIf { targets.get().isNotEmpty() } } diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt index 5f88e56..6086db2 100644 --- a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt +++ b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt @@ -3,22 +3,19 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec import io.github.lemcoder.external.ExternalBuildTask -import io.github.lemcoder.external.ExternalBuildVariable import io.github.lemcoder.external.addSteps -import io.github.lemcoder.external.registerExternalBuild -import io.github.lemcoder.taskSuffix -import io.github.lemcoder.wiring.capitalized +import io.github.lemcoder.external.registerExternalBuilds +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ExternalLibraryVariable +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.selectOne import org.gradle.api.Project -import org.gradle.api.file.Directory -import org.gradle.api.provider.Provider -import org.gradle.api.provider.ProviderFactory import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.konan.target.KonanTarget /** - * How a library's archives come to exist: compiled by the plugin, or built by the project's own build - * system. Every provider must leave one archive per target at [NativeArtifacts.archivePath] under the - * library's output directory β€” the only thing consumers know about it. + * How a library's archives come to exist: compiled by the plugin, built by the project's own build + * system, or brought in prebuilt. Every provider leaves one archive per target at + * [NativeArtifacts.archivePath] under the library's output directory β€” the only thing consumers know. */ internal sealed interface LibraryProvider { /** Registers the work that builds [library]'s archives. Called once the build script has run. */ @@ -30,97 +27,66 @@ internal sealed interface LibraryProvider { listOf(registerKonanClang(project, konan, library)) } - /** `prebuilt { }`: archives built elsewhere, one per target, copied into place. */ + /** `prebuilt { }`: archives built elsewhere, one per target, placed in the layout. */ data object Prebuilt : LibraryProvider { override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec) = listOf(registerPrebuilt(project, library)) } /** - * The project's own build makes the archive: one [ExternalBuildTask] per target, run with the - * [LibraryContract] as its environment, its archive copied to `lib.a`. A build system only - * adds the commands that build one target. + * The project's own build makes the archive, once per target, told the [ExternalLibraryVariable] + * contract; its archive is copied to `lib.a`. A build system only adds its commands. */ sealed class External : LibraryProvider { - /** Task-name prefix, e.g. `zigBuild` in `zigBuildMymathMacos_arm64`. */ protected abstract val taskPrefix: String /** The build system, for task descriptions. */ protected abstract val toolName: String - /** Adds the commands, sources and produced archive that build [contract]'s target. */ - protected abstract fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) + protected abstract fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) final override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): List> { val libraryName = library.name - return library.targets.getOrElse(emptyList()).map { target -> - val contract = LibraryContract(target, libraryName, library.archiveDirectory(target), library.sourceDirectory, project.providers) - project.registerExternalBuild( - taskName = "$taskPrefix${libraryName.capitalized()}${target.taskSuffix}", - taskDescription = "Build the '$libraryName' library for ${target.name} with $toolName.", - environment = contract.environment(), - outputDirectory = contract.outputDirectory, - libraryFileName = project.providers.provider { NativeArtifacts.archiveFileName(libraryName) }, - ) { - addCommands(this, project, library, contract) - } - } + val fileName = project.providers.provider { NativeArtifacts.archiveFileName(libraryName) } + return project.registerExternalBuilds( + taskPrefix = taskPrefix, + owner = libraryName, + description = { target -> "Build the '$libraryName' library for ${target.konanName} with $toolName." }, + targets = library.targets.getOrElse(emptyList()), + contextFor = { target -> + val output = library.archiveDirectory(target) + ExternalBuildContext( + target, output, + mapOf( + ExternalLibraryVariable.LIB_NAME to project.providers.provider { libraryName }, + ExternalLibraryVariable.OUTPUT_DIR to output.map { it.asFile.absolutePath }, + ExternalLibraryVariable.SOURCE_DIR to library.sourceDirectory.map { it.asFile.absolutePath }, + ExternalLibraryVariable.TARGET to project.providers.provider { target.konanName }, + ), + ) + }, + libraryFileName = { fileName }, + ) { context -> addCommands(this, project, library, context) } } /** `external { }`: any build system, as the commands the build script lists. */ data object Generic : External() { override val taskPrefix = "externalBuild" override val toolName = "its external build" - override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) = - task.addSteps(library.external, contract.target, "konan.libraries.${library.name}") + override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = + task.addSteps(library.external, context.target, "konan.libraries.${library.name}") } - /** `zig { }`: the project's own `build.zig`, once per target. */ + /** `zig { }`: the project's own `build.zig`. */ data object Zig : External() { override val taskPrefix = "zigBuild" override val toolName = "zig build" - override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) = - addZigCommands(task, project, library, contract) + override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = + addZigCommands(task, project, library, context) } } } -/** What the plugin tells an external library build about one target. */ -internal class LibraryContract( - val target: KonanTarget, - private val libraryName: String, - val outputDirectory: Provider, - private val sourceDirectory: Provider, - private val providers: ProviderFactory, -) { - fun environment(): Map> = mapOf( - ExternalLibraryVariable.LIB_NAME to providers.provider { libraryName }, - ExternalLibraryVariable.OUTPUT_DIR to outputDirectory.map { it.asFile.absolutePath }, - ExternalLibraryVariable.SOURCE_DIR to sourceDirectory.map { it.asFile.absolutePath }, - ExternalLibraryVariable.TARGET to providers.provider { target.name }, - ) -} - -/** The environment of every external library build β€” the names are public contract. */ -internal enum class ExternalLibraryVariable(override val variableName: String) : ExternalBuildVariable { - /** The library's name: its archive must become `lib.a`. */ - LIB_NAME("KONAN_LIBRARY_NAME"), - - /** Where this target's archive must land, unless the build names a produced library. */ - OUTPUT_DIR("KONAN_LIBRARY_OUTPUT_DIR"), - - /** The library's `sourceDirectory`. */ - SOURCE_DIR("KONAN_LIBRARY_SOURCE_DIR"), - - /** The Kotlin/Native target, e.g. `linux_x64`. */ - TARGET("KONAN_TARGET"), -} - /** The provider [library] selected: at most one block, else [LibraryProvider.KonanClang]. */ -internal fun providerFor(library: NativeLibrarySpec): LibraryProvider { - val selected = library.selectedProviders - check(selected.size <= 1) { - "konan.libraries.${library.name} selects ${selected.joinToString(" and ")}; a library is built one way." - } - return selected.singleOrNull() ?: LibraryProvider.KonanClang -} +internal fun providerFor(library: NativeLibrarySpec): LibraryProvider = + selectOne("konan.libraries.${library.name}", library.selectedProviders, LibraryProvider.KonanClang) diff --git a/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt index 95c22ae..1de01bb 100644 --- a/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt +++ b/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt @@ -1,7 +1,9 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.NativeLibrarySpec -import io.github.lemcoder.wiring.capitalized +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection @@ -16,7 +18,6 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.work.DisableCachingByDefault -import org.jetbrains.kotlin.konan.target.KonanTarget import java.io.File /** @@ -29,11 +30,11 @@ internal fun registerPrebuilt(project: Project, library: NativeLibrarySpec): Tas val targets = library.targets.getOrElse(emptyList()) val missing = targets.filter { it !in archives } check(missing.isEmpty()) { - "konan.libraries.$libraryName.prebuilt has no archive for ${missing.joinToString { it.name }}." + "konan.libraries.$libraryName.prebuilt has no archive for ${missing.joinToString { it.konanName }}." } val placements = targets.associateWith { archives.getValue(it) } - return project.tasks.register("prebuilt${libraryName.capitalized()}", PlacePrebuiltArchivesTask::class.java) { + return project.tasks.register(taskName("prebuilt", libraryName), PlacePrebuiltArchivesTask::class.java) { group = "konan" description = "Place the prebuilt '$libraryName' archives where consumers look for them." archiveFiles.from(placements.values) @@ -52,7 +53,7 @@ internal abstract class PlacePrebuiltArchivesTask : DefaultTask() { @get:InputFiles @get:PathSensitive(PathSensitivity.NONE) abstract val archiveFiles: ConfigurableFileCollection /** Which archive each target gets. */ - @get:Input abstract val placements: MapProperty + @get:Input abstract val placements: MapProperty @get:Input abstract val libraryFileName: Property @@ -65,7 +66,7 @@ internal abstract class PlacePrebuiltArchivesTask : DefaultTask() { check(absent.isEmpty()) { "$path: no such archive: ${absent.joinToString()}" } val output = outputDirectory.get().asFile archives.forEach { (target, archive) -> - archive.copyTo(output.resolve(target.name).resolve(libraryFileName.get()), overwrite = true) + archive.copyTo(output.resolve(target.konanName).resolve(libraryFileName.get()), overwrite = true) } } } diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt b/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt index c8e1c5d..6588425 100644 --- a/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt @@ -2,21 +2,22 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.NativeLibrarySpec import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.TargetFamily import io.github.lemcoder.toolchain.zigTriple import org.gradle.api.Project -import org.jetbrains.kotlin.konan.target.Family /** * [LibraryProvider.External.Zig]: `zig build` for one target, with `-Dtarget` set to the Zig triple that * matches Kotlin/Native's sysroot, installed into a prefix of its own; its static library is then copied * to where every other provider puts the archive. */ -internal fun addZigCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, contract: LibraryContract) { +internal fun addZigCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) { val zig = library.zig - val target = contract.target + val target = context.target // Zig's prefix and cache stay out of the project directory, which is the task's input. val work = project.layout.buildDirectory.dir("konan/libraries/${library.name}/zig") - val prefix = work.map { it.dir(target.name) } + val prefix = work.map { it.dir(target.konanName) } task.command { executable.set(zig.executable) @@ -31,7 +32,7 @@ internal fun addZigCommands(task: ExternalBuildTask, project: Project, library: // Zig names the archive after the target's convention: libfoo.a, or foo.lib on Windows. task.producedLibrary.set( prefix.zip(zig.artifactName) { directory, artifact -> - directory.file(if (target.family == Family.MINGW) "lib/$artifact.lib" else "lib/lib$artifact.a") + directory.file(if (target.family == TargetFamily.MINGW) "lib/$artifact.lib" else "lib/lib$artifact.a") } ) } diff --git a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt index 2dc9f13..d87ee2a 100644 --- a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt @@ -1,10 +1,9 @@ package io.github.lemcoder.link -import org.jetbrains.kotlin.konan.target.Family -import org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.isAndroid -import io.github.lemcoder.sharedLibraryName -import io.github.lemcoder.toolchain.NativeToolchain +import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.TargetFamily +import io.github.lemcoder.toolchain.Xcode import io.github.lemcoder.util.execCapture import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection @@ -12,12 +11,10 @@ import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property -import java.io.File -import javax.inject.Inject import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive @@ -25,73 +22,74 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject /** - * Links a target's static archive into a shared library: with [stubCFile], the generated JNI stub - * compiled against it (JNI); without, the whole archive on its own, every symbol exported (FFM). + * Links a target's static archive into a shared library with Kotlin/Native's clang: with [stubCFile], the + * generated JNI stub compiled against it (JNI); without, the whole archive on its own, every symbol + * exported (FFM). */ @DisableCachingByDefault(because = "Native link output depends on the local toolchain; cheap to rebuild") internal abstract class LinkSharedLibraryTask @Inject constructor( private val exec: ExecOperations, ) : DefaultTask() { - @get:Input @get:Optional abstract val konanPath: Property - @get:Input abstract val target: Property + /** The Kotlin/Native distribution whose clang links; resolved when the task runs. */ + @get:Internal abstract val konanHome: DirectoryProperty + @get:Input abstract val target: Property + /** Base name of the library, e.g. `examplestubs` for `libexamplestubs.so`. */ @get:Input abstract val libraryBaseName: Property @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.RELATIVE) abstract val stubCFile: RegularFileProperty + /** Include roots for the stub compile. */ @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val includeDirs: ConfigurableFileCollection + /** Further include roots, as paths: ones that must not be tracked as inputs, like the `.def`'s directory. */ @get:Input @get:Optional abstract val includePaths: ListProperty + + /** The JDK's `include` directories, for `jni.h`; Android gets it from the NDK sysroot instead. */ + @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val jniIncludeDirs: ConfigurableFileCollection + @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val staticLibrary: RegularFileProperty - @get:Input @get:Optional abstract val jniIncludeDirs: ListProperty - @get:Input @get:Optional abstract val ndkResourceDir: Property @get:Input @get:Optional abstract val additionalLinkerArgs: ListProperty @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction fun run() { - // Resolved here rather than through the property's convention: that is queried while the - // configuration cache is stored, which is before the Kotlin plugin has downloaded the - // distribution, and an absent value would then be cached for the run that needs it. - val konanHome = konanPath.orNull?.let(::File) - ?: NativeToolchain.findKonanHome() - ?: error(NativeToolchain.MISSING_KONAN) - val tgt = target.get() - val isWindows = System.getProperty("os.name").lowercase().contains("windows") - val runKonan = konanHome.resolve(if (isWindows) "bin/run_konan.bat" else "bin/run_konan") - check(runKonan.isFile) { "run_konan not found: $runKonan" } + val distribution = KonanDistribution.resolve(konanHome.orNull?.asFile) + check(distribution.runKonan.isFile) { "run_konan not found: ${distribution.runKonan}" } + val nativeTarget = target.get() val outDir = outputDirectory.get().asFile outDir.deleteRecursively(); outDir.mkdirs() - val outLib = outDir.resolve(tgt.sharedLibraryName(libraryBaseName.get())) + val outLib = outDir.resolve(nativeTarget.sharedLibraryName(libraryBaseName.get())) val archive = staticLibrary.get().asFile.absolutePath val stub = stubCFile.orNull?.asFile - val cmd = buildList { - add(runKonan.absolutePath); add("clang"); add("clang"); add(tgt.name) + val arguments = buildList { add("-shared") includeDirs.files.filter { it.exists() }.forEach { add("-I${it.absolutePath}") } includePaths.getOrElse(emptyList()).forEach { add("-I$it") } - if (tgt.isAndroid) { + if (nativeTarget.isAndroid) { // jni.h comes from the NDK sysroot; supply compiler-rt and skip the (absent) unwinder. - ndkResourceDir.orNull?.takeIf { it.isNotEmpty() }?.let { add("-resource-dir=$it") } + distribution.ndkResourceDirectory()?.let { add("-resource-dir=${it.absolutePath}") } add("--unwindlib=none") } else { - jniIncludeDirs.getOrElse(emptyList()).forEach { add("-I$it") } + jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } } if (stub != null) { add(stub.absolutePath) add(archive) - } else if (tgt.family.isAppleFamily) { + } else if (nativeTarget.family.isApple) { add("-Wl,-force_load,$archive") } else { add("-Wl,--whole-archive"); add(archive); add("-Wl,--no-whole-archive") } - if (tgt.family == Family.OSX) { + if (nativeTarget.family == TargetFamily.OSX) { // Konan's LLVM has no host compiler-rt; Apple-framework code needs its builtins. - NativeToolchain.appleCompilerRt(xcodeDeveloperDir())?.let { add(it.absolutePath) } + Xcode.macosCompilerRt(exec)?.let { add(it.absolutePath) } } add("-o"); add(outLib.absolutePath) // A C++ runtime, extra -L, frameworks: all caller-supplied. Adding konan's own NDK lib @@ -100,17 +98,9 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( addAll(additionalLinkerArgs.getOrElse(emptyList())) } - val result = exec.execCapture { commandLine(cmd) } + val result = exec.execCapture { commandLine(distribution.clang(nativeTarget, arguments)) } logger.lifecycle(result.output) result.assertNormalExitValue() logger.lifecycle("Shared library: $outLib") } - - /** `xcode-select -p`, or null when there is no Xcode (then only the Command Line Tools are tried). */ - private fun xcodeDeveloperDir(): File? = runCatching { - exec.execCapture { commandLine("xcode-select", "-p") } - .takeIf { it.exitValue == 0 } - ?.let { File(it.output.trim()) } - ?.takeIf { it.isDirectory } - }.getOrNull() -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/DefFile.kt b/src/main/kotlin/io/github/lemcoder/model/DefFile.kt similarity index 98% rename from src/main/kotlin/io/github/lemcoder/wiring/DefFile.kt rename to src/main/kotlin/io/github/lemcoder/model/DefFile.kt index cc10fc9..f783c36 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/DefFile.kt +++ b/src/main/kotlin/io/github/lemcoder/model/DefFile.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.wiring +package io.github.lemcoder.model import java.io.File diff --git a/src/main/kotlin/io/github/lemcoder/dsl/Executable.kt b/src/main/kotlin/io/github/lemcoder/model/Executable.kt similarity index 94% rename from src/main/kotlin/io/github/lemcoder/dsl/Executable.kt rename to src/main/kotlin/io/github/lemcoder/model/Executable.kt index 000b4c0..7516c77 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/Executable.kt +++ b/src/main/kotlin/io/github/lemcoder/model/Executable.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.model import java.io.Serializable diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt new file mode 100644 index 0000000..65f1b5c --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt @@ -0,0 +1,58 @@ +package io.github.lemcoder.model + +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider + +/** A variable of an external build's contract: what the plugin tells the build, by environment. */ +internal sealed interface ExternalBuildVariable { + /** The environment variable's name β€” public contract, read by build scripts. */ + val variableName: String +} + +/** The contract of a build that makes a library's archive (`libraries { zig { } }`, `external { }`). */ +internal enum class ExternalLibraryVariable(override val variableName: String) : ExternalBuildVariable { + /** The library's name: its archive must become `lib.a`. */ + LIB_NAME("KONAN_LIBRARY_NAME"), + + /** Where this target's archive must land, unless the build names a produced library. */ + OUTPUT_DIR("KONAN_LIBRARY_OUTPUT_DIR"), + + /** The library's `sourceDirectory`. */ + SOURCE_DIR("KONAN_LIBRARY_SOURCE_DIR"), + + /** The Kotlin/Native target, e.g. `linux_x64`. */ + TARGET("KONAN_TARGET"), +} + +/** + * The contract of a build that makes a binding's JNI library (`jni { cmake { } }`, `external { }`), read + * the way an AGP project reads `ANDROID_ABI`. + */ +internal enum class ExternalStubVariable(override val variableName: String) : ExternalBuildVariable { + /** The directory holding the generated `.c` stub. */ + STUB_DIR("KONAN_JNI_STUB_DIR"), + + /** The library name the generated bindings pass to `System.loadLibrary`. */ + LIB_NAME("KONAN_JNI_LIB_NAME"), + + /** Where the target's library must land, unless the build names a produced library. */ + OUTPUT_DIR("KONAN_JNI_OUTPUT_DIR"), + + /** The Kotlin/Native target, e.g. `android_arm64`. */ + TARGET("KONAN_TARGET"), + + /** The target's Android ABI, e.g. `arm64-v8a`; the target's name elsewhere. */ + ABI("KONAN_JNI_ABI"), + + /** A JDK that ships `include/jni.h`. */ + JAVA_HOME("JAVA_HOME"), +} + +/** What an external build is told for one [target]: where to leave its output, and its contract. */ +internal class ExternalBuildContext( + val target: NativeTarget, + val outputDirectory: Provider, + val environment: Map>, +) { + operator fun get(variable: ExternalBuildVariable): Provider = environment.getValue(variable) +} diff --git a/src/main/kotlin/io/github/lemcoder/model/HostOs.kt b/src/main/kotlin/io/github/lemcoder/model/HostOs.kt new file mode 100644 index 0000000..b91f036 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/HostOs.kt @@ -0,0 +1,26 @@ +package io.github.lemcoder.model + +/** The operating system the build runs on: the one place the plugin reads `os.name` and `os.arch`. */ +internal enum class HostOs { + MACOS, LINUX, WINDOWS; + + companion object { + val current: HostOs by lazy { + val name = System.getProperty("os.name").lowercase() + when { + name.contains("mac") || name.contains("darwin") -> MACOS + name.contains("windows") -> WINDOWS + else -> LINUX + } + } + + val architecture: Architecture by lazy { + when (System.getProperty("os.arch").lowercase()) { + "aarch64", "arm64" -> Architecture.ARM64 + "x86", "i386", "i686" -> Architecture.X86 + "arm" -> Architecture.ARM32 + else -> Architecture.X64 + } + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/model/Naming.kt b/src/main/kotlin/io/github/lemcoder/model/Naming.kt new file mode 100644 index 0000000..fdf92a1 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/Naming.kt @@ -0,0 +1,14 @@ +package io.github.lemcoder.model + +/** `mymath` β†’ `Mymath`, as task names embed a name. */ +internal fun String.capitalized(): String = replaceFirstChar { it.uppercase() } + +/** Every task name the plugin registers: `[]`, e.g. `linkJniMymathAndroid_arm64`. */ +internal fun taskName(prefix: String, owner: String, target: NativeTarget? = null): String = + prefix + owner.capitalized() + (target?.taskSuffix ?: "") + +/** + * The JNI library a binding's generated code loads: cinterop's own stub naming, the package's parts + * joined plus `stubs` β€” `io.example.m` β†’ `ioexamplemstubs`. + */ +internal fun stubLibraryBaseName(packageName: String): String = packageName.split('.').joinToString("") + "stubs" diff --git a/src/main/kotlin/io/github/lemcoder/library/NativeArtifacts.kt b/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt similarity index 70% rename from src/main/kotlin/io/github/lemcoder/library/NativeArtifacts.kt rename to src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt index b5850c0..09b41e3 100644 --- a/src/main/kotlin/io/github/lemcoder/library/NativeArtifacts.kt +++ b/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.library +package io.github.lemcoder.model import io.github.lemcoder.dsl.NativeLibrarySpec import org.gradle.api.Project @@ -6,7 +6,6 @@ import org.gradle.api.file.Directory import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider -import org.jetbrains.kotlin.konan.target.KonanTarget /** * What a library provider guarantees and every consumer relies on: one static archive per target, at @@ -17,22 +16,22 @@ internal class NativeArtifacts( /** The library's name; the archives are `lib.a`. */ val name: String, private val outputDirectory: Provider, - val targets: Provider>, + val targets: Provider>, /** Depend on this, not on a provider's task: it stands for "the archives exist". */ val builtBy: TaskProvider<*>, ) { - fun archive(target: KonanTarget): Provider = outputDirectory.map { it.file(archivePath(name, target)) } + fun archive(target: NativeTarget): Provider = outputDirectory.map { it.file(archivePath(name, target)) } - fun directory(target: KonanTarget): Provider = outputDirectory.map { it.dir(target.name) } + fun directory(target: NativeTarget): Provider = outputDirectory.map { it.dir(target.konanName) } companion object { /** The one place the layout is written: `/lib.a` under a library's output directory. */ - fun archivePath(name: String, target: KonanTarget): String = "${target.name}/${archiveFileName(name)}" + fun archivePath(name: String, target: NativeTarget): String = "${target.konanName}/${archiveFileName(name)}" fun archiveFileName(name: String): String = "lib$name.a" /** The lifecycle task every library has, whatever its provider. */ - fun taskName(name: String): String = "compileNativeLibrary${name.replaceFirstChar { it.uppercase() }}" + fun taskName(name: String): String = taskName("compileNativeLibrary", name) fun of(project: Project, library: NativeLibrarySpec) = NativeArtifacts( name = library.name, diff --git a/src/main/kotlin/io/github/lemcoder/model/NativeTarget.kt b/src/main/kotlin/io/github/lemcoder/model/NativeTarget.kt new file mode 100644 index 0000000..235c9dc --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/NativeTarget.kt @@ -0,0 +1,86 @@ +package io.github.lemcoder.model + +/** + * A platform the plugin builds native code for: the plugin's own target type, so its API does not depend + * on the Kotlin compiler's. Each target carries its Kotlin/Native name ([konanName]) β€” the spelling + * Kotlin/Native's tools, directory layouts and task names use β€” and every other ecosystem's name for + * it is derived here or next to that ecosystem's code. + * + * Mirrors Kotlin/Native's predefined targets one for one; a test fails when a Kotlin version adds one + * this list lacks. From a KGP target, use `NativeTarget.of(kotlinNativeTarget.konanTarget)`. + */ +enum class NativeTarget( + val konanName: String, + val family: TargetFamily, + val architecture: Architecture, + /** The Android ABI, as `jniLibs/` and the NDK name it. */ + val androidAbi: String? = null, +) { + ANDROID_ARM32("android_arm32", TargetFamily.ANDROID, Architecture.ARM32, "armeabi-v7a"), + ANDROID_ARM64("android_arm64", TargetFamily.ANDROID, Architecture.ARM64, "arm64-v8a"), + ANDROID_X64("android_x64", TargetFamily.ANDROID, Architecture.X64, "x86_64"), + ANDROID_X86("android_x86", TargetFamily.ANDROID, Architecture.X86, "x86"), + IOS_ARM64("ios_arm64", TargetFamily.IOS, Architecture.ARM64), + IOS_SIMULATOR_ARM64("ios_simulator_arm64", TargetFamily.IOS, Architecture.ARM64), + IOS_X64("ios_x64", TargetFamily.IOS, Architecture.X64), + LINUX_ARM32_HFP("linux_arm32_hfp", TargetFamily.LINUX, Architecture.ARM32), + LINUX_ARM64("linux_arm64", TargetFamily.LINUX, Architecture.ARM64), + LINUX_X64("linux_x64", TargetFamily.LINUX, Architecture.X64), + MACOS_ARM64("macos_arm64", TargetFamily.OSX, Architecture.ARM64), + MACOS_X64("macos_x64", TargetFamily.OSX, Architecture.X64), + MINGW_X64("mingw_x64", TargetFamily.MINGW, Architecture.X64), + TVOS_ARM64("tvos_arm64", TargetFamily.TVOS, Architecture.ARM64), + TVOS_SIMULATOR_ARM64("tvos_simulator_arm64", TargetFamily.TVOS, Architecture.ARM64), + TVOS_X64("tvos_x64", TargetFamily.TVOS, Architecture.X64), + WATCHOS_ARM32("watchos_arm32", TargetFamily.WATCHOS, Architecture.ARM32), + WATCHOS_ARM64("watchos_arm64", TargetFamily.WATCHOS, Architecture.ARM64), + WATCHOS_DEVICE_ARM64("watchos_device_arm64", TargetFamily.WATCHOS, Architecture.ARM64), + WATCHOS_SIMULATOR_ARM64("watchos_simulator_arm64", TargetFamily.WATCHOS, Architecture.ARM64), + WATCHOS_X64("watchos_x64", TargetFamily.WATCHOS, Architecture.X64), + ; + + val isAndroid: Boolean get() = family == TargetFamily.ANDROID + + /** Task-name segment, e.g. `Android_arm64` in `linkJniMymathAndroid_arm64`. */ + internal val taskSuffix: String get() = konanName.capitalized() + + /** Directory name for this target's JNI library: its Android ABI under `jniLibs/`, else [konanName]. */ + internal val libraryDirectoryName: String get() = androidAbi ?: konanName + + /** Shared-library file name for [baseName], e.g. `libfoo.dylib`, `foo.dll`. */ + internal fun sharedLibraryName(baseName: String): String = "${family.sharedLibraryPrefix}$baseName.${family.sharedLibrarySuffix}" + + companion object { + /** The build host, as a target. */ + val host: NativeTarget by lazy { + when (HostOs.current) { + HostOs.MACOS -> if (HostOs.architecture == Architecture.ARM64) MACOS_ARM64 else MACOS_X64 + HostOs.LINUX -> if (HostOs.architecture == Architecture.ARM64) LINUX_ARM64 else LINUX_X64 + HostOs.WINDOWS -> MINGW_X64 + } + } + + /** Every Android ABI. */ + val android: List get() = entries.filter { it.isAndroid } + + /** The target Kotlin/Native calls [konanName], if the plugin knows it. */ + fun byKonanName(konanName: String): NativeTarget? = entries.firstOrNull { it.konanName == konanName } + } +} + +/** The operating-system family of a [NativeTarget], which decides its binary conventions. */ +enum class TargetFamily( + internal val sharedLibraryPrefix: String, + internal val sharedLibrarySuffix: String, + val isApple: Boolean, +) { + ANDROID("lib", "so", isApple = false), + LINUX("lib", "so", isApple = false), + MINGW("", "dll", isApple = false), + OSX("lib", "dylib", isApple = true), + IOS("lib", "dylib", isApple = true), + TVOS("lib", "dylib", isApple = true), + WATCHOS("lib", "dylib", isApple = true), +} + +enum class Architecture { ARM32, ARM64, X64, X86 } diff --git a/src/main/kotlin/io/github/lemcoder/model/Selection.kt b/src/main/kotlin/io/github/lemcoder/model/Selection.kt new file mode 100644 index 0000000..2d33460 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/Selection.kt @@ -0,0 +1,10 @@ +package io.github.lemcoder.model + +/** + * The one choice [selected] holds, else [default]: a DSL owner such as a library or a binding's `jni` + * picks how it is built by calling one block, and calling two is an error naming [owner]. + */ +internal fun selectOne(owner: String, selected: Set, default: T): T { + check(selected.size <= 1) { "$owner selects ${selected.joinToString(" and ")}; it is built one way." } + return selected.singleOrNull() ?: default +} diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt b/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt index 3a2d75b..188ff8f 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt +++ b/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt @@ -1,6 +1,7 @@ package io.github.lemcoder.toolchain -import io.github.lemcoder.dsl.Executable +import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.HostOs import java.io.File /** Where installs live that a daemon's `PATH` misses. */ @@ -17,7 +18,7 @@ internal fun Executable.command(): String = when (this) { private fun find(name: String): File? { val directories = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter { it.isNotEmpty() } + FALLBACK_DIRECTORIES - val extensions = if (File.separatorChar == '\\') WINDOWS_EXTENSIONS else listOf("") + val extensions = if (HostOs.current == HostOs.WINDOWS) WINDOWS_EXTENSIONS else listOf("") return directories.asSequence() .flatMap { directory -> extensions.asSequence().map { File(directory, name + it) } } .firstOrNull { it.isFile && it.canExecute() } diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt b/src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt new file mode 100644 index 0000000..c857560 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt @@ -0,0 +1,43 @@ +package io.github.lemcoder.toolchain + +import io.github.lemcoder.model.HostOs +import java.io.File + +/** + * A JDK that ships `include/jni.h`, which the JNI stub compiles against. The JVM running Gradle is not a + * reliable one: IDE-bundled JBRs β€” Android Studio's, often the Gradle daemon β€” strip `include/`. + */ +internal object Jdk { + private const val MISSING = "No JDK with include/jni.h found. Set jni.jdkHome." + + /** `include` and its OS subdirectory under [jdkHome], if it ships jni.h; the macOS `Contents/Home` layout too. */ + fun jniIncludeDirectoriesOf(jdkHome: File): List? = + homeWithHeaders(jdkHome)?.let { listOf(it.resolve("include"), it.resolve("include/${HostOs.current.jniIncludeSubdirectory}")) } + + /** [jniIncludeDirectoriesOf] the first installed JDK that has them. */ + fun detectJniIncludeDirectories(): List = + candidates().firstNotNullOfOrNull(::jniIncludeDirectoriesOf) ?: error(MISSING) + + /** The home of the JDK [detectJniIncludeDirectories] uses, for an external build's `JAVA_HOME`. */ + fun detectHome(): File = candidates().firstNotNullOfOrNull(::homeWithHeaders) ?: error(MISSING) + + private fun homeWithHeaders(jdkHome: File): File? = + listOf(jdkHome, jdkHome.resolve("Contents/Home")).firstOrNull { it.resolve("include/jni.h").isFile } + + /** JDK homes worth checking, most specific first. */ + private fun candidates(): List = buildList { + System.getenv("JNI_HOME")?.let { add(File(it)) } + System.getenv("JAVA_HOME")?.let { add(File(it)) } + System.getProperty("java.home")?.let { add(File(it)) } + File(System.getProperty("user.home"), "Library/Java/JavaVirtualMachines").listFiles()?.let { addAll(it) } + File("/Library/Java/JavaVirtualMachines").listFiles()?.let { addAll(it) } + File("/usr/lib/jvm").listFiles()?.let { addAll(it) } + } + + private val HostOs.jniIncludeSubdirectory: String + get() = when (this) { + HostOs.MACOS -> "darwin" + HostOs.LINUX -> "linux" + HostOs.WINDOWS -> "win32" + } +} diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/NativeToolchain.kt b/src/main/kotlin/io/github/lemcoder/toolchain/NativeToolchain.kt deleted file mode 100644 index cc3d624..0000000 --- a/src/main/kotlin/io/github/lemcoder/toolchain/NativeToolchain.kt +++ /dev/null @@ -1,92 +0,0 @@ -package io.github.lemcoder.toolchain - -import java.io.File - -/** Pure helpers for the JVM/JNI interop leg: toolchain discovery and naming. */ -internal object NativeToolchain { - - // --- toolchain discovery ------------------------------------------------- - - /** The Kotlin/Native distribution, or null when none is installed yet. */ - fun findKonanHome(): File? { - System.getenv("KONAN_HOME")?.let { return File(it) } - val konanDir = File(System.getProperty("user.home"), ".konan") - return konanDir.listFiles { f -> f.isDirectory && f.name.startsWith("kotlin-native-prebuilt-") } - ?.maxByOrNull { it.name } - } - - const val MISSING_KONAN: String = - "No Kotlin/Native distribution found. The Kotlin plugin downloads one when it first compiles " + - "a native target, so build one of those first, or set konanConfig.konanPath or KONAN_HOME." - - private fun osIncludeSubDir(): String { - val os = System.getProperty("os.name").lowercase() - return when { - os.contains("mac") || os.contains("darwin") -> "darwin" - os.contains("windows") -> "win32" - else -> "linux" - } - } - - /** Returns [include, include/] for [jdkHome] if it ships jni.h, trying the macOS `Contents/Home` layout too. */ - fun jniIncludeDirsOf(jdkHome: File): List? { - val sub = osIncludeSubDir() - for (root in listOf(jdkHome, jdkHome.resolve("Contents/Home"))) { - if (root.resolve("include/jni.h").isFile) { - return listOf(root.resolve("include"), root.resolve("include/$sub")) - } - } - return null - } - - /** - * JDK homes worth checking for `include/jni.h`, most specific first. Not just the running JVM: - * IDE-bundled JBRs β€” Android Studio's, which is often the Gradle daemon β€” strip the headers. - */ - private fun jdkCandidates(): List = buildList { - System.getenv("JNI_HOME")?.let { add(File(it)) } - System.getenv("JAVA_HOME")?.let { add(File(it)) } - System.getProperty("java.home")?.let { add(File(it)) } - File(System.getProperty("user.home"), "Library/Java/JavaVirtualMachines").listFiles()?.let { addAll(it) } - File("/Library/Java/JavaVirtualMachines").listFiles()?.let { addAll(it) } - File("/usr/lib/jvm").listFiles()?.let { addAll(it) } - } - - /** Scans the running JVM and installed JDKs for one that ships jni.h. */ - fun detectJniIncludeDirs(): List = jdkCandidates().firstNotNullOfOrNull { jniIncludeDirsOf(it) } - ?: error("No JDK with include/jni.h found. Set the interop's jniHome.") - - /** The home of the JDK [detectJniIncludeDirs] would use. */ - fun detectJniHome(): File = jdkCandidates() - .flatMap { listOf(it, it.resolve("Contents/Home")) } - .firstOrNull { it.resolve("include/jni.h").isFile } - ?: error("No JDK with include/jni.h found. Set the interop's jniHome.") - - /** - * Xcode's compiler-rt builtins for macOS. Konan's `essentials` LLVM ships none for the host, so a - * stub that pulls in Apple-framework code (e.g. ggml's Metal backend) is otherwise short - * `___isPlatformVersionAtLeast`. [developerDir] is `xcode-select -p`, when that succeeded. - */ - fun appleCompilerRt(developerDir: File?): File? { - val roots = listOfNotNull( - developerDir?.resolve("Toolchains/XcodeDefault.xctoolchain/usr/lib/clang"), - File("/Library/Developer/CommandLineTools/usr/lib/clang"), - ) - return roots.filter { it.isDirectory } - .flatMap { it.listFiles()?.sortedByDescending { v -> v.name }?.toList() ?: emptyList() } - .map { it.resolve("lib/darwin/libclang_rt.osx.a") } - .firstOrNull { it.isFile } - } - - /** Android NDK clang resource dir (holds compiler-rt builtins), discovered under the konan dependencies. */ - fun ndkResourceDir(konanHome: File): File? { - val deps = File(konanHome.parentFile, "dependencies") - val ndk = deps.listFiles { f -> f.isDirectory && f.name.contains("android_ndk") }?.firstOrNull() ?: return null - return File(ndk, "lib64/clang").listFiles { f -> f.isDirectory }?.maxByOrNull { it.name } - } - - // --- naming -------------------------------------------------------------- - - /** cinterop's stub library base name: package parts joined, plus "stubs" (e.g. `io.example.m` -> `ioexamplemstubs`). */ - fun stubBaseName(packageName: String): String = packageName.split('.').joinToString("") + "stubs" -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Xcode.kt b/src/main/kotlin/io/github/lemcoder/toolchain/Xcode.kt new file mode 100644 index 0000000..82780b2 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/toolchain/Xcode.kt @@ -0,0 +1,32 @@ +package io.github.lemcoder.toolchain + +import io.github.lemcoder.util.execCapture +import org.gradle.process.ExecOperations +import java.io.File + +/** What the plugin borrows from Xcode on a macOS host. */ +internal object Xcode { + /** + * Xcode's compiler-rt builtins for macOS. Kotlin/Native's `essentials` LLVM ships none for the host, + * so a library that pulls in Apple-framework code (e.g. ggml's Metal backend) is otherwise short + * `___isPlatformVersionAtLeast`. + */ + fun macosCompilerRt(exec: ExecOperations): File? { + val roots = listOfNotNull( + developerDirectory(exec)?.resolve("Toolchains/XcodeDefault.xctoolchain/usr/lib/clang"), + File("/Library/Developer/CommandLineTools/usr/lib/clang"), + ) + return roots.filter { it.isDirectory } + .flatMap { it.listFiles()?.sortedByDescending { version -> version.name }.orEmpty() } + .map { it.resolve("lib/darwin/libclang_rt.osx.a") } + .firstOrNull { it.isFile } + } + + /** `xcode-select -p`, or null without Xcode β€” then only the Command Line Tools are tried. */ + private fun developerDirectory(exec: ExecOperations): File? = runCatching { + exec.execCapture { commandLine("xcode-select", "-p") } + .takeIf { it.exitValue == 0 } + ?.let { File(it.output.trim()) } + ?.takeIf { it.isDirectory } + }.getOrNull() +} diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt b/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt index 3f0ed4c..4fe8b6a 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt +++ b/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt @@ -1,24 +1,24 @@ package io.github.lemcoder.toolchain -import org.jetbrains.kotlin.konan.target.KonanTarget +import io.github.lemcoder.model.NativeTarget /** * Zig's target triple for a Kotlin/Native target, pinned to what Kotlin/Native's own sysroots expect, * so an archive Zig builds links wherever konan's would: glibc 2.19 on Linux, macOS 12.0 and iOS 14.0 * on Apple. Android is mapped, but needs a libc file for the NDK's bionic, which Zig does not bundle. */ -internal fun KonanTarget.zigTriple(): String = when (this) { - KonanTarget.MACOS_ARM64 -> "aarch64-macos.12.0" - KonanTarget.MACOS_X64 -> "x86_64-macos.12.0" - KonanTarget.IOS_ARM64 -> "aarch64-ios.14.0" - KonanTarget.IOS_SIMULATOR_ARM64 -> "aarch64-ios.14.0-simulator" - KonanTarget.IOS_X64 -> "x86_64-ios.14.0-simulator" - KonanTarget.LINUX_X64 -> "x86_64-linux-gnu.2.19" - KonanTarget.LINUX_ARM64 -> "aarch64-linux-gnu.2.19" - KonanTarget.MINGW_X64 -> "x86_64-windows-gnu" - KonanTarget.ANDROID_ARM64 -> "aarch64-linux-android" - KonanTarget.ANDROID_X64 -> "x86_64-linux-android" - KonanTarget.ANDROID_ARM32 -> "arm-linux-androideabi" - KonanTarget.ANDROID_X86 -> "x86-linux-android" - else -> error("No Zig target for Kotlin/Native target $name") +internal fun NativeTarget.zigTriple(): String = when (this) { + NativeTarget.MACOS_ARM64 -> "aarch64-macos.12.0" + NativeTarget.MACOS_X64 -> "x86_64-macos.12.0" + NativeTarget.IOS_ARM64 -> "aarch64-ios.14.0" + NativeTarget.IOS_SIMULATOR_ARM64 -> "aarch64-ios.14.0-simulator" + NativeTarget.IOS_X64 -> "x86_64-ios.14.0-simulator" + NativeTarget.LINUX_X64 -> "x86_64-linux-gnu.2.19" + NativeTarget.LINUX_ARM64 -> "aarch64-linux-gnu.2.19" + NativeTarget.MINGW_X64 -> "x86_64-windows-gnu" + NativeTarget.ANDROID_ARM64 -> "aarch64-linux-android" + NativeTarget.ANDROID_X64 -> "x86_64-linux-android" + NativeTarget.ANDROID_ARM32 -> "arm-linux-androideabi" + NativeTarget.ANDROID_X86 -> "x86-linux-android" + else -> error("No Zig target for $konanName") } diff --git a/src/main/kotlin/io/github/lemcoder/util/Gradle.kt b/src/main/kotlin/io/github/lemcoder/util/Gradle.kt new file mode 100644 index 0000000..07f295e --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/util/Gradle.kt @@ -0,0 +1,9 @@ +package io.github.lemcoder.util + +import org.gradle.api.Project + +/** + * The `clean` task as a lazy collection β€” empty when the base plugin isn't applied, so it is safe to + * pass to `mustRunAfter` unconditionally. + */ +internal fun Project.cleanTask() = tasks.matching { it.name == "clean" } diff --git a/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt b/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt index e589457..1eb5663 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt @@ -2,13 +2,11 @@ package io.github.lemcoder.wiring import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec -import io.github.lemcoder.library.NativeArtifacts import io.github.lemcoder.library.providerFor +import io.github.lemcoder.model.NativeArtifacts import org.gradle.api.Action import org.gradle.api.Project -internal fun String.capitalized() = replaceFirstChar { it.uppercase() } - /** * Every library gets its lifecycle task now β€” the name bindings depend on β€” and its provider's tasks once * the build script has said which provider it uses. diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 4c00eb0..2ee8f34 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -1,6 +1,7 @@ package io.github.lemcoder -import io.github.lemcoder.index.PinnedKotlinNative +import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.model.NativeTarget import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Assumptions.assumeTrue @@ -18,8 +19,8 @@ import kotlin.test.assertTrue class KonanPluginFunctionalTest { private val projectDir = createTempDirectory("konan-testkit").toFile() - private val host = hostKonanTarget() - private val hostSuffix = host.name.replaceFirstChar { it.uppercase() } + private val host = NativeTarget.host + private val hostSuffix = host.konanName.replaceFirstChar { it.uppercase() } private fun runner(vararg args: String) = GradleRunner.create() .withProjectDir(projectDir) @@ -34,14 +35,14 @@ class KonanPluginFunctionalTest { File(projectDir, "mymath.def").writeText("headers = mymath.h\npackage = example\n") File(projectDir, "mymath.h").writeText("int my_add(int a, int b);\n") File(projectDir, "build.gradle.kts").writeText( - "import org.jetbrains.kotlin.konan.target.KonanTarget\n\n" + + "import io.github.lemcoder.model.NativeTarget\nimport io.github.lemcoder.model.Executable\n\n" + "plugins {\n${plugins.trimIndent().prependIndent(" ")}\n}\n\n" + "konan {\n konanHome.set(file(\"${konanStub()}\"))\n${konan.trimIndent().prependIndent(" ")}\n}\n" ) } private val mymath = """ - val mymath = libraries.create("mymath") { targets(KonanTarget.${host.name.uppercase()}) } + val mymath = libraries.create("mymath") { targets(NativeTarget.${host.name}) } bindings { create("mymath") { defFile.set(file("mymath.def")) @@ -52,7 +53,7 @@ class KonanPluginFunctionalTest { @Test fun `a library compiles from conventions alone`() { - write("""id("io.github.lemcoder.konanplugin")""", """libraries { create("mymath") { targets(KonanTarget.MACOS_ARM64) } }""") + write("""id("io.github.lemcoder.konanplugin")""", """libraries { create("mymath") { targets(NativeTarget.MACOS_ARM64) } }""") val result = runner("compileNativeLibraryMymath", "--dry-run").build() @@ -63,7 +64,7 @@ class KonanPluginFunctionalTest { fun `a library built with zig sits behind the same task, with no konan clang`() { write( """id("io.github.lemcoder.konanplugin")""", - """libraries { create("mymath") { targets(KonanTarget.MACOS_ARM64); zig { } } }""", + """libraries { create("mymath") { targets(NativeTarget.MACOS_ARM64); zig { } } }""", ) val result = runner("compileNativeLibraryMymath", "--dry-run").build() @@ -80,10 +81,10 @@ class KonanPluginFunctionalTest { """ libraries { create("mymath") { - targets(KonanTarget.LINUX_X64, KonanTarget.MINGW_X64) + targets(NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) external { step { - executable.set(io.github.lemcoder.dsl.Executable.OnPath("sh")) + executable.set(Executable.OnPath("sh")) arguments.add("build.sh") workingDirectory.set(layout.projectDirectory) } @@ -119,8 +120,8 @@ class KonanPluginFunctionalTest { libraries { create("mymath") { prebuilt { - archive(KonanTarget.LINUX_X64, file("libs/libmymath-linux.a")) - archive(KonanTarget.MINGW_X64, file("libs/mymath.lib")) + archive(NativeTarget.LINUX_X64, file("libs/libmymath-linux.a")) + archive(NativeTarget.MINGW_X64, file("libs/mymath.lib")) } } } @@ -141,15 +142,15 @@ class KonanPluginFunctionalTest { """ libraries { create("mymath") { - targets(KonanTarget.LINUX_X64, KonanTarget.MACOS_ARM64) - prebuilt { archive(KonanTarget.LINUX_X64, file("libs/absent.a")) } + targets(NativeTarget.LINUX_X64, NativeTarget.MACOS_ARM64) + prebuilt { archive(NativeTarget.LINUX_X64, file("libs/absent.a")) } } } """, ) assertTrue("has no archive for macos_arm64" in runner("compileNativeLibraryMymath").buildAndFail().output) - File(projectDir, "build.gradle.kts").writeText(File(projectDir, "build.gradle.kts").readText().replace(", KonanTarget.MACOS_ARM64", "")) + File(projectDir, "build.gradle.kts").writeText(File(projectDir, "build.gradle.kts").readText().replace(", NativeTarget.MACOS_ARM64", "")) assertTrue("no such archive" in runner("compileNativeLibraryMymath").buildAndFail().output) } @@ -176,8 +177,8 @@ class KonanPluginFunctionalTest { """ libraries { create("mymath") { - targets(KonanTarget.MACOS_ARM64, KonanTarget.LINUX_X64, KonanTarget.MINGW_X64) - zig { executable.set(io.github.lemcoder.dsl.Executable.File(file("${zig.replace("\\", "/")}"))) } + targets(NativeTarget.MACOS_ARM64, NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) + zig { executable.set(Executable.File(file("${zig.replace("\\", "/")}"))) } } } """, @@ -216,14 +217,14 @@ class KonanPluginFunctionalTest { fun `JNI targets default to the library's that a JVM can load`() { write( """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", - mymath.replace("targets(KonanTarget.${host.name.uppercase()})", "targets(KonanTarget.${host.name.uppercase()}, KonanTarget.ANDROID_ARM64, KonanTarget.LINUX_ARM64)"), + mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.${host.name}, NativeTarget.ANDROID_ARM64, NativeTarget.LINUX_ARM64)"), ) val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() assertTrue(":linkJniMymath$hostSuffix SKIPPED" in result.output, result.output) assertTrue(":linkJniMymathAndroid_arm64 SKIPPED" in result.output, result.output) - if (host.name != "linux_arm64") assertFalse("linkJniMymathLinux_arm64" in result.output, "not loadable here:\n${result.output}") + if (host != NativeTarget.LINUX_ARM64) assertFalse("linkJniMymathLinux_arm64" in result.output, "not loadable here:\n${result.output}") } @Test @@ -277,7 +278,7 @@ class KonanPluginFunctionalTest { runner("linkJniMymath").withPluginClasspathAndKgp().build() - val contract = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.name}/contract.txt").readText().split('|') + val contract = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.konanName}/contract.txt").readText().split('|') assertTrue(contract[0].isNotEmpty(), "library name: $contract") assertTrue(File(contract[1]).isDirectory, "stub directory: $contract") } @@ -294,11 +295,11 @@ class KonanPluginFunctionalTest { jni { external { step { - executable.set(io.github.lemcoder.dsl.Executable.File(file("/bin/sh"))) + executable.set(Executable.File(file("/bin/sh"))) arguments.add("build.sh") workingDirectory.set(layout.projectDirectory) } - producedLibrary(KonanTarget.${host.name.uppercase()}, file("out/built")) + producedLibrary(NativeTarget.${host.name}, file("out/built")) } } } @@ -313,10 +314,10 @@ class KonanPluginFunctionalTest { val result = runner("linkJniMymath").withPluginClasspathAndKgp().build() assertEquals(TaskOutcome.SUCCESS, result.task(":externalBuildMymath$hostSuffix")?.outcome, result.output) - val libraries = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.name}").listFiles().orEmpty() + val libraries = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.konanName}").listFiles().orEmpty() val library = libraries.singleOrNull() ?: error("expected one library, got ${libraries.toList()}") val (target, name, stubDirectory) = library.readText().split('|') - assertEquals(host.name, target) + assertEquals(host.konanName, target) assertTrue(library.name.contains(name), "${library.name} is named after $name") assertTrue(File(stubDirectory).isDirectory, "stub directory: $stubDirectory") } @@ -336,9 +337,9 @@ class KonanPluginFunctionalTest { @Test fun `a multiplatform project binds through KGP's cinterop, after compiling the library`() { - assumeTrue(PinnedKotlinNative.home() != null, "Kotlin/Native ${PinnedKotlinNative.version} is not installed") + assumeTrue(KonanDistribution.pinned() != null, "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed") // KMP's DSL names targets in camelCase: macos_arm64 -> macosArm64. - val dslName = host.name.split('_').mapIndexed { i, part -> if (i == 0) part else part.replaceFirstChar { it.uppercase() } }.joinToString("") + val dslName = host.konanName.split('_').mapIndexed { i, part -> if (i == 0) part else part.replaceFirstChar { it.uppercase() } }.joinToString("") val targetSuffix = dslName.replaceFirstChar { it.uppercase() } File(projectDir, "native").mkdirs() @@ -351,7 +352,7 @@ class KonanPluginFunctionalTest { write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) // The real distribution, not the stub: this one compiles. File(projectDir, "build.gradle.kts").appendText( - "\nkonan.konanHome.set(file(\"${PinnedKotlinNative.home()!!.absolutePath.replace("\\", "/")}\"))\n" + + "\nkonan.konanHome.set(file(\"${KonanDistribution.pinned()!!.home.absolutePath.replace("\\", "/")}\"))\n" + "kotlin { $dslName() }\nrepositories { mavenCentral() }\n" ) File(projectDir, "mymath.def").writeText("headers = mymath.h\npackage = example\n") diff --git a/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt index 186423b..6ff1925 100644 --- a/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.index +import io.github.lemcoder.model.BindingVisibility import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -9,7 +10,7 @@ class ExporterProtocolTest { private val requests = listOf( GeneratorRequest.Dump, GeneratorRequest.Jni(stubLibrary = "examplestubs"), - GeneratorRequest.Jni(stubLibrary = "examplestubs", visibility = GeneratedVisibility.PUBLIC), + GeneratorRequest.Jni(stubLibrary = "examplestubs", visibility = BindingVisibility.PUBLIC), GeneratorRequest.Jextract(library = "mymath"), GeneratorRequest.Jextract(library = "mymath", jextract = File("/opt/jextract/bin/jextract"), headerClass = "MyMath"), ) diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index 24528f7..2806c0d 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.index +import io.github.lemcoder.kotlin.KonanDistribution import org.gradle.testkit.runner.GradleRunner import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File @@ -13,7 +14,7 @@ import kotlin.test.assertTrue * distribution, then the `dump` generator over the index it built. * * Skipped when that distribution is not installed; any Kotlin Multiplatform build of - * [PinnedKotlinNative.version] downloads it. + * [KonanDistribution.pinnedVersion] downloads it. */ class GenerateFromNativeIndexTest { @@ -94,7 +95,7 @@ class GenerateFromNativeIndexTest { } private fun assumeInstalled() = - assumeTrue(PinnedKotlinNative.home() != null, "Kotlin/Native ${PinnedKotlinNative.version} is not installed") + assumeTrue(KonanDistribution.pinned() != null, "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed") private companion object { val sampleDef = File(checkNotNull(GenerateFromNativeIndexTest::class.java.classLoader.getResource("nativeindex/sample.def")).toURI()) @@ -117,7 +118,7 @@ class GenerateFromNativeIndexTest { tasks.register("generate_$name") { defFile.set(file("${path(def)}")) includeDirs.from("${path(includeDir)}") - target.set(hostKonanTarget()) + target.set(NativeTarget.host) this.request.set(${kotlinSource(request)}) outputDirectory.set(layout.buildDirectory.dir("generated/$name")) } @@ -127,7 +128,7 @@ class GenerateFromNativeIndexTest { fun kotlinSource(request: GeneratorRequest): String = when (request) { GeneratorRequest.Dump -> "GeneratorRequest.Dump" is GeneratorRequest.Jni -> - "GeneratorRequest.Jni(stubLibrary = \"${request.stubLibrary}\", visibility = GeneratedVisibility.${request.visibility})" + "GeneratorRequest.Jni(stubLibrary = \"${request.stubLibrary}\", visibility = BindingVisibility.${request.visibility})" is GeneratorRequest.Jextract -> "GeneratorRequest.Jextract(library = \"${request.library}\", jextract = file(\"${request.jextract?.let(::path)}\"))" } @@ -137,9 +138,9 @@ class GenerateFromNativeIndexTest { projectDir.resolve("settings.gradle.kts").writeText("""rootProject.name = "native-index"""") projectDir.resolve("build.gradle.kts").writeText( (listOf( - "import io.github.lemcoder.hostKonanTarget", + "import io.github.lemcoder.model.NativeTarget", "import io.github.lemcoder.index.GenerateFromNativeIndexTask", - "import io.github.lemcoder.index.GeneratedVisibility", + "import io.github.lemcoder.model.BindingVisibility", "import io.github.lemcoder.index.GeneratorRequest", "plugins { id(\"io.github.lemcoder.konanplugin\") }", ) + tasks).joinToString("\n") diff --git a/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt b/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt new file mode 100644 index 0000000..b7c55c7 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt @@ -0,0 +1,41 @@ +package io.github.lemcoder.model + +import io.github.lemcoder.kotlin.of +import org.jetbrains.kotlin.konan.target.Family +import org.jetbrains.kotlin.konan.target.HostManager +import org.jetbrains.kotlin.konan.target.KonanTarget +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The plugin's targets against Kotlin/Native's, for the version the plugin is built against. A Kotlin + * bump that adds or retires a target fails here, naming it β€” add or remove the [NativeTarget] entry. + */ +class NativeTargetTest { + private val konanTargets = KonanTarget.predefinedTargets.values + + @Test + fun `every Kotlin-Native target has a NativeTarget, and no other`() { + assertEquals(konanTargets.map { it.name }.sorted(), NativeTarget.entries.map { it.konanName }.sorted()) + } + + @Test + fun `families, architectures and library names agree with Kotlin-Native's`() { + konanTargets.forEach { konan -> + val target = NativeTarget.of(konan) + assertEquals(konan.family.name, target.family.name, "family of ${konan.name}") + assertEquals(konan.architecture.name, target.architecture.name, "architecture of ${konan.name}") + assertEquals( + "${konan.family.dynamicPrefix}x.${konan.family.dynamicSuffix}", + target.sharedLibraryName("x"), + "shared library of ${konan.name}", + ) + assertEquals(konan.family == Family.ANDROID, target.androidAbi != null, "Android ABI of ${konan.name}") + } + } + + @Test + fun `the host is Kotlin-Native's host`() { + assertEquals(HostManager.host.name, NativeTarget.host.konanName) + } +} From c58c98403ed4d9a3b49d38e287892ab15b470b08 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 19:23:20 +0200 Subject: [PATCH 19/57] feat: FFM bindings on Android through PanamaPort `ffm()` in an Android module now replaces JNI there as it does in kotlin("jvm"): jextract's bindings are moved from java.lang.foreign onto PanamaPort's com.v7878.foreign (and its VarHandle), PanamaPort is added as a dependency (ffm.panamaPortVersion, default v0.1.5), and the library is linked as lib.so per 64-bit ABI into jniLibs with no stub. - GeneratorRequest.Jextract carries an FfmApi (JAVA, PANAMA_PORT); the exporter rewrites the packages for PANAMA_PORT. - FFM links are per target (linkFfm) under a linkFfm lifecycle task. - AgpWiring handles Kotlin or Java sources, and its registrations take an enabled check evaluated inside onVariants, so JNI's Android wiring steps aside when FFM replaces it. - examples/android-ffm runs end to end on an emulator. Apps need compileSdk 37 and minSdk 26 (PanamaPort) and Java 21 source (jextract). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 + README.md | 20 +- doc/interop-architecture.md | 3 +- examples/README.md | 1 + examples/android-ffm/README.md | 33 +++ examples/android-ffm/build.gradle.kts | 42 +++ examples/android-ffm/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + examples/android-ffm/gradlew | 252 ++++++++++++++++++ examples/android-ffm/native/mymath.c | 18 ++ examples/android-ffm/native/mymath.h | 13 + examples/android-ffm/settings.gradle.kts | 21 ++ .../android-ffm/src/main/AndroidManifest.xml | 17 ++ .../kotlin/com/example/ffmapp/MainActivity.kt | 38 +++ .../src/main/nativeInterop/mymath.def | 3 + .../github/lemcoder/index/ExporterProtocol.kt | 21 +- .../index/exporter/JextractGenerator.kt | 22 +- .../github/lemcoder/consumer/FfmConsumer.kt | 126 +++++++-- .../github/lemcoder/consumer/JniConsumer.kt | 17 +- .../kotlin/io/github/lemcoder/dsl/FfmSpec.kt | 12 +- .../io/github/lemcoder/wiring/AgpWiring.kt | 59 ++-- .../lemcoder/KonanPluginFunctionalTest.kt | 1 + .../lemcoder/index/ExporterProtocolTest.kt | 1 + 24 files changed, 680 insertions(+), 61 deletions(-) create mode 100644 examples/android-ffm/README.md create mode 100644 examples/android-ffm/build.gradle.kts create mode 100644 examples/android-ffm/gradle.properties create mode 100644 examples/android-ffm/gradle/wrapper/gradle-wrapper.jar create mode 100644 examples/android-ffm/gradle/wrapper/gradle-wrapper.properties create mode 100755 examples/android-ffm/gradlew create mode 100644 examples/android-ffm/native/mymath.c create mode 100644 examples/android-ffm/native/mymath.h create mode 100644 examples/android-ffm/settings.gradle.kts create mode 100644 examples/android-ffm/src/main/AndroidManifest.xml create mode 100644 examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt create mode 100644 examples/android-ffm/src/main/nativeInterop/mymath.def diff --git a/CLAUDE.md b/CLAUDE.md index d6fc218..9ea9cc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,18 @@ not used β€” it emits address-only bridges, every pointer a raw `Long`. Bindings are `internal` by default and each carries **`@JvmName`**. JNI resolves the C symbol from the unmangled method name, so a mangled internal name compiles, links, and dies on the first call. +## FFM on Android + +- **jextract's output only needs its packages moved** onto PanamaPort: `java.lang.foreign` β†’ + `com.v7878.foreign`, `VarHandle` β†’ `com.v7878.invoke.VarHandle` (a single-type import beside + jextract's `java.lang.invoke.*`). The exporter does it for `FfmApi.PANAMA_PORT`. +- PanamaPort's AAR metadata **requires `compileSdk` 37**; jextract's output **needs Java 21 source** + (switch patterns, which D8 desugars). Neither is the plugin's to set. +- **One index, the host's**: the generated layouts are LP64, so Android FFM links only 64-bit ABIs. +- **`ffm()` decides consumers after configuration**, but AGP callbacks are registered eagerly β€” so + `AgpWiring` takes an `enabled` check evaluated inside `onVariants`, where configure blocks have run. + JNI's Android wiring is gated the same way, since FFM replaces it. + ## Kotlin DSL gotchas - The `kotlin-dsl` plugin adds a `T.() -> Unit` overload of `whenObjectAdded`, making the SAM form diff --git a/README.md b/README.md index 540676b..1cdc784 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and - **Kotlin/Native** targets through KGP's own cinterop, the library's archive embedded in the klib; - **JVM** targets and **Android** through generated JNI bindings and a stub library per target, with strings and primitive buffers marshalled; - - the **JVM 22+** through jextract's FFM bindings, on request. + - the **JVM 22+** through jextract's FFM bindings, on request β€” and **Android** too, over + [PanamaPort](https://github.com/vova7878/PanamaPort)'s backport of the FFM API. - Has its own target type, `NativeTarget`, one entry per Kotlin/Native target; a KGP target converts with `NativeTarget.of(kotlinNativeTarget.konanTarget)`. The plugin's API does not depend on the Kotlin compiler's. @@ -60,6 +61,7 @@ A binding is consumed wherever the project's Kotlin targets need it, with nothin | Kotlin/Native targets (`kotlin("multiplatform")`) | a cinterop klib per target the library is built for | KGP's own `cinterops {}`, with the library's archive embedded | | `kotlin("jvm")`, multiplatform `jvm()` or Android targets, or an Android app | JNI bindings in the main sources, a JNI library per target | the plugin's JNI generator and link | | `ffm()` on the binding, in a `kotlin("jvm")` project | FFM bindings in place of JNI, the library as a shared library | jextract | +| `ffm()` on the binding, in an Android module | FFM bindings on PanamaPort in place of JNI, the library as `.so` per 64-bit ABI | jextract | ### `libraries { }` A C/C++ library built to `//lib.a` for each target: by Kotlin/Native's @@ -301,8 +303,22 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. | Property | Type | Default | Description | |--------------------|-------------|----------------------|-------------| | `jextract` | `Executable`| `OnPath("jextract")` | The jextract program. | +| `panamaPortVersion`| `String` | `v0.1.5` | The PanamaPort an Android module's bindings use, added as a dependency. | | `linkerOptions` | `List` | β€” | Extra options for linking the library the bindings load. | -| `libraryDirectory` | `Directory` | output | Where the shared library ends up. | +| `libraryDirectory` | `Directory` | output | Where the shared library ends up: the host's, or the `jniLibs` root on Android. | + +#### FFM on Android +In an Android module `ffm()` replaces JNI there too. jextract's output is moved from `java.lang.foreign` +onto PanamaPort's `com.v7878.foreign` β€” the FFM API backported to Android 8.0+ β€” which the plugin adds +as an `implementation` dependency, and the library is linked as `lib.so` per ABI into `jniLibs/`, +with no stub. The app needs: + +- `compileSdk` 37 and `minSdk` 26, which PanamaPort requires; +- `sourceCompatibility` 21: jextract's output uses pattern matching in `switch`, which D8 desugars; +- 64-bit ABIs only: jextract writes the build host's LP64 layouts, so 32-bit ABIs get no library and + a warning. + +`examples/android-ffm` runs end to end on an emulator. ## Tasks | Task | Description | diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index ee7027b..ea26f09 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -330,7 +330,8 @@ take the library only as `NativeArtifacts`. A new consumer is one more entry in | Kotlin/Native | Static archive, embedded in the klib (`-staticLibrary` + `-libraryPath`) | None | KGP's `cinterops {}` on each target's main compilation, declared by the adapter | | JVM (JNI) | Shared library: stub + archive | The JNI stub | Generated Kotlin as sources; the library on `java.library.path` or in resources | | JVM (FFM) | Shared library of the C code itself (`linkFfm`: the archive linked whole) | None | Generated Java in the main Java sources; the build supplies a JDK 22+ toolchain, `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path` | -| Android | Shared library: stub + archive, per ABI | The JNI stub | Generated Kotlin as sources; `jniLibs/` via the variant API. No FFM on Android | +| Android | Shared library: stub + archive, per ABI | The JNI stub | Generated Kotlin as sources; `jniLibs/` via the variant API | +| Android (FFM) | Shared library of the C code itself, per 64-bit ABI (`linkFfm`) | None | jextract's Java moved onto PanamaPort (`com.v7878.foreign`, its own `VarHandle`) β€” the generator's `FfmApi.PANAMA_PORT` β€” as Java sources via the variant API; PanamaPort added as a dependency; `jniLibs/`. Needs `compileSdk` 37, `minSdk` 26, Java 21 source. One host index, so only LP64 ABIs | Gaps that remain: diff --git a/examples/README.md b/examples/README.md index 1953574..a85ac8c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,6 +8,7 @@ and gets the binding its Kotlin targets need: |---|---|---|---| | [`jvm/`](jvm) | a library (.a) + a binding β†’ JNI bindings and library | full JNI path, end to end | `../../gradlew -p jvm run` | | [`android/`](android) | same, cross-compiled per ABI, real app | APK with `.so` (arm64-v8a + x86_64) + `MainActivity` | `cd android && ./gradlew installDebug` | +| [`android-ffm/`](android-ffm) | same library, FFM instead of JNI: jextract's bindings on PanamaPort, `libmymath.so` per ABI, no stub | FFM on Android, end to end on an emulator | `cd android-ffm && ./gradlew installDebug` | | [`native/`](native) | a library (.a) + a binding β†’ KGP's cinterop | the native path | `../../gradlew -p native runDebugExecutableMacosArm64` | `jvm` and `native` build the plugin from source via `includeBuild`. `android` needs AGP 9 (Gradle 9.4, diff --git a/examples/android-ffm/README.md b/examples/android-ffm/README.md new file mode 100644 index 0000000..e2855b3 --- /dev/null +++ b/examples/android-ffm/README.md @@ -0,0 +1,33 @@ +# Android FFM example + +An Android app that calls `native/mymath.c` through **FFM** bindings instead of JNI. The plugin: + +- cross-compiles `native/*.c` to `libmymath.a` per ABI; +- runs jextract over the same `.def` the JNI and Kotlin/Native bindings use, and moves its output from + `java.lang.foreign` onto [PanamaPort](https://github.com/vova7878/PanamaPort)'s `com.v7878.foreign`, + the FFM API backported to Android 8.0+, which it adds as a dependency; +- links the library itself as `libmymath.so` per ABI into `jniLibs/` β€” FFM needs no stub. + +## Requirements + +- jextract on the `PATH` (or `ffm { jextract.set(Executable.File(file(…))) }`). +- `compileSdk` 37 and `minSdk` 26, which PanamaPort requires; Java 21 source, which jextract's output uses. +- 64-bit ABIs: jextract writes the build host's LP64 layouts, so 32-bit ABIs get no library. + +## Run + +```bash +./gradlew installDebug +adb shell am start -n com.example.ffmapp/.MainActivity +adb logcat -s KonanFfm +``` + +``` +FFM via PanamaPort: +my_add(2, 3) = 5 +my_scale(4.0) = 40.0 +my_length("kotlin") = 6 +my_greeting() = hello from C +my_sum([1, 2, 3, 4]) = 10 +my_fill -> [7, 7, 7] +``` diff --git a/examples/android-ffm/build.gradle.kts b/examples/android-ffm/build.gradle.kts new file mode 100644 index 0000000..d576d84 --- /dev/null +++ b/examples/android-ffm/build.gradle.kts @@ -0,0 +1,42 @@ +// An Android app that calls a C library through FFM bindings: jextract writes them from the same .def, +// the plugin moves them onto PanamaPort (the FFM API backported to Android 8.0+) and links the library +// itself as libmymath.so per ABI β€” no JNI stub. Needs jextract on the PATH (or ffm { jextract.set(…) }). + +import io.github.lemcoder.model.NativeTarget + +plugins { + id("com.android.application") + id("io.github.lemcoder.konanplugin") +} + +konan { + // 64-bit ABIs only: jextract writes the host's LP64 layouts. + val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } + bindings { + create("mymath") { + defFile.set(file("src/main/nativeInterop/mymath.def")) + includeDirs.from("native") + library(mymath) + ffm() // in an Android module: PanamaPort bindings in place of JNI + } + } +} + +android { + namespace = "com.example.ffmapp" + compileSdk = 37 // PanamaPort's AAR metadata requires it + + defaultConfig { + applicationId = "com.example.ffmapp" + minSdk = 26 // PanamaPort's floor + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + compileOptions { + // jextract's output uses pattern matching in switch. + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} diff --git a/examples/android-ffm/gradle.properties b/examples/android-ffm/gradle.properties new file mode 100644 index 0000000..1edd5f5 --- /dev/null +++ b/examples/android-ffm/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true diff --git a/examples/android-ffm/gradle/wrapper/gradle-wrapper.jar b/examples/android-ffm/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/examples/android-ffm/gradle/wrapper/gradle-wrapper.properties b/examples/android-ffm/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/examples/android-ffm/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/android-ffm/gradlew b/examples/android-ffm/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/examples/android-ffm/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright Β© 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β», +# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»; +# * compound commands having a testable exit status, especially Β«caseΒ»; +# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/examples/android-ffm/native/mymath.c b/examples/android-ffm/native/mymath.c new file mode 100644 index 0000000..b2969f0 --- /dev/null +++ b/examples/android-ffm/native/mymath.c @@ -0,0 +1,18 @@ +#include +#include "mymath.h" + +int my_add(int a, int b) { return a + b; } +double my_scale(double x) { return x * 10.0; } + +int my_length(const char* text) { return text ? (int)strlen(text) : -1; } +const char* my_greeting(void) { return "hello from C"; } + +int my_sum(const int* values, int count) { + int total = 0; + for (int i = 0; i < count; i++) total += values[i]; + return total; +} + +void my_fill(int* out, int count, int value) { + for (int i = 0; i < count; i++) out[i] = value; +} diff --git a/examples/android-ffm/native/mymath.h b/examples/android-ffm/native/mymath.h new file mode 100644 index 0000000..6c65efe --- /dev/null +++ b/examples/android-ffm/native/mymath.h @@ -0,0 +1,13 @@ +#ifndef MYMATH_H +#define MYMATH_H + +int my_add(int a, int b); +double my_scale(double x); + +/* Marshalled shapes: strings both ways, a read-only buffer, and one the callee writes into. */ +int my_length(const char* text); +const char* my_greeting(void); +int my_sum(const int* values, int count); +void my_fill(int* out, int count, int value); + +#endif diff --git a/examples/android-ffm/settings.gradle.kts b/examples/android-ffm/settings.gradle.kts new file mode 100644 index 0000000..3a4e98a --- /dev/null +++ b/examples/android-ffm/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } + plugins { + id("com.android.application") version "9.2.1" + } + // The konan plugin, built from this checkout. + includeBuild("../..") +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() // PanamaPort + } +} + +rootProject.name = "android-ffm-example" diff --git a/examples/android-ffm/src/main/AndroidManifest.xml b/examples/android-ffm/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5c3488e --- /dev/null +++ b/examples/android-ffm/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt b/examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt new file mode 100644 index 0000000..8daecdc --- /dev/null +++ b/examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt @@ -0,0 +1,38 @@ +package com.example.ffmapp + +import android.app.Activity +import android.os.Bundle +import android.util.Log +import android.view.Gravity +import android.widget.TextView +import com.v7878.foreign.Arena +import com.v7878.foreign.ValueLayout.JAVA_INT +import example.example as mymath + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Calls into C through jextract's FFM bindings on PanamaPort (libmymath.so, no JNI stub). + val text = buildString { + appendLine("FFM via PanamaPort:") + appendLine("my_add(2, 3) = ${mymath.my_add(2, 3)}") + appendLine("my_scale(4.0) = ${mymath.my_scale(4.0)}") + Arena.ofConfined().use { arena -> + appendLine("my_length(\"kotlin\") = ${mymath.my_length(arena.allocateFrom("kotlin"))}") + appendLine("my_greeting() = ${mymath.my_greeting().reinterpret(Long.MAX_VALUE).getString(0)}") + appendLine("my_sum([1, 2, 3, 4]) = ${mymath.my_sum(arena.allocateFrom(JAVA_INT, 1, 2, 3, 4), 4)}") + val buffer = arena.allocate(JAVA_INT, 3) + mymath.my_fill(buffer, 3, 7) + appendLine("my_fill -> ${buffer.toArray(JAVA_INT).toList()}") + } + } + Log.i("KonanFfm", text) + + setContentView(TextView(this).apply { + this.text = text + textSize = 18f + gravity = Gravity.CENTER + }) + } +} diff --git a/examples/android-ffm/src/main/nativeInterop/mymath.def b/examples/android-ffm/src/main/nativeInterop/mymath.def new file mode 100644 index 0000000..4acf40c --- /dev/null +++ b/examples/android-ffm/src/main/nativeInterop/mymath.def @@ -0,0 +1,3 @@ +headers = mymath.h +headerFilter = mymath.h +package = example diff --git a/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt index 1117ef0..0a9fec5 100644 --- a/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt +++ b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt @@ -28,9 +28,23 @@ sealed class GeneratorRequest : Serializable { * @property library the shared library the bindings `System.loadLibrary`. * @property jextract the jextract executable; `jextract` on the PATH when null. * @property headerClass the class holding the functions; the package's last segment when null. + * @property api the FFM API the bindings are written against. */ - data class Jextract(val library: String, val jextract: File? = null, val headerClass: String? = null) : - GeneratorRequest() + data class Jextract( + val library: String, + val jextract: File? = null, + val headerClass: String? = null, + val api: FfmApi = FfmApi.JAVA, + ) : GeneratorRequest() +} + +/** Which implementation of the Foreign Function & Memory API generated FFM bindings call. */ +enum class FfmApi { + /** The JDK's own `java.lang.foreign`, JDK 22+. */ + JAVA, + + /** PanamaPort's backport for Android 8.0+: `com.v7878.foreign`, with its own `VarHandle`. */ + PANAMA_PORT, } /** @@ -56,6 +70,7 @@ data class ExporterInvocation( flag(Flag.LIBRARY, request.library) request.jextract?.let { flag(Flag.JEXTRACT, it.absolutePath) } request.headerClass?.let { flag(Flag.HEADER_CLASS, it) } + flag(Flag.FFM_API, request.api) } } add(SEPARATOR) @@ -86,6 +101,7 @@ data class ExporterInvocation( library = required(Flag.LIBRARY), jextract = values[Flag.JEXTRACT]?.let(::File), headerClass = values[Flag.HEADER_CLASS], + api = FfmApi.valueOf(required(Flag.FFM_API)), ) } return ExporterInvocation(request, File(required(Flag.OUTPUT)), args.copyOfRange(separator + 1, args.size).toList()) @@ -112,5 +128,6 @@ data class ExporterInvocation( LIBRARY("--library"), JEXTRACT("--jextract"), HEADER_CLASS("--header-class"), + FFM_API("--ffm-api"), } } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt index 9975d7f..22be089 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.index.exporter +import io.github.lemcoder.index.FfmApi import io.github.lemcoder.index.GeneratorRequest import java.io.File import java.nio.file.Files @@ -15,8 +16,11 @@ import java.nio.file.Files * Only the user-level `-I`, `-D` and `-U` options reach jextract: it brings its own clang and builtin * headers, so the target flags cinterop parsed with do not apply. * - * The bindings load the request's library with `System.loadLibrary`, so from `java.library.path`; see - * [GeneratorRequest.Jextract] for the rest. + * The bindings load the request's library with `System.loadLibrary`: from `java.library.path` on a JVM, + * from the APK on Android. See [GeneratorRequest.Jextract] for the rest. + * + * For [FfmApi.PANAMA_PORT] the output is moved onto PanamaPort's packages, which is all its author says + * jextract's output needs: `java.lang.foreign` becomes `com.v7878.foreign`, and `VarHandle` its own. */ object JextractGenerator : BindingGenerator { override fun generate(request: GeneratorRequest.Jextract, context: GenerationContext, outputDirectory: File) { @@ -46,11 +50,25 @@ object JextractGenerator : BindingGenerator { val process = ProcessBuilder(command).redirectErrorStream(true).start() val log = process.inputStream.bufferedReader().readText() check(process.waitFor() == 0) { "jextract failed:\n${command.joinToString(" ")}\n$log" } + when (request.api) { + FfmApi.JAVA -> {} + FfmApi.PANAMA_PORT -> outputDirectory.resolve("java").walk().filter { it.extension == "java" }.forEach(::portToPanamaPort) + } } finally { scratch.deleteRecursively() } } + private fun portToPanamaPort(source: File) { + source.writeText( + source.readText() + .replace("java.lang.foreign.", "com.v7878.foreign.") + .replace("java.lang.invoke.VarHandle", "com.v7878.invoke.VarHandle") + // A single-type import wins over the java.lang.invoke.* wildcard jextract writes. + .replace("import java.lang.invoke.*;", "import java.lang.invoke.*;\nimport com.v7878.invoke.VarHandle;") + ) + } + /** * `--include-*` filters for everything cinterop's index holds, as (option, name). * diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt index f52687e..7b5173e 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt @@ -1,30 +1,63 @@ package io.github.lemcoder.consumer +import io.github.lemcoder.index.FfmApi import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.link.LinkSharedLibraryTask +import io.github.lemcoder.model.Architecture +import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import io.github.lemcoder.toolchain.command +import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId +import io.github.lemcoder.wiring.SourceKind +import org.gradle.api.Task +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.TaskProvider import java.io.File /** - * FFM bindings: jextract over the same `.def`, and the library itself as a shared library for the host β€” - * FFM loads it directly, with no stub. Opted into with `ffm { }`; in a `kotlin("jvm")` project it + * FFM bindings: jextract over the same `.def`, and the library itself as a shared library β€” FFM loads it + * directly, with no stub. Opted into with `ffm()`; in a `kotlin("jvm")` project or an Android module it * replaces JNI. + * + * On a JVM the bindings use the JDK's `java.lang.foreign` and the library is linked for the host. In an + * Android module they use PanamaPort's backport, which the plugin adds as a dependency, and the library + * is linked for each 64-bit Android ABI into `jniLibs/`. */ internal class FfmConsumer(private val context: BindingContext) : BindingConsumer { - override fun isUsed(): Boolean = context.binding.ffm.isEnabled + private val project = context.project + private val binding = context.binding + private val bindingName = context.bindingName + + private val generatedRoot = context.buildDirectory.dir("generated/konan/$bindingName/ffm") + private val jniLibsRoot = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/jniLibs") + + private lateinit var linkAll: TaskProvider + + private val isAndroid get() = context.hasPlugin(GradlePluginId.ANDROID_BASE) + + override fun prepare() { + linkAll = project.tasks.register(taskName("linkFfm", bindingName)) { + group = "konan" + description = "Make the shared library the '$bindingName' FFM bindings load exist for every target." + } + // Eager, as the variant API needs; whether FFM is on is only known in the callbacks. linkFfm + // depends on generation, so it orders both the compile and the packaging. + val enabled = { binding.ffm.isEnabled } + AgpWiring.addSources(project, SourceKind.JAVA, generatedRoot.map { it.dir("java") }, linkAll, linkAll, enabled) + AgpWiring.addJniLibs(project, jniLibsRoot, linkAll, enabled) + } + + override fun isUsed(): Boolean = binding.ffm.isEnabled override fun register() { - val project = context.project - val binding = context.binding - val bindingName = context.bindingName val artifacts = context.artifacts() val libraryName = artifacts?.name ?: bindingName - val host = NativeTarget.host + val api = if (isAndroid) FfmApi.PANAMA_PORT else FfmApi.JAVA val packageName = context.packageName val generate = project.tasks.register(taskName("generateFfm", bindingName), GenerateFromNativeIndexTask::class.java) { group = "konan" @@ -32,36 +65,75 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume defFile.set(binding.defFile) includeDirs.from(binding.includeDirs) compilerOptions.set(binding.compilerOptions) - target.set(host) + // One index, for the host: its type layouts are those of every LP64 target, which is why Android + // gets only its 64-bit ABIs. + target.set(NativeTarget.host) // Resolved here, in the daemon, which knows the usual install locations; unresolved, the exporter // runs `jextract` from its own PATH. request.set(binding.ffm.jextract.map { jextract -> - GeneratorRequest.Jextract(library = libraryName, jextract = File(jextract.command()).takeIf { it.isAbsolute }) + GeneratorRequest.Jextract(library = libraryName, jextract = File(jextract.command()).takeIf { it.isAbsolute }, api = api) }) packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } - outputDirectory.set(context.buildDirectory.dir("generated/konan/$bindingName/ffm")) - } - val ffmLib = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib/${host.konanName}") - binding.ffm.libraryDirectory.convention(ffmLib) - if (artifacts != null && host in artifacts.targets.getOrElse(emptyList())) { - project.tasks.register(taskName("linkFfm", bindingName), LinkSharedLibraryTask::class.java) { - group = "konan" - description = "Link the '$libraryName' library as a shared library for the FFM bindings." - dependsOn(artifacts.builtBy) - konanHome.set(context.konan.konanHome) - target.set(host) - libraryBaseName.set(libraryName) - staticLibrary.set(artifacts.archive(host)) - additionalLinkerArgs.set(binding.ffm.linkerOptions) - outputDirectory.set(ffmLib) - } + outputDirectory.set(generatedRoot) } + linkAll.configure { dependsOn(generate) } + + if (isAndroid) registerAndroid(artifacts, libraryName) else registerJvm(artifacts, libraryName) if (context.hasPlugin(GradlePluginId.KOTLIN_JVM)) { // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. project.extensions.getByType(SourceSetContainer::class.java).getByName("main") .java.srcDir(generate.map { it.outputDirectory.dir("java") }) - } else { - project.logger.info("konan.bindings.$bindingName: FFM bindings are only wired into kotlin(\"jvm\") projects; add build/generated/konan/$bindingName/ffm/java yourself.") + } else if (!isAndroid) { + project.logger.info("konan.bindings.$bindingName: FFM bindings are wired into kotlin(\"jvm\") and Android projects; add build/generated/konan/$bindingName/ffm/java yourself.") } } + + /** The host's library, for `java.library.path`. */ + private fun registerJvm(artifacts: NativeArtifacts?, libraryName: String) { + val host = NativeTarget.host + val directory = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib/${host.konanName}") + binding.ffm.libraryDirectory.convention(directory) + if (artifacts == null || host !in artifacts.targets.getOrElse(emptyList())) return + registerLink(artifacts, libraryName, host, directory) + } + + /** One library per 64-bit Android ABI, packaged from `jniLibs/`, and PanamaPort on the classpath. */ + private fun registerAndroid(artifacts: NativeArtifacts?, libraryName: String) { + binding.ffm.libraryDirectory.convention(jniLibsRoot) + project.dependencies.add("implementation", binding.ffm.panamaPortVersion.map { "$PANAMA_PORT:$it" }) + val androidTargets = artifacts?.targets?.getOrElse(emptyList())?.filter { it.isAndroid }.orEmpty() + val (supported, unsupported) = androidTargets.partition { it.architecture in LP64 } + if (unsupported.isNotEmpty()) { + project.logger.warn( + "konan.bindings.$bindingName: FFM bindings are generated for 64-bit layouts, so no library is linked for " + + "${unsupported.joinToString { it.androidAbi ?: it.konanName }}; leave those ABIs out of the app." + ) + } + supported.forEach { target -> registerLink(artifacts!!, libraryName, target, jniLibsRoot.map { it.dir(target.libraryDirectoryName) }) } + } + + private fun registerLink( + artifacts: NativeArtifacts, + libraryName: String, + target: NativeTarget, + directory: Provider, + ) { + val link = project.tasks.register(taskName("linkFfm", bindingName, target), LinkSharedLibraryTask::class.java) { + group = "konan" + description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." + dependsOn(artifacts.builtBy) + konanHome.set(context.konan.konanHome) + this.target.set(target) + libraryBaseName.set(libraryName) + staticLibrary.set(artifacts.archive(target)) + additionalLinkerArgs.set(binding.ffm.linkerOptions) + outputDirectory.set(directory) + } + linkAll.configure { dependsOn(link) } + } + + private companion object { + const val PANAMA_PORT = "io.github.vova7878.panama:Core" + val LP64 = setOf(Architecture.ARM64, Architecture.X64) + } } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt index ced5543..025c2f1 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt @@ -10,6 +10,7 @@ import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId +import io.github.lemcoder.wiring.SourceKind import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Task import org.gradle.api.tasks.TaskProvider @@ -40,19 +41,25 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume // Eager: the variant API must be wired at configuration time. project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.addKmpJvmSources(project, sources) } - AgpWiring.addKotlinSources(project, sources, generate, linkAll) - AgpWiring.addJniLibs(project, binding.jni.jniLibsRoot, linkAll) + AgpWiring.addSources(project, SourceKind.KOTLIN, sources, generate, linkAll) { !replacedByFfm } + AgpWiring.addJniLibs(project, binding.jni.jniLibsRoot, linkAll) { !replacedByFfm } // AGP looks for a `baselineProfiles` directory beside every source root, including the generated // one, by plain path β€” no task dependency. Matched by name: inert without AGP. project.tasks.matching { it.name.contains("ArtProfile") }.configureEach { dependsOn(generate) } } - /** In a `kotlin("jvm")` project FFM replaces JNI: both would define the same facade class. */ - private val isPlainJvmConsumer get() = context.hasPlugin(GradlePluginId.KOTLIN_JVM) && !binding.ffm.isEnabled + /** + * In a `kotlin("jvm")` project or an Android module, `ffm()` replaces JNI: both would define the same + * facade class. Read once the build script has run. + */ + private val replacedByFfm get() = + binding.ffm.isEnabled && (context.hasPlugin(GradlePluginId.KOTLIN_JVM) || context.hasPlugin(GradlePluginId.ANDROID_BASE)) + + private val isPlainJvmConsumer get() = context.hasPlugin(GradlePluginId.KOTLIN_JVM) && !replacedByFfm override fun isUsed(): Boolean = isPlainJvmConsumer || - context.hasPlugin(GradlePluginId.ANDROID_BASE) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || + (context.hasPlugin(GradlePluginId.ANDROID_BASE) && !replacedByFfm) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || (context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project)) override fun register() { diff --git a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt index ec7bd8d..5d2c943 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt @@ -9,17 +9,25 @@ import org.gradle.api.provider.Property abstract class FfmSpec { init { jextract.convention(Executable.OnPath("jextract")) + panamaPortVersion.convention("v0.1.5") } /** The jextract program. Defaults to `jextract` on the PATH or in the usual install locations. */ abstract val jextract: Property + /** + * The version of PanamaPort (`io.github.vova7878.panama:Core`), the FFM backport an Android module's + * bindings use; the plugin adds it as an `implementation` dependency. It needs `minSdk` 26. + */ + abstract val panamaPortVersion: Property + /** Extra options for linking the library the bindings load. */ abstract val linkerOptions: ListProperty /** - * Where the shared library the bindings load ends up, for the build host: put it on - * `java.library.path`, together with `--enable-native-access`. + * Where the shared library the bindings load ends up: for the build host, to put on + * `java.library.path` together with `--enable-native-access`; in an Android module, the `jniLibs` + * root the plugin packages. */ abstract val libraryDirectory: DirectoryProperty diff --git a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt index e8d1029..63f7780 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt @@ -8,47 +8,66 @@ import org.gradle.api.file.Directory import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider +/** The kinds of generated sources the plugin hands to AGP. */ +internal enum class SourceKind { KOTLIN, JAVA } + /** * Everything that touches the Android Gradle plugin's types, behind `plugins.withId`. The KMP Android * plugin does not apply `com.android.base`, so both ids need handling. + * + * Registration is eager β€” the variant API rejects callbacks added later β€” but each takes an [enabled] + * check, evaluated in the variant callback, after the build script's configure blocks have run. */ internal object AgpWiring { /** - * An Android module outside Kotlin Multiplatform has no Kotlin compilation to attach to, so the - * generated sources go in through the variant API β€” registered at configuration time, since the API - * rejects callbacks added later. A multiplatform module gets them through its Android target instead. + * An Android module outside Kotlin Multiplatform has no Kotlin compilation to attach to, so generated + * sources go in through the variant API. A multiplatform module gets Kotlin ones through its Android + * target instead. */ - fun addKotlinSources(project: Project, sources: Provider, generate: TaskProvider<*>, link: TaskProvider) { + fun addSources( + project: Project, + kind: SourceKind, + sources: Provider, + generate: TaskProvider<*>, + link: TaskProvider, + enabled: () -> Boolean, + ) { project.withPlugin(GradlePluginId.ANDROID_BASE) { - val relative = sources.get().asFile.apply { mkdirs() }.relativeTo(project.projectDir).path + val relative = sources.get().asFile.relativeTo(project.projectDir).path project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { variant -> - if (!project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM)) { - variant.sources.kotlin?.addStaticSourceDirectory(relative) + if (!enabled()) return@onVariants + project.file(relative).mkdirs() + when (kind) { + SourceKind.KOTLIN -> + if (!project.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM)) variant.sources.kotlin?.addStaticSourceDirectory(relative) + SourceKind.JAVA -> variant.sources.java?.addStaticSourceDirectory(relative) } } - project.tasks.matching { it.name == "preBuild" }.configureEach { dependsOn(generate, link) } + project.tasks.matching { it.name == "preBuild" }.configureEach { if (enabled()) dependsOn(generate, link) } project.tasks - .matching { (it.name.startsWith("compile") && it.name.contains("Kotlin")) || it.name == "compileJava" } - .configureEach { dependsOn(generate) } + .matching { (it.name.startsWith("compile") && (it.name.contains("Kotlin") || it.name.contains("JavaWithJavac"))) || it.name == "compileJava" } + .configureEach { if (enabled()) dependsOn(generate) } } } /** - * Packages the per-ABI libraries into the APK or AAR. The directory has to exist at configuration - * time; nothing consumes it as a task output, so packaging needs the ordering spelled out. Added once: - * a second registration fails the merge with "Duplicate resources". + * Packages the per-ABI libraries under [jniLibsRoot] into the APK or AAR. Nothing consumes the + * directory as a task output, so packaging needs the ordering spelled out. Added once per directory: + * a second registration of the same one fails the merge with "Duplicate resources". */ - fun addJniLibs(project: Project, jniLibsRoot: Provider, link: TaskProvider) { - val relative = jniLibsRoot.get().asFile.apply { mkdirs() }.relativeTo(project.projectDir).path + fun addJniLibs(project: Project, jniLibsRoot: Provider, link: TaskProvider, enabled: () -> Boolean) { + val relative = jniLibsRoot.get().asFile.relativeTo(project.projectDir).path project.withPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) { - project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java) - ?.onVariants { variant -> variant.sources.jniLibs?.addStaticSourceDirectory(relative) } + project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java)?.onVariants { variant -> + if (enabled()) variant.sources.jniLibs?.addStaticSourceDirectory(project.file(relative).apply { mkdirs() }.path) + } } project.withPlugin(GradlePluginId.ANDROID_BASE) { - project.extensions.findByType(AndroidComponentsExtension::class.java) - ?.onVariants { variant -> variant.sources.jniLibs?.addStaticSourceDirectory(relative) } + project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { variant -> + if (enabled()) variant.sources.jniLibs?.addStaticSourceDirectory(project.file(relative).apply { mkdirs() }.path) + } } project.tasks.matching { it.name.contains("JniLibFolders") || it.name.contains("MergeJniLib") } - .configureEach { dependsOn(link) } + .configureEach { if (enabled()) dependsOn(link) } } } diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 2ee8f34..a7efa86 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -333,6 +333,7 @@ class KonanPluginFunctionalTest { assertTrue(":generateFfmMymath SKIPPED" in result.output, "the Java sources come from jextract:\n${result.output}") assertTrue(":linkFfmMymath SKIPPED" in result.output, result.output) + assertTrue(":linkFfmMymath$hostSuffix SKIPPED" in result.output, "the host's library:\n${result.output}") } @Test diff --git a/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt index 6ff1925..c2fea47 100644 --- a/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt @@ -13,6 +13,7 @@ class ExporterProtocolTest { GeneratorRequest.Jni(stubLibrary = "examplestubs", visibility = BindingVisibility.PUBLIC), GeneratorRequest.Jextract(library = "mymath"), GeneratorRequest.Jextract(library = "mymath", jextract = File("/opt/jextract/bin/jextract"), headerClass = "MyMath"), + GeneratorRequest.Jextract(library = "mymath", api = FfmApi.PANAMA_PORT), ) @Test From 873240da9d89b60818fecacfd02562eeaff547c9 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 22:23:17 +0200 Subject: [PATCH 20/57] feat: FFM shared library from the project's build or prebuilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared library FFM bindings load becomes a sealed FfmLibraryBuild, shaped like JniStubBuild: KonanLink (the default, the library's archive linked whole), Prebuilt (`ffm { prebuilt { sharedLibrary(target, file) } }`) or External.Generic (`ffm { external { step { } } }`, run per target by ExternalBuildTask with the ExternalFfmVariable contract: KONAN_FFM_LIB_NAME, KONAN_FFM_OUTPUT_DIR, KONAN_TARGET, KONAN_FFM_ABI). - ffm.libraryName (default: the library's name, else the binding's) is what the bindings load, so a vendor library can keep its own name. - ffm.targets defaults to the host on a JVM and 64-bit ABIs on Android, narrowed to the library's; neither external nor prebuilt needs a library(…), and the default link without one now warns. - Library and FFM prebuilts share one PlacePrebuiltFilesTask. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 24 +++- doc/interop-architecture.md | 2 +- .../github/lemcoder/consumer/FfmConsumer.kt | 101 +++++++------- .../kotlin/io/github/lemcoder/dsl/FfmSpec.kt | 74 +++++++++- .../io/github/lemcoder/ffm/FfmLibraryBuild.kt | 126 ++++++++++++++++++ .../lemcoder/library/PrebuiltLibrary.kt | 48 +++---- .../lemcoder/model/ExternalBuildContract.kt | 15 +++ .../lemcoder/KonanPluginFunctionalTest.kt | 59 +++++++- 9 files changed, 359 insertions(+), 92 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt diff --git a/CLAUDE.md b/CLAUDE.md index 9ea9cc4..9747828 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. after evaluation (its block's conventions are set in the DSL constructors); a `BindingConsumer` (`consumer/`) has `prepare()` for the eager part, then `isUsed()` and `register()` after evaluation. - **Build systems are sealed hierarchies with a shared `External` branch** β€” `LibraryProvider` for - libraries, `JniStubBuild` for the JNI stub. Every external one is `ExternalBuildTask` per target + libraries, `JniStubBuild` for the JNI stub, `FfmLibraryBuild` for the shared library FFM loads. Every external one is `ExternalBuildTask` per target (`external/ExternalBuilds.kt`); a preset (Zig, CMake) only adds commands. Prefer a preset over a new task type. - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with diff --git a/README.md b/README.md index 1cdc784..d4ba687 100644 --- a/README.md +++ b/README.md @@ -304,9 +304,27 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. |--------------------|-------------|----------------------|-------------| | `jextract` | `Executable`| `OnPath("jextract")` | The jextract program. | | `panamaPortVersion`| `String` | `v0.1.5` | The PanamaPort an Android module's bindings use, added as a dependency. | +| `libraryName` | `String` | the library's, else the binding's | What the bindings `System.loadLibrary`. Set it to a prebuilt library's own name. | +| `targets` | `List` | the library's loadable ones, else all loadable | The host on a JVM; 64-bit ABIs on Android. | +| `external { }` | | β€” | The project's own build makes the shared library; below. | +| `prebuilt { }` | | β€” | Shared libraries built elsewhere: `sharedLibrary(target, file)`; below. | | `linkerOptions` | `List` | β€” | Extra options for linking the library the bindings load. | | `libraryDirectory` | `Directory` | output | Where the shared library ends up: the host's, or the `jniLibs` root on Android. | +#### The shared library the bindings load +FFM needs no stub: the bindings load the C library itself. By default the plugin links the binding's +library's archive into it (`linkFfm`). Instead: + +- `ffm { external { step { } } }` β€” the same block as `jni { external { } }`, run once per target by + `externalBuildFfm`. Each run gets `KONAN_FFM_LIB_NAME`, `KONAN_FFM_OUTPUT_DIR`, + `KONAN_TARGET` and `KONAN_FFM_ABI`, and writes `lib` with the platform's suffix + into the output directory, or names where it leaves it with `producedLibrary(target, file)`. +- `ffm { prebuilt { sharedLibrary(NativeTarget.ANDROID_ARM64, file("vendor/libggml.so")) } }` β€” a vendor's + libraries, placed by `prebuiltFfm`. Set `libraryName` to their own name (`"ggml"`), so libraries + that depend on them by that name still find them. + +Neither needs a `library(…)`; the bindings then come from the `.def` and its headers alone. + #### FFM on Android In an Android module `ffm()` replaces JNI there too. jextract's output is moved from `java.lang.foreign` onto PanamaPort's `com.v7878.foreign` β€” the FFM API backported to Android 8.0+ β€” which the plugin adds @@ -328,8 +346,10 @@ with no stub. The app needs: | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | | `linkJni` | Links it for one target. | -| `cmakeBuild`, `externalBuild` | The project's own build, in place of the link. | -| `generateFfm`, `linkFfm` | jextract's bindings, and the shared library they load. | +| `cmakeBuild`, `externalBuild` | The project's own build, in place of the JNI link. | +| `linkFfm`, `linkFfm` | Makes the library FFM loads exist; the plugin's link of it for one target. | +| `externalBuildFfm`, `prebuiltFfm` | The project's own build of it, or placing a prebuilt one, in place of that link. | +| `generateFfm` | jextract's bindings. | | `cinterop` | KGP's own cinterop, declared by the plugin. | On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks automatically, so diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index ea26f09..318dd08 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -110,7 +110,7 @@ flowchart TD | 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | -| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); FFM links the library itself, no glue | +| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); for FFM there is no glue, but the shared library it loads is a sealed `FfmLibraryBuild` of the same shape: **KonanLink** (the archive linked whole), **Prebuilt** (`ffm { prebuilt { sharedLibrary(…) } }`),, or **External.Generic** (`ffm { external { } }`, the `ExternalFfmVariable` contract) | | 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | `BindingConsumer`: **KotlinNativeConsumer** (the KGP adapter), **JniConsumer** (JVM, multiplatform JVM, Android; own link or CMake), **FfmConsumer** (`kotlin("jvm")` only); publications not yet (section D) | How the two paths use them: diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt index 7b5173e..7c83610 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt @@ -1,11 +1,11 @@ package io.github.lemcoder.consumer +import io.github.lemcoder.ffm.FfmLibraryInputs +import io.github.lemcoder.ffm.ffmLibraryBuildFor import io.github.lemcoder.index.FfmApi import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest -import io.github.lemcoder.link.LinkSharedLibraryTask import io.github.lemcoder.model.Architecture -import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import io.github.lemcoder.toolchain.command @@ -13,20 +13,18 @@ import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId import io.github.lemcoder.wiring.SourceKind import org.gradle.api.Task -import org.gradle.api.file.Directory -import org.gradle.api.provider.Provider import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.TaskProvider import java.io.File /** - * FFM bindings: jextract over the same `.def`, and the library itself as a shared library β€” FFM loads it - * directly, with no stub. Opted into with `ffm()`; in a `kotlin("jvm")` project or an Android module it - * replaces JNI. + * FFM bindings: jextract over the same `.def`, and the C library itself as a shared library β€” FFM loads it + * directly, with no stub β€” from the binding's [io.github.lemcoder.ffm.FfmLibraryBuild]. Opted into with + * `ffm()`; in a `kotlin("jvm")` project or an Android module it replaces JNI. * - * On a JVM the bindings use the JDK's `java.lang.foreign` and the library is linked for the host. In an + * On a JVM the bindings use the JDK's `java.lang.foreign` and the library is provided for the host. In an * Android module they use PanamaPort's backport, which the plugin adds as a dependency, and the library - * is linked for each 64-bit Android ABI into `jniLibs/`. + * is provided for each 64-bit ABI under `jniLibs/`. */ internal class FfmConsumer(private val context: BindingContext) : BindingConsumer { private val project = context.project @@ -34,29 +32,33 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume private val bindingName = context.bindingName private val generatedRoot = context.buildDirectory.dir("generated/konan/$bindingName/ffm") - private val jniLibsRoot = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/jniLibs") private lateinit var linkAll: TaskProvider private val isAndroid get() = context.hasPlugin(GradlePluginId.ANDROID_BASE) override fun prepare() { + val ffm = binding.ffm + ffm.libraryName.convention(binding.library.map { it.name }.orElse(bindingName)) + ffm.jniLibsRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/ffm/jniLibs")) + ffm.hostLibRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib")) linkAll = project.tasks.register(taskName("linkFfm", bindingName)) { group = "konan" - description = "Make the shared library the '$bindingName' FFM bindings load exist for every target." + description = "Make the shared library the '$bindingName' FFM bindings load exist for every target, however it is built." } // Eager, as the variant API needs; whether FFM is on is only known in the callbacks. linkFfm // depends on generation, so it orders both the compile and the packaging. - val enabled = { binding.ffm.isEnabled } + val enabled = { ffm.isEnabled } AgpWiring.addSources(project, SourceKind.JAVA, generatedRoot.map { it.dir("java") }, linkAll, linkAll, enabled) - AgpWiring.addJniLibs(project, jniLibsRoot, linkAll, enabled) + AgpWiring.addJniLibs(project, ffm.jniLibsRoot, linkAll, enabled) } override fun isUsed(): Boolean = binding.ffm.isEnabled override fun register() { + val ffm = binding.ffm val artifacts = context.artifacts() - val libraryName = artifacts?.name ?: bindingName + val libraryName = ffm.libraryName.get() val api = if (isAndroid) FfmApi.PANAMA_PORT else FfmApi.JAVA val packageName = context.packageName val generate = project.tasks.register(taskName("generateFfm", bindingName), GenerateFromNativeIndexTask::class.java) { @@ -70,7 +72,7 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume target.set(NativeTarget.host) // Resolved here, in the daemon, which knows the usual install locations; unresolved, the exporter // runs `jextract` from its own PATH. - request.set(binding.ffm.jextract.map { jextract -> + request.set(ffm.jextract.map { jextract -> GeneratorRequest.Jextract(library = libraryName, jextract = File(jextract.command()).takeIf { it.isAbsolute }, api = api) }) packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } @@ -78,7 +80,16 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume } linkAll.configure { dependsOn(generate) } - if (isAndroid) registerAndroid(artifacts, libraryName) else registerJvm(artifacts, libraryName) + val targets = targets(artifacts?.targets?.getOrElse(emptyList()).orEmpty()) + val builds = ffmLibraryBuildFor(binding).register(project, context.konan, binding, FfmLibraryInputs(artifacts, targets, libraryName)) + linkAll.configure { dependsOn(builds) } + + if (isAndroid) { + ffm.libraryDirectory.convention(ffm.jniLibsRoot) + project.dependencies.add("implementation", ffm.panamaPortVersion.map { "$PANAMA_PORT:$it" }) + } else { + ffm.libraryDirectory.convention(ffm.libraryDirectoryFor(NativeTarget.host)) + } if (context.hasPlugin(GradlePluginId.KOTLIN_JVM)) { // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. project.extensions.getByType(SourceSetContainer::class.java).getByName("main") @@ -88,48 +99,28 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume } } - /** The host's library, for `java.library.path`. */ - private fun registerJvm(artifacts: NativeArtifacts?, libraryName: String) { - val host = NativeTarget.host - val directory = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib/${host.konanName}") - binding.ffm.libraryDirectory.convention(directory) - if (artifacts == null || host !in artifacts.targets.getOrElse(emptyList())) return - registerLink(artifacts, libraryName, host, directory) - } - - /** One library per 64-bit Android ABI, packaged from `jniLibs/`, and PanamaPort on the classpath. */ - private fun registerAndroid(artifacts: NativeArtifacts?, libraryName: String) { - binding.ffm.libraryDirectory.convention(jniLibsRoot) - project.dependencies.add("implementation", binding.ffm.panamaPortVersion.map { "$PANAMA_PORT:$it" }) - val androidTargets = artifacts?.targets?.getOrElse(emptyList())?.filter { it.isAndroid }.orEmpty() - val (supported, unsupported) = androidTargets.partition { it.architecture in LP64 } - if (unsupported.isNotEmpty()) { + /** + * `ffm.targets`, else the library's targets the project can load, else all of those: the host on a JVM, + * the 64-bit ABIs in an Android module β€” jextract writes LP64 layouts, so 32-bit ABIs are left out. + */ + private fun targets(libraryTargets: List): List { + val loadable = if (isAndroid) NativeTarget.android.filter { it.architecture in LP64 } else listOf(NativeTarget.host) + binding.ffm.targets.getOrElse(emptyList()).takeIf { it.isNotEmpty() }?.let { declared -> + val unsupported = declared - loadable.toSet() + check(unsupported.isEmpty()) { + "konan.bindings.$bindingName.ffm.targets: ${unsupported.joinToString { it.konanName }} cannot load these bindings here; " + + "they are generated for ${loadable.joinToString { it.konanName }}." + } + return declared + } + val skipped = libraryTargets.filter { it.isAndroid && it.architecture !in LP64 } + if (isAndroid && skipped.isNotEmpty()) { project.logger.warn( - "konan.bindings.$bindingName: FFM bindings are generated for 64-bit layouts, so no library is linked for " + - "${unsupported.joinToString { it.androidAbi ?: it.konanName }}; leave those ABIs out of the app." + "konan.bindings.$bindingName: FFM bindings are generated for 64-bit layouts, so no library is provided for " + + "${skipped.joinToString { it.androidAbi ?: it.konanName }}; leave those ABIs out of the app." ) } - supported.forEach { target -> registerLink(artifacts!!, libraryName, target, jniLibsRoot.map { it.dir(target.libraryDirectoryName) }) } - } - - private fun registerLink( - artifacts: NativeArtifacts, - libraryName: String, - target: NativeTarget, - directory: Provider, - ) { - val link = project.tasks.register(taskName("linkFfm", bindingName, target), LinkSharedLibraryTask::class.java) { - group = "konan" - description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." - dependsOn(artifacts.builtBy) - konanHome.set(context.konan.konanHome) - this.target.set(target) - libraryBaseName.set(libraryName) - staticLibrary.set(artifacts.archive(target)) - additionalLinkerArgs.set(binding.ffm.linkerOptions) - outputDirectory.set(directory) - } - linkAll.configure { dependsOn(link) } + return libraryTargets.filter { it in loadable }.ifEmpty { loadable } } private companion object { diff --git a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt index 5d2c943..410a81d 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt @@ -1,12 +1,25 @@ package io.github.lemcoder.dsl +import io.github.lemcoder.ffm.FfmLibraryBuild import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.Action +import org.gradle.api.file.Directory import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import java.io.File +import javax.inject.Inject -/** How the FFM bindings are generated. The generated Java needs a JDK 22+ toolchain. */ -abstract class FfmSpec { +/** + * How the FFM bindings are generated, and how the shared library they load comes to exist: linked by the + * plugin from the binding's library (the default), built by the project's own build ([external]), or + * built elsewhere ([prebuilt]). On a JVM the generated Java needs a JDK 22+ toolchain. + */ +abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { init { jextract.convention(Executable.OnPath("jextract")) panamaPortVersion.convention("v0.1.5") @@ -21,15 +34,64 @@ abstract class FfmSpec { */ abstract val panamaPortVersion: Property - /** Extra options for linking the library the bindings load. */ + /** + * The library the bindings load: `System.loadLibrary(libraryName)`, so `lib.so`. Defaults + * to the binding's library's name, else the binding's. Set it to a prebuilt library's own name so that + * libraries depending on it by that name still find it. + */ + abstract val libraryName: Property + + /** + * Targets to provide the shared library for: the build host on a JVM, 64-bit ABIs in an Android + * module. Defaults to the binding's library's targets among those, else all of those. + */ + abstract val targets: ListProperty + + /** Extra options for the plugin's own link of the library. */ abstract val linkerOptions: ListProperty /** - * Where the shared library the bindings load ends up: for the build host, to put on - * `java.library.path` together with `--enable-native-access`; in an Android module, the `jniLibs` - * root the plugin packages. + * Where the shared library ends up: for the build host, to put on `java.library.path` together with + * `--enable-native-access`; in an Android module, the `jniLibs` root the plugin packages. */ abstract val libraryDirectory: DirectoryProperty + fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) + + /** Builds the shared library with the project's own build system, as a list of commands per target. */ + val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) + + fun external(action: Action) { + selectedLibraryBuilds += FfmLibraryBuild.External.Generic + action.execute(external) + } + + /** Shared libraries built elsewhere, one per target. */ + val prebuilt: FfmPrebuiltSettings = objects.newInstance(FfmPrebuiltSettings::class.java) + + fun prebuilt(action: Action) { + selectedLibraryBuilds += FfmLibraryBuild.Prebuilt + action.execute(prebuilt) + } + + /** Where [target]'s shared library must land: `jniLibs//` on Android, a per-target directory otherwise. */ + fun libraryDirectoryFor(target: NativeTarget): Provider = + (if (target.isAndroid) jniLibsRoot else hostLibRoot).dir(target.libraryDirectoryName) + + internal val jniLibsRoot: DirectoryProperty = objects.directoryProperty() + internal val hostLibRoot: DirectoryProperty = objects.directoryProperty() + + /** The library build blocks this binding's script called; at most one may be. */ + internal val selectedLibraryBuilds = mutableSetOf() + internal var isEnabled: Boolean = false } + +/** Shared libraries the FFM bindings load, built elsewhere β€” a vendor's `.so`, say. */ +abstract class FfmPrebuiltSettings { + /** The shared library for each target. */ + abstract val sharedLibraries: MapProperty + + /** The shared library built for [target]; placed as `lib` with the target's suffix. */ + fun sharedLibrary(target: NativeTarget, file: File) = sharedLibraries.put(target, file) +} diff --git a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt new file mode 100644 index 0000000..18c833d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt @@ -0,0 +1,126 @@ +package io.github.lemcoder.ffm + +import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.external.addSteps +import io.github.lemcoder.external.registerExternalBuilds +import io.github.lemcoder.library.PlacePrebuiltFilesTask +import io.github.lemcoder.link.LinkSharedLibraryTask +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ExternalFfmVariable +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.selectOne +import io.github.lemcoder.model.taskName +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** + * How the shared library FFM bindings load comes to exist, per target: linked by the plugin from the + * binding's library, built by the project's own build, or built elsewhere. FFM needs no stub β€” this is the + * C library itself. Every build leaves `ffm.libraryDirectoryFor(target)/`. + */ +internal sealed interface FfmLibraryBuild { + /** Registers the work for [inputs]' targets. Called once the build script has run. */ + fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> + + /** The default: the binding's library's archive, linked whole into a shared library with Kotlin/Native's clang. */ + data object KonanLink : FfmLibraryBuild { + override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { + val artifacts = inputs.artifacts + val built = artifacts?.targets?.getOrElse(emptyList()).orEmpty() + val (linked, missing) = inputs.targets.partition { it in built } + if (missing.isNotEmpty()) { + project.logger.warn( + "konan.bindings.${binding.name}.ffm: no library is built for ${missing.joinToString { it.konanName }}, so nothing " + + "is linked there β€” name one with library(…), or use ffm { external { } } or ffm { prebuilt { } }." + ) + } + return linked.map { target -> registerLink(project, konan, binding, artifacts!!, inputs.libraryName, target) } + } + + private fun registerLink( + project: Project, + konan: KonanExtension, + binding: BindingSpec, + artifacts: NativeArtifacts, + libraryName: String, + target: NativeTarget, + ) = project.tasks.register(taskName("linkFfm", binding.name, target), LinkSharedLibraryTask::class.java) { + group = "konan" + description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." + dependsOn(artifacts.builtBy) + konanHome.set(konan.konanHome) + this.target.set(target) + libraryBaseName.set(libraryName) + staticLibrary.set(artifacts.archive(target)) + additionalLinkerArgs.set(binding.ffm.linkerOptions) + outputDirectory.set(binding.ffm.libraryDirectoryFor(target)) + } + } + + /** `ffm { prebuilt { } }`: shared libraries built elsewhere, placed under the name the bindings load. */ + data object Prebuilt : FfmLibraryBuild { + override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { + val libraries = binding.ffm.prebuilt.sharedLibraries.getOrElse(emptyMap()) + val missing = inputs.targets.filter { it !in libraries } + check(missing.isEmpty()) { + "konan.bindings.${binding.name}.ffm.prebuilt has no shared library for ${missing.joinToString { it.konanName }}." + } + val placements = inputs.targets.associate { target -> + libraries.getValue(target) to binding.ffm.libraryDirectoryFor(target).get().file(target.sharedLibraryName(inputs.libraryName)).asFile + } + return listOf( + project.tasks.register(taskName("prebuiltFfm", binding.name), PlacePrebuiltFilesTask::class.java) { + group = "konan" + description = "Place the prebuilt '${inputs.libraryName}' libraries the '${binding.name}' FFM bindings load." + place(placements) + } + ) + } + } + + /** The project's own build makes the shared library, once per target, told the [ExternalFfmVariable] contract. */ + sealed class External : FfmLibraryBuild { + /** `ffm { external { } }`: any build system, as the commands the build script lists. */ + data object Generic : External() + + override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { + val ffm = binding.ffm + val libraryName = inputs.libraryName + return project.registerExternalBuilds( + taskPrefix = "externalBuildFfm", + owner = binding.name, + description = { target -> "Build the '$libraryName' library for ${target.konanName} with its external build, for the FFM bindings." }, + targets = inputs.targets, + contextFor = { target -> + val output = ffm.libraryDirectoryFor(target) + ExternalBuildContext( + target, output, + mapOf( + ExternalFfmVariable.LIB_NAME to project.provider { libraryName }, + ExternalFfmVariable.OUTPUT_DIR to output.map { it.asFile.absolutePath }, + ExternalFfmVariable.TARGET to project.provider { target.konanName }, + ExternalFfmVariable.ABI to project.provider { target.libraryDirectoryName }, + ), + ) + }, + libraryFileName = { target -> project.provider { target.sharedLibraryName(libraryName) } }, + ) { context -> addSteps(ffm.external, context.target, "konan.bindings.${binding.name}.ffm") } + } + } +} + +/** The build [binding]'s `ffm` selected: at most one block, else [FfmLibraryBuild.KonanLink]. */ +internal fun ffmLibraryBuildFor(binding: BindingSpec): FfmLibraryBuild = + selectOne("konan.bindings.${binding.name}.ffm", binding.ffm.selectedLibraryBuilds, FfmLibraryBuild.KonanLink) + +/** What every FFM library build gets from the FFM consumer. */ +internal class FfmLibraryInputs( + /** The archives of the binding's library, if it names one. */ + val artifacts: NativeArtifacts?, + /** `ffm.targets`, resolved for the project: the host on a JVM, 64-bit ABIs on Android. */ + val targets: List, + /** The name the bindings load. */ + val libraryName: String, +) diff --git a/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt index 1de01bb..68d2ee0 100644 --- a/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt +++ b/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt @@ -2,17 +2,14 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.NativeLibrarySpec import io.github.lemcoder.model.NativeArtifacts -import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFiles import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction @@ -32,41 +29,40 @@ internal fun registerPrebuilt(project: Project, library: NativeLibrarySpec): Tas check(missing.isEmpty()) { "konan.libraries.$libraryName.prebuilt has no archive for ${missing.joinToString { it.konanName }}." } - val placements = targets.associateWith { archives.getValue(it) } + val output = library.outputDirectory.get().asFile + val placements = targets.associate { archives.getValue(it) to output.resolve(NativeArtifacts.archivePath(libraryName, it)) } - return project.tasks.register(taskName("prebuilt", libraryName), PlacePrebuiltArchivesTask::class.java) { + return project.tasks.register(taskName("prebuilt", libraryName), PlacePrebuiltFilesTask::class.java) { group = "konan" description = "Place the prebuilt '$libraryName' archives where consumers look for them." - archiveFiles.from(placements.values) - this.placements.set(placements) - libraryFileName.set(NativeArtifacts.archiveFileName(libraryName)) - outputDirectory.set(library.outputDirectory) + place(placements) } } /** - * Copies each target's archive to `//`. Not a `Copy`: that - * skips a missing source as NO-SOURCE, and a consumer would then fail far from the cause. + * Copies each prebuilt file to where the plugin's layout wants it, under the name consumers look for. Not + * a `Copy`: that skips a missing source as NO-SOURCE, and a consumer would then fail far from the cause. */ @DisableCachingByDefault(because = "Copying files is not worth caching") -internal abstract class PlacePrebuiltArchivesTask : DefaultTask() { - @get:InputFiles @get:PathSensitive(PathSensitivity.NONE) abstract val archiveFiles: ConfigurableFileCollection +internal abstract class PlacePrebuiltFilesTask : DefaultTask() { + @get:InputFiles @get:PathSensitive(PathSensitivity.NONE) abstract val sources: ConfigurableFileCollection - /** Which archive each target gets. */ - @get:Input abstract val placements: MapProperty + /** Each prebuilt file and where it goes. */ + @get:Input abstract val placements: MapProperty - @get:Input abstract val libraryFileName: Property + @get:OutputFiles abstract val destinations: ConfigurableFileCollection - @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + fun place(placements: Map) { + sources.from(placements.keys) + destinations.from(placements.values) + this.placements.set(placements) + } @TaskAction - fun place() { - val archives = placements.get() - val absent = archives.values.filterNot { it.isFile } - check(absent.isEmpty()) { "$path: no such archive: ${absent.joinToString()}" } - val output = outputDirectory.get().asFile - archives.forEach { (target, archive) -> - archive.copyTo(output.resolve(target.konanName).resolve(libraryFileName.get()), overwrite = true) - } + fun copy() { + val files = placements.get() + val absent = files.keys.filterNot { it.isFile } + check(absent.isEmpty()) { "$path: no such file: ${absent.joinToString()}" } + files.forEach { (source, destination) -> source.copyTo(destination, overwrite = true) } } } diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt index 65f1b5c..78f54c7 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt +++ b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt @@ -48,6 +48,21 @@ internal enum class ExternalStubVariable(override val variableName: String) : Ex JAVA_HOME("JAVA_HOME"), } +/** The contract of a build that makes the shared library FFM bindings load (`ffm { external { } }`). */ +internal enum class ExternalFfmVariable(override val variableName: String) : ExternalBuildVariable { + /** The library name the bindings pass to `System.loadLibrary`. */ + LIB_NAME("KONAN_FFM_LIB_NAME"), + + /** Where the target's library must land, unless the build names a produced library. */ + OUTPUT_DIR("KONAN_FFM_OUTPUT_DIR"), + + /** The Kotlin/Native target, e.g. `android_arm64`. */ + TARGET("KONAN_TARGET"), + + /** The target's Android ABI, e.g. `arm64-v8a`; the target's name elsewhere. */ + ABI("KONAN_FFM_ABI"), +} + /** What an external build is told for one [target]: where to leave its output, and its contract. */ internal class ExternalBuildContext( val target: NativeTarget, diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index a7efa86..e0fb8be 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -151,7 +151,7 @@ class KonanPluginFunctionalTest { assertTrue("has no archive for macos_arm64" in runner("compileNativeLibraryMymath").buildAndFail().output) File(projectDir, "build.gradle.kts").writeText(File(projectDir, "build.gradle.kts").readText().replace(", NativeTarget.MACOS_ARM64", "")) - assertTrue("no such archive" in runner("compileNativeLibraryMymath").buildAndFail().output) + assertTrue("no such file" in runner("compileNativeLibraryMymath").buildAndFail().output) } @Test @@ -322,6 +322,63 @@ class KonanPluginFunctionalTest { assertTrue(File(stubDirectory).isDirectory, "stub directory: $stubDirectory") } + @Test + fun `an FFM library built by the project's own build lands where the bindings load it`() { + assumeTrue(File("/bin/sh").canExecute(), "needs /bin/sh") + write( + """id("io.github.lemcoder.konanplugin")""", + """ + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + ffm { + external { + step { + executable.set(Executable.OnPath("sh")) + arguments.add("build.sh") + workingDirectory.set(layout.projectDirectory) + } + } + } + } + } + """, + ) + // Writes straight into the output directory the contract names, under the name it gives. + File(projectDir, "build.sh").writeText( + "printf '%s' \"${'$'}KONAN_TARGET\" > \"${'$'}KONAN_FFM_OUTPUT_DIR/${host.sharedLibraryName("x").replace("x", "${'$'}KONAN_FFM_LIB_NAME")}\"\n" + ) + + val result = runner("externalBuildFfmMymath$hostSuffix").build() + + val library = File(projectDir, "build/konan/bindings/mymath/ffm/lib/${host.konanName}/${host.sharedLibraryName("mymath")}") + assertEquals(host.konanName, library.readText(), result.output) + } + + @Test + fun `a prebuilt FFM library keeps its own name`() { + File(projectDir, "vendor").mkdirs() + File(projectDir, "vendor/libggml-host.so").writeText("vendor") + write( + """id("io.github.lemcoder.konanplugin")""", + """ + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + ffm { + libraryName.set("ggml") + prebuilt { sharedLibrary(NativeTarget.${host.name}, file("vendor/libggml-host.so")) } + } + } + } + """, + ) + + runner("prebuiltFfmMymath").build() + + assertEquals("vendor", File(projectDir, "build/konan/bindings/mymath/ffm/lib/${host.konanName}/${host.sharedLibraryName("ggml")}").readText()) + } + @Test fun `ffm() adds jextract generation and a shared library for the host`() { write( From 61010a4661c95cee7e0af8c4285da3229c63ece0 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 6 Oct 2026 22:23:17 +0200 Subject: [PATCH 21/57] feat: CMake preset for the FFM shared library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ffm { cmake { } }` builds the library FFM bindings load with the project's CMakeLists, per target: the contract as cache entries and CMAKE_LIBRARY_OUTPUT_DIRECTORY set to where the bindings load it, so add_library(${KONAN_FFM_LIB_NAME} SHARED …) is all the CMakeLists needs. It builds for its target(…) blocks, else for ffm.targets; on Android the NDK toolchain is passed per ABI. The CMake commands move to external/CMakeCommands.kt, shared by the JNI and FFM presets. JNI's CMake build now also receives the whole contract as cache entries. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 ++++++- doc/interop-architecture.md | 2 +- .../io/github/lemcoder/dsl/CMakeSettings.kt | 16 +++--- .../kotlin/io/github/lemcoder/dsl/FfmSpec.kt | 15 ++++- .../CMakeCommands.kt} | 40 ++++++++------ .../io/github/lemcoder/ffm/FfmLibraryBuild.kt | 55 ++++++++++++++++--- .../io/github/lemcoder/jni/JniStubBuild.kt | 6 +- .../lemcoder/KonanPluginFunctionalTest.kt | 31 +++++++++++ 8 files changed, 147 insertions(+), 39 deletions(-) rename src/main/kotlin/io/github/lemcoder/{jni/CMakeStubBuild.kt => external/CMakeCommands.kt} (57%) diff --git a/README.md b/README.md index d4ba687..7a5a726 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,8 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. | `panamaPortVersion`| `String` | `v0.1.5` | The PanamaPort an Android module's bindings use, added as a dependency. | | `libraryName` | `String` | the library's, else the binding's | What the bindings `System.loadLibrary`. Set it to a prebuilt library's own name. | | `targets` | `List` | the library's loadable ones, else all loadable | The host on a JVM; 64-bit ABIs on Android. | -| `external { }` | | β€” | The project's own build makes the shared library; below. | +| `cmake { }` | | β€” | The project's CMakeLists makes the shared library; below. | +| `external { }` | | β€” | Any other build system makes it, as commands; below. | | `prebuilt { }` | | β€” | Shared libraries built elsewhere: `sharedLibrary(target, file)`; below. | | `linkerOptions` | `List` | β€” | Extra options for linking the library the bindings load. | | `libraryDirectory` | `Directory` | output | Where the shared library ends up: the host's, or the `jniLibs` root on Android. | @@ -315,6 +316,22 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. FFM needs no stub: the bindings load the C library itself. By default the plugin links the binding's library's archive into it (`linkFfm`). Instead: +- `ffm { cmake { } }` β€” the same block as `jni { cmake { } }`, run once per target by + `cmakeBuildFfm`: the contract as cache entries, `CMAKE_LIBRARY_OUTPUT_DIRECTORY` set to + where the bindings load the library, so `add_library(${KONAN_FFM_LIB_NAME} SHARED …)` is all the + CMakeLists needs. It builds for its `target(…) { }` blocks, else for `ffm.targets`; on Android, pass + the NDK's toolchain per ABI: + + ```kotlin + ffm { + cmake { + path.set(file("native/CMakeLists.txt")) + arguments.addAll("-DCMAKE_TOOLCHAIN_FILE=$ndk/build/cmake/android.toolchain.cmake", "-DANDROID_PLATFORM=26") + target(NativeTarget.ANDROID_ARM64) { arguments.add("-DANDROID_ABI=arm64-v8a") } + target(NativeTarget.ANDROID_X64) { arguments.add("-DANDROID_ABI=x86_64") } + } + } + ``` - `ffm { external { step { } } }` β€” the same block as `jni { external { } }`, run once per target by `externalBuildFfm`. Each run gets `KONAN_FFM_LIB_NAME`, `KONAN_FFM_OUTPUT_DIR`, `KONAN_TARGET` and `KONAN_FFM_ABI`, and writes `lib` with the platform's suffix @@ -348,7 +365,7 @@ with no stub. The app needs: | `linkJni` | Links it for one target. | | `cmakeBuild`, `externalBuild` | The project's own build, in place of the JNI link. | | `linkFfm`, `linkFfm` | Makes the library FFM loads exist; the plugin's link of it for one target. | -| `externalBuildFfm`, `prebuiltFfm` | The project's own build of it, or placing a prebuilt one, in place of that link. | +| `cmakeBuildFfm`, `externalBuildFfm`, `prebuiltFfm` | The project's own build of it, or placing a prebuilt one, in place of that link. | | `generateFfm` | jextract's bindings. | | `cinterop` | KGP's own cinterop, declared by the plugin. | diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index 318dd08..b66d4f8 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -110,7 +110,7 @@ flowchart TD | 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | -| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); for FFM there is no glue, but the shared library it loads is a sealed `FfmLibraryBuild` of the same shape: **KonanLink** (the archive linked whole), **Prebuilt** (`ffm { prebuilt { sharedLibrary(…) } }`),, or **External.Generic** (`ffm { external { } }`, the `ExternalFfmVariable` contract) | +| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); for FFM there is no glue, but the shared library it loads is a sealed `FfmLibraryBuild` of the same shape: **KonanLink** (the archive linked whole), **Prebuilt** (`ffm { prebuilt { sharedLibrary(…) } }`), or **External** with the `ExternalFfmVariable` contract β€” **Generic** (`ffm { external { } }`) and the **CMake** preset (`ffm { cmake { } }`). Both CMake presets are one implementation, `external/CMakeCommands.kt` | | 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | `BindingConsumer`: **KotlinNativeConsumer** (the KGP adapter), **JniConsumer** (JVM, multiplatform JVM, Android; own link or CMake), **FfmConsumer** (`kotlin("jvm")` only); publications not yet (section D) | How the two paths use them: diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt index 78f16d9..f51f46c 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt @@ -11,11 +11,12 @@ import org.gradle.api.provider.Property import javax.inject.Inject /** - * An external CMake build that compiles the generated JNI stub, the way AGP's `externalNativeBuild` - * declares that CMake owns a module's native code. The plugin then stops after generating and drives - * that build instead of linking anything itself: it passes the stub's location and the library name the - * bindings expect (`KONAN_JNI_STUB_DIR`, `KONAN_JNI_LIB_NAME`), points `JAVA_HOME` at a JDK that ships - * `jni.h`, and orders generation before configuration before build. + * An external CMake build that makes the library a binding loads β€” the JNI stub (`jni { cmake { } }`) or + * the C library FFM loads (`ffm { cmake { } }`) β€” the way AGP's `externalNativeBuild` declares that CMake + * owns a module's native code. The plugin then drives that build instead of linking anything itself: it + * passes the contract as cache entries (`KONAN_JNI_STUB_DIR` and `KONAN_JNI_LIB_NAME`, or + * `KONAN_FFM_LIB_NAME`, among others), sets `CMAKE_LIBRARY_OUTPUT_DIRECTORY` to where the library must + * land, and orders it before whatever loads the library. * * This is the right shape whenever the native library is not the plugin's to build β€” a vendored CMake * project, say β€” because linking an archive from a foreign toolchain with konan's linker is what @@ -54,8 +55,9 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact abstract val buildDirectory: DirectoryProperty /** - * Directory holding the linked JNI library, if the build does not leave it where - * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` says. + * Directory holding the linked library, if the build does not leave it where + * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` says: for JNI, where consumers then look; for FFM, where the plugin + * copies it from. */ abstract val libraryDirectory: DirectoryProperty diff --git a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt index 410a81d..4eb56bb 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt @@ -16,8 +16,8 @@ import javax.inject.Inject /** * How the FFM bindings are generated, and how the shared library they load comes to exist: linked by the - * plugin from the binding's library (the default), built by the project's own build ([external]), or - * built elsewhere ([prebuilt]). On a JVM the generated Java needs a JDK 22+ toolchain. + * plugin from the binding's library (the default), built by the project's own build ([cmake], + * [external]), or built elsewhere ([prebuilt]). On a JVM the generated Java needs a JDK 22+ toolchain. */ abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { init { @@ -66,6 +66,17 @@ abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { action.execute(external) } + /** + * Builds the shared library with the project's CMakeLists: configured per target with the contract as + * cache entries and `CMAKE_LIBRARY_OUTPUT_DIRECTORY` set to where the bindings load it, then built. + */ + val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) + + fun cmake(action: Action) { + selectedLibraryBuilds += FfmLibraryBuild.External.CMake + action.execute(cmake) + } + /** Shared libraries built elsewhere, one per target. */ val prebuilt: FfmPrebuiltSettings = objects.newInstance(FfmPrebuiltSettings::class.java) diff --git a/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt similarity index 57% rename from src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt rename to src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt index 959206b..054b12d 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/CMakeStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt @@ -1,54 +1,58 @@ -package io.github.lemcoder.jni +package io.github.lemcoder.external -import io.github.lemcoder.dsl.BindingSpec -import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.dsl.CMakeSettings import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ExternalStubVariable import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider /** - * [JniStubBuild.External.CMake]: configure, then build, the project's CMakeLists for one target. The - * contract also goes in as cache entries β€” what a CMakeLists conventionally reads β€” and - * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` lands the library where the plugin says, so the CMakeLists needs no - * knowledge of the layout. + * The CMake preset of an external build: configure, then build, the project's CMakeLists for one target. + * The contract also goes in as cache entries β€” what a CMakeLists conventionally reads β€” and + * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` lands the library in the context's output directory, so the + * CMakeLists needs no knowledge of the layout. Used for the JNI stub and for the library FFM loads. * * Both commands are one task, which always runs: CMake tracks its own sources, and a changed configure * argument then always reaches the build. + * + * @param owner the DSL path, for messages, e.g. `konan.bindings.mymath.jni.cmake`. + * @param defaultBuildRoot where builds go without a preset or a build directory: `/`. */ -internal fun addCMakeCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) { - val cmake = binding.jni.cmake +internal fun ExternalBuildTask.addCMakeCommands( + project: Project, + cmake: CMakeSettings, + context: ExternalBuildContext, + owner: String, + defaultBuildRoot: Provider, +) { val perTarget = cmake.targetSettings[context.target] val preset = perTarget?.preset?.orElse(cmake.preset) ?: cmake.preset - check(cmake.path.isPresent || preset.isPresent) { "konan.bindings.${binding.name}.jni.cmake needs a path or a preset." } + check(cmake.path.isPresent || preset.isPresent) { "$owner needs a path or a preset." } val sourceDirectory = cmake.path.map { it.asFile.parentFile } // With a preset the binary directory is the preset's to choose, and `-B` cannot override it; the // conventional layout is /build/, which stays overridable. val buildDirectory: Provider = (perTarget?.buildDirectory ?: cmake.buildDirectory).orElse( project.layout.dir( preset.map { presetName -> sourceDirectory.get().resolve("build/$presetName") } - .orElse(project.layout.buildDirectory.dir("konan/bindings/${binding.name}/jni/cmake/${context.target.konanName}").map { it.asFile }) + .orElse(defaultBuildRoot.map { it.dir(context.target.konanName).asFile }) ) ) - task.command { + command { executable.set(cmake.executable) arguments.addAll( preset.map { listOf("--preset", it) }.orElse( sourceDirectory.zip(buildDirectory) { source, build -> listOf("-S", source.absolutePath, "-B", build.asFile.absolutePath) } ) ) - listOf(ExternalStubVariable.STUB_DIR, ExternalStubVariable.LIB_NAME).forEach { variable -> - arguments.add(context[variable].map { "-D${variable.variableName}=$it" }) - } - arguments.add(context[ExternalStubVariable.OUTPUT_DIR].map { "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=$it" }) + context.environment.forEach { (variable, value) -> arguments.add(value.map { "-D${variable.variableName}=$it" }) } + arguments.add(context.outputDirectory.map { "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=${it.asFile.absolutePath}" }) arguments.addAll(cmake.arguments) perTarget?.let { arguments.addAll(it.arguments) } // `--preset` resolves CMakePresets.json from the working directory. workingDirectory.set(sourceDirectory.map { it.absolutePath }) } - task.command { + command { executable.set(cmake.executable) arguments.addAll(buildDirectory.map { listOf("--build", it.asFile.absolutePath) }) arguments.addAll(cmake.buildTargets.map { targets -> targets.flatMap { listOf("--target", it) } }) diff --git a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt index 18c833d..b9a4eed 100644 --- a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt @@ -2,6 +2,8 @@ package io.github.lemcoder.ffm import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.external.addCMakeCommands import io.github.lemcoder.external.addSteps import io.github.lemcoder.external.registerExternalBuilds import io.github.lemcoder.library.PlacePrebuiltFilesTask @@ -80,19 +82,28 @@ internal sealed interface FfmLibraryBuild { } } - /** The project's own build makes the shared library, once per target, told the [ExternalFfmVariable] contract. */ + /** + * The project's own build makes the shared library, once per target, told the [ExternalFfmVariable] + * contract. A build system says which targets it builds and adds its commands. + */ sealed class External : FfmLibraryBuild { - /** `ffm { external { } }`: any build system, as the commands the build script lists. */ - data object Generic : External() + protected abstract val taskPrefix: String - override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { + /** The build system, for task descriptions. */ + protected abstract val toolName: String + + protected open fun targets(binding: BindingSpec, inputs: FfmLibraryInputs): List = inputs.targets + + protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) + + final override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { val ffm = binding.ffm val libraryName = inputs.libraryName return project.registerExternalBuilds( - taskPrefix = "externalBuildFfm", + taskPrefix = taskPrefix, owner = binding.name, - description = { target -> "Build the '$libraryName' library for ${target.konanName} with its external build, for the FFM bindings." }, - targets = inputs.targets, + description = { target -> "Build the '$libraryName' library for ${target.konanName} with $toolName, for the FFM bindings." }, + targets = targets(binding, inputs), contextFor = { target -> val output = ffm.libraryDirectoryFor(target) ExternalBuildContext( @@ -106,7 +117,35 @@ internal sealed interface FfmLibraryBuild { ) }, libraryFileName = { target -> project.provider { target.sharedLibraryName(libraryName) } }, - ) { context -> addSteps(ffm.external, context.target, "konan.bindings.${binding.name}.ffm") } + ) { context -> addCommands(this, project, binding, context) } + } + + /** `ffm { external { } }`: any build system, as the commands the build script lists. */ + data object Generic : External() { + override val taskPrefix = "externalBuildFfm" + override val toolName = "its external build" + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = + task.addSteps(binding.ffm.external, context.target, "konan.bindings.${binding.name}.ffm") + } + + /** + * `ffm { cmake { } }`: a CMakeLists, configured with the contract as cache entries, then built β€” for + * its `target(…)` blocks if it has any, else for the FFM targets. + */ + data object CMake : External() { + override val taskPrefix = "cmakeBuildFfm" + override val toolName = "CMake" + override fun targets(binding: BindingSpec, inputs: FfmLibraryInputs) = + binding.ffm.cmake.targetSettings.keys.toList().ifEmpty { inputs.targets } + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) { + val cmake = binding.ffm.cmake + task.addCMakeCommands( + project, cmake, context, "konan.bindings.${binding.name}.ffm.cmake", + project.layout.buildDirectory.dir("konan/bindings/${binding.name}/ffm/cmake"), + ) + // A build that ignores CMAKE_LIBRARY_OUTPUT_DIRECTORY: its library is copied into place. + task.producedLibrary.set(cmake.libraryDirectory.zip(task.libraryFileName) { directory, file -> directory.file(file) }) + } } } } diff --git a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt index 76135e6..0dae999 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt @@ -4,6 +4,7 @@ import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.dsl.JniSpec import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.external.addCMakeCommands import io.github.lemcoder.external.addSteps import io.github.lemcoder.external.registerExternalBuilds import io.github.lemcoder.model.ExternalBuildContext @@ -103,7 +104,10 @@ internal sealed interface JniStubBuild { override fun targets(binding: BindingSpec, inputs: JniStubInputs) = binding.jni.cmake.targetSettings.keys.toList().ifEmpty { listOf(NativeTarget.host) } override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = - addCMakeCommands(task, project, binding, context) + task.addCMakeCommands( + project, binding.jni.cmake, context, "konan.bindings.${binding.name}.jni.cmake", + project.layout.buildDirectory.dir("konan/bindings/${binding.name}/jni/cmake"), + ) override fun libraryDirectory(binding: BindingSpec, default: Provider) = binding.jni.cmake.libraryDirectory.orElse(default) } diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index e0fb8be..46348e9 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -355,6 +355,37 @@ class KonanPluginFunctionalTest { assertEquals(host.konanName, library.readText(), result.output) } + @Test + fun `CMake builds the FFM library under the name the bindings load`() { + val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } + assumeTrue(cmake != null, "needs cmake") + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") + File(projectDir, "native/CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.16) + project(mymath C) + add_library(${'$'}{KONAN_FFM_LIB_NAME} SHARED mymath.c) + """.trimIndent() + ) + write( + """id("io.github.lemcoder.konanplugin")""", + """ + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + ffm { cmake { path.set(file("native/CMakeLists.txt")) } } + } + } + """, + ) + + val result = runner("cmakeBuildFfmMymath$hostSuffix").build() + + val library = File(projectDir, "build/konan/bindings/mymath/ffm/lib/${host.konanName}/${host.sharedLibraryName("mymath")}") + assertTrue(library.isFile, "expected $library:\n${result.output}") + } + @Test fun `a prebuilt FFM library keeps its own name`() { File(projectDir, "vendor").mkdirs() From c9f1cfaa0b324e571a1526ae5d6fc814381663ab Mon Sep 17 00:00:00 2001 From: mikolaj Date: Wed, 7 Oct 2026 23:39:45 +0200 Subject: [PATCH 22/57] feat: Kotlin/Native toolchain for external builds, Make preset, libraries in the jar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toolchain export: konanToolchain writes cc/c++ wrappers around the clang run_konan would run (captured once, no JVM per compile), an ar, and a CMake toolchain file. External builds choose with ExternalToolchain (AUTO: Kotlin/Native's when cross-compiling; KONAN; SYSTEM) and then get CC, CXX, AR and KONAN_CMAKE_TOOLCHAIN_FILE; the CMake preset passes the toolchain file unless it uses a preset. Make and CMake default to AUTO, generic external steps to SYSTEM. - Per-target link flags live in KonanDistribution.linkArguments. Adds -fuse-ld=lld for Linux, so the plugin's own links for Linux now work from a macOS host. - `make { }` preset for libraries, jni and ffm: make -C -j CC=… CXX=… AR=… per target. - JNI external build tasks are named *BuildJni, so they no longer clash with a library of the same name. Libraries in the jar: in a kotlin("jvm") project or a multiplatform jvm() target, each JVM target's JNI library and the FFM library go into the jar under konan/natives// (bundleJni, bundleFfm; bundleInJar to opt out). A loader generated into the bindings tries System.loadLibrary, then extracts the platform's library from the jar; jextract's loadLibrary is rerouted to it. The JVM example needs no java.library.path any more. Also gives examples/native the daemon memory the Kotlin/Native compiler needs (repeated links ran out of Metaspace). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 24 +++ README.md | 96 +++++++++-- doc/interop-architecture.md | 6 +- examples/jvm/build.gradle.kts | 9 +- examples/native/gradle.properties | 3 + .../github/lemcoder/model/BundledLibraries.kt | 10 ++ .../index/exporter/JextractGenerator.kt | 16 +- .../lemcoder/index/exporter/JniGenerator.kt | 4 +- .../index/exporter/NativeLoaderSource.kt | 99 +++++++++++ .../github/lemcoder/consumer/FfmConsumer.kt | 14 +- .../io/github/lemcoder/consumer/JarBundle.kt | 35 ++++ .../github/lemcoder/consumer/JniConsumer.kt | 20 +++ .../io/github/lemcoder/dsl/CMakeSettings.kt | 8 + .../lemcoder/dsl/ExternalBuildSettings.kt | 12 ++ .../kotlin/io/github/lemcoder/dsl/FfmSpec.kt | 15 ++ .../kotlin/io/github/lemcoder/dsl/JniSpec.kt | 15 ++ .../io/github/lemcoder/dsl/MakeSettings.kt | 68 ++++++++ .../github/lemcoder/dsl/NativeLibrarySpec.kt | 14 +- .../github/lemcoder/external/CMakeCommands.kt | 9 +- .../lemcoder/external/ExternalBuilds.kt | 13 +- .../github/lemcoder/external/MakeCommands.kt | 24 +++ .../io/github/lemcoder/ffm/FfmLibraryBuild.kt | 17 ++ .../io/github/lemcoder/jni/JniStubBuild.kt | 22 ++- .../lemcoder/kotlin/KonanDistribution.kt | 42 ++++- .../github/lemcoder/kotlin/KonanToolchain.kt | 155 ++++++++++++++++++ .../lemcoder/kotlin/KotlinGradlePlugin.kt | 13 ++ .../lemcoder/library/LibraryProvider.kt | 18 ++ .../lemcoder/link/LinkSharedLibraryTask.kt | 16 +- .../lemcoder/model/ExternalBuildContract.kt | 18 ++ .../lemcoder/model/ExternalToolchain.kt | 23 +++ .../lemcoder/KonanPluginFunctionalTest.kt | 96 ++++++++++- .../index/GenerateFromNativeIndexTest.kt | 2 +- 32 files changed, 887 insertions(+), 49 deletions(-) create mode 100644 examples/native/gradle.properties create mode 100644 src/exporterProtocol/kotlin/io/github/lemcoder/model/BundledLibraries.kt create mode 100644 src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt create mode 100644 src/main/kotlin/io/github/lemcoder/consumer/JarBundle.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt create mode 100644 src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt create mode 100644 src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt create mode 100644 src/main/kotlin/io/github/lemcoder/model/ExternalToolchain.kt diff --git a/CLAUDE.md b/CLAUDE.md index 9747828..dd75614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,30 @@ not used β€” it emits address-only bridges, every pointer a raw `Long`. Bindings are `internal` by default and each carries **`@JvmName`**. JNI resolves the C symbol from the unmangled method name, so a mangled internal name compiles, links, and dies on the first call. +## Kotlin/Native's toolchain for external builds + +- **`run_konan` starts a JVM per call**, so `CC` is not a wrapper around it: `KonanToolchainTask` runs + `run_konan clang --version` once, takes the clang command it echoes, and writes `cc` + and `c++` scripts around that, plus a CMake toolchain file. Android is `Linux` to CMake β€” its Android + platform wants an NDK β€” and Apple targets set `CMAKE_OSX_SYSROOT`, or CMake's host SDK would win. +- **Link flags live in `KonanDistribution.linkArguments`**: `-fuse-ld=lld` for Linux (the GCC toolchain's + `ld.bfd` is a Linux binary; a cross link from macOS fails without it), NDK compiler-rt and + `--unwindlib=none` for Android, Xcode's compiler-rt for macOS. The wrappers add them only when linking. +- **`external { }` defaults to `ExternalToolchain.SYSTEM`**, the presets to `AUTO`: generic steps may not + compile anything, and must not need a Kotlin/Native distribution to run. +- **Task names carry the side**: `Build` for a library, `BuildJni…`, `BuildFfm…` + for a binding β€” a library and a binding are usually both called `mymath`. + +## Libraries in the jar + +- **The loader is generated, not a runtime dependency**: `NativeLoaderSource` writes a Kotlin function + into the JNI bindings and a `KonanNativeLoader` Java class beside jextract's output, which the exporter + points jextract's `System.loadLibrary` at (JVM only β€” PanamaPort's Android bindings keep the plain + call). `System.loadLibrary` first, so `java.library.path` still wins; then the jar's resource. +- **The resource layout is shared**: `BundledLibraries.RESOURCE_ROOT` (`konan/natives//`) + lives in `src/exporterProtocol`, compiled into both the plugin that bundles and the exporter that + writes the loader. The loader maps `os.name`/`os.arch` to Kotlin/Native target names itself. + ## FFM on Android - **jextract's output only needs its packages moved** onto PanamaPort: `java.lang.foreign` β†’ diff --git a/README.md b/README.md index 7a5a726..a4ca873 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ either way. | `outputDirectory` | `Directory` | `build/konan/libraries/` | Root of the per-target archives; `archive(target)` gives one. | | `zig { }` | | β€” | Build with the project's `build.zig` instead; below. | | `external { }` | | β€” | Build with any other build system, as commands; below. | +| `make { }` | | β€” | Build with the project's Makefile; below. | | `prebuilt { }` | | β€” | Use archives built elsewhere; below. | #### Building with Zig β€” `zig { }` @@ -102,6 +103,58 @@ library (`b.addLibrary(.{ .linkage = .static, … })`). Android targets need a libc file for the NDK's bionic, which Zig does not bundle. +#### Building with Make β€” `make { }` +```kotlin +create("mymath") { + targets(NativeTarget.LINUX_X64, NativeTarget.ANDROID_ARM64) + make { directory.set(file("native")) } // make -C native -j CC=… CXX=… AR=… KONAN_…=… +} +``` + +`makeBuild` runs make once per target. The compilers and the contract go on make's command +line, where they win over the Makefile's own assignments, so a Makefile written against `$(CC)`, `$(AR)` +and `$(KONAN_LIBRARY_OUTPUT_DIR)` cross-compiles unchanged: + +```make +OUT := $(KONAN_LIBRARY_OUTPUT_DIR) +all: + $(CC) -c -fPIC mymath.c -o $(OUT)/mymath.o + $(AR) rcs $(OUT)/lib$(KONAN_LIBRARY_NAME).a $(OUT)/mymath.o +``` + +Every target builds in the same directory: keep intermediate files per target, as above, or a second +target reuses the first one's objects. + +| Property | Type | Default | Description | +|--------------------|---------------------|--------------------|-------------| +| `directory` | `Directory` | β€” | Where make runs (`-C`). Required. | +| `makefile` | `File` | `Makefile` | `-f`. | +| `buildTargets` | `List` | the default goal | Make targets. | +| `arguments` | `List` | β€” | Further arguments; `argumentsFor(target, …)` for one target. | +| `jobs` | `Int` | processors | `-j`. | +| `toolchain` | `ExternalToolchain` | `AUTO` | Whose compilers; below. | +| `executable` | `Executable` | `OnPath("make")` | The `make` program. | +| `sources`, `producedLibrary(target, file)` | | β€” | As for `external { }`. | + +`jni { make { } }` and `ffm { make { } }` take the same block, for the libraries those load. + +#### Kotlin/Native's compilers for any build β€” `toolchain` +Cross-compiling with your own build needs a compiler and sysroot per target. The plugin hands over +Kotlin/Native's β€” the ones its own builds use, so the archives match Kotlin/Native's ABI (glibc 2.19 on +Linux, its NDK sysroot on Android): + +| `toolchain` | Meaning | +|---------------|---------| +| `AUTO` | Kotlin/Native's when cross-compiling, the system's for the build host. The default of `make { }` and `cmake { }`. | +| `KONAN` | Always Kotlin/Native's. | +| `SYSTEM` | Never: the build finds its own. The default of `external { }`, whose steps are the build script's own. | + +With Kotlin/Native's, the build gets `CC`, `CXX` and `AR` (and `KONAN_CMAKE_TOOLCHAIN_FILE`) in its +environment; `make { }` also passes them on the command line, and `cmake { }` passes the toolchain file +as `-DCMAKE_TOOLCHAIN_FILE` unless it uses a preset β€” a toolchain file in `arguments` still wins, an NDK's +say. They are written per target by `konanToolchain` under `build/konan/toolchains//`: +`cc` and `c++` wrap the clang `run_konan` would run, with the target's link flags when linking. + #### Archives built elsewhere β€” `prebuilt { }` ```kotlin create("mymath") { @@ -166,7 +219,8 @@ A C API described by a cinterop `.def`. | `jdkHome` | `Directory` | auto-detected | A JDK shipping `include/jni.h`. | | `linkerOptions` | `List` | β€” | Extra options for every target's link. | | `linkerOptionsFor(target, …)` | | β€” | Extra options for one target's link; `-framework` belongs here, since the Android linker rejects it. | -| `libraryDirectory` | `Directory` | output | Where the host's JNI library ends up: put it on `java.library.path`. | +| `libraryDirectory` | `Directory` | output | Where the host's JNI library ends up. | +| `bundleInJar` | `Boolean` | `true` | Puts each JVM target's library in the jar, where the bindings load it from; see below. | | `cmake { }` | | β€” | An external CMake build that compiles the stub instead; below. | | `external { }` | | β€” | Any other build system that compiles the stub, as commands; below. | | `stubDirectory`, `stubLibraryName`, `libraryDirectoryFor(target)` | read-only | β€” | The external-build contract as providers, for arguments. | @@ -183,7 +237,8 @@ link task is registered: one build task per target takes its place, ordered afte `linkJni` depends on them, so "make the JNI library exist" means the same thing either way. A binding selects at most one of `cmake { }` and `external { }`. -Every run gets the plugin's contract β€” what only it knows β€” as environment variables: +Every run gets the plugin's contract β€” what only it knows β€” as environment variables (and, with +Kotlin/Native's `toolchain`, `CC`, `CXX`, `AR`; see above): | variable | meaning | |---|---| @@ -214,7 +269,7 @@ create("koinference") { } ``` -`cmakeBuild` configures, then builds. It always runs and leaves incrementality to CMake, +`cmakeBuildJni` configures, then builds. It always runs and leaves incrementality to CMake, so a changed configure argument always reaches the build. Android needs a library per ABI, so declare targets: `target(NativeTarget.ANDROID_ARM64) { preset.set("androidNativeArm64") }`; without any, it builds once for the host. The contract also arrives as cache entries, and `CMAKE_LIBRARY_OUTPUT_DIRECTORY` @@ -258,7 +313,7 @@ jni { } ``` -`externalBuild` runs the steps. The build writes the library into `KONAN_JNI_OUTPUT_DIR`, +`externalBuildJni` runs the steps. The build writes the library into `KONAN_JNI_OUTPUT_DIR`, or names where it leaves it with `producedLibrary(target, file)`, and the plugin copies it into place under the name the bindings load. With `sources` declared the task is up to date while they and the steps are unchanged; without, the plugin cannot know what the build reads, so it always runs. @@ -271,6 +326,23 @@ steps are unchanged; without, the plugin cannot know what the build reads, so it | `environment` | `Map` | Variables beyond the contract; these win over it. | | `workingDirectory` | `Directory` | Where it runs. | +### Libraries in the jar +In a `kotlin("jvm")` project or a multiplatform `jvm()` target, the JNI library of every JVM target β€” +and, with `ffm()`, the library FFM loads β€” goes into the jar as `konan/natives//` +(`bundleJni`, `bundleFfm`). The generated bindings load it with `System.loadLibrary` when +`java.library.path` has it, so a build's own setup still wins, and otherwise extract it for the running +platform to a temporary file. `run`, `installDist` and a plain `java -cp` need no library path. + +Declare more JVM targets to ship one jar for several platforms β€” Linux libraries cross-link from a Mac: + +```kotlin +val mymath = libraries.create("mymath") { targets(NativeTarget.host, NativeTarget.LINUX_X64) } +bindings { create("mymath") { library(mymath); jni { targets(NativeTarget.host, NativeTarget.LINUX_X64) } } } +``` + +The loader is generated into each binding's package, so the bindings still need nothing at runtime. +`jni { bundleInJar.set(false) }` (or `ffm { … }`) leaves the libraries out. + ### The generated JNI bindings One `external fun` per C function, named after it and carrying its C signature as a doc comment. cinterop's own indexer reads the headers β€” the same `.def` the Kotlin/Native targets use β€” and the @@ -298,7 +370,8 @@ different Gradle module. FFM (`java.lang.foreign`) bindings, written by jextract from the same `.def`, and the library linked as a shared library for the build host β€” FFM needs no stub. In a `kotlin("jvm")` project they replace the JNI bindings and are added to the main Java sources; compile them with a JDK 22+ toolchain and run -with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. +with `--enable-native-access`; the library travels in the jar (below), or put `ffm.libraryDirectory` on +`java.library.path`. | Property | Type | Default | Description | |--------------------|-------------|----------------------|-------------| @@ -311,6 +384,7 @@ with `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path`. | `prebuilt { }` | | β€” | Shared libraries built elsewhere: `sharedLibrary(target, file)`; below. | | `linkerOptions` | `List` | β€” | Extra options for linking the library the bindings load. | | `libraryDirectory` | `Directory` | output | Where the shared library ends up: the host's, or the `jniLibs` root on Android. | +| `bundleInJar` | `Boolean` | `true` | Puts the library in a `kotlin("jvm")` project's jar; see "Libraries in the jar". | #### The shared library the bindings load FFM needs no stub: the bindings load the C library itself. By default the plugin links the binding's @@ -359,13 +433,14 @@ with no stub. The app needs: | Task | Description | |-------------------------------|-------------| | `compileNativeLibrary` | Builds a library to `lib.a` per target, with whichever provider it uses. | -| `konanClang`, `zigBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, the library's own steps, or placing prebuilt archives. | +| `konanClang`, `zigBuild`, `makeBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, make, the library's own steps, or placing prebuilt archives. | +| `konanToolchain` | Writes Kotlin/Native's compilers for a target, for external builds that use them. | | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | | `linkJni` | Links it for one target. | -| `cmakeBuild`, `externalBuild` | The project's own build, in place of the JNI link. | +| `cmakeBuildJni`, `makeBuildJni`, `externalBuildJni` | The project's own build, in place of the JNI link. | | `linkFfm`, `linkFfm` | Makes the library FFM loads exist; the plugin's link of it for one target. | -| `cmakeBuildFfm`, `externalBuildFfm`, `prebuiltFfm` | The project's own build of it, or placing a prebuilt one, in place of that link. | +| `cmakeBuildFfm`, `makeBuildFfm`, `externalBuildFfm`, `prebuiltFfm` | The project's own build of it, or placing a prebuilt one, in place of that link. | | `generateFfm` | jextract's bindings. | | `cinterop` | KGP's own cinterop, declared by the plugin. | @@ -374,7 +449,7 @@ On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks au ## Examples - `examples/native` β€” Kotlin/Native consumer through KGP's cinterop. -- `examples/jvm` β€” plain JVM app loading the JNI library from `java.library.path`. +- `examples/jvm` β€” plain JVM app loading the JNI library bundled in its jar. - `examples/android` β€” Android app calling C through the generated bindings. `scripts/run-android.sh` publishes the plugin, builds, installs and launches it in one command. @@ -389,7 +464,8 @@ The DSL is new and there is no compatibility shim. `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` β†’ `jdkHome`, `library` β†’ `archive`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ `linkerOptionsFor`, `externalNativeBuild { cmake { } }` β†’ `cmake { }` (`targets` β†’ `buildTargets`, - `abi("arm64-v8a")` β†’ `target(NativeTarget.ANDROID_ARM64)`, `executable` β†’ an `Executable`, no `$CMAKE`), + `abi("arm64-v8a")` β†’ `target(NativeTarget.ANDROID_ARM64)`, `executable` β†’ an `Executable`, no `$CMAKE`; + its tasks are `cmakeBuildJni`), `resolvedLibraryDirectory` β†’ `libraryDirectory`. `additionalCompilerArgs` becomes `compilerOptions`. Bindings are wired into the project's JVM and Android targets automatically. diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index b66d4f8..559521f 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -106,7 +106,7 @@ flowchart TD | --- | --- | --- | --- | --- | | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `model.NativeTarget`: one entry per Kotlin/Native target (checked by a test against the pinned version), with family, architecture, Android ABI, library naming and Zig triple; `NativeTarget.of(konanTarget)` is the only conversion | -| 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Konan (clang + downloaded sysroots) today; Xcode, Android NDK, system clang/GCC/MSVC, Zig later | +| 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Kotlin/Native's (clang + downloaded sysroots, `KonanDistribution`), also handed to external builds per target as `CC`/`CXX`/`AR` and a CMake toolchain file (`KonanToolchain`, chosen by `ExternalToolchain`); Zig brings its own; the system's for host builds | | 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | @@ -328,8 +328,8 @@ take the library only as `NativeArtifacts`. A new consumer is one more entry in | Consumer | Binary | Glue | Wiring | | --- | --- | --- | --- | | Kotlin/Native | Static archive, embedded in the klib (`-staticLibrary` + `-libraryPath`) | None | KGP's `cinterops {}` on each target's main compilation, declared by the adapter | -| JVM (JNI) | Shared library: stub + archive | The JNI stub | Generated Kotlin as sources; the library on `java.library.path` or in resources | -| JVM (FFM) | Shared library of the C code itself (`linkFfm`: the archive linked whole) | None | Generated Java in the main Java sources; the build supplies a JDK 22+ toolchain, `--enable-native-access` and `ffm.libraryDirectory` on `java.library.path` | +| JVM (JNI) | Shared library: stub + archive, per JVM target | The JNI stub | Generated Kotlin as sources; every JVM target's library in the jar under `konan/natives//` (`bundleJni`), loaded by a loader generated into the bindings β€” `java.library.path` first, else extracted from the jar | +| JVM (FFM) | Shared library of the C code itself (`linkFfm`: the archive linked whole) | None | Generated Java in the main Java sources, jextract's `System.loadLibrary` rewritten to the generated `KonanNativeLoader`; the library in the jar like JNI's (`bundleFfm`); the build supplies a JDK 22+ toolchain and `--enable-native-access` | | Android | Shared library: stub + archive, per ABI | The JNI stub | Generated Kotlin as sources; `jniLibs/` via the variant API | | Android (FFM) | Shared library of the C code itself, per 64-bit ABI (`linkFfm`) | None | jextract's Java moved onto PanamaPort (`com.v7878.foreign`, its own `VarHandle`) β€” the generator's `FfmApi.PANAMA_PORT` β€” as Java sources via the variant API; PanamaPort added as a dependency; `jniLibs/`. Needs `compileSdk` 37, `minSdk` 26, Java 21 source. One host index, so only LP64 ABIs | diff --git a/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts index ea375c6..107980b 100644 --- a/examples/jvm/build.gradle.kts +++ b/examples/jvm/build.gradle.kts @@ -24,11 +24,6 @@ konan { kotlin { jvmToolchain(17) } +// The JNI library travels in the jar (konan/natives//), where the bindings load it from: +// `run`, `installDist` and a plain `java -cp` need no java.library.path. application { mainClass.set("example.MainKt") } - -tasks.named("run") { - dependsOn("linkJniMymath") - // loadLibrary() resolves the stub from java.library.path. - val libDir = konan.bindings.named("mymath").flatMap { it.jni.libraryDirectory } - jvmArgumentProviders.add { listOf("-Djava.library.path=${libDir.get().asFile.absolutePath}") } -} diff --git a/examples/native/gradle.properties b/examples/native/gradle.properties new file mode 100644 index 0000000..d206271 --- /dev/null +++ b/examples/native/gradle.properties @@ -0,0 +1,3 @@ +# The Kotlin/Native compiler runs inside the Gradle daemon; Gradle's default 512 MiB heap and 384 MiB +# metaspace run out on repeated links ("OutOfMemoryError: Metaspace"). +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g diff --git a/src/exporterProtocol/kotlin/io/github/lemcoder/model/BundledLibraries.kt b/src/exporterProtocol/kotlin/io/github/lemcoder/model/BundledLibraries.kt new file mode 100644 index 0000000..0533bf5 --- /dev/null +++ b/src/exporterProtocol/kotlin/io/github/lemcoder/model/BundledLibraries.kt @@ -0,0 +1,10 @@ +package io.github.lemcoder.model + +/** + * Where a jar carries the native libraries generated bindings load: `konan/natives//`, the + * target by its Kotlin/Native name. The plugin bundles them there and the generated loader looks there, so + * both sides compile this one spelling. + */ +object BundledLibraries { + const val RESOURCE_ROOT: String = "konan/natives" +} diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt index 22be089..3747cd1 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt @@ -50,15 +50,27 @@ object JextractGenerator : BindingGenerator { val process = ProcessBuilder(command).redirectErrorStream(true).start() val log = process.inputStream.bufferedReader().readText() check(process.waitFor() == 0) { "jextract failed:\n${command.joinToString(" ")}\n$log" } + val sources = outputDirectory.resolve("java").walk().filter { it.extension == "java" }.toList() when (request.api) { - FfmApi.JAVA -> {} - FfmApi.PANAMA_PORT -> outputDirectory.resolve("java").walk().filter { it.extension == "java" }.forEach(::portToPanamaPort) + // On Android the library is in the APK; on a JVM it may be in the jar. + FfmApi.JAVA -> { + sources.forEach(::loadThroughKonanLoader) + outputDirectory.resolve("java/${context.packageName.replace('.', '/')}/KonanNativeLoader.java") + .apply { parentFile.mkdirs() } + .writeText(NativeLoaderSource.java(context.packageName)) + } + FfmApi.PANAMA_PORT -> sources.forEach(::portToPanamaPort) } } finally { scratch.deleteRecursively() } } + /** jextract's `System.loadLibrary` becomes [NativeLoaderSource]'s, which also finds a library bundled in the jar. */ + private fun loadThroughKonanLoader(source: File) { + source.writeText(source.readText().replace("System.loadLibrary(", "KonanNativeLoader.load(")) + } + private fun portToPanamaPort(source: File) { source.writeText( source.readText() diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt index 64d23af..c63be1b 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -184,7 +184,9 @@ object JniGenerator : BindingGenerator { appendLine("// Generated JNI bindings; do not edit. Strings and primitive buffers are marshalled,") appendLine("// structs by value cross as their bytes, and other pointers as addresses (Long).") appendLine() - appendLine("""private val nativeLibrary: Unit = System.loadLibrary("$stubLibrary")""") + appendLine("""private val nativeLibrary: Unit = loadNativeLibrary("$stubLibrary")""") + appendLine() + append(NativeLoaderSource.kotlin()) bound.forEach { b -> val params = b.params.joinToString { (name, crossing) -> "${kotlinIdentifier(name)}: ${crossing.kotlinType}" } appendLine() diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt new file mode 100644 index 0000000..13fb199 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt @@ -0,0 +1,99 @@ +package io.github.lemcoder.index.exporter + +import io.github.lemcoder.model.BundledLibraries + +/** + * The loader generated bindings use in place of a bare `System.loadLibrary`: the library from + * `java.library.path` if it is there β€” so a build's own setup still wins β€” else from the jar's + * `konan/natives//`, where the Konan plugin bundles each JVM target's library, extracted to a + * temporary file. On Android the first step always succeeds: the library is in the APK. + * + * Written into each binding's package, so the bindings keep needing nothing at runtime. The resource + * layout and the platform names are the plugin's (`konan/natives/` + Kotlin/Native target names). + */ +internal object NativeLoaderSource { + private const val RESOURCE_ROOT = BundledLibraries.RESOURCE_ROOT + + /** A private top-level Kotlin function `loadNativeLibrary(name)`, for the JNI bindings' file. */ + fun kotlin(): String = """ + |/** + | * Loads [name] from `java.library.path`, else from this jar's `$RESOURCE_ROOT//`, where the Konan + | * plugin bundles it, through a temporary file. + | */ + |private fun loadNativeLibrary(name: String) { + | try { + | System.loadLibrary(name) + | } catch (notOnPath: UnsatisfiedLinkError) { + | val os = System.getProperty("os.name").lowercase() + | val arch = when (System.getProperty("os.arch").lowercase()) { + | "aarch64", "arm64" -> "arm64" + | "amd64", "x86_64" -> "x64" + | else -> throw notOnPath + | } + | val target = when { + | os.contains("mac") || os.contains("darwin") -> "macos_${'$'}arch" + | os.contains("windows") -> "mingw_${'$'}arch" + | os.contains("linux") -> "linux_${'$'}arch" + | else -> throw notOnPath + | } + | val file = System.mapLibraryName(name) + | val input = java.lang.invoke.MethodHandles.lookup().lookupClass() + | .getResourceAsStream("/$RESOURCE_ROOT/${'$'}target/${'$'}file") ?: throw notOnPath + | val directory = java.nio.file.Files.createTempDirectory("konan-${'$'}name").toFile().apply { deleteOnExit() } + | val library = java.io.File(directory, file).apply { deleteOnExit() } + | input.use { from -> library.outputStream().use { from.copyTo(it) } } + | System.load(library.absolutePath) + | } + |} + |""".trimMargin() + + /** A package-private Java class `KonanNativeLoader` with `load(name)`, for jextract's output. */ + fun java(packageName: String): String = """ + |// Generated by the Konan plugin; do not edit. + |${if (packageName.isEmpty()) "" else "package $packageName;\n"} + |import java.io.File; + |import java.io.InputStream; + |import java.nio.file.Files; + |import java.nio.file.StandardCopyOption; + | + |/** + | * Loads a library from {@code java.library.path}, else from this jar's {@code $RESOURCE_ROOT//}, + | * where the Konan plugin bundles it, through a temporary file. + | */ + |final class KonanNativeLoader { + | private KonanNativeLoader() {} + | + | static void load(String name) { + | try { + | System.loadLibrary(name); + | } catch (UnsatisfiedLinkError notOnPath) { + | String os = System.getProperty("os.name").toLowerCase(); + | String arch = switch (System.getProperty("os.arch").toLowerCase()) { + | case "aarch64", "arm64" -> "arm64"; + | case "amd64", "x86_64" -> "x64"; + | default -> throw notOnPath; + | }; + | String target; + | if (os.contains("mac") || os.contains("darwin")) target = "macos_" + arch; + | else if (os.contains("windows")) target = "mingw_" + arch; + | else if (os.contains("linux")) target = "linux_" + arch; + | else throw notOnPath; + | String file = System.mapLibraryName(name); + | try (InputStream input = KonanNativeLoader.class.getResourceAsStream("/$RESOURCE_ROOT/" + target + "/" + file)) { + | if (input == null) throw notOnPath; + | File directory = Files.createTempDirectory("konan-" + name).toFile(); + | directory.deleteOnExit(); + | File library = new File(directory, file); + | library.deleteOnExit(); + | Files.copy(input, library.toPath(), StandardCopyOption.REPLACE_EXISTING); + | System.load(library.getAbsolutePath()); + | } catch (java.io.IOException e) { + | UnsatisfiedLinkError error = new UnsatisfiedLinkError("Could not extract " + file + ": " + e); + | error.addSuppressed(notOnPath); + | throw error; + | } + | } + | } + |} + |""".trimMargin() +} diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt index 7c83610..6320436 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt @@ -92,8 +92,18 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume } if (context.hasPlugin(GradlePluginId.KOTLIN_JVM)) { // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. - project.extensions.getByType(SourceSetContainer::class.java).getByName("main") - .java.srcDir(generate.map { it.outputDirectory.dir("java") }) + val main = project.extensions.getByType(SourceSetContainer::class.java).getByName("main") + main.java.srcDir(generate.map { it.outputDirectory.dir("java") }) + if (ffm.bundleInJar.get()) { + val bundle = project.registerJarBundle( + taskName = taskName("bundleFfm", bindingName), + taskDescription = "Put the libraries the '$bindingName' FFM bindings load in the jar.", + libraries = targets.associateWith { target -> ffm.libraryDirectoryFor(target).map { it.file(target.sharedLibraryName(libraryName)) } }, + destination = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/resources").get().asFile, + builtBy = linkAll, + ) + main.resources.srcDir(bundle.map { it.destinationDir }) + } } else if (!isAndroid) { project.logger.info("konan.bindings.$bindingName: FFM bindings are wired into kotlin(\"jvm\") and Android projects; add build/generated/konan/$bindingName/ffm/java yourself.") } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JarBundle.kt b/src/main/kotlin/io/github/lemcoder/consumer/JarBundle.kt new file mode 100644 index 0000000..37b3cb4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/consumer/JarBundle.kt @@ -0,0 +1,35 @@ +package io.github.lemcoder.consumer + +import io.github.lemcoder.model.BundledLibraries +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.util.cleanTask +import org.gradle.api.Project +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Sync +import org.gradle.api.tasks.TaskProvider +import java.io.File + +/** + * A resources root holding [libraries] as `konan/natives//` β€” where the generated bindings' + * loader finds them when `java.library.path` does not have them β€” built after [builtBy]. + */ +internal fun Project.registerJarBundle( + taskName: String, + taskDescription: String, + libraries: Map>, + destination: File, + builtBy: Any, +): TaskProvider { + val clean = cleanTask() + return tasks.register(taskName, Sync::class.java) { + group = "konan" + description = taskDescription + dependsOn(builtBy) + mustRunAfter(clean) + libraries.forEach { (target, library) -> + from(library) { into("${BundledLibraries.RESOURCE_ROOT}/${target.konanName}") } + } + into(destination) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt index 025c2f1..15b18cf 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt @@ -68,6 +68,26 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume val inputs = JniStubInputs(generate, artifacts, targets(artifacts)) val builds = stubBuildFor(bindingName, binding.jni).register(project, context.konan, binding, inputs) linkAll.configure { dependsOn(builds) } + bundleInJar(inputs.targets.filter { !it.isAndroid }) + } + + /** The JVM targets' libraries into the jar of a `kotlin("jvm")` project or a multiplatform `jvm()` target. */ + private fun bundleInJar(jvmTargets: List) { + val jni = binding.jni + val kmpJvm = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project) + if (!jni.bundleInJar.get() || jvmTargets.isEmpty() || !(isPlainJvmConsumer || kmpJvm)) return + val bundle = project.registerJarBundle( + taskName = taskName("bundleJni", bindingName), + taskDescription = "Put the '$bindingName' JNI libraries in the jar, for the bindings to load.", + libraries = jvmTargets.associateWith { target -> + jni.libraryDirectoryFor(target).zip(jni.stubLibraryName) { directory, name -> directory.file(target.sharedLibraryName(name)) } + }, + destination = context.buildDirectory.dir("konan/bindings/$bindingName/jni/resources").get().asFile, + builtBy = linkAll, + ) + val resources = bundle.map { it.destinationDir } + if (isPlainJvmConsumer) KotlinGradlePlugin.addJvmResources(project, resources) + if (kmpJvm) KotlinGradlePlugin.addKmpJvmResources(project, resources) } /** `jni.targets`, else the library's targets a JVM can load β€” the host and Android β€” else the host. */ diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt index f51f46c..031fb06 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt @@ -1,6 +1,7 @@ package io.github.lemcoder.dsl import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action import org.gradle.api.file.DirectoryProperty @@ -32,8 +33,15 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact init { executable.convention(Executable.OnPath("cmake")) + toolchain.convention(ExternalToolchain.AUTO) } + /** + * Whose compilers CMake gets: Kotlin/Native's, as `-DCMAKE_TOOLCHAIN_FILE`, when cross-compiling, by + * default. Not passed with a [preset], which names its own; a toolchain file in [arguments] wins. + */ + abstract val toolchain: Property + /** Builds for [target] β€” an Android ABI, say β€” with its own settings, instead of once for the host. */ fun target(target: NativeTarget, action: Action) { action.execute(targetSettings.getOrPut(target) { objects.newInstance(CMakeTargetSettings::class.java) }) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt index edecf86..5f8c9f0 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt @@ -1,6 +1,7 @@ package io.github.lemcoder.dsl import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action import org.gradle.api.file.ConfigurableFileCollection @@ -20,6 +21,17 @@ import javax.inject.Inject * says, or names where it leaves it with [producedLibrary], and the plugin copies it into place. */ abstract class ExternalBuildSettings @Inject constructor(private val objects: ObjectFactory) { + init { + toolchain.convention(ExternalToolchain.SYSTEM) + } + + /** + * Whose compilers the build gets. None by default: the steps are the build script's own. Set it to + * [ExternalToolchain.KONAN] or [ExternalToolchain.AUTO] to have Kotlin/Native's in the environment + * (`CC`, `CXX`, `AR`, `KONAN_CMAKE_TOOLCHAIN_FILE`). + */ + abstract val toolchain: Property + /** * What the build reads. With none, it runs every time and its own incrementality decides; with them, * it is up to date while they and its commands are unchanged. diff --git a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt index 4eb56bb..0cf06f3 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt @@ -23,8 +23,15 @@ abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { init { jextract.convention(Executable.OnPath("jextract")) panamaPortVersion.convention("v0.1.5") + bundleInJar.convention(true) } + /** + * Whether a JVM project's jar carries the library of each FFM target, under `konan/natives/`: the + * bindings then load it from there when `java.library.path` does not have it. On by default. + */ + abstract val bundleInJar: Property + /** The jextract program. Defaults to `jextract` on the PATH or in the usual install locations. */ abstract val jextract: Property @@ -77,6 +84,14 @@ abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { action.execute(cmake) } + /** Builds the shared library with the project's Makefile, once per target. */ + val make: MakeSettings = objects.newInstance(MakeSettings::class.java) + + fun make(action: Action) { + selectedLibraryBuilds += FfmLibraryBuild.External.Make + action.execute(make) + } + /** Shared libraries built elsewhere, one per target. */ val prebuilt: FfmPrebuiltSettings = objects.newInstance(FfmPrebuiltSettings::class.java) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt index a2944e7..fe8f9bd 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt @@ -18,8 +18,15 @@ import javax.inject.Inject abstract class JniSpec @Inject constructor(objects: ObjectFactory) { init { visibility.convention(BindingVisibility.INTERNAL) + bundleInJar.convention(true) } + /** + * Whether a JVM project's jar carries the JNI library of each JVM target, under `konan/natives/`: the + * bindings then load it from there when `java.library.path` does not have it. On by default. + */ + abstract val bundleInJar: Property + /** * Visibility of the generated bindings. They are an implementation detail β€” the idiomatic API is * written on top of them β€” so they are `internal` by default. @@ -71,6 +78,14 @@ abstract class JniSpec @Inject constructor(objects: ObjectFactory) { action.execute(external) } + /** The project's Makefile compiles the stub, once per target; replaces the plugin's own link. */ + val make: MakeSettings = objects.newInstance(MakeSettings::class.java) + + fun make(action: Action) { + selectedStubBuilds += JniStubBuild.External.Make + action.execute(make) + } + /** The directory holding the generated `.c` stub β€” what an external build compiles. */ val stubDirectory: Provider get() = stubDirectoryProperty diff --git a/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt new file mode 100644 index 0000000..86e9c32 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt @@ -0,0 +1,68 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.ExternalToolchain +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import java.io.File + +/** + * A Makefile that builds a library, run once per target as + * `make -C -j CC=… CXX=… AR=… `: Kotlin/Native's + * compilers on the command line, so they win over the Makefile's own, and the contract β€” the output + * directory and library name among it β€” as variables too. + * + * Every target builds in the same [directory], one after another or at once: keep intermediate files + * apart per target (under the contract's output directory, or `KONAN_TARGET`), or a second target reuses + * the first one's objects. + */ +abstract class MakeSettings { + init { + executable.convention(Executable.OnPath("make")) + toolchain.convention(ExternalToolchain.AUTO) + jobs.convention(Runtime.getRuntime().availableProcessors()) + } + + /** Where make runs (`-C`). Required. */ + abstract val directory: DirectoryProperty + + /** The Makefile, if not `Makefile` in [directory] (`-f`). */ + abstract val makefile: RegularFileProperty + + /** Make targets to build. Defaults to the Makefile's default goal. */ + abstract val buildTargets: ListProperty + + /** Further arguments for every target, e.g. `VAR=value`. */ + abstract val arguments: ListProperty + + /** Further arguments for one target, after [arguments]. */ + abstract val targetArguments: MapProperty> + + /** `-j`. Defaults to the number of processors. */ + abstract val jobs: Property + + /** The `make` program. Defaults to `make` on the PATH or in the usual install locations. */ + abstract val executable: Property + + /** Whose compilers make gets: Kotlin/Native's when cross-compiling, by default. */ + abstract val toolchain: Property + + /** What the build reads. With none, it runs every time and make's own incrementality decides. */ + abstract val sources: ConfigurableFileCollection + + /** Where the build leaves each target's library, when not in the contract's output directory. */ + abstract val producedLibraries: MapProperty + + fun argumentsFor(target: NativeTarget, vararg arguments: String) { + val existing = targetArguments.getOrElse(emptyMap())[target].orEmpty() + targetArguments.put(target, existing + arguments) + } + + /** The library the build leaves at [file] for [target]; the plugin copies and renames it into place. */ + fun producedLibrary(target: NativeTarget, file: File) = producedLibraries.put(target, file) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt index b15d137..a3dfaca 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt @@ -16,7 +16,7 @@ import javax.inject.Inject /** * A C/C++ library built into a static archive per target, `//lib.a`: by - * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [external]), or built + * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [make], [external]), or built * elsewhere ([prebuilt]). */ abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { @@ -72,6 +72,18 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o action.execute(external) } + /** + * Builds the library with the project's Makefile, once per target, with Kotlin/Native's compilers on + * make's command line when cross-compiling; the archive lands in `KONAN_LIBRARY_OUTPUT_DIR` as + * `lib.a`, or where `producedLibrary` says. + */ + val make: MakeSettings = objects.newInstance(MakeSettings::class.java) + + fun make(action: Action) { + selectedProviders += LibraryProvider.External.Make + action.execute(make) + } + /** Archives built elsewhere, one per target; [targets] default to the targets they are declared for. */ val prebuilt: PrebuiltSettings = objects.newInstance(PrebuiltSettings::class.java) diff --git a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt index 054b12d..2842322 100644 --- a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt @@ -2,6 +2,7 @@ package io.github.lemcoder.external import io.github.lemcoder.dsl.CMakeSettings import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ToolchainVariable import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider @@ -45,7 +46,13 @@ internal fun ExternalBuildTask.addCMakeCommands( sourceDirectory.zip(buildDirectory) { source, build -> listOf("-S", source.absolutePath, "-B", build.asFile.absolutePath) } ) ) - context.environment.forEach { (variable, value) -> arguments.add(value.map { "-D${variable.variableName}=$it" }) } + // Kotlin/Native's compilers by toolchain file β€” the arguments after it can still name another, and + // a preset names its own. + if (!preset.isPresent) { + context.environment[ToolchainVariable.CMAKE_TOOLCHAIN_FILE]?.let { file -> arguments.add(file.map { "-DCMAKE_TOOLCHAIN_FILE=$it" }) } + } + context.environment.filterKeys { it !is ToolchainVariable } + .forEach { (variable, value) -> arguments.add(value.map { "-D${variable.variableName}=$it" }) } arguments.add(context.outputDirectory.map { "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=${it.asFile.absolutePath}" }) arguments.addAll(cmake.arguments) perTarget?.let { arguments.addAll(it.arguments) } diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt index 7b76776..d3571d6 100644 --- a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt @@ -1,8 +1,11 @@ package io.github.lemcoder.external import io.github.lemcoder.dsl.ExternalBuildSettings +import io.github.lemcoder.kotlin.KonanToolchain import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.ToolchainVariable import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask import org.gradle.api.Project @@ -14,6 +17,9 @@ import org.gradle.api.tasks.TaskProvider * ``, run with [contextFor]'s contract as environment and expected to leave * [libraryFileName] in the context's output directory. [configure] adds the build system's commands β€” * the only part that differs between build systems and between a library and a JNI stub. + * + * Where [toolchain] picks Kotlin/Native's for a target, the context also carries its [ToolchainVariable]s + * and the build waits for its files; null means the build system brings its own (Zig). */ internal fun Project.registerExternalBuilds( taskPrefix: String, @@ -22,17 +28,22 @@ internal fun Project.registerExternalBuilds( targets: List, contextFor: (NativeTarget) -> ExternalBuildContext, libraryFileName: (NativeTarget) -> Provider, + toolchain: ExternalToolchain?, configure: ExternalBuildTask.(ExternalBuildContext) -> Unit, ): List> { val clean = cleanTask() return targets.map { target -> - val context = contextFor(target) + val konanToolchain = toolchain?.takeIf { it.usesKonan(target) }?.let { KonanToolchain.of(this, target) } + val context = contextFor(target).let { context -> + konanToolchain?.let { ExternalBuildContext(context.target, context.outputDirectory, context.environment + it.environment) } ?: context + } val taskDescription = description(target) val fileName = libraryFileName(target) tasks.register(taskName(taskPrefix, owner, target), ExternalBuildTask::class.java) { group = "konan" this.description = taskDescription mustRunAfter(clean) + konanToolchain?.let { dependsOn(it.task) } context.environment.forEach { (variable, value) -> environment.put(variable.variableName, value) } this.libraryFileName.set(fileName) outputDirectory.set(context.outputDirectory) diff --git a/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt b/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt new file mode 100644 index 0000000..591439f --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt @@ -0,0 +1,24 @@ +package io.github.lemcoder.external + +import io.github.lemcoder.dsl.MakeSettings +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ToolchainVariable + +/** The Make preset of an external build: one `make` run for the context's target; see [MakeSettings]. */ +internal fun ExternalBuildTask.addMakeCommands(make: MakeSettings, context: ExternalBuildContext, owner: String) { + check(make.directory.isPresent) { "$owner needs a directory." } + command { + executable.set(make.executable) + arguments.addAll(make.directory.map { listOf("-C", it.asFile.absolutePath) }) + arguments.addAll(make.makefile.map { listOf("-f", it.asFile.absolutePath) }.orElse(emptyList())) + arguments.add(make.jobs.map { "-j$it" }) + // Command-line variables override the Makefile's assignments: the compilers, then the contract. + context.environment.filterKeys { it != ToolchainVariable.CMAKE_TOOLCHAIN_FILE } + .forEach { (variable, value) -> arguments.add(value.map { "${variable.variableName}=$it" }) } + arguments.addAll(make.arguments) + arguments.addAll(make.targetArguments.getting(context.target).orElse(emptyList())) + arguments.addAll(make.buildTargets) + } + sources.from(make.sources) + producedLibrary.fileProvider(make.producedLibraries.getting(context.target)) +} diff --git a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt index b9a4eed..fb9df82 100644 --- a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt @@ -4,12 +4,14 @@ import io.github.lemcoder.dsl.BindingSpec import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.external.ExternalBuildTask import io.github.lemcoder.external.addCMakeCommands +import io.github.lemcoder.external.addMakeCommands import io.github.lemcoder.external.addSteps import io.github.lemcoder.external.registerExternalBuilds import io.github.lemcoder.library.PlacePrebuiltFilesTask import io.github.lemcoder.link.LinkSharedLibraryTask import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.ExternalFfmVariable +import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.selectOne @@ -96,6 +98,9 @@ internal sealed interface FfmLibraryBuild { protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) + /** Whose compilers the build gets. */ + protected abstract fun toolchain(binding: BindingSpec): ExternalToolchain + final override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { val ffm = binding.ffm val libraryName = inputs.libraryName @@ -117,6 +122,7 @@ internal sealed interface FfmLibraryBuild { ) }, libraryFileName = { target -> project.provider { target.sharedLibraryName(libraryName) } }, + toolchain = toolchain(binding), ) { context -> addCommands(this, project, binding, context) } } @@ -126,6 +132,16 @@ internal sealed interface FfmLibraryBuild { override val toolName = "its external build" override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = task.addSteps(binding.ffm.external, context.target, "konan.bindings.${binding.name}.ffm") + override fun toolchain(binding: BindingSpec) = binding.ffm.external.toolchain.get() + } + + /** `ffm { make { } }`: the project's Makefile, told what to call the library and where to leave it. */ + data object Make : External() { + override val taskPrefix = "makeBuildFfm" + override val toolName = "make" + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = + task.addMakeCommands(binding.ffm.make, context, "konan.bindings.${binding.name}.ffm.make") + override fun toolchain(binding: BindingSpec) = binding.ffm.make.toolchain.get() } /** @@ -146,6 +162,7 @@ internal sealed interface FfmLibraryBuild { // A build that ignores CMAKE_LIBRARY_OUTPUT_DIRECTORY: its library is copied into place. task.producedLibrary.set(cmake.libraryDirectory.zip(task.libraryFileName) { directory, file -> directory.file(file) }) } + override fun toolchain(binding: BindingSpec) = binding.ffm.cmake.toolchain.get() } } } diff --git a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt index 0dae999..c9d6837 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt @@ -5,10 +5,12 @@ import io.github.lemcoder.dsl.JniSpec import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.external.ExternalBuildTask import io.github.lemcoder.external.addCMakeCommands +import io.github.lemcoder.external.addMakeCommands import io.github.lemcoder.external.addSteps import io.github.lemcoder.external.registerExternalBuilds import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.ExternalStubVariable +import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.selectOne @@ -50,6 +52,9 @@ internal sealed interface JniStubBuild { protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) + /** Whose compilers the build gets. */ + protected abstract fun toolchain(binding: BindingSpec): ExternalToolchain + /** `jni.libraryDirectory`'s convention, given the plugin's [default]. */ protected open fun libraryDirectory(binding: BindingSpec, default: Provider): Provider = default @@ -78,6 +83,7 @@ internal sealed interface JniStubBuild { ) }, libraryFileName = { target -> jni.stubLibraryName.map { target.sharedLibraryName(it) } }, + toolchain = toolchain(binding), ) { context -> dependsOn(inputs.generate) generatedSources.from(jni.stubDirectory) @@ -90,16 +96,27 @@ internal sealed interface JniStubBuild { /** `jni { external { } }`: any build system, as the commands the build script lists. */ data object Generic : External() { - override val taskPrefix = "externalBuild" + override val taskPrefix = "externalBuildJni" override val toolName = "its external build" override fun targets(binding: BindingSpec, inputs: JniStubInputs) = inputs.targets override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = task.addSteps(binding.jni.external, context.target, "konan.bindings.${binding.name}.jni") + override fun toolchain(binding: BindingSpec) = binding.jni.external.toolchain.get() + } + + /** `jni { make { } }`: the project's Makefile, told where the stub is and what to call the library. */ + data object Make : External() { + override val taskPrefix = "makeBuildJni" + override val toolName = "make" + override fun targets(binding: BindingSpec, inputs: JniStubInputs) = inputs.targets + override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = + task.addMakeCommands(binding.jni.make, context, "konan.bindings.${binding.name}.jni.make") + override fun toolchain(binding: BindingSpec) = binding.jni.make.toolchain.get() } /** `jni { cmake { } }`: a CMakeLists, configured with the contract as cache entries, then built. */ data object CMake : External() { - override val taskPrefix = "cmakeBuild" + override val taskPrefix = "cmakeBuildJni" override val toolName = "CMake" override fun targets(binding: BindingSpec, inputs: JniStubInputs) = binding.jni.cmake.targetSettings.keys.toList().ifEmpty { listOf(NativeTarget.host) } @@ -110,6 +127,7 @@ internal sealed interface JniStubBuild { ) override fun libraryDirectory(binding: BindingSpec, default: Provider) = binding.jni.cmake.libraryDirectory.orElse(default) + override fun toolchain(binding: BindingSpec) = binding.jni.cmake.toolchain.get() } } } diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt index 205defc..ae33e87 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -2,6 +2,11 @@ package io.github.lemcoder.kotlin import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.TargetFamily +import io.github.lemcoder.toolchain.Xcode +import io.github.lemcoder.util.ExecCaptureResult +import io.github.lemcoder.util.execCapture +import org.gradle.process.ExecOperations import java.io.File /** @@ -19,9 +24,37 @@ internal class KonanDistribution(val home: File) { /** Native libraries the compiler jar loads, libclang among them. */ val nativeLibraryDirectory: File get() = home.resolve("konan/nativelib") - /** clang for [target], with Kotlin/Native's sysroot and flags for it. */ - fun clang(target: NativeTarget, arguments: List): List = - listOf(runKonan.absolutePath, "clang", "clang", target.konanName) + arguments + /** clang (or [driver]) for [target], with Kotlin/Native's sysroot and flags for it. */ + fun clang(target: NativeTarget, arguments: List, driver: ClangDriver = ClangDriver.C): List = + listOf(runKonan.absolutePath, "clang", driver.toolName, target.konanName) + arguments + + /** + * What clang needs, beyond what `run_konan` sets, to link a shared library or executable for + * [target] β€” the one place these are known, for the plugin's own links and the toolchain it hands + * to external builds: + * - Linux: `lld`. The GCC toolchain's own `ld.bfd` is a Linux binary, so a cross link fails without it. + * - Android: the NDK's compiler-rt, which Kotlin/Native's LLVM lacks, and no unwinder, which the NDK + * sysroot lacks. + * - macOS: Xcode's compiler-rt, which Apple-framework code needs and Kotlin/Native's LLVM lacks. + */ + fun linkArguments(target: NativeTarget, exec: ExecOperations): List = when (target.family) { + TargetFamily.LINUX -> listOf("-fuse-ld=lld") + TargetFamily.ANDROID -> listOfNotNull(ndkResourceDirectory()?.let { "-resource-dir=${it.absolutePath}" }, "--unwindlib=none") + TargetFamily.OSX -> listOfNotNull(Xcode.macosCompilerRt(exec)?.absolutePath) + else -> emptyList() + } + + /** + * The clang command line `run_konan` builds for [target] β€” the compiler and every flag it adds β€” without + * running a JVM per compile: `run_konan` echoes the command it runs, so one `--version` call yields it. + */ + fun clangCommand(target: NativeTarget, driver: ClangDriver, exec: ExecOperations): List { + val result = exec.execCapture { commandLine(clang(target, listOf("--version"), driver)) } + check(result is ExecCaptureResult.Success) { "run_konan could not run ${driver.toolName} for ${target.konanName}:\n${result.output}" } + val line = result.output.lineSequence().firstOrNull { it.endsWith(" --version") && it.contains(" -target ") } + ?: error("run_konan did not echo its ${driver.toolName} command for ${target.konanName}:\n${result.output}") + return line.removeSuffix(" --version").split(' ').filter { it.isNotEmpty() } + } /** llvm-ar, for archiving. */ fun llvmAr(arguments: List): List = listOf(runKonan.absolutePath, "llvm", "llvm-ar") + arguments @@ -72,3 +105,6 @@ internal class KonanDistribution(val home: File) { ?.let(::KonanDistribution) } } + +/** The clang drivers `run_konan` runs. */ +internal enum class ClangDriver(val toolName: String) { C("clang"), CXX("clang++") } diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt new file mode 100644 index 0000000..20ed6c5 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt @@ -0,0 +1,155 @@ +package io.github.lemcoder.kotlin + +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.model.Architecture +import io.github.lemcoder.model.ExternalBuildVariable +import io.github.lemcoder.model.HostOs +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.TargetFamily +import io.github.lemcoder.model.ToolchainVariable +import io.github.lemcoder.model.taskName +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.TaskProvider +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault +import java.io.File +import javax.inject.Inject + +/** + * Kotlin/Native's toolchain for one target, as files an external build can be handed: `cc` and `c++` + * wrappers around the clang `run_konan` would run, llvm-ar, and a CMake toolchain file naming them. + */ +internal class KonanToolchain(val task: TaskProvider, directory: Provider) { + val cc: Provider = directory.map { it.file("bin/${script("cc")}").asFile } + val cxx: Provider = directory.map { it.file("bin/${script("c++")}").asFile } + val ar: Provider = directory.map { it.file("bin/${script("ar")}").asFile } + val cmakeToolchainFile: Provider = directory.map { it.file("toolchain.cmake").asFile } + + /** The [ToolchainVariable]s, for an external build's environment. */ + val environment: Map> = mapOf( + ToolchainVariable.CC to cc.map { it.absolutePath }, + ToolchainVariable.CXX to cxx.map { it.absolutePath }, + ToolchainVariable.AR to ar.map { it.absolutePath }, + ToolchainVariable.CMAKE_TOOLCHAIN_FILE to cmakeToolchainFile.map { it.absolutePath }, + ) + + companion object { + /** [target]'s toolchain, whose task is registered by the first build that asks for it. */ + fun of(project: Project, target: NativeTarget): KonanToolchain { + val name = taskName("konanToolchain", "", target) + val directory = project.layout.buildDirectory.dir("konan/toolchains/${target.konanName}") + val task = if (name in project.tasks.names) { + project.tasks.named(name, KonanToolchainTask::class.java) + } else { + val konan = project.extensions.getByType(KonanExtension::class.java) + project.tasks.register(name, KonanToolchainTask::class.java) { + group = "konan" + description = "Write Kotlin/Native's compilers for ${target.konanName} as files external builds can use." + konanHome.set(konan.konanHome) + // An input so another distribution regenerates the wrappers; resolved again when the task runs. + distribution.set(konan.konanHome.map { it.asFile.absolutePath }.orElse(project.provider { KonanDistribution.findHome()?.absolutePath ?: "" })) + this.target.set(target) + outputDirectory.set(directory) + } + } + return KonanToolchain(task, directory) + } + + private fun script(name: String) = if (HostOs.current == HostOs.WINDOWS) "$name.bat" else name + } +} + +/** Writes [KonanToolchain]'s files for [target]. */ +@DisableCachingByDefault(because = "The files name absolute paths into the local Kotlin/Native distribution") +internal abstract class KonanToolchainTask @Inject constructor(private val exec: ExecOperations) : DefaultTask() { + @get:Internal abstract val konanHome: DirectoryProperty + @get:Input abstract val distribution: Property + @get:Input abstract val target: Property + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun write() { + val konan = KonanDistribution.resolve(konanHome.orNull?.asFile) + val target = target.get() + val cc = konan.clangCommand(target, ClangDriver.C, exec) + val cxx = konan.clangCommand(target, ClangDriver.CXX, exec) + val ar = File(cc.first()).resolveSibling("llvm-ar").absolutePath + val link = konan.linkArguments(target, exec) + + val directory = outputDirectory.get().asFile.apply { deleteRecursively() } + val bin = directory.resolve("bin").apply { mkdirs() } + val windows = HostOs.current == HostOs.WINDOWS + fun script(name: String, text: String) = bin.resolve(if (windows) "$name.bat" else name).apply { + writeText(text) + setExecutable(true) + } + val ccFile = script("cc", driver(target, cc, link, windows)) + val cxxFile = script("c++", driver(target, cxx, link, windows)) + val arFile = script("ar", if (windows) "@\"$ar\" %*\r\n" else "#!/bin/sh\nexec ${quote(ar)} \"$@\"\n") + directory.resolve("toolchain.cmake").writeText(cmakeToolchain(target, cc, ccFile, cxxFile, arFile)) + } + + /** + * A compiler driver: [command] plus the caller's arguments, plus [link] when linking β€” a compile-only + * call (`-c`, `-S`, `-E`, `-M…`, `--version`) gets none, so no unused-argument warning meets `-Werror`. + */ + private fun driver(target: NativeTarget, command: List, link: List, windows: Boolean): String = + if (windows) { + "@REM Generated by the Konan plugin: Kotlin/Native's ${File(command.first()).name} for ${target.konanName}.\r\n" + + "@${command.joinToString(" ") { "\"$it\"" }} %* ${link.joinToString(" ") { "\"$it\"" }}\r\n" + } else { + """ + |#!/bin/sh + |# Generated by the Konan plugin: Kotlin/Native's ${File(command.first()).name} for ${target.konanName}. + |for arg in "$@"; do + | case "${'$'}arg" in + | -c|-S|-E|-M|-MM|--version|-v|-dumpversion|-dumpmachine|-print-*) exec ${command.joinToString(" ", transform = ::quote)} "$@" ;; + | esac + |done + |exec ${command.joinToString(" ", transform = ::quote)} "$@" ${link.joinToString(" ", transform = ::quote)} + |""".trimMargin() + } + + /** + * A CMake toolchain file for [target]. Android is described as Linux: CMake's own Android platform + * looks for an NDK, and the compilers already target Android. Apple targets name the SDK clang uses, + * or CMake would add the host's after it. + */ + private fun cmakeToolchain(target: NativeTarget, cc: List, ccFile: File, cxxFile: File, arFile: File): String { + val system = when (target.family) { + TargetFamily.LINUX, TargetFamily.ANDROID -> "Linux" + TargetFamily.MINGW -> "Windows" + TargetFamily.OSX, TargetFamily.IOS, TargetFamily.TVOS, TargetFamily.WATCHOS -> "Darwin" + } + val processor = when (target.architecture) { + Architecture.ARM64 -> if (target.family.isApple) "arm64" else "aarch64" + Architecture.ARM32 -> "arm" + Architecture.X64 -> "x86_64" + Architecture.X86 -> "i686" + } + val sysroot = cc.indexOf("-isysroot").takeIf { it >= 0 }?.let { cc[it + 1] } + fun path(file: File) = file.absolutePath.replace('\\', '/') + return buildString { + appendLine("# Generated by the Konan plugin: Kotlin/Native's toolchain for ${target.konanName}.") + appendLine("set(CMAKE_SYSTEM_NAME $system)") + appendLine("set(CMAKE_SYSTEM_PROCESSOR $processor)") + appendLine("set(CMAKE_C_COMPILER \"${path(ccFile)}\")") + appendLine("set(CMAKE_CXX_COMPILER \"${path(cxxFile)}\")") + appendLine("set(CMAKE_AR \"${path(arFile)}\" CACHE FILEPATH \"Archiver\")") + sysroot?.let { appendLine("set(CMAKE_OSX_SYSROOT \"$it\" CACHE PATH \"SDK\")") } + // A cross toolchain may not link executables for the build machine to run; libraries are what we build. + appendLine("set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)") + } + } + + private fun quote(argument: String) = "'" + argument.replace("'", "'\\''") + "'" +} diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index a294a22..79be7cf 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -6,6 +6,7 @@ import io.github.lemcoder.model.NativeTarget import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider +import java.io.File import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType @@ -31,6 +32,18 @@ internal object KotlinGradlePlugin { .configureEach { compilations.matching { it.name == "main" }.configureEach { defaultSourceSet.kotlin.srcDir(sources) } } } + /** Resources of a `kotlin("jvm")` project's main source set. */ + fun addJvmResources(project: Project, resources: Provider) { + project.extensions.getByType(KotlinJvmProjectExtension::class.java).sourceSets.getByName("main").resources.srcDir(resources) + } + + /** …and of the main compilation of every JVM target of a multiplatform project β€” not Android's, which packages jniLibs. */ + fun addKmpJvmResources(project: Project, resources: Provider) { + project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets + .matching { it.platformType == KotlinPlatformType.jvm } + .configureEach { compilations.matching { it.name == "main" }.configureEach { defaultSourceSet.resources.srcDir(resources) } } + } + fun hasKmpJvmTarget(project: Project): Boolean = project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets.any { it.platformType in JVM_PLATFORMS } diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt index 6086db2..c68e166 100644 --- a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt +++ b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt @@ -3,10 +3,12 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.external.addMakeCommands import io.github.lemcoder.external.addSteps import io.github.lemcoder.external.registerExternalBuilds import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.ExternalLibraryVariable +import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.selectOne import org.gradle.api.Project @@ -45,6 +47,9 @@ internal sealed interface LibraryProvider { protected abstract fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) + /** Whose compilers the build gets; null when the build system brings its own. */ + protected abstract fun toolchain(library: NativeLibrarySpec): ExternalToolchain? + final override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): List> { val libraryName = library.name val fileName = project.providers.provider { NativeArtifacts.archiveFileName(libraryName) } @@ -66,6 +71,7 @@ internal sealed interface LibraryProvider { ) }, libraryFileName = { fileName }, + toolchain = toolchain(library), ) { context -> addCommands(this, project, library, context) } } @@ -75,6 +81,16 @@ internal sealed interface LibraryProvider { override val toolName = "its external build" override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = task.addSteps(library.external, context.target, "konan.libraries.${library.name}") + override fun toolchain(library: NativeLibrarySpec) = library.external.toolchain.get() + } + + /** `make { }`: the project's Makefile, with Kotlin/Native's compilers when cross-compiling. */ + data object Make : External() { + override val taskPrefix = "makeBuild" + override val toolName = "make" + override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = + task.addMakeCommands(library.make, context, "konan.libraries.${library.name}.make") + override fun toolchain(library: NativeLibrarySpec) = library.make.toolchain.get() } /** `zig { }`: the project's own `build.zig`. */ @@ -83,6 +99,8 @@ internal sealed interface LibraryProvider { override val toolName = "zig build" override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = addZigCommands(task, project, library, context) + // Zig is its own cross toolchain. + override fun toolchain(library: NativeLibrarySpec): ExternalToolchain? = null } } } diff --git a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt index d87ee2a..9d5441f 100644 --- a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt @@ -2,8 +2,6 @@ package io.github.lemcoder.link import io.github.lemcoder.kotlin.KonanDistribution import io.github.lemcoder.model.NativeTarget -import io.github.lemcoder.model.TargetFamily -import io.github.lemcoder.toolchain.Xcode import io.github.lemcoder.util.execCapture import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection @@ -72,13 +70,9 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( add("-shared") includeDirs.files.filter { it.exists() }.forEach { add("-I${it.absolutePath}") } includePaths.getOrElse(emptyList()).forEach { add("-I$it") } - if (nativeTarget.isAndroid) { - // jni.h comes from the NDK sysroot; supply compiler-rt and skip the (absent) unwinder. - distribution.ndkResourceDirectory()?.let { add("-resource-dir=${it.absolutePath}") } - add("--unwindlib=none") - } else { - jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } - } + // On Android jni.h comes from the NDK sysroot. + if (!nativeTarget.isAndroid) jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } + addAll(distribution.linkArguments(nativeTarget, exec)) if (stub != null) { add(stub.absolutePath) add(archive) @@ -87,10 +81,6 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( } else { add("-Wl,--whole-archive"); add(archive); add("-Wl,--no-whole-archive") } - if (nativeTarget.family == TargetFamily.OSX) { - // Konan's LLVM has no host compiler-rt; Apple-framework code needs its builtins. - Xcode.macosCompilerRt(exec)?.let { add(it.absolutePath) } - } add("-o"); add(outLib.absolutePath) // A C++ runtime, extra -L, frameworks: all caller-supplied. Adding konan's own NDK lib // dir here would pull its static libc in ahead of the sysroot's shared one, which fails diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt index 78f54c7..d0c5cc1 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt +++ b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt @@ -63,6 +63,24 @@ internal enum class ExternalFfmVariable(override val variableName: String) : Ext ABI("KONAN_FFM_ABI"), } +/** + * Kotlin/Native's toolchain for the target, when the build uses it ([ExternalToolchain.KONAN]): the + * standard names Make, autotools and most build scripts read. + */ +internal enum class ToolchainVariable(override val variableName: String) : ExternalBuildVariable { + /** A C compiler driver: Kotlin/Native's clang with the target's flags and sysroot. */ + CC("CC"), + + /** The C++ driver, likewise. */ + CXX("CXX"), + + /** llvm-ar. */ + AR("AR"), + + /** A CMake toolchain file naming all three, with the target's system and processor. */ + CMAKE_TOOLCHAIN_FILE("KONAN_CMAKE_TOOLCHAIN_FILE"), +} + /** What an external build is told for one [target]: where to leave its output, and its contract. */ internal class ExternalBuildContext( val target: NativeTarget, diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalToolchain.kt b/src/main/kotlin/io/github/lemcoder/model/ExternalToolchain.kt new file mode 100644 index 0000000..4f7736b --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/ExternalToolchain.kt @@ -0,0 +1,23 @@ +package io.github.lemcoder.model + +/** Which compilers an external build uses for a target. */ +enum class ExternalToolchain { + /** Kotlin/Native's when cross-compiling, the system's for the build host. */ + AUTO, + + /** + * Kotlin/Native's clang with its sysroot for the target, the one the plugin's own builds use: `CC`, + * `CXX` and `AR` in the environment, a CMake toolchain file, and `CC=…` on make's command line. + */ + KONAN, + + /** Whatever the build finds itself; the plugin hands no compilers over. */ + SYSTEM; + + /** Whether [target] is built with Kotlin/Native's toolchain. */ + internal fun usesKonan(target: NativeTarget): Boolean = when (this) { + AUTO -> target != NativeTarget.host + KONAN -> true + SYSTEM -> false + } +} diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 46348e9..758c68b 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -154,6 +154,84 @@ class KonanPluginFunctionalTest { assertTrue("no such file" in runner("compileNativeLibraryMymath").buildAndFail().output) } + /** The installed Kotlin/Native distribution, for tests that really cross-compile with it. */ + private fun realKonanHome(): String { + assumeTrue(KonanDistribution.pinned() != null, "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed") + return KonanDistribution.pinned()!!.home.absolutePath.replace("\\", "/") + } + + @Test + fun `make cross-compiles a library with Kotlin-Native's toolchain`() { + assumeTrue(listOf("/usr/bin/make", "/opt/homebrew/bin/make").any { File(it).canExecute() }, "needs make") + assumeTrue(host != NativeTarget.LINUX_X64, "needs a host other than linux_x64") + val home = realKonanHome() + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") + // Nothing but the contract and the compilers make is handed. + File(projectDir, "native/Makefile").writeText( + "OUT := $(KONAN_LIBRARY_OUTPUT_DIR)\n" + + "all:\n\t$(CC) -c -fPIC mymath.c -o $(OUT)/mymath.o\n\t$(AR) rcs $(OUT)/lib$(KONAN_LIBRARY_NAME).a $(OUT)/mymath.o\n" + ) + write( + """id("io.github.lemcoder.konanplugin")""", + """ + konanHome.set(file("$home")) + libraries { + create("mymath") { + targets(NativeTarget.LINUX_X64) + make { directory.set(file("native")) } + } + } + """, + ) + + val result = runner("compileNativeLibraryMymath").build() + + val archive = File(projectDir, "build/konan/libraries/mymath/linux_x64/libmymath.a").readBytes() + assertTrue(archive.copyOfRange(0, 8).decodeToString() == "!\n", result.output) + // An x86-64 ELF object: e_machine 0x3e, 18 bytes into the member. + val elf = archive.indexOfSequence(byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())) + assertTrue(elf > 0 && archive[elf + 18] == 0x3e.toByte(), "expected an x86-64 ELF member:\n${result.output}") + } + + @Test + fun `CMake cross-compiles and links a JNI library through Kotlin-Native's toolchain file`() { + val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } + assumeTrue(cmake != null, "needs cmake") + assumeTrue(host != NativeTarget.LINUX_X64, "needs a host other than linux_x64") + val home = realKonanHome() + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") + File(projectDir, "native/CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.16) + project(mymath C) + add_library(${'$'}{KONAN_JNI_LIB_NAME} SHARED mymath.c) + """.trimIndent() + ) + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + """ + konanHome.set(file("$home")) + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + jni { cmake { path.set(file("native/CMakeLists.txt")); target(NativeTarget.LINUX_X64) { } } } + } + } + """, + ) + + val result = runner("cmakeBuildJniMymathLinux_x64").withPluginClasspathAndKgp().build() + + val library = File(projectDir, "build/konan/bindings/mymath/jni/lib/linux_x64").listFiles().orEmpty().single { it.name.endsWith(".so") } + val bytes = library.readBytes() + assertTrue(bytes.copyOfRange(1, 4).decodeToString() == "ELF" && bytes[18] == 0x3e.toByte(), "expected an x86-64 ELF library:\n${result.output}") + } + + private fun ByteArray.indexOfSequence(sequence: ByteArray): Int = + (0..size - sequence.size).firstOrNull { start -> sequence.indices.all { this[start + it] == sequence[it] } } ?: -1 + @Test fun `zig builds each target's archive where every provider puts it`() { val zig = System.getenv("ZIG") @@ -213,6 +291,20 @@ class KonanPluginFunctionalTest { } } + @Test + fun `a JVM project's jar carries the JNI library unless told not to`() { + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) + + val bundled = runner("jar", "--dry-run").withPluginClasspathAndKgp().build() + listOf(":linkJniMymath$hostSuffix", ":bundleJniMymath", ":processResources", ":jar").forEach { + assertTrue("$it SKIPPED" in bundled.output, "expected $it before the jar:\n${bundled.output}") + } + + File(projectDir, "build.gradle.kts").appendText("konan.bindings.named(\"mymath\") { jni.bundleInJar.set(false) }\n") + val plain = runner("jar", "--dry-run").withPluginClasspathAndKgp().build() + assertFalse(":bundleJniMymath" in plain.output, plain.output) + } + @Test fun `JNI targets default to the library's that a JVM can load`() { write( @@ -249,7 +341,7 @@ class KonanPluginFunctionalTest { val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() - assertTrue(":cmakeBuildMymath$hostSuffix SKIPPED" in result.output, result.output) + assertTrue(":cmakeBuildJniMymath$hostSuffix SKIPPED" in result.output, result.output) assertFalse(":linkJniMymath$hostSuffix" in result.output, "no plugin link with an external build:\n${result.output}") } @@ -313,7 +405,7 @@ class KonanPluginFunctionalTest { val result = runner("linkJniMymath").withPluginClasspathAndKgp().build() - assertEquals(TaskOutcome.SUCCESS, result.task(":externalBuildMymath$hostSuffix")?.outcome, result.output) + assertEquals(TaskOutcome.SUCCESS, result.task(":externalBuildJniMymath$hostSuffix")?.outcome, result.output) val libraries = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.konanName}").listFiles().orEmpty() val library = libraries.singleOrNull() ?: error("expected one library, got ${libraries.toList()}") val (target, name, stubDirectory) = library.readText().split('|') diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index 2806c0d..2ae83ff 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -45,7 +45,7 @@ class GenerateFromNativeIndexTest { @Test fun `the jni generator binds each C function under its own name`() { val kotlin = jni().resolve("kotlin/sample/sample.kt").readText() - assertContains(kotlin, "private val nativeLibrary: Unit = System.loadLibrary(\"samplestubs\")") + assertContains(kotlin, "private val nativeLibrary: Unit = loadNativeLibrary(\"samplestubs\")") assertContains(kotlin, "@JvmName(\"sample_sum\")\ninternal external fun sample_sum(values: IntArray?, count: Long): Int") assertContains(kotlin, "internal external fun sample_name(): String?") assertContains(kotlin, "internal external fun sample_buffer(handle: Long, c: Int, s: Int): ByteArray") From a29fc0fdd62b902d493a24e65b59f7b999a5bdd5 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 8 Oct 2026 10:15:07 +0200 Subject: [PATCH 23/57] feat: Cargo preset, FFM for multiplatform jvm(), dependency fetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `libraries { create(…) { cargo { } } }` builds a Rust crate (crate-type = ["staticlib"]) per target with `cargo build --lib --target `; its archive becomes the library's, so every consumer binds Rust like C. Crates with C code get Kotlin/Native's compilers as CC_/CXX_/AR_, the cc crate's target-specific names; the build context now carries the toolchain files (ToolchainFiles) and only some builds export the standard names. - In a multiplatform project ffm() serves the jvm() targets: Java into the Main source set, the library into the jar. Android targets keep JNI. JNI's sources leave jvm() lazily, with the compile's dependency on generation spelled out. - KonanDistribution.ensureDependencies fetches a target's sysroot when missing (run_konan never does) by linking a trivial program; the compile, link and toolchain tasks call it first. - Zig's linux_arm64 triple is pinned to glibc 2.25, Kotlin/Native's. - Tests for androidTargets(), linkerOptionsFor, both argumentsFor overloads, multiplatform FFM and JNI ordering, and Cargo. Verified: one jar built on macOS runs on Linux arm64 and x86-64; Rust through JNI on the JVM and on an Android arm64 emulator. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 22 ++++ README.md | 34 ++++- doc/interop-architecture.md | 10 +- .../github/lemcoder/consumer/FfmConsumer.kt | 44 ++++--- .../github/lemcoder/consumer/JniConsumer.kt | 10 +- .../io/github/lemcoder/dsl/CargoSettings.kt | 49 +++++++ .../github/lemcoder/dsl/NativeLibrarySpec.kt | 11 +- .../github/lemcoder/external/CMakeCommands.kt | 2 +- .../lemcoder/external/ExternalBuilds.kt | 11 +- .../lemcoder/kotlin/KonanDistribution.kt | 21 +++ .../github/lemcoder/kotlin/KonanToolchain.kt | 12 +- .../lemcoder/kotlin/KotlinGradlePlugin.kt | 30 ++++- .../io/github/lemcoder/library/CargoBuild.kt | 41 ++++++ .../library/CompileNativeLibraryTask.kt | 1 + .../lemcoder/library/LibraryProvider.kt | 15 +++ .../lemcoder/link/LinkSharedLibraryTask.kt | 1 + .../lemcoder/model/ExternalBuildContract.kt | 19 +++ .../github/lemcoder/toolchain/RustTargets.kt | 24 ++++ .../github/lemcoder/toolchain/ZigTargets.kt | 5 +- .../lemcoder/KonanPluginFunctionalTest.kt | 124 ++++++++++++++++++ 20 files changed, 439 insertions(+), 47 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt create mode 100644 src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt create mode 100644 src/main/kotlin/io/github/lemcoder/toolchain/RustTargets.kt diff --git a/CLAUDE.md b/CLAUDE.md index dd75614..e0dbcd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,9 +95,31 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t `--unwindlib=none` for Android, Xcode's compiler-rt for macOS. The wrappers add them only when linking. - **`external { }` defaults to `ExternalToolchain.SYSTEM`**, the presets to `AUTO`: generic steps may not compile anything, and must not need a Kotlin/Native distribution to run. +- **Cargo gets the compilers under the `cc` crate's target-specific names** (`CC_`…), not `CC`: + a plain `CC` would also reach build scripts compiled for the host. `registerExternalBuilds` therefore + carries the toolchain in the context (`ToolchainFiles`) and puts the standard names in the environment + only where asked. Never register a toolchain task from inside another task's configuration β€” Gradle + forbids it; resolve it in `registerExternalBuilds`. - **Task names carry the side**: `Build` for a library, `BuildJni…`, `BuildFfm…` for a binding β€” a library and a binding are usually both called `mymath`. +## Kotlin/Native's dependencies + +- **`run_konan` never downloads a target's sysroot**; the compiler does, when it links. So before + compiling or linking for a target, `KonanDistribution.ensureDependencies` checks the `~/.konan/ + dependencies` paths in the clang command `run_konan` would run and, if any is missing, links a trivial + program for that target. Without it, a target Kotlin itself has not built yet fails with + "'string.h' file not found". Linux arm64's sysroot is glibc 2.25, x86-64's 2.19 β€” Zig's triples match. + +## Multiplatform projects + +- **FFM serves `jvm()` targets, JNI the Android ones**: one binding cannot be both the JDK's API and + PanamaPort's. FFM's Java goes into the Gradle source set Kotlin creates per JVM compilation + (`Main`, compiled by `compileMainJava`). +- **JNI's sources leave `jvm()` lazily**, since targets are declared before `ffm()` is known β€” and a + `project.provider { }` around a generated directory carries no producer, so the compile task's + dependency on generation is spelled out alongside it. A missing one compiles before generating. + ## Libraries in the jar - **The loader is generated, not a runtime dependency**: `NativeLoaderSource` writes a Kotlin function diff --git a/README.md b/README.md index a4ca873..76888f1 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ A binding is consumed wherever the project's Kotlin targets need it, with nothin |---|---|---| | Kotlin/Native targets (`kotlin("multiplatform")`) | a cinterop klib per target the library is built for | KGP's own `cinterops {}`, with the library's archive embedded | | `kotlin("jvm")`, multiplatform `jvm()` or Android targets, or an Android app | JNI bindings in the main sources, a JNI library per target | the plugin's JNI generator and link | -| `ffm()` on the binding, in a `kotlin("jvm")` project | FFM bindings in place of JNI, the library as a shared library | jextract | +| `ffm()` on the binding, in a `kotlin("jvm")` project or for a multiplatform `jvm()` target | FFM bindings in place of JNI, the library as a shared library | jextract | | `ffm()` on the binding, in an Android module | FFM bindings on PanamaPort in place of JNI, the library as `.so` per 64-bit ABI | jextract | ### `libraries { }` @@ -77,6 +77,7 @@ either way. | `outputDirectory` | `Directory` | `build/konan/libraries/` | Root of the per-target archives; `archive(target)` gives one. | | `zig { }` | | β€” | Build with the project's `build.zig` instead; below. | | `external { }` | | β€” | Build with any other build system, as commands; below. | +| `cargo { }` | | β€” | Build a Rust crate; below. | | `make { }` | | β€” | Build with the project's Makefile; below. | | `prebuilt { }` | | β€” | Use archives built elsewhere; below. | @@ -103,6 +104,31 @@ library (`b.addLibrary(.{ .linkage = .static, … })`). Android targets need a libc file for the NDK's bionic, which Zig does not bundle. +#### Building a Rust crate β€” `cargo { }` +```kotlin +create("mymath") { + targets(NativeTarget.host, NativeTarget.ANDROID_ARM64) + cargo { } // native/Cargo.toml, crate-type = ["staticlib"] +} +``` + +`cargoBuild` runs `cargo build --lib --target --release` per target into +`build/konan/libraries//cargo`, and its `lib.a` becomes the library's archive: bindings +written against a C header for the crate's `extern "C"` functions (cbindgen writes one) bind it like any +C library, for every consumer. Each target's standard library must be installed (`rustup target add +aarch64-linux-android`). Crates with C code get Kotlin/Native's compilers as `CC_`, +`CXX_` and `AR_` β€” what the `cc` crate reads for the target, leaving build scripts on +the host's. On Android, link with `panic = "abort"`: Kotlin/Native's NDK sysroot has no unwinder. + +| Property | Type | Default | Description | +|--------------|---------------------|--------------------------|-------------| +| `manifest` | `File` | `sourceDirectory/Cargo.toml` | The crate. | +| `crateName` | `String` | the library's name | As `Cargo.toml` spells it; `-` read as `_` in the archive name. | +| `profile` | `CargoProfile` | `RELEASE` | `DEBUG` or `RELEASE`. | +| `arguments` | `List` | β€” | Further `cargo build` arguments, e.g. `--features`. | +| `toolchain` | `ExternalToolchain` | `AUTO` | Whose C compilers C dependencies get. | +| `executable` | `Executable` | `OnPath("cargo")` | The `cargo` program. | + #### Building with Make β€” `make { }` ```kotlin create("mymath") { @@ -369,7 +395,9 @@ different Gradle module. ### `ffm { }` FFM (`java.lang.foreign`) bindings, written by jextract from the same `.def`, and the library linked as a shared library for the build host β€” FFM needs no stub. In a `kotlin("jvm")` project they replace -the JNI bindings and are added to the main Java sources; compile them with a JDK 22+ toolchain and run +the JNI bindings and are added to the main Java sources; in a multiplatform project they replace them on +the `jvm()` targets, in each one's Java source set (`src/Main/java`'s), while Android targets +keep JNI; compile them with a JDK 22+ toolchain and run with `--enable-native-access`; the library travels in the jar (below), or put `ffm.libraryDirectory` on `java.library.path`. @@ -433,7 +461,7 @@ with no stub. The app needs: | Task | Description | |-------------------------------|-------------| | `compileNativeLibrary` | Builds a library to `lib.a` per target, with whichever provider it uses. | -| `konanClang`, `zigBuild`, `makeBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, make, the library's own steps, or placing prebuilt archives. | +| `konanClang`, `zigBuild`, `cargoBuild`, `makeBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, make, the library's own steps, or placing prebuilt archives. | | `konanToolchain` | Writes Kotlin/Native's compilers for a target, for external builds that use them. | | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index 559521f..208fb3b 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -107,7 +107,7 @@ flowchart TD | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `model.NativeTarget`: one entry per Kotlin/Native target (checked by a test against the pinned version), with family, architecture, Android ABI, library naming and Zig triple; `NativeTarget.of(konanTarget)` is the only conversion | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Kotlin/Native's (clang + downloaded sysroots, `KonanDistribution`), also handed to external builds per target as `CC`/`CXX`/`AR` and a CMake toolchain file (`KonanToolchain`, chosen by `ExternalToolchain`); Zig brings its own; the system's for host builds | -| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | +| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`), **Cargo** (`cargo { }`, a Rust `staticlib` per Rust triple), **Make** (`make { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | | 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); for FFM there is no glue, but the shared library it loads is a sealed `FfmLibraryBuild` of the same shape: **KonanLink** (the archive linked whole), **Prebuilt** (`ffm { prebuilt { sharedLibrary(…) } }`), or **External** with the `ExternalFfmVariable` contract β€” **Generic** (`ffm { external { } }`) and the **CMake** preset (`ffm { cmake { } }`). Both CMake presets are one implementation, `external/CMakeCommands.kt` | @@ -209,7 +209,7 @@ A **Prebuilt** provider takes an archive per target from local paths β€” a vendo each as `lib.a`, so a Windows `name.lib` works unchanged; the library's targets default to the ones it has archives for. It is not an `External`: there is nothing to run. -Still open: prebuilt archives from a Maven artifact, FFM in multiplatform projects, and publishing a +Still open: prebuilt archives from a Maven artifact, and publishing a library's bindings and binaries. ## B. Generator model @@ -318,8 +318,8 @@ Partly settled by what exists: ## D. Toolchains, prebuilt binaries and packaging -What the generators showed each consumer needs. The plugin wires every row today, FFM only in a -`kotlin("jvm")` project. Each row is a `BindingConsumer`, the binding-side counterpart of +What the generators showed each consumer needs. The plugin wires every row today; in a multiplatform +project FFM serves the `jvm()` targets and Android targets keep JNI. Each row is a `BindingConsumer`, the binding-side counterpart of `LibraryProvider`: `prepare()` runs when the binding is added, for wiring that must be eager (AGP's variant callbacks, the multiplatform source directory); `isUsed()` decides from the project's plugins and the binding's settings once the build script has run; `register()` then adds the tasks. Consumers @@ -335,8 +335,6 @@ take the library only as `NativeArtifacts`. A new consumer is one more entry in Gaps that remain: -- **FFM outside `kotlin("jvm")`.** A multiplatform JVM target gets the bindings generated but not - wired; the runtime flags (`--enable-native-access`, `java.library.path`) stay the build's. - **Tool provisioning.** Generation needs the Kotlin/Native distribution of exactly `kotlinNativeVersion` in `~/.konan` (any KMP build of that version downloads it); jextract is a path option. The plugin should locate or download both. diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt index 6320436..47940ff 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt @@ -3,6 +3,7 @@ package io.github.lemcoder.consumer import io.github.lemcoder.ffm.FfmLibraryInputs import io.github.lemcoder.ffm.ffmLibraryBuildFor import io.github.lemcoder.index.FfmApi +import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.model.Architecture @@ -35,7 +36,8 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume private lateinit var linkAll: TaskProvider - private val isAndroid get() = context.hasPlugin(GradlePluginId.ANDROID_BASE) + /** An Android module: PanamaPort, 64-bit ABIs. A multiplatform project's FFM serves its `jvm()` targets instead. */ + private val isAndroid get() = context.hasPlugin(GradlePluginId.ANDROID_BASE) && !context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) override fun prepare() { val ffm = binding.ffm @@ -90,22 +92,32 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume } else { ffm.libraryDirectory.convention(ffm.libraryDirectoryFor(NativeTarget.host)) } - if (context.hasPlugin(GradlePluginId.KOTLIN_JVM)) { - // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. - val main = project.extensions.getByType(SourceSetContainer::class.java).getByName("main") - main.java.srcDir(generate.map { it.outputDirectory.dir("java") }) - if (ffm.bundleInJar.get()) { - val bundle = project.registerJarBundle( - taskName = taskName("bundleFfm", bindingName), - taskDescription = "Put the libraries the '$bindingName' FFM bindings load in the jar.", - libraries = targets.associateWith { target -> ffm.libraryDirectoryFor(target).map { it.file(target.sharedLibraryName(libraryName)) } }, - destination = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/resources").get().asFile, - builtBy = linkAll, - ) - main.resources.srcDir(bundle.map { it.destinationDir }) + val javaSources = generate.flatMap { it.outputDirectory.dir("java") } + val kmpJvm = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project) + val bundle = if (!isAndroid && ffm.bundleInJar.get() && (context.hasPlugin(GradlePluginId.KOTLIN_JVM) || kmpJvm)) { + project.registerJarBundle( + taskName = taskName("bundleFfm", bindingName), + taskDescription = "Put the libraries the '$bindingName' FFM bindings load in the jar.", + libraries = targets.associateWith { target -> ffm.libraryDirectoryFor(target).map { it.file(target.sharedLibraryName(libraryName)) } }, + destination = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/resources").get().asFile, + builtBy = linkAll, + ).map { it.destinationDir } + } else null + when { + context.hasPlugin(GradlePluginId.KOTLIN_JVM) -> { + // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. + val main = project.extensions.getByType(SourceSetContainer::class.java).getByName("main") + main.java.srcDir(javaSources) + bundle?.let { main.resources.srcDir(it) } } - } else if (!isAndroid) { - project.logger.info("konan.bindings.$bindingName: FFM bindings are wired into kotlin(\"jvm\") and Android projects; add build/generated/konan/$bindingName/ffm/java yourself.") + kmpJvm -> { + KotlinGradlePlugin.addKmpJvmJava(project, javaSources) + bundle?.let { KotlinGradlePlugin.addKmpJvmResources(project, it) } + } + !isAndroid -> project.logger.info( + "konan.bindings.$bindingName: FFM bindings are wired into kotlin(\"jvm\"), multiplatform jvm() and Android projects; " + + "add build/generated/konan/$bindingName/ffm/java yourself." + ) } } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt index 15b18cf..2105a00 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt @@ -40,7 +40,7 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume val sources = generate.flatMap { it.kotlinSourceDirectory } // Eager: the variant API must be wired at configuration time. - project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.addKmpJvmSources(project, sources) } + project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.addKmpJvmSources(project, sources, generate) { !binding.ffm.isEnabled } } AgpWiring.addSources(project, SourceKind.KOTLIN, sources, generate, linkAll) { !replacedByFfm } AgpWiring.addJniLibs(project, binding.jni.jniLibsRoot, linkAll) { !replacedByFfm } // AGP looks for a `baselineProfiles` directory beside every source root, including the generated @@ -50,10 +50,12 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume /** * In a `kotlin("jvm")` project or an Android module, `ffm()` replaces JNI: both would define the same - * facade class. Read once the build script has run. + * facade class. In a multiplatform project it replaces JNI on the `jvm()` targets only β€” Android + * targets keep JNI. Read once the build script has run. */ private val replacedByFfm get() = - binding.ffm.isEnabled && (context.hasPlugin(GradlePluginId.KOTLIN_JVM) || context.hasPlugin(GradlePluginId.ANDROID_BASE)) + binding.ffm.isEnabled && !context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && + (context.hasPlugin(GradlePluginId.KOTLIN_JVM) || context.hasPlugin(GradlePluginId.ANDROID_BASE)) private val isPlainJvmConsumer get() = context.hasPlugin(GradlePluginId.KOTLIN_JVM) && !replacedByFfm @@ -74,7 +76,7 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume /** The JVM targets' libraries into the jar of a `kotlin("jvm")` project or a multiplatform `jvm()` target. */ private fun bundleInJar(jvmTargets: List) { val jni = binding.jni - val kmpJvm = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project) + val kmpJvm = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project) && !binding.ffm.isEnabled if (!jni.bundleInJar.get() || jvmTargets.isEmpty() || !(isPlainJvmConsumer || kmpJvm)) return val bundle = project.registerJarBundle( taskName = taskName("bundleJni", bindingName), diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt new file mode 100644 index 0000000..d626cf1 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt @@ -0,0 +1,49 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.ExternalToolchain +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * `cargo build` for a library written in Rust, once per target, `--target` set to the Rust triple for + * it. The crate must build a static library (`crate-type = ["staticlib"]`); its `lib.a` becomes the + * library's archive, so bindings and consumers do not change. Each target's standard library has to be + * installed: `rustup target add `. + */ +abstract class CargoSettings { + init { + executable.convention(Executable.OnPath("cargo")) + profile.convention(CargoProfile.RELEASE) + toolchain.convention(ExternalToolchain.AUTO) + } + + /** The crate's `Cargo.toml`. Defaults to the library's `sourceDirectory/Cargo.toml`. */ + abstract val manifest: RegularFileProperty + + /** The crate's name, as `Cargo.toml` spells it; the archive is `lib.a`, `-` read as `_`. Defaults to the library's name. */ + abstract val crateName: Property + + /** Debug or release build. Defaults to [CargoProfile.RELEASE]. */ + abstract val profile: Property + + /** Further `cargo build` arguments, e.g. `--features`. */ + abstract val arguments: ListProperty + + /** The `cargo` program. Defaults to `cargo` on the PATH or in the usual install locations. */ + abstract val executable: Property + + /** + * Whose C compilers crates with C code (the `cc` crate) get, as `CC_`, `CXX_` and + * `AR_` β€” target-specific, so build scripts compiled for the host keep the host's. + * Kotlin/Native's when cross-compiling, by default. + */ + abstract val toolchain: Property +} + +/** Cargo's build profiles. */ +enum class CargoProfile(internal val directory: String) { + DEBUG("debug"), + RELEASE("release"), +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt index a3dfaca..be3a307 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt @@ -16,7 +16,7 @@ import javax.inject.Inject /** * A C/C++ library built into a static archive per target, `//lib.a`: by - * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [make], [external]), or built + * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [cargo], [make], [external]), or built * elsewhere ([prebuilt]). */ abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { @@ -84,6 +84,14 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o action.execute(make) } + /** Builds the library from a Rust crate (`crate-type = ["staticlib"]`) with `cargo build`, once per target. */ + val cargo: CargoSettings = objects.newInstance(CargoSettings::class.java) + + fun cargo(action: Action) { + selectedProviders += LibraryProvider.External.Cargo + action.execute(cargo) + } + /** Archives built elsewhere, one per target; [targets] default to the targets they are declared for. */ val prebuilt: PrebuiltSettings = objects.newInstance(PrebuiltSettings::class.java) @@ -98,6 +106,7 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o init { zig.projectDirectory.convention(sourceDirectory) zig.artifactName.convention(name) + cargo.crateName.convention(name) targets.convention(prebuilt.archives.keySet().map { it.toList() }) } } diff --git a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt index 2842322..2b272e3 100644 --- a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt @@ -49,7 +49,7 @@ internal fun ExternalBuildTask.addCMakeCommands( // Kotlin/Native's compilers by toolchain file β€” the arguments after it can still name another, and // a preset names its own. if (!preset.isPresent) { - context.environment[ToolchainVariable.CMAKE_TOOLCHAIN_FILE]?.let { file -> arguments.add(file.map { "-DCMAKE_TOOLCHAIN_FILE=$it" }) } + context.toolchain?.let { toolchain -> arguments.add(toolchain.cmakeToolchainFile.map { "-DCMAKE_TOOLCHAIN_FILE=${it.absolutePath}" }) } } context.environment.filterKeys { it !is ToolchainVariable } .forEach { (variable, value) -> arguments.add(value.map { "-D${variable.variableName}=$it" }) } diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt index d3571d6..0b81bde 100644 --- a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt @@ -18,8 +18,9 @@ import org.gradle.api.tasks.TaskProvider * [libraryFileName] in the context's output directory. [configure] adds the build system's commands β€” * the only part that differs between build systems and between a library and a JNI stub. * - * Where [toolchain] picks Kotlin/Native's for a target, the context also carries its [ToolchainVariable]s - * and the build waits for its files; null means the build system brings its own (Zig). + * Where [toolchain] picks Kotlin/Native's for a target, the build waits for its files and the context + * carries them β€” and, with [toolchainInEnvironment], its [ToolchainVariable]s under the standard names. + * Null means the build system brings its own (Zig). */ internal fun Project.registerExternalBuilds( taskPrefix: String, @@ -29,13 +30,17 @@ internal fun Project.registerExternalBuilds( contextFor: (NativeTarget) -> ExternalBuildContext, libraryFileName: (NativeTarget) -> Provider, toolchain: ExternalToolchain?, + toolchainInEnvironment: Boolean = true, configure: ExternalBuildTask.(ExternalBuildContext) -> Unit, ): List> { val clean = cleanTask() return targets.map { target -> val konanToolchain = toolchain?.takeIf { it.usesKonan(target) }?.let { KonanToolchain.of(this, target) } val context = contextFor(target).let { context -> - konanToolchain?.let { ExternalBuildContext(context.target, context.outputDirectory, context.environment + it.environment) } ?: context + konanToolchain?.let { + val environment = if (toolchainInEnvironment) context.environment + it.files.environment else context.environment + ExternalBuildContext(context.target, context.outputDirectory, environment, it.files) + } ?: context } val taskDescription = description(target) val fileName = libraryFileName(target) diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt index ae33e87..107a16a 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -56,6 +56,27 @@ internal class KonanDistribution(val home: File) { return line.removeSuffix(" --version").split(' ').filter { it.isNotEmpty() } } + /** + * Downloads what [target] needs β€” its sysroot and toolchain under `~/.konan/dependencies` β€” when + * missing. `run_konan` uses them but never fetches them; the compiler does when it links, so a trivial + * program is linked in [scratch]. A target Kotlin/Native itself has not built for yet would otherwise + * fail with "'string.h' file not found". + */ + fun ensureDependencies(target: NativeTarget, exec: ExecOperations, scratch: File) { + val dependencies = home.parentFile.resolve("dependencies").absolutePath + val missing = clangCommand(target, ClangDriver.C, exec) + .mapNotNull { argument -> argument.indexOf(dependencies).takeIf { it >= 0 }?.let { File(argument.substring(it)) } } + .filterNot { it.exists() } + if (missing.isEmpty()) return + scratch.mkdirs() + val main = scratch.resolve("main.kt").apply { writeText("fun main() {}\n") } + val compiler = home.resolve(if (HostOs.current == HostOs.WINDOWS) "bin/kotlinc-native.bat" else "bin/kotlinc-native") + val result = exec.execCapture { + commandLine(compiler.absolutePath, "-target", target.konanName, "-p", "program", "-o", scratch.resolve("main").absolutePath, main.absolutePath) + } + check(result is ExecCaptureResult.Success) { "Downloading Kotlin/Native's dependencies for ${target.konanName} failed:\n${result.output}" } + } + /** llvm-ar, for archiving. */ fun llvmAr(arguments: List): List = listOf(runKonan.absolutePath, "llvm", "llvm-ar") + arguments diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt index 20ed6c5..41d103c 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt @@ -2,11 +2,10 @@ package io.github.lemcoder.kotlin import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.model.Architecture -import io.github.lemcoder.model.ExternalBuildVariable import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.TargetFamily -import io.github.lemcoder.model.ToolchainVariable +import io.github.lemcoder.model.ToolchainFiles import io.github.lemcoder.model.taskName import org.gradle.api.DefaultTask import org.gradle.api.Project @@ -34,13 +33,7 @@ internal class KonanToolchain(val task: TaskProvider, direct val ar: Provider = directory.map { it.file("bin/${script("ar")}").asFile } val cmakeToolchainFile: Provider = directory.map { it.file("toolchain.cmake").asFile } - /** The [ToolchainVariable]s, for an external build's environment. */ - val environment: Map> = mapOf( - ToolchainVariable.CC to cc.map { it.absolutePath }, - ToolchainVariable.CXX to cxx.map { it.absolutePath }, - ToolchainVariable.AR to ar.map { it.absolutePath }, - ToolchainVariable.CMAKE_TOOLCHAIN_FILE to cmakeToolchainFile.map { it.absolutePath }, - ) + val files: ToolchainFiles = ToolchainFiles(cc, cxx, ar, cmakeToolchainFile) companion object { /** [target]'s toolchain, whose task is registered by the first build that asks for it. */ @@ -80,6 +73,7 @@ internal abstract class KonanToolchainTask @Inject constructor(private val exec: fun write() { val konan = KonanDistribution.resolve(konanHome.orNull?.asFile) val target = target.get() + konan.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) val cc = konan.clangCommand(target, ClangDriver.C, exec) val cxx = konan.clangCommand(target, ClangDriver.CXX, exec) val ar = File(cc.first()).resolveSibling("llvm-ar").absolutePath diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index 79be7cf..e8754e0 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -6,6 +6,8 @@ import io.github.lemcoder.model.NativeTarget import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider +import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.TaskProvider import java.io.File import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension @@ -26,10 +28,34 @@ internal object KotlinGradlePlugin { } /** …and of the main compilation of every JVM and Android target of a multiplatform project. */ - fun addKmpJvmSources(project: Project, sources: Provider) { + fun addKmpJvmSources(project: Project, sources: Provider, generate: TaskProvider<*>, onJvmTargets: () -> Boolean) { + // Decided when the sources are read, not now: targets are usually declared before konan { }. A + // provider of our own carries no producer, so the jvm() compile's dependency is spelled out too. + val jvmSources = project.provider { if (onJvmTargets()) listOf(sources.get()) else emptyList() } + val jvmGenerate = project.provider { if (onJvmTargets()) listOf(generate) else emptyList() } project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets .matching { it.platformType in JVM_PLATFORMS } - .configureEach { compilations.matching { it.name == "main" }.configureEach { defaultSourceSet.kotlin.srcDir(sources) } } + .configureEach { + val jvm = platformType == KotlinPlatformType.jvm + compilations.matching { it.name == "main" }.configureEach { + defaultSourceSet.kotlin.srcDir(if (jvm) jvmSources else sources) + if (jvm) compileTaskProvider.configure { dependsOn(jvmGenerate) } + } + } + } + + /** + * Java sources of every JVM target's main compilation in a multiplatform project: the Gradle source set + * Kotlin creates for it, `Main`, compiled by `compileMainJava`. + */ + fun addKmpJvmJava(project: Project, sources: Provider) { + val sourceSets = project.extensions.getByType(SourceSetContainer::class.java) + project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets + .matching { it.platformType == KotlinPlatformType.jvm } + .configureEach { + val name = "${targetName}Main" + sourceSets.matching { it.name == name }.configureEach { java.srcDir(sources) } + } } /** Resources of a `kotlin("jvm")` project's main source set. */ diff --git a/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt b/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt new file mode 100644 index 0000000..ec0ce10 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt @@ -0,0 +1,41 @@ +package io.github.lemcoder.library + +import io.github.lemcoder.dsl.CargoProfile +import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.toolchain.rustTriple +import org.gradle.api.Project + +/** + * [LibraryProvider.External.Cargo]: `cargo build --lib` for one target, into a target directory of the + * plugin's own; the crate's static library is then copied to where every other provider puts the archive. + */ +internal fun addCargoCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) { + val cargo = library.cargo + val target = context.target + val triple = target.rustTriple() + val targetDirectory = project.layout.buildDirectory.dir("konan/libraries/${library.name}/cargo") + val manifest = cargo.manifest.orElse(library.sourceDirectory.file("Cargo.toml")) + + task.command { + executable.set(cargo.executable) + arguments.addAll("build", "--lib", "--target", triple) + arguments.addAll(manifest.map { listOf("--manifest-path", it.asFile.absolutePath) }) + arguments.addAll(targetDirectory.map { listOf("--target-dir", it.asFile.absolutePath) }) + arguments.addAll(cargo.profile.map { if (it == CargoProfile.RELEASE) listOf("--release") else emptyList() }) + arguments.addAll(cargo.arguments) + workingDirectory.set(manifest.map { it.asFile.parentFile.absolutePath }) + // Target-specific, as the cc crate reads them: a plain CC would also reach what is built for the host. + context.toolchain?.let { toolchain -> + val suffix = triple.replace('-', '_') + environment.put("CC_$suffix", toolchain.cc.map { it.absolutePath }) + environment.put("CXX_$suffix", toolchain.cxx.map { it.absolutePath }) + environment.put("AR_$suffix", toolchain.ar.map { it.absolutePath }) + } + } + task.producedLibrary.set( + targetDirectory.zip(cargo.profile) { directory, profile -> directory.dir("$triple/${profile.directory}") } + .zip(cargo.crateName) { directory, crate -> directory.file("lib${crate.replace('-', '_')}.a") } + ) +} diff --git a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt index 0d5306a..9a088db 100644 --- a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt @@ -76,6 +76,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( val workingDir = workingDir.asFile.getOrElse(temporaryDir) val distribution = KonanDistribution.resolve(konanHome.orNull?.asFile) targets.get().forEach { target -> + distribution.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) // prepare output dirs val sourcesDir = workingDir.resolve("sources") val headersDir = workingDir.resolve("headers") diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt index c68e166..4482ba6 100644 --- a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt +++ b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt @@ -50,6 +50,9 @@ internal sealed interface LibraryProvider { /** Whose compilers the build gets; null when the build system brings its own. */ protected abstract fun toolchain(library: NativeLibrarySpec): ExternalToolchain? + /** Whether those compilers go into the environment as `CC`, `CXX`, `AR`. */ + protected open val toolchainInEnvironment: Boolean = true + final override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): List> { val libraryName = library.name val fileName = project.providers.provider { NativeArtifacts.archiveFileName(libraryName) } @@ -72,6 +75,7 @@ internal sealed interface LibraryProvider { }, libraryFileName = { fileName }, toolchain = toolchain(library), + toolchainInEnvironment = toolchainInEnvironment, ) { context -> addCommands(this, project, library, context) } } @@ -93,6 +97,17 @@ internal sealed interface LibraryProvider { override fun toolchain(library: NativeLibrarySpec) = library.make.toolchain.get() } + /** `cargo { }`: a Rust crate built as a static library, per Rust target triple. */ + data object Cargo : External() { + override val taskPrefix = "cargoBuild" + override val toolName = "cargo" + override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = + addCargoCommands(task, project, library, context) + override fun toolchain(library: NativeLibrarySpec): ExternalToolchain = library.cargo.toolchain.get() + // Under the cc crate's target-specific names instead; see addCargoCommands. + override val toolchainInEnvironment = false + } + /** `zig { }`: the project's own `build.zig`. */ data object Zig : External() { override val taskPrefix = "zigBuild" diff --git a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt index 9d5441f..39fb274 100644 --- a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt @@ -59,6 +59,7 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( val distribution = KonanDistribution.resolve(konanHome.orNull?.asFile) check(distribution.runKonan.isFile) { "run_konan not found: ${distribution.runKonan}" } val nativeTarget = target.get() + distribution.ensureDependencies(nativeTarget, exec, temporaryDir.resolve("dependencies")) val outDir = outputDirectory.get().asFile outDir.deleteRecursively(); outDir.mkdirs() diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt index d0c5cc1..cf3f8bc 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt +++ b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt @@ -2,6 +2,7 @@ package io.github.lemcoder.model import org.gradle.api.file.Directory import org.gradle.api.provider.Provider +import java.io.File /** A variable of an external build's contract: what the plugin tells the build, by environment. */ internal sealed interface ExternalBuildVariable { @@ -86,6 +87,24 @@ internal class ExternalBuildContext( val target: NativeTarget, val outputDirectory: Provider, val environment: Map>, + /** Kotlin/Native's compilers for [target], when the build uses them. */ + val toolchain: ToolchainFiles? = null, ) { operator fun get(variable: ExternalBuildVariable): Provider = environment.getValue(variable) } + +/** Kotlin/Native's compilers for one target, as files a build can be pointed at. */ +internal class ToolchainFiles( + val cc: Provider, + val cxx: Provider, + val ar: Provider, + val cmakeToolchainFile: Provider, +) { + /** The [ToolchainVariable]s, under the standard names. */ + val environment: Map> = mapOf( + ToolchainVariable.CC to cc.map { it.absolutePath }, + ToolchainVariable.CXX to cxx.map { it.absolutePath }, + ToolchainVariable.AR to ar.map { it.absolutePath }, + ToolchainVariable.CMAKE_TOOLCHAIN_FILE to cmakeToolchainFile.map { it.absolutePath }, + ) +} diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/RustTargets.kt b/src/main/kotlin/io/github/lemcoder/toolchain/RustTargets.kt new file mode 100644 index 0000000..ba98c8a --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/toolchain/RustTargets.kt @@ -0,0 +1,24 @@ +package io.github.lemcoder.toolchain + +import io.github.lemcoder.model.NativeTarget + +/** + * Rust's target triple for a Kotlin/Native target β€” what `cargo build --target` and `rustup target add` + * take. Targets Rust ships no standard library for are left out. + */ +internal fun NativeTarget.rustTriple(): String = when (this) { + NativeTarget.MACOS_ARM64 -> "aarch64-apple-darwin" + NativeTarget.MACOS_X64 -> "x86_64-apple-darwin" + NativeTarget.IOS_ARM64 -> "aarch64-apple-ios" + NativeTarget.IOS_SIMULATOR_ARM64 -> "aarch64-apple-ios-sim" + NativeTarget.IOS_X64 -> "x86_64-apple-ios" + NativeTarget.LINUX_X64 -> "x86_64-unknown-linux-gnu" + NativeTarget.LINUX_ARM64 -> "aarch64-unknown-linux-gnu" + NativeTarget.LINUX_ARM32_HFP -> "armv7-unknown-linux-gnueabihf" + NativeTarget.MINGW_X64 -> "x86_64-pc-windows-gnu" + NativeTarget.ANDROID_ARM64 -> "aarch64-linux-android" + NativeTarget.ANDROID_X64 -> "x86_64-linux-android" + NativeTarget.ANDROID_ARM32 -> "armv7-linux-androideabi" + NativeTarget.ANDROID_X86 -> "i686-linux-android" + else -> error("No Rust target with a standard library for $konanName") +} diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt b/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt index 4fe8b6a..b2b36bc 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt +++ b/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt @@ -4,7 +4,8 @@ import io.github.lemcoder.model.NativeTarget /** * Zig's target triple for a Kotlin/Native target, pinned to what Kotlin/Native's own sysroots expect, - * so an archive Zig builds links wherever konan's would: glibc 2.19 on Linux, macOS 12.0 and iOS 14.0 + * so an archive Zig builds links wherever konan's would: glibc 2.19 on x86-64 Linux and 2.25 on arm64, + * macOS 12.0 and iOS 14.0 * on Apple. Android is mapped, but needs a libc file for the NDK's bionic, which Zig does not bundle. */ internal fun NativeTarget.zigTriple(): String = when (this) { @@ -14,7 +15,7 @@ internal fun NativeTarget.zigTriple(): String = when (this) { NativeTarget.IOS_SIMULATOR_ARM64 -> "aarch64-ios.14.0-simulator" NativeTarget.IOS_X64 -> "x86_64-ios.14.0-simulator" NativeTarget.LINUX_X64 -> "x86_64-linux-gnu.2.19" - NativeTarget.LINUX_ARM64 -> "aarch64-linux-gnu.2.19" + NativeTarget.LINUX_ARM64 -> "aarch64-linux-gnu.2.25" NativeTarget.MINGW_X64 -> "x86_64-windows-gnu" NativeTarget.ANDROID_ARM64 -> "aarch64-linux-android" NativeTarget.ANDROID_X64 -> "x86_64-linux-android" diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 758c68b..5fd7a3d 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -232,6 +232,38 @@ class KonanPluginFunctionalTest { private fun ByteArray.indexOfSequence(sequence: ByteArray): Int = (0..size - sequence.size).firstOrNull { start -> sequence.indices.all { this[start + it] == sequence[it] } } ?: -1 + @Test + fun `cargo builds a Rust crate into the library's archive`() { + val cargo = File(System.getProperty("user.home"), ".cargo/bin/cargo") + assumeTrue(cargo.canExecute(), "needs cargo") + File(projectDir, "native/src").mkdirs() + File(projectDir, "native/Cargo.toml").writeText( + "[package]\nname = \"my-math\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\ncrate-type = [\"staticlib\"]\n" + ) + File(projectDir, "native/src/lib.rs").writeText("#[no_mangle]\npub extern \"C\" fn my_add(a: i32, b: i32) -> i32 { a + b }\n") + write( + """id("io.github.lemcoder.konanplugin")""", + """ + libraries { + create("mymath") { + targets(NativeTarget.${host.name}) + cargo { + executable.set(Executable.File(file("${cargo.absolutePath.replace("\\", "/")}"))) + crateName.set("my-math") + } + } + } + """, + ) + + val result = runner("compileNativeLibraryMymath").build() + + // Cargo names it libmy_math.a; consumers find the library's own name. + val archive = File(projectDir, "build/konan/libraries/mymath/${host.konanName}/libmymath.a") + assertTrue(archive.isFile && archive.readBytes().copyOfRange(0, 8).decodeToString() == "!\n", result.output) + assertTrue(":cargoBuildMymath$hostSuffix" in result.output, result.output) + } + @Test fun `zig builds each target's archive where every provider puts it`() { val zig = System.getenv("ZIG") @@ -305,6 +337,98 @@ class KonanPluginFunctionalTest { assertFalse(":bundleJniMymath" in plain.output, plain.output) } + @Test + fun `per-target DSL options reach the tasks`() { + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + """ + val mymath = libraries.create("mymath") { androidTargets() } + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + library(mymath) + jni { + targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) + linkerOptions.add("-lcommon") + linkerOptionsFor(NativeTarget.ANDROID_ARM64, "-larm64only") + } + } + } + """, + ) + // Read back what the tasks were given, through their declared inputs. + File(projectDir, "build.gradle.kts").appendText( + """ + afterEvaluate { + println("TARGETS=" + konan.libraries.getByName("mymath").targets.get().map { it.konanName }.sorted()) + listOf("Android_arm64", "Android_x64").forEach { + println("LINK_${'$'}it=" + tasks.getByName("linkJniMymath${'$'}it").inputs.properties["additionalLinkerArgs"]) + } + } + """.trimIndent() + ) + + val output = runner("help").withPluginClasspathAndKgp().build().output + + assertTrue("TARGETS=[android_arm32, android_arm64, android_x64, android_x86]" in output, output) + assertTrue("LINK_Android_arm64=[-lcommon, -larm64only]" in output, output) + assertTrue("LINK_Android_x64=[-lcommon]" in output, output) + } + + @Test + fun `an external step gets its per-target arguments`() { + assumeTrue(File("/bin/sh").canExecute(), "needs /bin/sh") + write( + """id("io.github.lemcoder.konanplugin")""", + """ + libraries { + create("mymath") { + targets(NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) + external { + step { + executable.set(Executable.OnPath("sh")) + arguments.add("build.sh") + argumentsFor(NativeTarget.LINUX_X64, "A=1", "B=2") + argumentsFor(NativeTarget.MINGW_X64, provider { listOf("C=3") }) + workingDirectory.set(layout.projectDirectory) + } + } + } + } + """, + ) + // Records the arguments it was run with as the "archive". + File(projectDir, "build.sh").writeText("printf '%s ' \"$@\" > \"${'$'}KONAN_LIBRARY_OUTPUT_DIR/lib${'$'}KONAN_LIBRARY_NAME.a\"\n") + + runner("compileNativeLibraryMymath").build() + + assertEquals("A=1 B=2 ", File(projectDir, "build/konan/libraries/mymath/linux_x64/libmymath.a").readText()) + assertEquals("C=3 ", File(projectDir, "build/konan/libraries/mymath/mingw_x64/libmymath.a").readText()) + } + + @Test + fun `a multiplatform jvm() target compiles after its JNI bindings are generated`() { + write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) + File(projectDir, "build.gradle.kts").appendText("kotlin { jvm() }\nrepositories { mavenCentral() }\n") + + val result = runner("compileKotlinJvm", "--dry-run").withPluginClasspathAndKgp().build() + + assertTrue(":generateJniMymath SKIPPED" in result.output, result.output) + } + + @Test + fun `ffm() serves a multiplatform project's jvm() targets in place of JNI`() { + write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath.replace("library(mymath)", "library(mymath)\n ffm()")) + File(projectDir, "build.gradle.kts").appendText("kotlin { jvm() }\nrepositories { mavenCentral() }\n") + + val result = runner("jvmJar", "--dry-run").withPluginClasspathAndKgp().build() + + listOf(":generateFfmMymath", ":compileJvmMainJava", ":linkFfmMymath$hostSuffix", ":bundleFfmMymath").forEach { + assertTrue("$it SKIPPED" in result.output, "expected $it:\n${result.output}") + } + assertFalse(":generateJniMymath" in result.output, "JNI is replaced on jvm():\n${result.output}") + } + @Test fun `JNI targets default to the library's that a JVM can load`() { write( From f24fefb48265d2fb13fc0a3040a1bccc99de1532 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 13 Oct 2026 10:58:21 +0200 Subject: [PATCH 24/57] =?UTF-8?q?feat:=20konan=20{=20config=20{=20}=20}=20?= =?UTF-8?q?for=20machine=20paths,=20exporters=20for=20Kotlin=202.0.0?= =?UTF-8?q?=E2=80=932.4.21?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Machine paths come from one place: konan { config { } }, then the Gradle property konan.config., then the environment, with strict errors and a konanConfig report. Nothing is searched for; jni.h ships with the plugin, so no JDK is needed. The index exporter is compiled once per Kotlin/Native patch release from 2.0.0 to 2.4.21, against that release's compiler jar, and the plugin runs the one matching konanHome's version; any other version is an error. What differs between releases lives in src/indexExporter/compat, chosen by version thresholds. kotlinNativeDistribution() follows the project's Kotlin plugin version, and multiplatform jvm() targets get withJava() before Kotlin 2.1.20 so FFM's Java is compiled. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 55 +- README.md | 172 ++- build.gradle.kts | 15 +- .../src/main/kotlin/native-index.gradle.kts | 112 +- doc/interop-architecture.md | 11 +- examples/README.md | 10 +- examples/android-ffm/README.md | 2 +- examples/android-ffm/build.gradle.kts | 5 +- examples/android/README.md | 4 +- examples/android/build.gradle.kts | 3 + examples/jvm/build.gradle.kts | 3 + examples/native/build.gradle.kts | 3 + gradle.properties | 8 +- src/indexExporter/compat/README.md | 16 + .../github/lemcoder/index/exporter/Access.kt | 16 + .../github/lemcoder/index/exporter/Access.kt | 12 + .../lemcoder/index/exporter/Anonymous.kt | 13 + .../lemcoder/index/exporter/Frontend.kt | 30 + .../lemcoder/index/exporter/Frontend.kt | 33 + .../lemcoder/index/exporter/DumpGenerator.kt | 9 +- .../lemcoder/index/exporter/JniGenerator.kt | 112 +- .../index/exporter/NativeIndexExporter.kt | 26 +- .../kotlin/io/github/lemcoder/KonanPlugin.kt | 26 +- .../github/lemcoder/consumer/FfmConsumer.kt | 144 ++- .../github/lemcoder/consumer/JniConsumer.kt | 15 +- .../io/github/lemcoder/dsl/CMakeSettings.kt | 4 - .../io/github/lemcoder/dsl/CargoSettings.kt | 5 - .../kotlin/io/github/lemcoder/dsl/FfmSpec.kt | 5 - .../kotlin/io/github/lemcoder/dsl/JniSpec.kt | 3 - .../io/github/lemcoder/dsl/KonanConfig.kt | 109 ++ .../io/github/lemcoder/dsl/KonanExtension.kt | 15 +- .../io/github/lemcoder/dsl/MakeSettings.kt | 5 - .../github/lemcoder/dsl/NativeLibrarySpec.kt | 16 +- .../io/github/lemcoder/dsl/ZigSettings.kt | 5 - .../github/lemcoder/external/CMakeCommands.kt | 15 +- .../github/lemcoder/external/MakeCommands.kt | 5 +- .../io/github/lemcoder/ffm/FfmLibraryBuild.kt | 13 +- .../index/GenerateFromNativeIndexTask.kt | 20 +- .../lemcoder/jni/GenerateJniBindingsTask.kt | 20 +- .../io/github/lemcoder/jni/JniStubBuild.kt | 4 +- .../github/lemcoder/jni/KonanLinkStubBuild.kt | 23 +- .../github/lemcoder/kotlin/IndexExporter.kt | 34 +- .../lemcoder/kotlin/KonanDistribution.kt | 115 +- .../github/lemcoder/kotlin/KonanToolchain.kt | 23 +- .../lemcoder/kotlin/KotlinGradlePlugin.kt | 62 +- .../io/github/lemcoder/library/CargoBuild.kt | 5 +- .../library/CompileNativeLibraryTask.kt | 13 +- .../lemcoder/library/KonanClangBuild.kt | 1 - .../lemcoder/library/LibraryProvider.kt | 17 + .../io/github/lemcoder/library/ZigBuild.kt | 5 +- .../lemcoder/link/LinkSharedLibraryTask.kt | 33 +- .../io/github/lemcoder/model/ConfigField.kt | 87 ++ .../io/github/lemcoder/model/DefFile.kt | 4 + .../io/github/lemcoder/model/Executable.kt | 9 +- .../lemcoder/model/ExternalBuildContract.kt | 9 +- .../github/lemcoder/toolchain/Executables.kt | 19 +- .../io/github/lemcoder/toolchain/Jdk.kt | 43 - .../io/github/lemcoder/wiring/AgpWiring.kt | 43 +- .../io/github/lemcoder/wiring/ConfigReport.kt | 64 + .../github/lemcoder/wiring/GradlePluginId.kt | 2 + .../lemcoder/index/kotlin-native.properties | 1 + .../resources/io/github/lemcoder/jni/jni.h | 1143 +++++++++++++++++ .../lemcoder/KonanPluginFunctionalTest.kt | 129 +- .../io/github/lemcoder/TestKitSupport.kt | 8 + .../index/GenerateFromNativeIndexTest.kt | 83 +- .../github/lemcoder/model/ConfigValueTest.kt | 36 + .../io/github/lemcoder/model/DefFileTest.kt | 22 + src/test/resources/nativeindex/sample.h | 4 + 68 files changed, 2693 insertions(+), 443 deletions(-) create mode 100644 src/indexExporter/compat/README.md create mode 100644 src/indexExporter/compat/access/direct/io/github/lemcoder/index/exporter/Access.kt create mode 100644 src/indexExporter/compat/access/none/io/github/lemcoder/index/exporter/Access.kt create mode 100644 src/indexExporter/compat/anonymous/spelling/io/github/lemcoder/index/exporter/Anonymous.kt create mode 100644 src/indexExporter/compat/frontend/2.0.0/io/github/lemcoder/index/exporter/Frontend.kt create mode 100644 src/indexExporter/compat/frontend/2.4.20/io/github/lemcoder/index/exporter/Frontend.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/KonanConfig.kt create mode 100644 src/main/kotlin/io/github/lemcoder/model/ConfigField.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt create mode 100644 src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt create mode 100644 src/main/resources/io/github/lemcoder/jni/jni.h create mode 100644 src/test/kotlin/io/github/lemcoder/model/ConfigValueTest.kt create mode 100644 src/test/kotlin/io/github/lemcoder/model/DefFileTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index e0dbcd1..b984cab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. a Kotlin bump adds or retires a target; add the `NativeTarget` entry (and its Zig triple, if any). - **One home per concept.** `model/` holds the domain (targets, naming, the artifact layout, the external-build contract, `selectOne`), `kotlin/` the touchpoints with Kotlin, `toolchain/` the - others (JDK, Xcode, executables, Zig). Task names come from `taskName(...)`, OS checks from + others (Xcode, running executables, Zig). Task names come from `taskName(...)`, OS checks from `HostOs`, the distribution's layout from `KonanDistribution` β€” add to those, don't re-derive. `BindingVisibility` lives in `src/exporterProtocol` so the DSL and the generator share one enum. - AGP types are only touched inside `plugins.withId(...)` blocks. A plain reference in an eagerly @@ -30,11 +30,25 @@ resolved the moment the plugin is applied, in *every* project that applies it. plugin-only build. The KMP Android plugin does **not** apply `com.android.base`, so both ids need handling. +## Machine paths: `konan { config { } }` + +- **The single source of every path the plugin needs from the machine** β€” `konanHome`, `jextract`, + `cmake`, `make`, `cargo`, `zig`. Nothing is searched for: no `PATH` scan, no install-directory or + `~/.konan` lookup, no JDK. `KonanConfig.readConfigField(field, usedBy)` reads the DSL, then the Gradle + property `konan.config.`, then the environment variable, and records `usedBy` for `konanConfig`. + Add a path by adding a `ConfigField` entry and a property, never a per-block `executable`. +- **Tasks that run Kotlin/Native implement `UsesKonanDistribution`**; the plugin sets their `konanHome` + in one `withType().configureEach`, and `distribution()` checks it when the task runs. +- **The plugin ships Android's `jni.h`** (`src/main/resources/io/github/lemcoder/jni/`, Apache 2.0): the + JNI generator writes it to its `include/` output, the host link and `KONAN_JNI_INCLUDE_DIR` use it. Its + JNI is 1.6 β€” enough for the generated stubs. + ## Ordering rules that are not obvious - **Configuration cache resolves conventions when it stores.** A `konanHome` convention that threw broke *configuration*; one that returned absent got the absent value cached before KGP had - downloaded the distribution. Toolchain detection happens in the task action. + downloaded the distribution. So `konan.config` fields are always-present providers of a + `ConfigValue` (possibly unset), and `ConfigValue.require()` checks them in the task action. - **`all`/`whenObjectAdded` fires before `create(name) { … }` runs its configure block.** Reading a binding's `library` or a library's `targets` there gives the default, not what the build declared β€” per-target tasks, consumer detection and KGP's cinterops are deferred to `afterEvaluate` (KGP @@ -68,7 +82,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. `System.loadLibrary`. It, and the stub directory, are reported by the task (`stubLibraryBaseName`, `stubSourceDirectory`, `kotlinSourceDirectory`) β€” consumers must never reconstruct them. The external-build contract is `ExternalStubVariable` (`KONAN_JNI_STUB_DIR`, - `KONAN_JNI_LIB_NAME`, `KONAN_JNI_OUTPUT_DIR`, `KONAN_TARGET`, `KONAN_JNI_ABI`, `JAVA_HOME`): every + `KONAN_JNI_LIB_NAME`, `KONAN_JNI_OUTPUT_DIR`, `KONAN_TARGET`, `KONAN_JNI_ABI`, `KONAN_JNI_INCLUDE_DIR`): every `JniStubBuild.External` gets it as environment in `External.register`; a subclass only adds commands (CMake also passes it as cache entries). The same values are on `JniSpec` as providers. - **`cmake --preset` resolves `CMakePresets.json` from the working directory**, so configure runs @@ -93,6 +107,8 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t - **Link flags live in `KonanDistribution.linkArguments`**: `-fuse-ld=lld` for Linux (the GCC toolchain's `ld.bfd` is a Linux binary; a cross link from macOS fails without it), NDK compiler-rt and `--unwindlib=none` for Android, Xcode's compiler-rt for macOS. The wrappers add them only when linking. +- **The toolchain needs its own `ranlib`** (`llvm-ar s`): CMake indexes static archives with the host's, + and Apple's `ranlib` silently drops ELF objects β€” a cross-built archive came out empty. - **`external { }` defaults to `ExternalToolchain.SYSTEM`**, the presets to `AUTO`: generic steps may not compile anything, and must not need a Kotlin/Native distribution to run. - **Cargo gets the compilers under the `cc` crate's target-specific names** (`CC_`…), not `CC`: @@ -113,9 +129,14 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t ## Multiplatform projects -- **FFM serves `jvm()` targets, JNI the Android ones**: one binding cannot be both the JDK's API and - PanamaPort's. FFM's Java goes into the Gradle source set Kotlin creates per JVM compilation - (`Main`, compiled by `compileMainJava`). +- **`ffm()` replaces JNI everywhere, generated once per API**: `generateFfm` (JDK) for + `kotlin("jvm")` and `jvm()` targets, `generateFfmAndroid` (PanamaPort) for Android modules and + multiplatform Android targets. The JDK's Java goes into the Gradle source set Kotlin creates per JVM + compilation (`Main`, compiled by `compileMainJava`); PanamaPort's through AGP's + variant API β€” the multiplatform Android plugin's only with `withJava()`, and its Kotlin compile + (`compileAndroidMain`, no `Kotlin` in the name) gets the dependency through KGP, not by task name. +- **Before Kotlin 2.1.20 a `jvm()` target has no Java source set** unless the build calls `withJava()`, so + `addKmpJvmJava` calls it there, and the Java goes into `main` (`compileJava`) rather than `jvmMain`. - **JNI's sources leave `jvm()` lazily**, since targets are declared before `ffm()` is known β€” and a `project.provider { }` around a generated directory carries no producer, so the compile task's dependency on generation is spelled out alongside it. A missing one compiles before generating. @@ -164,9 +185,22 @@ distribution's JVM: the exporter (`src/indexExporter`) indexes headers with cint `BindingGenerator` over the index in the same process. Only generated files leave it; the plugin never holds a `NativeIndex`. -- **One version everywhere.** `kotlinNativeVersion` in `gradle.properties` sets `kotlin-native-utils`, - the compiler jar the exporter compiles against, and the distribution it must run in. The wiring lives - in `buildSrc/src/main/kotlin/native-index.gradle.kts`. +- **One exporter per Kotlin/Native version.** It runs only in the distribution whose compiler it was + compiled against, so it is compiled once per entry of `kotlinNativeVersions` in `gradle.properties` β€” + every patch release from 2.0.0 β€” into `native-index-exporter-.jar`, and `runIndexExporter` runs + the one matching `konanHome`'s version (its compiler jar's `Implementation-Version`); any other version + is an error. A jar per patch, not per minor: the `$default` overloads of cinterop's functions change + shape between patches. Each variant compiles with `apiVersion` of its own minor, since it runs on that + distribution's stdlib. `kotlinNativeVersion` is the default: `kotlin-native-utils`, KGP and the tests. +- **What differs between versions lives in `src/indexExporter/compat`**, one directory per facet variant, + picked by a version threshold in `native-index.gradle.kts` (its README has the table). The shared + sources call the same declarations whichever variant they got. When a Kotlin release breaks the + exporter, add a variant and a threshold; check every version with + `./gradlew compileIndexExporterKotlin`. Variants compile non-incrementally, two at a time: each + holds a compiler jar, and twenty in parallel exhaust the heap. +- **Adding a Kotlin release**: append it to `kotlinNativeVersions`; if it compiles, run + `GenerateFromNativeIndexTest` with that distribution installed β€” its `@TestFactory` requires every + installed version to generate the same JNI bindings as the default. - **The exporter is its own source set** because it compiles against the compiler jar, and anything it links against exists only in the distribution. - **It calls cinterop's `internal` functions** (`prepareTool`, `buildNativeLibrary`) by compiling with @@ -189,6 +223,9 @@ holds a `NativeIndex`. ## Testing and publishing +`-PtestGradleVersion=8.7 -PkgpTestVersion=2.0.0` runs the suite as a Kotlin 2.0 project would: TestKit on +that Gradle, that KGP on the plugin-under-test classpath. Run it after touching the KGP adapter. + The TestKit functional tests are what catch decoration and classloading failures, because they apply the plugin to a project with neither KGP nor AGP. Keep at least one fixture like that. diff --git a/README.md b/README.md index 76888f1..138a35d 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and Kotlin compiler's. - Wires generated sources, `jniLibs` and task order into Kotlin and Android builds; can hand the JNI stub to the project's own build β€” CMake, or any build system as a list of commands β€” instead. -- Auto-detects the Kotlin/Native distribution, the JDK that supplies `jni.h`, and the compiler-rt - builtins each target needs (NDK on Android, Xcode on macOS). +- Takes every machine path from one place, [`config { }`](#config--) β€” nothing is searched for β€” and + finds the compiler-rt builtins each target needs inside the distribution (NDK on Android, Xcode on + macOS). Ships `jni.h`, so no JDK is needed to link JNI. ## Installation 1. Apply the plugin in your `build.gradle.kts`: @@ -29,10 +30,33 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and } ``` -2. Ensure a Kotlin/Native distribution is installed. It is found automatically via `$KONAN_HOME`, else - the newest `~/.konan/kotlin-native-prebuilt-*`; override with `konan.konanHome`. Generating JNI or - FFM bindings needs the version the plugin was built against (2.4.20), which any Kotlin - Multiplatform build of that version with a native target downloads. +2. Tell it where the Kotlin/Native distribution is β€” nothing is searched for (see [`config { }`](#config--)): + ```properties + # ~/.gradle/gradle.properties, for every build on this machine + konan.config.konanHome=/Users/me/.konan/kotlin-native-prebuilt-macos-aarch64-2.4.20 + ``` + or `konan { config { konanHome.set(kotlinNativeDistribution()) } }`, which names where the Kotlin + plugin installs your project's Kotlin/Native version β€” see [Kotlin versions](#kotlin-versions). + +### Kotlin versions +JNI and FFM bindings are generated from cinterop's own C model, by code that runs inside the +Kotlin/Native distribution `konanHome` names and calls its compiler. The plugin carries that code for +**every Kotlin/Native release from 2.0.0 to 2.4.21** and runs the one matching `konanHome`; any other +version fails, listing the supported ones. + +`kotlinNativeDistribution()` names the distribution of your project's Kotlin version, the one the Kotlin +plugin downloads for a native target: + +```kotlin +konan { config { konanHome.set(kotlinNativeDistribution()) } } // ~/.konan/kotlin-native-prebuilt-- +``` + +A project with no native target never makes the Kotlin plugin download it: build any Kotlin Multiplatform +project with a native target on that version once, or unpack `kotlin-native-prebuilt--` +from [Maven Central](https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-native-prebuilt/). +Kotlin/Native bindings (KGP's cinterop, the klib) always use your project's Kotlin, whatever `konanHome` is. + +The plugin itself is tested on Gradle 8.7 with Kotlin 2.0.0 and on Gradle 9.8 with Kotlin 2.4.20. ## Configuration Everything lives in one block: the **libraries** the project builds, and the **bindings** that reach @@ -61,7 +85,57 @@ A binding is consumed wherever the project's Kotlin targets need it, with nothin | Kotlin/Native targets (`kotlin("multiplatform")`) | a cinterop klib per target the library is built for | KGP's own `cinterops {}`, with the library's archive embedded | | `kotlin("jvm")`, multiplatform `jvm()` or Android targets, or an Android app | JNI bindings in the main sources, a JNI library per target | the plugin's JNI generator and link | | `ffm()` on the binding, in a `kotlin("jvm")` project or for a multiplatform `jvm()` target | FFM bindings in place of JNI, the library as a shared library | jextract | -| `ffm()` on the binding, in an Android module | FFM bindings on PanamaPort in place of JNI, the library as `.so` per 64-bit ABI | jextract | +| `ffm()` on the binding, in an Android module or for a multiplatform Android target | FFM bindings on PanamaPort in place of JNI, the library as `.so` per 64-bit ABI | jextract | + +| Property | Type | Default | Description | +|-------------------|-------|---------|-------------| +| `androidApiLevel` | `Int` | the module's lowest `minSdk`, else Kotlin/Native's, 21 | The API level whose sysroot the plugin's Android links use, so platform libraries such as `libaaudio` or `libcamera2ndk` link. Kotlin/Native's NDK stops at 29; a higher level uses the highest it has. | +| `config { }` | | | The paths the plugin needs from the machine; below. | + +### `config { }` +The machine's paths: the Kotlin/Native distribution and the programs the plugin runs. They are the +plugin's only source β€” nothing is looked up on the `PATH` or in install directories. Each field is read +from the build script, else the Gradle property `konan.config.` (the build's `gradle.properties`, +`~/.gradle/gradle.properties`, or `-P`), else its environment variable: + +| Field | Gradle property | Environment variable | Needed by | +|-------------|----------------------------|----------------------|-----------| +| `konanHome` | `konan.config.konanHome` | `KONAN_HOME` | anything that compiles, links or generates bindings | +| `jextract` | `konan.config.jextract` | `KONAN_JEXTRACT` | `ffm()` | +| `cmake` | `konan.config.cmake` | `KONAN_CMAKE` | `cmake { }` | +| `make` | `konan.config.make` | `KONAN_MAKE` | `make { }` | +| `cargo` | `konan.config.cargo` | `KONAN_CARGO` | `cargo { }` | +| `zig` | `konan.config.zig` | `KONAN_ZIG` | `zig { }` | + +```kotlin +konan { + config { + konanHome.set(kotlinNativeDistribution()) // ~/.konan/kotlin-native-prebuilt-- + jextract.set(file("/opt/jextract-25/bin/jextract")) + } +} +``` + +Machine paths belong in `~/.gradle/gradle.properties` rather than a shared build script: + +```properties +konan.config.jextract=/Users/me/opt/jextract-25/bin/jextract +konan.config.cmake=/opt/homebrew/bin/cmake +``` + +Values are absolute paths. Every field is optional until a task that needs it runs; that task then fails +naming the field and the three ways to set it: + +``` +konan.config.jextract is not set β€” :generateFfmAndroidAaudio needs it. +Set it to an absolute path in one of these; the first one set wins: + 1. build script konan { config { jextract.set(file("/path/to/jextract")) } } + 2. gradle.properties konan.config.jextract=/path/to/jextract + (~/.gradle/gradle.properties sets it for every build on this machine) + 3. environment KONAN_JEXTRACT=/path/to/jextract +``` + +`gradlew konanConfig` prints every field, where its value comes from, and which tasks need it. ### `libraries { }` A C/C++ library built to `//lib.a` for each target: by Kotlin/Native's @@ -77,6 +151,7 @@ either way. | `outputDirectory` | `Directory` | `build/konan/libraries/` | Root of the per-target archives; `archive(target)` gives one. | | `zig { }` | | β€” | Build with the project's `build.zig` instead; below. | | `external { }` | | β€” | Build with any other build system, as commands; below. | +| `cmake { }` | | β€” | Build with the project's CMakeLists; below. | | `cargo { }` | | β€” | Build a Rust crate; below. | | `make { }` | | β€” | Build with the project's Makefile; below. | | `prebuilt { }` | | β€” | Use archives built elsewhere; below. | @@ -97,13 +172,26 @@ library (`b.addLibrary(.{ .linkage = .static, … })`). | Property | Type | Default | Description | |--------------------|----------------|---------------------|-------------| | `projectDirectory` | `Directory` | `sourceDirectory` | Where `build.zig` is. | -| `executable` | `Executable` | `OnPath("zig")` | The `zig` program: `Executable.File(file(…))`, or `Executable.OnPath(name)`. | | `optimize` | `ZigOptimize` | `RELEASE_FAST` | `-Doptimize`. Zig's `DEBUG` and `RELEASE_SAFE` compile C with UBSan traps, whose runtime the Kotlin/Native and JNI links do not have. | | `arguments` | `List` | β€” | Further `zig build` arguments. | | `artifactName` | `String` | the library's name | The static library `build.zig` installs. | Android targets need a libc file for the NDK's bionic, which Zig does not bundle. +#### Building with CMake β€” `cmake { }` +```kotlin +create("mymath") { + targets(NativeTarget.host, NativeTarget.LINUX_X64, NativeTarget.ANDROID_ARM64) + cmake { } // native/CMakeLists.txt: add_library(${KONAN_LIBRARY_NAME} STATIC …) +} +``` + +The same block as `jni { cmake { } }`, below, building the library's archive: `cmakeBuild` +configures and builds per target, with the contract as cache entries and `CMAKE_ARCHIVE_OUTPUT_DIRECTORY` +set to where the archive must land. Cross-compiled targets get Kotlin/Native's toolchain file, so the +Linux and Android archives above build on a Mac with no NDK or sysroot of your own. `path` defaults to +`sourceDirectory/CMakeLists.txt`. + #### Building a Rust crate β€” `cargo { }` ```kotlin create("mymath") { @@ -127,7 +215,6 @@ the host's. On Android, link with `panic = "abort"`: Kotlin/Native's NDK sysroot | `profile` | `CargoProfile` | `RELEASE` | `DEBUG` or `RELEASE`. | | `arguments` | `List` | β€” | Further `cargo build` arguments, e.g. `--features`. | | `toolchain` | `ExternalToolchain` | `AUTO` | Whose C compilers C dependencies get. | -| `executable` | `Executable` | `OnPath("cargo")` | The `cargo` program. | #### Building with Make β€” `make { }` ```kotlin @@ -159,7 +246,6 @@ target reuses the first one's objects. | `arguments` | `List` | β€” | Further arguments; `argumentsFor(target, …)` for one target. | | `jobs` | `Int` | processors | `-j`. | | `toolchain` | `ExternalToolchain` | `AUTO` | Whose compilers; below. | -| `executable` | `Executable` | `OnPath("make")` | The `make` program. | | `sources`, `producedLibrary(target, file)` | | β€” | As for `external { }`. | `jni { make { } }` and `ffm { make { } }` take the same block, for the libraries those load. @@ -175,7 +261,7 @@ Linux, its NDK sysroot on Android): | `KONAN` | Always Kotlin/Native's. | | `SYSTEM` | Never: the build finds its own. The default of `external { }`, whose steps are the build script's own. | -With Kotlin/Native's, the build gets `CC`, `CXX` and `AR` (and `KONAN_CMAKE_TOOLCHAIN_FILE`) in its +With Kotlin/Native's, the build gets `CC`, `CXX`, `AR` and `RANLIB` (and `KONAN_CMAKE_TOOLCHAIN_FILE`) in its environment; `make { }` also passes them on the command line, and `cmake { }` passes the toolchain file as `-DCMAKE_TOOLCHAIN_FILE` unless it uses a preset β€” a toolchain file in `arguments` still wins, an NDK's say. They are written per target by `konanToolchain` under `build/konan/toolchains//`: @@ -212,7 +298,7 @@ create("mymath") { targets(NativeTarget.LINUX_X64) external { step { - executable.set(Executable.OnPath("cargo")) + executable.set(Executable.File(file("/Users/me/.cargo/bin/cargo"))) arguments.addAll("build", "--release", "--target", "x86_64-unknown-linux-gnu") workingDirectory.set(layout.projectDirectory.dir("native")) } @@ -242,7 +328,6 @@ A C API described by a cinterop `.def`. | `visibility` | `BindingVisibility` | `INTERNAL` | `PUBLIC` when the idiomatic wrapper lives in another module. | | `targets` | `List` | the library's targets a JVM can load β€” the host and Android β€” else the host | Targets to link the JNI library for. | | `archive` | `File` | the library's | An archive to link instead, e.g. one built elsewhere. | -| `jdkHome` | `Directory` | auto-detected | A JDK shipping `include/jni.h`. | | `linkerOptions` | `List` | β€” | Extra options for every target's link. | | `linkerOptionsFor(target, …)` | | β€” | Extra options for one target's link; `-framework` belongs here, since the Android linker rejects it. | | `libraryDirectory` | `Directory` | output | Where the host's JNI library ends up. | @@ -251,8 +336,10 @@ A C API described by a cinterop `.def`. | `external { }` | | β€” | Any other build system that compiles the stub, as commands; below. | | `stubDirectory`, `stubLibraryName`, `libraryDirectoryFor(target)` | read-only | β€” | The external-build contract as providers, for arguments. | -Without a library, `jni.archive` or `staticLibraries` in the `.def`, the plugin stops after the Kotlin -bindings and the `.c` stub, for another build system to compile. That is the mode for a native library +A platform library needs no archive: `linkerOpts = -laaudio` in the `.def` (or `jni.linkerOptions`) is +enough for the plugin to link the stub against it. Without a library, `jni.archive`, `staticLibraries` +or linker options, the plugin stops after the Kotlin bindings and the `.c` stub, for another build system +to compile. That is the mode for a native library owned by CMake, Bazel or a vendor: linking an archive built by a foreign toolchain with konan's linker is what produces mismatched C++ runtimes and missing compiler-rt. @@ -273,12 +360,15 @@ Kotlin/Native's `toolchain`, `CC`, `CXX`, `AR`; see above): | `KONAN_JNI_OUTPUT_DIR` | where this target's library must land: `jniLibs//` on Android, which the plugin packages | | `KONAN_TARGET` | the Kotlin/Native target, e.g. `android_arm64` | | `KONAN_JNI_ABI` | its Android ABI, e.g. `arm64-v8a`; the target's name elsewhere | -| `JAVA_HOME` | `jni.jdkHome`, else a JDK that ships `include/jni.h` β€” IDE-bundled JBRs strip the headers | +| `KONAN_JNI_INCLUDE_DIR` | the directory holding `jni.h`, which the plugin ships β€” no JDK needed | The same values are on `jni` as providers β€” `stubDirectory`, `stubLibraryName`, -`libraryDirectoryFor(target)` β€” for building arguments. Programs are an `Executable`: `File(file)`, or -`OnPath(name)`, looked up when the build runs on the `PATH` and in the usual install locations, because -the Gradle daemon does not inherit a login shell's `PATH`. +`libraryDirectoryFor(target)` β€” for building arguments. A step's program is an `Executable`: +`File(file)`, or `OnPath(name)`, which the Gradle daemon's own `PATH` resolves β€” not a login shell's, so +prefer `File` outside the system directories. `cmake { }` and `make { }` run `konan.config`'s. + +The plugin ships Android's `jni.h` (Apache 2.0, VM-neutral, JNI 1.6), so linking a JNI library for a +desktop JVM needs no JDK. A build that needs a newer JNI, or calls `find_package(JNI)`, finds its own. ##### CMake β€” `jni { cmake { } }` ```kotlin @@ -319,7 +409,6 @@ target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arriv | `buildTargets` | `List` | the default target | `--target`s to build. | | `arguments` | `List` | β€” | Extra configure arguments. | | `libraryDirectory` | `Directory` | `KONAN_JNI_OUTPUT_DIR` | Where the library is, if the build ignores `CMAKE_LIBRARY_OUTPUT_DIRECTORY`. | -| `executable` | `Executable` | `OnPath("cmake")` | The `cmake` program. | | `target(t) { }` | | the host | A per-target build with its own `preset`, `buildDirectory`, `arguments`. | ##### Any build system β€” `jni { external { } }` @@ -329,7 +418,7 @@ A list of commands, run in order once per JNI target (`jni.targets`): jni { external { step { - executable.set(Executable.OnPath("make")) + executable.set(Executable.File(file("/usr/bin/make"))) arguments.addAll("-C", "native/jni") argumentsFor(NativeTarget.ANDROID_ARM64, "ARCH=arm64") } @@ -380,10 +469,13 @@ plugin writes both sides of each JNI bridge from the C declarations: | `const char*` (argument or result) | `String?` | `GetStringUTFChars` / `NewStringUTF` | | `int*`, `uint8_t*`, `float*`, … buffer | `IntArray?`, `ByteArray?`, `FloatArray?`, … | pinned for the call; written back unless `const` | | struct by value (argument or result) | `ByteArray` | the struct's bytes | -| opaque handle, `void*`, struct or function pointer | `Long` | the address | +| pointer to pointer (`handle_t**` out-parameter) | `LongArray?` | addresses, copied into a C pointer array and back unless `const` | +| `void*`, `const void*` argument | `java.nio.Buffer?` | a direct buffer's start address (`GetDirectBufferAddress`); a heap buffer throws | +| opaque handle, typedef'd `void*`, struct or function pointer, any pointer result | `Long` | the address | -Functions that cannot be bound β€” variadic ones, `long double` β€” are listed with the reason at the end of -the generated file. Unsigned integers cross as the signed Kotlin type of the same size. Struct bytes are +Enum constants and integer, floating-point and string macros become `const val`s. Functions that +cannot be bound β€” variadic ones, `long double` β€” are listed with the reason at the end of the generated +file. Unsigned integers cross as the signed Kotlin type of the same size. Struct bytes are in native order, so read them with `ByteBuffer.order(ByteOrder.nativeOrder())` and the layout from the C header. @@ -396,14 +488,13 @@ different Gradle module. FFM (`java.lang.foreign`) bindings, written by jextract from the same `.def`, and the library linked as a shared library for the build host β€” FFM needs no stub. In a `kotlin("jvm")` project they replace the JNI bindings and are added to the main Java sources; in a multiplatform project they replace them on -the `jvm()` targets, in each one's Java source set (`src/Main/java`'s), while Android targets -keep JNI; compile them with a JDK 22+ toolchain and run +every target β€” the `jvm()` targets get the JDK's in their Java source set, the Android target +PanamaPort's (below); compile the JVM side with a JDK 22+ toolchain and run with `--enable-native-access`; the library travels in the jar (below), or put `ffm.libraryDirectory` on `java.library.path`. | Property | Type | Default | Description | |--------------------|-------------|----------------------|-------------| -| `jextract` | `Executable`| `OnPath("jextract")` | The jextract program. | | `panamaPortVersion`| `String` | `v0.1.5` | The PanamaPort an Android module's bindings use, added as a dependency. | | `libraryName` | `String` | the library's, else the binding's | What the bindings `System.loadLibrary`. Set it to a prebuilt library's own name. | | `targets` | `List` | the library's loadable ones, else all loadable | The host on a JVM; 64-bit ABIs on Android. | @@ -442,10 +533,16 @@ library's archive into it (`linkFfm`). Instead: libraries, placed by `prebuiltFfm`. Set `libraryName` to their own name (`"ggml"`), so libraries that depend on them by that name still find them. -Neither needs a `library(…)`; the bindings then come from the `.def` and its headers alone. +Neither needs a `library(…)`; the bindings then come from the `.def` and its headers alone. With none of +these and no `library(…)`, nothing is built: the bindings load `lib` as the system provides +it β€” `ffm { libraryName.set("aaudio") }` binds Android's `libaaudio.so`. #### FFM on Android -In an Android module `ffm()` replaces JNI there too. jextract's output is moved from `java.lang.foreign` +In an Android module, and for a multiplatform project's Android target, `ffm()` replaces JNI there too. +A multiplatform project with both `jvm()` and Android targets gets the bindings twice β€” `generateFfm` +for the JDK, `generateFfmAndroid` for PanamaPort β€” so `jvmMain` and `androidMain` each implement +your `expect`s over theirs. The multiplatform Android library plugin compiles Java only with +`kotlin { android { withJava() } }`, which the bindings need. jextract's output is moved from `java.lang.foreign` onto PanamaPort's `com.v7878.foreign` β€” the FFM API backported to Android 8.0+ β€” which the plugin adds as an `implementation` dependency, and the library is linked as `lib.so` per ABI into `jniLibs/`, with no stub. The app needs: @@ -461,7 +558,7 @@ with no stub. The app needs: | Task | Description | |-------------------------------|-------------| | `compileNativeLibrary` | Builds a library to `lib.a` per target, with whichever provider it uses. | -| `konanClang`, `zigBuild`, `cargoBuild`, `makeBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, make, the library's own steps, or placing prebuilt archives. | +| `konanClang`, `cmakeBuild`, `zigBuild`, `cargoBuild`, `makeBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, make, the library's own steps, or placing prebuilt archives. | | `konanToolchain` | Writes Kotlin/Native's compilers for a target, for external builds that use them. | | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | @@ -469,7 +566,7 @@ with no stub. The app needs: | `cmakeBuildJni`, `makeBuildJni`, `externalBuildJni` | The project's own build, in place of the JNI link. | | `linkFfm`, `linkFfm` | Makes the library FFM loads exist; the plugin's link of it for one target. | | `cmakeBuildFfm`, `makeBuildFfm`, `externalBuildFfm`, `prebuiltFfm` | The project's own build of it, or placing a prebuilt one, in place of that link. | -| `generateFfm` | jextract's bindings. | +| `generateFfm`, `generateFfmAndroid` | jextract's bindings: on the JDK's FFM, and on PanamaPort's for Android. | | `cinterop` | KGP's own cinterop, declared by the plugin. | On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks automatically, so @@ -489,10 +586,10 @@ The DSL is new and there is no compatibility shim. from `build/native//` to `build/konan/libraries///`, and `runKonanClang` becomes `compileNativeLibrary` (which runs `konanClang`). - `jvmInterops { create(…) { … } }` β€” on a compilation or on the project β€” becomes - `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` β†’ - `jdkHome`, `library` β†’ `archive`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ + `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` is gone + (the plugin ships `jni.h`), `library` β†’ `archive`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ `linkerOptionsFor`, `externalNativeBuild { cmake { } }` β†’ `cmake { }` (`targets` β†’ `buildTargets`, - `abi("arm64-v8a")` β†’ `target(NativeTarget.ANDROID_ARM64)`, `executable` β†’ an `Executable`, no `$CMAKE`; + `abi("arm64-v8a")` β†’ `target(NativeTarget.ANDROID_ARM64)`, `executable` β†’ `konan.config.cmake`, no `$CMAKE`; its tasks are `cmakeBuildJni`), `resolvedLibraryDirectory` β†’ `libraryDirectory`. `additionalCompilerArgs` becomes `compilerOptions`. Bindings are wired into the @@ -504,11 +601,16 @@ The DSL is new and there is no compatibility shim. - Targets are `NativeTarget`s (`io.github.lemcoder.model`), not `KonanTarget`s: `KonanTarget.X` β†’ `NativeTarget.X`, `hostKonanTarget()` β†’ `NativeTarget.host`, a KGP target's `konanTarget` β†’ `NativeTarget.of(konanTarget)` (`import io.github.lemcoder.kotlin.of`). `jniHome()` is gone: external - builds get `JAVA_HOME`. Programs (`zig`, `cmake`, `jextract`) are `Executable`s. + builds get `KONAN_JNI_INCLUDE_DIR`. Programs (`zig`, `cmake`, `jextract`) and the distribution come from + `konan { config { } }`. - JNI bindings are named after the C functions (`my_add`), not `kniBridge0`; tasks are `generateJni…` and `linkJni…`. ## License This project is licensed under the Apache 2.0 License. For more details, see the `LICENSE` file. +The plugin bundles `jni.h` from the Android Open Source Project +(`libnativehelper`), Copyright (C) 2006 The Android Open Source Project, licensed under the Apache +License 2.0; its license header is kept in the file. + Happy coding! 😊 diff --git a/build.gradle.kts b/build.gradle.kts index 72090ca..5c24330 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,8 +28,9 @@ gradlePlugin { id = "io.github.lemcoder.konanplugin" implementationClass = "io.github.lemcoder.KonanPlugin" displayName = "Konan Plugin" - description = "Gradle plugin to compile C sources to static libraries using Kotlin Konan compiler" - tags.set(listOf("cross-compile", "konan", "c", "kotlin")) + description = "Builds C/C++ libraries with Kotlin/Native's toolchain and binds them to Kotlin/Native, " + + "the JVM and Android from one cinterop .def: generated JNI bindings, or FFM through jextract." + tags.set(listOf("kotlin-native", "cinterop", "jni", "ffm", "jextract", "android", "cross-compile", "c")) } } } @@ -37,7 +38,11 @@ gradlePlugin { val kgpForTests: Configuration = configurations.create("kgpForTests") dependencies { - "kgpForTests"(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-gradle-plugin:$it" }) + // -PkgpTestVersion runs the KGP fixtures on another Kotlin version; -PtestGradleVersion on another Gradle. + "kgpForTests"( + providers.gradleProperty("kgpTestVersion").orElse(providers.gradleProperty("kotlinNativeVersion")) + .map { "org.jetbrains.kotlin:kotlin-gradle-plugin:$it" } + ) // KonanTarget, for NativeTarget.of(konanTarget) and the KGP adapter β€” both reached only where KGP, which // brings it, is applied. The DSL speaks the plugin's own NativeTarget, so nothing decorated needs it. compileOnly(providers.gradleProperty("kotlinNativeVersion").map { "org.jetbrains.kotlin:kotlin-native-utils:$it" }) @@ -63,5 +68,7 @@ tasks.test { // proving the plugin loads without KGP. val kgp: FileCollection = kgpForTests inputs.files(kgp).withPropertyName("kgpForTests").withNormalizer(ClasspathNormalizer::class.java) - jvmArgumentProviders.add(CommandLineArgumentProvider { listOf("-Dkgp.classpath=${kgp.asPath}") }) + val gradleVersion = providers.gradleProperty("testGradleVersion").orElse("") + inputs.property("testGradleVersion", gradleVersion) + jvmArgumentProviders.add(CommandLineArgumentProvider { listOf("-Dkgp.classpath=${kgp.asPath}", "-Dtest.gradle.version=${gradleVersion.get()}") }) } diff --git a/buildSrc/src/main/kotlin/native-index.gradle.kts b/buildSrc/src/main/kotlin/native-index.gradle.kts index 3247f79..ef6b650 100644 --- a/buildSrc/src/main/kotlin/native-index.gradle.kts +++ b/buildSrc/src/main/kotlin/native-index.gradle.kts @@ -5,53 +5,101 @@ // jar and main must not; its own process because that jar bundles a Kotlin stdlib that must not meet // Gradle's. // -// kotlinNativeVersion in gradle.properties picks the compiler jar it compiles against, and so the -// distribution it must run in. +// It calls cinterop's internals, which change between Kotlin releases, and must run in the distribution +// it was compiled against. So it is compiled once per version in kotlinNativeVersions, each against that +// version's compiler jar, and the plugin picks the jar matching konanHome's version. What differs between +// versions is in src/indexExporter/compat, chosen by the thresholds below. +// +// kotlinNativeVersion is the default: the `indexExporter` source set the IDE sees, and the version +// kotlinNativeDistribution() names when the project has no Kotlin plugin. +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -val kotlinNativeVersion = providers.gradleProperty("kotlinNativeVersion") +val kotlinNativeVersion = providers.gradleProperty("kotlinNativeVersion").get() +val kotlinNativeVersions = providers.gradleProperty("kotlinNativeVersions").get().split(',').map { it.trim() } +check(kotlinNativeVersion in kotlinNativeVersions) { "kotlinNativeVersion $kotlinNativeVersion is not in kotlinNativeVersions" } + +/** `2.3.20` as comparable parts. */ +fun parts(version: String): List = version.split('.').map { it.takeWhile(Char::isDigit).toInt() } +fun atLeast(version: String, threshold: String): Boolean { + val a = parts(version) + val b = parts(threshold) + for (i in 0 until maxOf(a.size, b.size)) { + val d = a.getOrElse(i) { 0 } - b.getOrElse(i) { 0 } + if (d != 0) return d > 0 + } + return true +} + +/** The compat directories (src/indexExporter/compat//) compiled into [version]'s exporter. */ +fun compat(version: String): List = listOfNotNull( + // isAnonymous on StructDecl and EnumDef; from 2.0.20 cinterop's own members are used. + "anonymous/spelling".takeUnless { atLeast(version, "2.0.20") }, + if (atLeast(version, "2.3.20")) "access/direct" else "access/none", + if (atLeast(version, "2.4.20")) "frontend/2.4.20" else "frontend/2.0.0", +) + +/** Lets few exporter compilations run at once: each holds a whole compiler jar in memory. */ +abstract class ExporterCompilations : BuildService pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { val sourceSets = the() - val indexExporter = sourceSets.create("indexExporter") + val compilations = gradle.sharedServices.registerIfAbsent("exporterCompilations", ExporterCompilations::class) { + maxParallelUsages.set(2) + } // The request types both sides of the process boundary speak: one source, compiled into each. val protocol = "src/exporterProtocol/kotlin" - listOf(sourceSets.getByName("main"), indexExporter).forEach { - (it.extensions.getByName("kotlin") as SourceDirectorySet).srcDir(protocol) - } + (sourceSets.getByName("main").extensions.getByName("kotlin") as SourceDirectorySet).srcDir(protocol) - dependencies { - // compileOnly: at run time the distribution's own copy of this jar is the classpath. - "indexExporterCompileOnly"(kotlinNativeVersion.map { "org.jetbrains.kotlin:kotlin-native-compiler-embeddable:$it" }) - } + val exporterJars = kotlinNativeVersions.map { version -> + val suffix = if (version == kotlinNativeVersion) "" else version.replace('.', '_') + val sourceSet = sourceSets.create("indexExporter$suffix") + (sourceSet.extensions.getByName("kotlin") as SourceDirectorySet).apply { + setSrcDirs(listOf(protocol, "src/indexExporter/kotlin") + compat(version).map { "src/indexExporter/compat/$it" }) + } + sourceSet.resources.setSrcDirs(emptyList()) + + dependencies { + // compileOnly: at run time the distribution's own copy of this jar is the classpath. + sourceSet.compileOnlyConfigurationName("org.jetbrains.kotlin:kotlin-native-compiler-embeddable:$version") + } - // The exporter calls cinterop's prepareTool and buildNativeLibrary, which are internal to the compiler - // jar. Compiling against that jar as a friend module makes them visible, still type-checked: a - // signature change in a Kotlin release fails this compilation at the kotlinNativeVersion bump. - tasks.named("compileIndexExporterKotlin") { - friendPaths.from( - configurations.named("indexExporterCompileClasspath").map { classpath -> - classpath.filter { it.name.startsWith("kotlin-native-compiler-embeddable-") } + // The exporter calls cinterop's prepareTool and buildNativeLibrary, which are internal to the compiler + // jar. Compiling against that jar as a friend module makes them visible, still type-checked: a + // signature change in a Kotlin release fails this compilation, for that version. + tasks.named(sourceSet.getCompileTaskName("kotlin")) { + usesService(compilations) + // Incremental compilation snapshots the classpath, the whole compiler jar, for a few files. + incremental = false + friendPaths.from( + configurations.named(sourceSet.compileClasspathConfigurationName).map { classpath -> + classpath.filter { it.name.startsWith("kotlin-native-compiler-embeddable-") } + } + ) + // It runs on the distribution's own stdlib, so it may not call anything newer. + val minor = KotlinVersion.fromVersion(parts(version).take(2).joinToString(".")) + compilerOptions { + apiVersion.set(minor) + languageVersion.set(minor) } - ) - } + } - // The exporter ships inside the plugin jar as a nested jar, extracted when GenerateFromNativeIndexTask runs. - val indexExporterJar = tasks.register("indexExporterJar") { - archiveFileName.set("native-index-exporter.jar") - destinationDirectory.set(layout.buildDirectory.dir("indexExporter")) - from(indexExporter.output) + tasks.register("${sourceSet.name}Jar") { + archiveFileName.set("native-index-exporter-$version.jar") + destinationDirectory.set(layout.buildDirectory.dir("indexExporter")) + from(sourceSet.output) + } } + // The exporters ship inside the plugin jar as nested jars, one per version; GenerateFromNativeIndexTask + // extracts the one matching konanHome. tasks.named("processResources") { - from(indexExporterJar) { into("io/github/lemcoder/index") } - // The distribution the plugin looks for at run time is the one the exporter was compiled against. - val version = kotlinNativeVersion - inputs.property("kotlinNativeVersion", version) - filesMatching("io/github/lemcoder/index/kotlin-native.properties") { - expand("kotlinNativeVersion" to version.get()) - } + from(exporterJars) { into("io/github/lemcoder/index/exporters") } + inputs.property("kotlinNativeVersion", kotlinNativeVersion) + inputs.property("kotlinNativeVersions", kotlinNativeVersions) + val properties = mapOf("kotlinNativeVersion" to kotlinNativeVersion, "kotlinNativeVersions" to kotlinNativeVersions.joinToString(",")) + filesMatching("io/github/lemcoder/index/kotlin-native.properties") { expand(properties) } } } diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index 208fb3b..23d5f54 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -107,7 +107,7 @@ flowchart TD | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `model.NativeTarget`: one entry per Kotlin/Native target (checked by a test against the pinned version), with family, architecture, Android ABI, library naming and Zig triple; `NativeTarget.of(konanTarget)` is the only conversion | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Kotlin/Native's (clang + downloaded sysroots, `KonanDistribution`), also handed to external builds per target as `CC`/`CXX`/`AR` and a CMake toolchain file (`KonanToolchain`, chosen by `ExternalToolchain`); Zig brings its own; the system's for host builds | -| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **Zig** (`zig { }`), **Cargo** (`cargo { }`, a Rust `staticlib` per Rust triple), **Make** (`make { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | +| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **CMake** (`cmake { }`), **Zig** (`zig { }`), **Cargo** (`cargo { }`, a Rust `staticlib` per Rust triple), **Make** (`make { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | | 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); for FFM there is no glue, but the shared library it loads is a sealed `FfmLibraryBuild` of the same shape: **KonanLink** (the archive linked whole), **Prebuilt** (`ffm { prebuilt { sharedLibrary(…) } }`), or **External** with the `ExternalFfmVariable` contract β€” **Generic** (`ffm { external { } }`) and the **CMake** preset (`ffm { cmake { } }`). Both CMake presets are one implementation, `external/CMakeCommands.kt` | @@ -299,9 +299,9 @@ Partly settled by what exists: | Package | Holds | | --- | --- | - | `model` | The domain, without tasks or KGP: `NativeTarget` (+ `TargetFamily`, `Architecture`, `HostOs`), `NativeArtifacts` (the archive layout), the external-build contract, `Executable`, `DefFile`, task naming, `selectOne` | + | `model` | The domain, without tasks or KGP: `NativeTarget` (+ `TargetFamily`, `Architecture`, `HostOs`), `NativeArtifacts` (the archive layout), the external-build contract, `Executable`, `ConfigField`/`ConfigValue` (`konan.config`), `DefFile`, task naming, `selectOne` | | `kotlin` | Every touchpoint with Kotlin, and the only package importing it: `NativeTarget.of(konanTarget)`, `KonanDistribution` (home, pinned version, `run_konan` clang and llvm-ar, NDK), `KotlinGradlePlugin` (KGP), the index exporter client | - | `toolchain` | The other toolchains: `Jdk` (`jni.h`), `Xcode` (compiler-rt), executable lookup, Zig triples | + | `toolchain` | The other toolchains: `Xcode` (compiler-rt), running an `Executable`, Zig triples | | `dsl` | Everything a build script touches, one file per spec | | `library` | `LibraryProvider` and its builds: Kotlin/Native's clang, prebuilt, Zig | | `jni`, `link`, `index` | `JniStubBuild` and its builds, the generate tasks, the shared-library link | @@ -318,8 +318,9 @@ Partly settled by what exists: ## D. Toolchains, prebuilt binaries and packaging -What the generators showed each consumer needs. The plugin wires every row today; in a multiplatform -project FFM serves the `jvm()` targets and Android targets keep JNI. Each row is a `BindingConsumer`, the binding-side counterpart of +What the generators showed each consumer needs. The plugin wires every row today, in plain and +multiplatform projects alike; a multiplatform project with JVM and Android targets gets the FFM bindings +twice, once per API. Each row is a `BindingConsumer`, the binding-side counterpart of `LibraryProvider`: `prepare()` runs when the binding is added, for wiring that must be eager (AGP's variant callbacks, the multiplatform source directory); `isUsed()` decides from the project's plugins and the binding's settings once the build script has run; `register()` then adds the tasks. Consumers diff --git a/examples/README.md b/examples/README.md index a85ac8c..61d06cc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,16 +8,18 @@ and gets the binding its Kotlin targets need: |---|---|---|---| | [`jvm/`](jvm) | a library (.a) + a binding β†’ JNI bindings and library | full JNI path, end to end | `../../gradlew -p jvm run` | | [`android/`](android) | same, cross-compiled per ABI, real app | APK with `.so` (arm64-v8a + x86_64) + `MainActivity` | `cd android && ./gradlew installDebug` | -| [`android-ffm/`](android-ffm) | same library, FFM instead of JNI: jextract's bindings on PanamaPort, `libmymath.so` per ABI, no stub | FFM on Android, end to end on an emulator | `cd android-ffm && ./gradlew installDebug` | +| [`android-ffm/`](android-ffm) | same library, FFM instead of JNI: jextract's bindings on PanamaPort, `libmymath.so` per ABI, no stub | FFM on Android, end to end on an emulator (a multiplatform library's Android target works the same, with `withJava()`) | `cd android-ffm && ./gradlew installDebug` | | [`native/`](native) | a library (.a) + a binding β†’ KGP's cinterop | the native path | `../../gradlew -p native runDebugExecutableMacosArm64` | `jvm` and `native` build the plugin from source via `includeBuild`. `android` needs AGP 9 (Gradle 9.4, its own wrapper), so it consumes the plugin from mavenLocal β€” publish it first: `(cd ../.. && ./gradlew publishToMavenLocal)`. -Each example auto-detects the newest `~/.konan/kotlin-native-prebuilt-*` (override with `KONAN_HOME`). -The `jvm` example also needs a JDK with `include/jni.h` (auto-scanned; the Android leg gets `jni.h` -from the NDK sysroot, so no JDK needed there). +Each example sets `konan { config { konanHome.set(kotlinNativeDistribution()) } }`: the Kotlin/Native +version the plugin needs, where the Kotlin plugin installs it. `android-ffm` also needs +`konan.config.jextract=/path/to/jextract`, e.g. in `~/.gradle/gradle.properties`. +No example needs a JDK's `jni.h`: the plugin ships one for desktop links, and Android's comes from the +NDK sysroot. ## The two flows diff --git a/examples/android-ffm/README.md b/examples/android-ffm/README.md index e2855b3..8fa85ca 100644 --- a/examples/android-ffm/README.md +++ b/examples/android-ffm/README.md @@ -10,7 +10,7 @@ An Android app that calls `native/mymath.c` through **FFM** bindings instead of ## Requirements -- jextract on the `PATH` (or `ffm { jextract.set(Executable.File(file(…))) }`). +- jextract, as `konan.config.jextract=/path/to/jextract` in `~/.gradle/gradle.properties` (or `KONAN_JEXTRACT`). - `compileSdk` 37 and `minSdk` 26, which PanamaPort requires; Java 21 source, which jextract's output uses. - 64-bit ABIs: jextract writes the build host's LP64 layouts, so 32-bit ABIs get no library. diff --git a/examples/android-ffm/build.gradle.kts b/examples/android-ffm/build.gradle.kts index d576d84..982f872 100644 --- a/examples/android-ffm/build.gradle.kts +++ b/examples/android-ffm/build.gradle.kts @@ -1,6 +1,6 @@ // An Android app that calls a C library through FFM bindings: jextract writes them from the same .def, // the plugin moves them onto PanamaPort (the FFM API backported to Android 8.0+) and links the library -// itself as libmymath.so per ABI β€” no JNI stub. Needs jextract on the PATH (or ffm { jextract.set(…) }). +// itself as libmymath.so per ABI β€” no JNI stub. Needs konan.config.jextract β€” e.g. in ~/.gradle/gradle.properties. import io.github.lemcoder.model.NativeTarget @@ -10,6 +10,9 @@ plugins { } konan { + // Where the Kotlin plugin installs the Kotlin/Native version this plugin needs; or set + // konan.config.konanHome in ~/.gradle/gradle.properties. + config { konanHome.set(kotlinNativeDistribution()) } // 64-bit ABIs only: jextract writes the host's LP64 layouts. val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } bindings { diff --git a/examples/android/README.md b/examples/android/README.md index f882726..5100b1c 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -9,7 +9,7 @@ plugin. Both plugins are applied to this single module: ## Prerequisites -- A Kotlin/Native distribution (auto-detected, or `KONAN_HOME`). +- The Kotlin/Native distribution the plugin needs (`config { konanHome.set(kotlinNativeDistribution()) }` in the build script). - Android SDK (`local.properties` β†’ `sdk.dir`); the example targets `compileSdk 36`, `minSdk 21`. - The plugin published to mavenLocal: ```bash @@ -70,7 +70,7 @@ konan { - the library β†’ `libmymath.a` per ABI, from `native/`, in `build/konan/libraries/mymath//`. - the binding β†’ Kotlin bindings and a `.so` per ABI the library is built for: an Android app gets JNI. - The Kotlin/Native distribution is auto-detected from `$KONAN_HOME` / `~/.konan`. + The Kotlin/Native distribution comes from `konan.config.konanHome`. When AGP is present the plugin **automatically** adds the generated Kotlin bridges as a source directory and the per-ABI `jniLibs/` as native libraries (via the AGP variant API), and orders diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts index 4556848..61b79dc 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -10,6 +10,9 @@ plugins { } konan { + // Where the Kotlin plugin installs the Kotlin/Native version this plugin needs; or set + // konan.config.konanHome in ~/.gradle/gradle.properties. + config { konanHome.set(kotlinNativeDistribution()) } // native/*.c -> one static archive per ABI: device + emulator. val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } // An Android app gets JNI bindings, wired into its variants, and a JNI library per ABI the library diff --git a/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts index 107980b..d0838a5 100644 --- a/examples/jvm/build.gradle.kts +++ b/examples/jvm/build.gradle.kts @@ -9,6 +9,9 @@ plugins { repositories { mavenCentral() } konan { + // Where the Kotlin plugin installs the Kotlin/Native version this plugin needs; or set + // konan.config.konanHome in ~/.gradle/gradle.properties. + config { konanHome.set(kotlinNativeDistribution()) } // native/*.c -> one static archive per target. val mymath = libraries.create("mymath") { targets(NativeTarget.host) } // The C API in the .def, linked against that library. A kotlin("jvm") project gets JNI bindings, diff --git a/examples/native/build.gradle.kts b/examples/native/build.gradle.kts index 8f3055b..c0a076e 100644 --- a/examples/native/build.gradle.kts +++ b/examples/native/build.gradle.kts @@ -16,6 +16,9 @@ kotlin { } konan { + // Where the Kotlin plugin installs the Kotlin/Native version this plugin needs; or set + // konan.config.konanHome in ~/.gradle/gradle.properties. + config { konanHome.set(kotlinNativeDistribution()) } // native/*.c -> build/konan/libraries/mymath/macos_arm64/libmymath.a. val mymath = libraries.create("mymath") { targets(NativeTarget.MACOS_ARM64) } // Every Kotlin/Native target the library is built for gets KGP's cinterop for the .def, with the diff --git a/gradle.properties b/gradle.properties index 5ae12ca..5acd404 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,8 @@ org.gradle.configuration-cache=true -# The Kotlin/Native version the plugin is built against: kotlin-native-utils, the vendored NativeIndex -# model, and the distribution its exporter runs in. After changing it, run updateNativeIndexModel. +# The Kotlin/Native versions binding generation supports: the index exporter is compiled once per version, +# against that version's compiler jar, and runs in the distribution of the same version. Every patch +# release; konanHome must name one of them. See src/indexExporter/compat for what differs between them. +kotlinNativeVersions=2.0.0,2.0.10,2.0.20,2.0.21,2.1.0,2.1.10,2.1.20,2.1.21,2.2.0,2.2.10,2.2.20,2.2.21,2.3.0,2.3.10,2.3.20,2.3.21,2.4.0,2.4.10,2.4.20,2.4.21 +# The default among them: what kotlin-native-utils, KGP and the tests are built against, and what +# kotlinNativeDistribution() names in a project without the Kotlin plugin. kotlinNativeVersion=2.4.20 diff --git a/src/indexExporter/compat/README.md b/src/indexExporter/compat/README.md new file mode 100644 index 0000000..e8aac98 --- /dev/null +++ b/src/indexExporter/compat/README.md @@ -0,0 +1,16 @@ +# Index exporter compatibility facets + +cinterop's API moves between Kotlin releases. The exporter is compiled once per supported Kotlin/Native +version (`kotlinNativeVersions` in `gradle.properties`), and each compilation adds one directory per +facet: the variant whose threshold the version reaches. Each directory defines the same declarations, so +the shared sources do not know which one they got. + +| Facet | Variant (from) | What changed in cinterop | +|-------------|---------------------------------|--------------------------| +| `anonymous` | `spelling` (2.0.0), none (2.0.20) | `StructDecl.isAnonymous`, `EnumDef.isAnonymous` arrive; before, clang's spelling says it | +| `access` | `none` (2.0.0), `direct` (2.3.20) | `FunctionDecl.directAccess`, `GlobalDecl.directAccess` arrive | +| `frontend` | `2.0.0`, `2.4.20` | `prepareTool(konanHome =)`, `-Xkonan-home`, `buildNativeIndex(macroNamesCollectingMode =)` | + +The thresholds live in `buildSrc/src/main/kotlin/native-index.gradle.kts`. A new Kotlin release that +breaks the exporter's compilation gets a new variant directory and a threshold, not an `if` in the +shared sources. diff --git a/src/indexExporter/compat/access/direct/io/github/lemcoder/index/exporter/Access.kt b/src/indexExporter/compat/access/direct/io/github/lemcoder/index/exporter/Access.kt new file mode 100644 index 0000000..67caa53 --- /dev/null +++ b/src/indexExporter/compat/access/direct/io/github/lemcoder/index/exporter/Access.kt @@ -0,0 +1,16 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.indexer.DirectAccess +import org.jetbrains.kotlin.native.interop.indexer.FunctionDecl +import org.jetbrains.kotlin.native.interop.indexer.GlobalDecl + +/** How [function] is reached, for the dump: ` symbol=…`, or empty when cinterop does not say. */ +internal fun access(function: FunctionDecl): String = render(function.directAccess) + +/** How [global] is reached, for the dump: ` symbol=…`, or empty when cinterop does not say. */ +internal fun access(global: GlobalDecl): String = render(global.directAccess) + +private fun render(access: DirectAccess): String = when (access) { + is DirectAccess.Symbol -> " symbol=${access.name}" + is DirectAccess.Unavailable -> " unavailable(${access.reason})" +} diff --git a/src/indexExporter/compat/access/none/io/github/lemcoder/index/exporter/Access.kt b/src/indexExporter/compat/access/none/io/github/lemcoder/index/exporter/Access.kt new file mode 100644 index 0000000..d7785e0 --- /dev/null +++ b/src/indexExporter/compat/access/none/io/github/lemcoder/index/exporter/Access.kt @@ -0,0 +1,12 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.indexer.FunctionDecl +import org.jetbrains.kotlin.native.interop.indexer.GlobalDecl + +// Before 2.3.20 cinterop does not record how a declaration is reached. + +/** How [function] is reached, for the dump: ` symbol=…`, or empty when cinterop does not say. */ +internal fun access(function: FunctionDecl): String = "" + +/** How [global] is reached, for the dump: ` symbol=…`, or empty when cinterop does not say. */ +internal fun access(global: GlobalDecl): String = "" diff --git a/src/indexExporter/compat/anonymous/spelling/io/github/lemcoder/index/exporter/Anonymous.kt b/src/indexExporter/compat/anonymous/spelling/io/github/lemcoder/index/exporter/Anonymous.kt new file mode 100644 index 0000000..453baa1 --- /dev/null +++ b/src/indexExporter/compat/anonymous/spelling/io/github/lemcoder/index/exporter/Anonymous.kt @@ -0,0 +1,13 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.indexer.EnumDef +import org.jetbrains.kotlin.native.interop.indexer.StructDecl + +// Before 2.0.20 cinterop has no isAnonymous; clang spells an unnamed tag "struct (unnamed at …)" or +// "(anonymous struct at …)". From 2.0.20 the members of the same name win over these. + +internal val StructDecl.isAnonymous: Boolean get() = isAnonymousSpelling(spelling) + +internal val EnumDef.isAnonymous: Boolean get() = isAnonymousSpelling(spelling) + +private fun isAnonymousSpelling(spelling: String) = "(unnamed" in spelling || "(anonymous" in spelling diff --git a/src/indexExporter/compat/frontend/2.0.0/io/github/lemcoder/index/exporter/Frontend.kt b/src/indexExporter/compat/frontend/2.0.0/io/github/lemcoder/index/exporter/Frontend.kt new file mode 100644 index 0000000..79003c5 --- /dev/null +++ b/src/indexExporter/compat/frontend/2.0.0/io/github/lemcoder/index/exporter/Frontend.kt @@ -0,0 +1,30 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.gen.jvm.KotlinPlatform +import org.jetbrains.kotlin.native.interop.gen.jvm.parseKeyValuePairs +import org.jetbrains.kotlin.native.interop.gen.jvm.prepareTool +import org.jetbrains.kotlin.native.interop.indexer.NativeIndex +import org.jetbrains.kotlin.native.interop.indexer.NativeLibrary +import org.jetbrains.kotlin.native.interop.indexer.buildNativeIndex +import org.jetbrains.kotlin.native.interop.tool.CInteropArguments +import org.jetbrains.kotlin.native.interop.tool.ToolConfig + +// Before 2.4.20 cinterop takes the distribution from the konan.home system property, which the plugin +// sets, and has no -Xkonan-home. + +/** [cinteropArgs] parsed by cinterop, as its own entry point parses them. */ +internal fun cinteropArguments(cinteropArgs: Array): CInteropArguments = + CInteropArguments().apply { argParser.parse(cinteropArgs) } + +/** cinterop's tool configuration for [arguments]: the target, its clang and sysroot. */ +internal fun toolConfig(arguments: CInteropArguments): ToolConfig = prepareTool( + arguments.target, + KotlinPlatform.NATIVE, + runFromDaemon = false, + parseKeyValuePairs(arguments.overrideKonanProperties), + konanDataDir = arguments.konanDataDir, +) + +/** cinterop's index of [library]. */ +internal fun nativeIndex(library: NativeLibrary, arguments: CInteropArguments): NativeIndex = + buildNativeIndex(library, arguments.verbose).index diff --git a/src/indexExporter/compat/frontend/2.4.20/io/github/lemcoder/index/exporter/Frontend.kt b/src/indexExporter/compat/frontend/2.4.20/io/github/lemcoder/index/exporter/Frontend.kt new file mode 100644 index 0000000..0f6b8c8 --- /dev/null +++ b/src/indexExporter/compat/frontend/2.4.20/io/github/lemcoder/index/exporter/Frontend.kt @@ -0,0 +1,33 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.gen.jvm.KotlinPlatform +import org.jetbrains.kotlin.native.interop.gen.jvm.parseKeyValuePairs +import org.jetbrains.kotlin.native.interop.gen.jvm.prepareTool +import org.jetbrains.kotlin.native.interop.indexer.NativeIndex +import org.jetbrains.kotlin.native.interop.indexer.NativeLibrary +import org.jetbrains.kotlin.native.interop.indexer.buildNativeIndex +import org.jetbrains.kotlin.native.interop.tool.CInteropArguments +import org.jetbrains.kotlin.native.interop.tool.ToolConfig + +/** + * [cinteropArgs] parsed by cinterop, as its own entry point parses them, with the distribution the + * plugin passes as the konan.home system property: from 2.4.20 cinterop reads it from -Xkonan-home. + */ +internal fun cinteropArguments(cinteropArgs: Array): CInteropArguments { + val home = checkNotNull(System.getProperty("konan.home")) { "konan.home is not set" } + return CInteropArguments().apply { argParser.parse(arrayOf("-Xkonan-home", home) + cinteropArgs) } +} + +/** cinterop's tool configuration for [arguments]: the target, its clang and sysroot. */ +internal fun toolConfig(arguments: CInteropArguments): ToolConfig = prepareTool( + arguments.target, + KotlinPlatform.NATIVE, + runFromDaemon = false, + parseKeyValuePairs(arguments.overrideKonanProperties), + konanDataDir = arguments.konanDataDir, + konanHome = arguments.konanHome, +) + +/** cinterop's index of [library]. */ +internal fun nativeIndex(library: NativeLibrary, arguments: CInteropArguments): NativeIndex = + buildNativeIndex(library, arguments.verbose, macroNamesCollectingMode = arguments.macroCollectionImpl).index diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt index 0d78f29..3060c64 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt @@ -72,17 +72,12 @@ private fun renderFunction(f: FunctionDecl): String { "${p.name ?: "_"}: ${render(p.type)}" + if (p.nsConsumed) " nsConsumed" else "" } val vararg = if (f.isVararg) ", ..." else "" - return "function ${f.fullName}($params$vararg): ${render(f.returnType)} ${renderAccess(f.directAccess)}" + return "function ${f.fullName}($params$vararg): ${render(f.returnType)}${access(f)}" } private fun renderGlobal(g: GlobalDecl): String { val const = if (g.isConst) "const " else "" - return "global $const${g.fullName}: ${render(g.type)} ${renderAccess(g.directAccess)}" -} - -private fun renderAccess(access: DirectAccess): String = when (access) { - is DirectAccess.Symbol -> "symbol=${access.name}" - is DirectAccess.Unavailable -> "unavailable(${access.reason})" + return "global $const${g.fullName}: ${render(g.type)}${access(g)}" } private fun renderConstant(c: ConstantDef): String { diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt index c63be1b..69ac179 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -15,8 +15,15 @@ import java.io.File * - `const char*`: `String?` in both directions, through `GetStringUTFChars` / `NewStringUTF`; * - other pointers to integers or floating point (`int*`, `uint8_t*`, `float*`, …): the matching * primitive array, pinned for the call and written back afterwards unless the pointee is `const`; + * - pointers to pointers (`handle_t**`, the usual out-parameter): a `LongArray?` of addresses, copied + * into a C array of pointers for the call and back afterwards unless the pointee is `const`; * - structs by value: a `ByteArray` holding the struct's bytes; - * - every other pointer (opaque handles, `void*`, structs, function pointers): its address as a `Long`. + * - `void*` and `const void*` arguments, spelled as such: a direct `java.nio.Buffer?`, whose start + * address β€” not its position β€” is passed; a heap buffer is an `IllegalArgumentException`; + * - every other pointer (opaque handles, typedef'd `void*`, structs, function pointers, results): its + * address as a `Long`. + * + * Enum constants and the integer, floating-point and string macro constants become `const val`s. * * Functions it cannot bind (variadic, `long double`, an anonymous struct by value, …) are listed with the * reason at the end of the Kotlin file instead. @@ -43,7 +50,7 @@ object JniGenerator : BindingGenerator { val kotlinFile = outputDirectory.resolve("kotlin/${packageName.replace('.', '/')}/$facade.kt") kotlinFile.parentFile.mkdirs() - kotlinFile.writeText(kotlinSource(packageName, facade, stubLibrary, visibility, bound, skipped)) + kotlinFile.writeText(kotlinSource(packageName, facade, stubLibrary, visibility, constants(context.index), bound, skipped)) val cFile = outputDirectory.resolve("c/$stubLibrary.c") cFile.parentFile.mkdirs() @@ -95,6 +102,20 @@ object JniGenerator : BindingGenerator { class PrimitiveArray(val element: JvmPrimitive, val cType: String, val readOnly: Boolean) : Crossing("${element.kotlinName}Array?", "${element.jniName}Array") + /** + * A pointer to pointers as a `LongArray?` of addresses, through a C array of `void*`; [cType] spells + * the pointer. A [readOnly] one (`const` pointee) is not copied back. Pointers may be narrower than a + * `jlong`, so the array cannot be pinned and passed as is. + */ + class AddressArray(val cType: String, val readOnly: Boolean) : + Crossing("${JvmPrimitive.LONG.kotlinName}Array?", "${JvmPrimitive.LONG.jniName}Array") + + /** + * A `void*` argument as a direct `java.nio.Buffer`'s address; [cType] spells the pointer. A typedef'd + * `void*` is a handle more often than memory, and stays an [Address]. + */ + class DirectBuffer(val cType: String) : Crossing("java.nio.Buffer?", "jobject") + /** A struct by value as its bytes; [cType] spells the struct. */ class StructBytes(val cType: String) : Crossing("${JvmPrimitive.BYTE.kotlinName}Array", "${JvmPrimitive.BYTE.jniName}Array") @@ -138,7 +159,12 @@ object JniGenerator : BindingGenerator { is FloatingType -> JvmPrimitive.floating(pointee.size) else -> null } - return if (element != null) Crossing.PrimitiveArray(element, cType, isConst) else Crossing.Address(cType) + return when { + element != null -> Crossing.PrimitiveArray(element, cType, isConst) + pointee == VoidType && declared !is Typedef -> Crossing.DirectBuffer(cType) + pointee is PointerType -> Crossing.AddressArray(cType, isConst) + else -> Crossing.Address(cType) + } } private fun primitive(type: Type): Crossing.Primitive = when (type) { @@ -174,6 +200,7 @@ object JniGenerator : BindingGenerator { facade: String, stubLibrary: String, visibility: BindingVisibility, + constants: List>, bound: List, skipped: List>, ) = buildString { @@ -181,12 +208,15 @@ object JniGenerator : BindingGenerator { appendLine("""@file:Suppress("FunctionName", "LocalVariableName", "unused")""") if (packageName.isNotEmpty()) appendLine("package $packageName") appendLine() - appendLine("// Generated JNI bindings; do not edit. Strings and primitive buffers are marshalled,") - appendLine("// structs by value cross as their bytes, and other pointers as addresses (Long).") + appendLine("// Generated JNI bindings; do not edit. Strings and primitive buffers are marshalled, void*") + appendLine("// arguments are direct Buffers, structs by value cross as their bytes, and other pointers as") + appendLine("// addresses (Long).") appendLine() appendLine("""private val nativeLibrary: Unit = loadNativeLibrary("$stubLibrary")""") appendLine() append(NativeLoaderSource.kotlin()) + if (constants.isNotEmpty()) appendLine() + constants.forEach { (name, value) -> appendLine("${visibility.name.lowercase()} const val ${kotlinIdentifier(name)}$value") } bound.forEach { b -> val params = b.params.joinToString { (name, crossing) -> "${kotlinIdentifier(name)}: ${crossing.kotlinType}" } appendLine() @@ -217,7 +247,7 @@ object JniGenerator : BindingGenerator { appendLine("#include ") context.headers.forEach { appendLine("#include <$it>") } context.preambleLines.forEach { appendLine(it) } - if (bound.any { b -> b.params.any { it.second is Crossing.StructBytes } }) { + if (bound.any { b -> b.params.any { it.second is Crossing.StructBytes || it.second is Crossing.DirectBuffer } }) { appendLine() appendLine("static void kniThrowIllegalArgument(JNIEnv* env, const char* message) {") appendLine(" jclass type = (*env)->FindClass(env, \"java/lang/IllegalArgumentException\");") @@ -236,6 +266,13 @@ object JniGenerator : BindingGenerator { // Checks first, so a failing one leaves nothing acquired. b.params.forEachIndexed { i, (_, c) -> + if (c is Crossing.DirectBuffer) { + appendLine(" void* d$i = p$i ? (*env)->GetDirectBufferAddress(env, p$i) : 0;") + appendLine(" if (p$i && !d$i) {") + appendLine(" kniThrowIllegalArgument(env, \"$name: argument $i must be a direct buffer\");") + appendLine(" $failure") + appendLine(" }") + } if (c is Crossing.StructBytes) { appendLine(" if (!p$i || (*env)->GetArrayLength(env, p$i) < (jsize)sizeof(${c.cType})) {") appendLine(" kniThrowIllegalArgument(env, \"$name: argument $i must hold sizeof(${c.cType}) bytes\");") @@ -247,6 +284,7 @@ object JniGenerator : BindingGenerator { when (c) { is Crossing.Primitive -> "p$i" is Crossing.Address -> "(${c.cType})(intptr_t)p$i" + is Crossing.DirectBuffer -> "(${c.cType})d$i" Crossing.CString -> { appendLine(" const char* s$i = p$i ? (*env)->GetStringUTFChars(env, p$i, 0) : 0;") "s$i" @@ -255,6 +293,13 @@ object JniGenerator : BindingGenerator { appendLine(" ${c.element.jniName}* a$i = p$i ? (*env)->Get${c.element.kotlinName}ArrayElements(env, p$i, 0) : 0;") "(${c.cType})a$i" } + is Crossing.AddressArray -> { + appendLine(" jsize n$i = p$i ? (*env)->GetArrayLength(env, p$i) : 0;") + appendLine(" jlong* a$i = p$i ? (*env)->GetLongArrayElements(env, p$i, 0) : 0;") + appendLine(" void* t$i[n$i > 0 ? n$i : 1];") + appendLine(" for (jsize k = 0; k < n$i; k++) t$i[k] = (void*)(intptr_t)a$i[k];") + "p$i ? (${c.cType})t$i : 0" + } is Crossing.StructBytes -> { appendLine(" jbyte* b$i = (*env)->GetByteArrayElements(env, p$i, 0);") "*(${c.cType}*)b$i" @@ -276,7 +321,7 @@ object JniGenerator : BindingGenerator { appendLine(" jbyteArray result = (*env)->NewByteArray(env, (jsize)sizeof r);") appendLine(" if (result) (*env)->SetByteArrayRegion(env, result, 0, (jsize)sizeof r, (const jbyte*)&r);") } - is Crossing.PrimitiveArray -> error("array result") + is Crossing.PrimitiveArray, is Crossing.AddressArray, is Crossing.DirectBuffer -> error("not a result crossing") } // Released in reverse acquisition order; writable arrays with mode 0, so the callee's writes are // copied back. @@ -287,6 +332,11 @@ object JniGenerator : BindingGenerator { val mode = if (c.readOnly) "JNI_ABORT" else "0" appendLine(" if (p$i) (*env)->Release${c.element.kotlinName}ArrayElements(env, p$i, a$i, $mode);") } + is Crossing.AddressArray -> { + if (!c.readOnly) appendLine(" for (jsize k = 0; k < n$i; k++) a$i[k] = (jlong)(intptr_t)t$i[k];") + val mode = if (c.readOnly) "JNI_ABORT" else "0" + appendLine(" if (p$i) (*env)->ReleaseLongArrayElements(env, p$i, a$i, $mode);") + } is Crossing.StructBytes -> appendLine(" (*env)->ReleaseByteArrayElements(env, p$i, b$i, JNI_ABORT);") else -> {} } @@ -295,6 +345,54 @@ object JniGenerator : BindingGenerator { appendLine("}") } + /** + * Enum constants and macro constants, as (name, `": Type = value"`), sorted by name. Unsigned values + * become the signed Kotlin type of the same size, as unsigned parameters do. + */ + private fun constants(index: NativeIndex): List> { + val enumConstants = index.enums.flatMap { enum -> + val type = runCatching { primitive(resolved(enum.baseType)).primitive }.getOrNull() ?: return@flatMap emptyList() + enum.constants.map { it.name to integerLiteral(type, it.value) } + } + val macroConstants = index.macroConstants.mapNotNull { c -> + when (c) { + is IntegerConstantDef -> runCatching { primitive(resolved(c.type)).primitive }.getOrNull() + ?.let { c.name to integerLiteral(it, c.value) } + is FloatingConstantDef -> when (JvmPrimitive.floating((resolved(c.type) as? FloatingType)?.size ?: 0)) { + JvmPrimitive.FLOAT -> c.name to ": Float = ${c.value.toFloat()}f" + JvmPrimitive.DOUBLE -> c.name to ": Double = ${c.value}" + else -> null + } + is StringConstantDef -> c.name to ": String = \"${kotlinStringContent(c.value)}\"" + else -> null + } + } + return (enumConstants + macroConstants).distinctBy { it.first }.sortedBy { it.first } + } + + /** `": Type = value"` for [value] as [type], wrapped to its size the way C converts it. */ + private fun integerLiteral(type: JvmPrimitive, value: Long): String = when (type) { + JvmPrimitive.BOOLEAN -> ": Boolean = ${value != 0L}" + JvmPrimitive.BYTE -> ": Byte = ${value.toByte()}" + JvmPrimitive.SHORT -> ": Short = ${value.toShort()}" + JvmPrimitive.INT -> ": Int = ${value.toInt()}" + // Long.MIN_VALUE has no literal: -9223372036854775808 is the negation of an out-of-range one. + JvmPrimitive.LONG -> if (value == Long.MIN_VALUE) ": Long = Long.MIN_VALUE" else ": Long = ${value}L" + JvmPrimitive.FLOAT, JvmPrimitive.DOUBLE -> error("not an integer") + } + + private fun kotlinStringContent(value: String): String = buildString { + value.forEach { c -> + when { + c == '\\' -> append("\\\\") + c == '"' -> append("\\\"") + c == '$' -> append("\\$") + c.code < 0x20 || c.code > 0x7e -> append("\\u%04x".format(c.code)) + else -> append(c) + } + } + } + /** `Java_`: the prefix of every JNI symbol for methods of the facade class. */ private fun jniClassPrefix(packageName: String, facade: String): String = "Java_" + (packageName.split('.').filter { it.isNotEmpty() } + facade).joinToString("_") { jniMangle(it) } diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt index cecf63c..0979396 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -4,12 +4,7 @@ import io.github.lemcoder.index.ExporterInvocation import kotlinx.cinterop.JvmCInteropCallbacks import org.jetbrains.kotlin.konan.util.DefFile import org.jetbrains.kotlin.native.interop.gen.ImportsImpl -import org.jetbrains.kotlin.native.interop.gen.jvm.KotlinPlatform import org.jetbrains.kotlin.native.interop.gen.jvm.buildNativeLibrary -import org.jetbrains.kotlin.native.interop.gen.jvm.parseKeyValuePairs -import org.jetbrains.kotlin.native.interop.gen.jvm.prepareTool -import org.jetbrains.kotlin.native.interop.indexer.buildNativeIndex -import org.jetbrains.kotlin.native.interop.tool.CInteropArguments import org.jetbrains.kotlin.utils.NativeMemoryAllocator import java.io.File @@ -31,23 +26,16 @@ fun main(args: Array) { * before generating any Kotlin. `-def`, `-pkg`, `-target`, `-compiler-option`, `-Xkonan-home` and the rest * are cinterop's options, parsed by cinterop. * - * [prepareTool] and [buildNativeLibrary] are `internal` to the compiler jar; this source set compiles with - * that jar as a friend module (see buildSrc's native-index plugin), so a signature change in a Kotlin - * release fails compilation here, at the `kotlinNativeVersion` bump. + * `prepareTool` and [buildNativeLibrary] are `internal` to the compiler jar; the exporter compiles with + * that jar as a friend module (see buildSrc's native-index plugin), once per supported Kotlin/Native + * version, so a signature change fails compilation for that version. What differs between versions is in + * `src/indexExporter/compat` ([cinteropArguments], [toolConfig], [nativeIndex]). */ private fun context(cinteropArgs: Array): GenerationContext { - val arguments = CInteropArguments() - arguments.argParser.parse(cinteropArgs) + val arguments = cinteropArguments(cinteropArgs) require(arguments.library.isEmpty()) { "Dependency klibs (-library) are not supported by the index exporter yet." } - val tool = prepareTool( - arguments.target, - KotlinPlatform.NATIVE, - runFromDaemon = false, - parseKeyValuePairs(arguments.overrideKonanProperties), - konanDataDir = arguments.konanDataDir, - konanHome = arguments.konanHome, - ) + val tool = toolConfig(arguments) val defFile = arguments.def?.let(::File) val def = DefFile(defFile, tool.target) @@ -64,7 +52,7 @@ private fun context(cinteropArgs: Array): GenerationContext { try { // No dependency klibs, so no headers are imported from elsewhere. val library = buildNativeLibrary(tool, def, arguments, ImportsImpl(emptyMap())) - val index = buildNativeIndex(library, arguments.verbose, macroNamesCollectingMode = arguments.macroCollectionImpl).index + val index = nativeIndex(library, arguments) IndexedLibrary(index, headers = library.includes.map { it.headerPath }, compilerArgs = library.compilerArgs) } finally { JvmCInteropCallbacks.dispose() diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 9ff6265..d3060b1 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -1,22 +1,32 @@ package io.github.lemcoder import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.kotlin.KotlinGradlePlugin +import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.model.ConfigField +import io.github.lemcoder.wiring.AgpWiring +import io.github.lemcoder.wiring.GradlePluginId import io.github.lemcoder.wiring.registerBindings +import io.github.lemcoder.wiring.registerConfigReport import io.github.lemcoder.wiring.registerLibraries +import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Plugin import org.gradle.api.Project abstract class KonanPlugin : Plugin { override fun apply(project: Project) { val konan = project.extensions.create("konan", KonanExtension::class.java) - // Auto-detected: $KONAN_HOME, else the newest ~/.konan/kotlin-native-prebuilt-*. Absent rather - // than failing when there is none: the convention is queried whenever the configuration cache is - // stored, so throwing here breaks configuration of builds that never touch a konan task. The - // tasks that need it resolve it again when they run, and say what to do. - konan.konanHome.convention( - project.layout.dir(project.providers.provider { KonanDistribution.findHome() }) - ) + // Every task that runs Kotlin/Native reads konanHome from config; checked when it runs, so a build + // that never runs one needs none. + project.tasks.withType(UsesKonanDistribution::class.java).configureEach { + konanHome.convention(konan.config.readConfigField(ConfigField.KONAN_HOME, path)) + } + project.registerConfigReport(konan.config) + // kotlinNativeDistribution() names the project's own Kotlin/Native version once the Kotlin plugin is known. + for (kotlin in listOf(GradlePluginId.KOTLIN_JVM, GradlePluginId.KOTLIN_MULTIPLATFORM, GradlePluginId.KOTLIN_ANDROID)) { + project.withPlugin(kotlin) { konan.config.kotlinPluginVersion = KotlinGradlePlugin.version(project) } + } + konan.androidApiLevel.convention(AgpWiring.minSdk(project)) project.registerLibraries(konan) project.registerBindings(konan) } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt index 47940ff..eb9d5de 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt @@ -3,41 +3,57 @@ package io.github.lemcoder.consumer import io.github.lemcoder.ffm.FfmLibraryInputs import io.github.lemcoder.ffm.ffmLibraryBuildFor import io.github.lemcoder.index.FfmApi -import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest +import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.model.Architecture +import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName -import io.github.lemcoder.toolchain.command import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId import io.github.lemcoder.wiring.SourceKind +import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Task +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.TaskProvider -import java.io.File /** * FFM bindings: jextract over the same `.def`, and the C library itself as a shared library β€” FFM loads it * directly, with no stub β€” from the binding's [io.github.lemcoder.ffm.FfmLibraryBuild]. Opted into with - * `ffm()`; in a `kotlin("jvm")` project or an Android module it replaces JNI. + * `ffm()`, which then replaces JNI wherever JNI would go. * - * On a JVM the bindings use the JDK's `java.lang.foreign` and the library is provided for the host. In an - * Android module they use PanamaPort's backport, which the plugin adds as a dependency, and the library - * is provided for each 64-bit ABI under `jniLibs/`. + * Generated once per API the project needs: the JDK's `java.lang.foreign` for `kotlin("jvm")` and + * multiplatform `jvm()` targets, the library provided for the host and bundled in the jar; PanamaPort's + * backport for Android modules and multiplatform Android targets, added as a dependency, the library + * provided for each 64-bit ABI under `jniLibs/`. */ internal class FfmConsumer(private val context: BindingContext) : BindingConsumer { private val project = context.project private val binding = context.binding private val bindingName = context.bindingName - private val generatedRoot = context.buildDirectory.dir("generated/konan/$bindingName/ffm") - private lateinit var linkAll: TaskProvider - /** An Android module: PanamaPort, 64-bit ABIs. A multiplatform project's FFM serves its `jvm()` targets instead. */ - private val isAndroid get() = context.hasPlugin(GradlePluginId.ANDROID_BASE) && !context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) + private val isMultiplatform get() = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) + + /** `kotlin("jvm")`, or a multiplatform `jvm()` target: the JDK's FFM. */ + private val servesJvm get() = + context.hasPlugin(GradlePluginId.KOTLIN_JVM) || (isMultiplatform && KotlinGradlePlugin.hasKmpPlainJvmTarget(project)) + + /** An Android module, or a multiplatform Android target: PanamaPort. */ + private val servesAndroid get() = + (context.hasPlugin(GradlePluginId.ANDROID_BASE) && (!isMultiplatform || KotlinGradlePlugin.hasKmpAndroidTarget(project))) || + context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) + + private fun generatedRoot(api: FfmApi): Provider = context.buildDirectory.dir( + when (api) { + FfmApi.JAVA -> "generated/konan/$bindingName/ffm" + FfmApi.PANAMA_PORT -> "generated/konan/$bindingName/ffm-android" + } + ) override fun prepare() { val ffm = binding.ffm @@ -51,8 +67,9 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume // Eager, as the variant API needs; whether FFM is on is only known in the callbacks. linkFfm // depends on generation, so it orders both the compile and the packaging. val enabled = { ffm.isEnabled } - AgpWiring.addSources(project, SourceKind.JAVA, generatedRoot.map { it.dir("java") }, linkAll, linkAll, enabled) + AgpWiring.addSources(project, SourceKind.JAVA, generatedRoot(FfmApi.PANAMA_PORT).map { it.dir("java") }, linkAll, linkAll, enabled) AgpWiring.addJniLibs(project, ffm.jniLibsRoot, linkAll, enabled) + project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.dependOnForKmpAndroid(project, linkAll, enabled) } } override fun isUsed(): Boolean = binding.ffm.isEnabled @@ -61,72 +78,89 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume val ffm = binding.ffm val artifacts = context.artifacts() val libraryName = ffm.libraryName.get() - val api = if (isAndroid) FfmApi.PANAMA_PORT else FfmApi.JAVA + val jvm = servesJvm + val android = servesAndroid + if (!jvm && !android) { + project.logger.info( + "konan.bindings.$bindingName: FFM bindings are wired into kotlin(\"jvm\"), multiplatform and Android projects; " + + "generate them with generateFfm$bindingName and add them yourself." + ) + } + // The JDK's bindings also when nothing is detected, for a build to wire itself. + val generateJvm = if (jvm || !android) registerGeneration(FfmApi.JAVA, libraryName) else null + if (android) registerGeneration(FfmApi.PANAMA_PORT, libraryName) + + val loadable = buildList { + if (jvm || !android) add(NativeTarget.host) + if (android) addAll(NativeTarget.android.filter { it.architecture in LP64 }) + } + val targets = targets(artifacts?.targets?.getOrElse(emptyList()).orEmpty(), loadable, warnAboutAndroid = android) + val builds = ffmLibraryBuildFor(binding).register(project, context.konan, binding, FfmLibraryInputs(artifacts, targets, libraryName)) + linkAll.configure { dependsOn(builds) } + + ffm.libraryDirectory.convention(if (jvm || !android) ffm.libraryDirectoryFor(NativeTarget.host) else ffm.jniLibsRoot) + if (android) { + val panamaPort = ffm.panamaPortVersion.map { "$PANAMA_PORT:$it" } + if (isMultiplatform) KotlinGradlePlugin.addKmpAndroidDependency(project, panamaPort) + else project.dependencies.add("implementation", panamaPort) + } + if (jvm && generateJvm != null) wireJvm(generateJvm, targets.filter { !it.isAndroid }, libraryName) + } + + private fun registerGeneration(api: FfmApi, libraryName: String): TaskProvider { + val ffm = binding.ffm val packageName = context.packageName - val generate = project.tasks.register(taskName("generateFfm", bindingName), GenerateFromNativeIndexTask::class.java) { + val config = context.konan.config + val task = project.tasks.register( + taskName(if (api == FfmApi.JAVA) "generateFfm" else "generateFfmAndroid", bindingName), + GenerateFromNativeIndexTask::class.java, + ) { group = "konan" - description = "Generate the '$bindingName' FFM bindings with jextract." + description = "Generate the '$bindingName' FFM bindings with jextract" + (if (api == FfmApi.PANAMA_PORT) ", for PanamaPort." else ".") defFile.set(binding.defFile) includeDirs.from(binding.includeDirs) compilerOptions.set(binding.compilerOptions) // One index, for the host: its type layouts are those of every LP64 target, which is why Android // gets only its 64-bit ABIs. target.set(NativeTarget.host) - // Resolved here, in the daemon, which knows the usual install locations; unresolved, the exporter - // runs `jextract` from its own PATH. - request.set(ffm.jextract.map { jextract -> - GeneratorRequest.Jextract(library = libraryName, jextract = File(jextract.command()).takeIf { it.isAbsolute }, api = api) - }) + request.set(GeneratorRequest.Jextract(library = libraryName, api = api)) + jextract.set(config.readConfigField(ConfigField.JEXTRACT, path)) packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } - outputDirectory.set(generatedRoot) + outputDirectory.set(generatedRoot(api)) } - linkAll.configure { dependsOn(generate) } - - val targets = targets(artifacts?.targets?.getOrElse(emptyList()).orEmpty()) - val builds = ffmLibraryBuildFor(binding).register(project, context.konan, binding, FfmLibraryInputs(artifacts, targets, libraryName)) - linkAll.configure { dependsOn(builds) } + linkAll.configure { dependsOn(task) } + return task + } - if (isAndroid) { - ffm.libraryDirectory.convention(ffm.jniLibsRoot) - project.dependencies.add("implementation", ffm.panamaPortVersion.map { "$PANAMA_PORT:$it" }) - } else { - ffm.libraryDirectory.convention(ffm.libraryDirectoryFor(NativeTarget.host)) - } + /** The JDK's bindings into the JVM compilation, and the host libraries into the jar. */ + private fun wireJvm(generate: TaskProvider, jvmTargets: List, libraryName: String) { + val ffm = binding.ffm val javaSources = generate.flatMap { it.outputDirectory.dir("java") } - val kmpJvm = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project) - val bundle = if (!isAndroid && ffm.bundleInJar.get() && (context.hasPlugin(GradlePluginId.KOTLIN_JVM) || kmpJvm)) { + val bundle = if (ffm.bundleInJar.get() && jvmTargets.isNotEmpty()) { project.registerJarBundle( taskName = taskName("bundleFfm", bindingName), taskDescription = "Put the libraries the '$bindingName' FFM bindings load in the jar.", - libraries = targets.associateWith { target -> ffm.libraryDirectoryFor(target).map { it.file(target.sharedLibraryName(libraryName)) } }, + libraries = jvmTargets.associateWith { target -> ffm.libraryDirectoryFor(target).map { it.file(target.sharedLibraryName(libraryName)) } }, destination = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/resources").get().asFile, builtBy = linkAll, ).map { it.destinationDir } } else null - when { - context.hasPlugin(GradlePluginId.KOTLIN_JVM) -> { - // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. - val main = project.extensions.getByType(SourceSetContainer::class.java).getByName("main") - main.java.srcDir(javaSources) - bundle?.let { main.resources.srcDir(it) } - } - kmpJvm -> { - KotlinGradlePlugin.addKmpJvmJava(project, javaSources) - bundle?.let { KotlinGradlePlugin.addKmpJvmResources(project, it) } - } - !isAndroid -> project.logger.info( - "konan.bindings.$bindingName: FFM bindings are wired into kotlin(\"jvm\"), multiplatform jvm() and Android projects; " + - "add build/generated/konan/$bindingName/ffm/java yourself." - ) + if (context.hasPlugin(GradlePluginId.KOTLIN_JVM)) { + // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. + val main = project.extensions.getByType(SourceSetContainer::class.java).getByName("main") + main.java.srcDir(javaSources) + bundle?.let { main.resources.srcDir(it) } + } else { + KotlinGradlePlugin.addKmpJvmJava(project, javaSources) + bundle?.let { KotlinGradlePlugin.addKmpJvmResources(project, it) } } } /** - * `ffm.targets`, else the library's targets the project can load, else all of those: the host on a JVM, - * the 64-bit ABIs in an Android module β€” jextract writes LP64 layouts, so 32-bit ABIs are left out. + * `ffm.targets`, else the library's targets among [loadable], else all of [loadable]: the host for the + * JDK's bindings, 64-bit ABIs for PanamaPort's β€” jextract writes LP64 layouts. */ - private fun targets(libraryTargets: List): List { - val loadable = if (isAndroid) NativeTarget.android.filter { it.architecture in LP64 } else listOf(NativeTarget.host) + private fun targets(libraryTargets: List, loadable: List, warnAboutAndroid: Boolean): List { binding.ffm.targets.getOrElse(emptyList()).takeIf { it.isNotEmpty() }?.let { declared -> val unsupported = declared - loadable.toSet() check(unsupported.isEmpty()) { @@ -136,7 +170,7 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume return declared } val skipped = libraryTargets.filter { it.isAndroid && it.architecture !in LP64 } - if (isAndroid && skipped.isNotEmpty()) { + if (warnAboutAndroid && skipped.isNotEmpty()) { project.logger.warn( "konan.bindings.$bindingName: FFM bindings are generated for 64-bit layouts, so no library is provided for " + "${skipped.joinToString { it.androidAbi ?: it.konanName }}; leave those ABIs out of the app." diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt index 2105a00..d2c3ac0 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt @@ -49,20 +49,17 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume } /** - * In a `kotlin("jvm")` project or an Android module, `ffm()` replaces JNI: both would define the same - * facade class. In a multiplatform project it replaces JNI on the `jvm()` targets only β€” Android - * targets keep JNI. Read once the build script has run. + * `ffm()` replaces JNI wherever JNI would go β€” JVM, multiplatform and Android alike β€” since both would + * define the same facade class. Read once the build script has run. */ - private val replacedByFfm get() = - binding.ffm.isEnabled && !context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && - (context.hasPlugin(GradlePluginId.KOTLIN_JVM) || context.hasPlugin(GradlePluginId.ANDROID_BASE)) + private val replacedByFfm get() = binding.ffm.isEnabled private val isPlainJvmConsumer get() = context.hasPlugin(GradlePluginId.KOTLIN_JVM) && !replacedByFfm - override fun isUsed(): Boolean = + override fun isUsed(): Boolean = !replacedByFfm && ( isPlainJvmConsumer || - (context.hasPlugin(GradlePluginId.ANDROID_BASE) && !replacedByFfm) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || - (context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project)) + context.hasPlugin(GradlePluginId.ANDROID_BASE) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || + (context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project))) override fun register() { if (isPlainJvmConsumer) KotlinGradlePlugin.addJvmSources(project, generate.flatMap { it.kotlinSourceDirectory }) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt index 031fb06..80e0777 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt @@ -1,6 +1,5 @@ package io.github.lemcoder.dsl -import io.github.lemcoder.model.Executable import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action @@ -32,7 +31,6 @@ import javax.inject.Inject abstract class CMakeSettings @Inject constructor(private val objects: ObjectFactory) { init { - executable.convention(Executable.OnPath("cmake")) toolchain.convention(ExternalToolchain.AUTO) } @@ -75,8 +73,6 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact /** Extra arguments for the configure step, e.g. your own `-D` switches. */ abstract val arguments: ListProperty - /** The `cmake` program. Defaults to `cmake` on the PATH or in the usual install locations. */ - abstract val executable: Property } /** One target's build: everything not set here comes from the enclosing [CMakeSettings]. */ diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt index d626cf1..5402cb3 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt @@ -1,6 +1,5 @@ package io.github.lemcoder.dsl -import io.github.lemcoder.model.Executable import io.github.lemcoder.model.ExternalToolchain import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty @@ -14,7 +13,6 @@ import org.gradle.api.provider.Property */ abstract class CargoSettings { init { - executable.convention(Executable.OnPath("cargo")) profile.convention(CargoProfile.RELEASE) toolchain.convention(ExternalToolchain.AUTO) } @@ -31,9 +29,6 @@ abstract class CargoSettings { /** Further `cargo build` arguments, e.g. `--features`. */ abstract val arguments: ListProperty - /** The `cargo` program. Defaults to `cargo` on the PATH or in the usual install locations. */ - abstract val executable: Property - /** * Whose C compilers crates with C code (the `cc` crate) get, as `CC_`, `CXX_` and * `AR_` β€” target-specific, so build scripts compiled for the host keep the host's. diff --git a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt index 0cf06f3..dd3490c 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt @@ -1,7 +1,6 @@ package io.github.lemcoder.dsl import io.github.lemcoder.ffm.FfmLibraryBuild -import io.github.lemcoder.model.Executable import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action import org.gradle.api.file.Directory @@ -21,7 +20,6 @@ import javax.inject.Inject */ abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { init { - jextract.convention(Executable.OnPath("jextract")) panamaPortVersion.convention("v0.1.5") bundleInJar.convention(true) } @@ -32,9 +30,6 @@ abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { */ abstract val bundleInJar: Property - /** The jextract program. Defaults to `jextract` on the PATH or in the usual install locations. */ - abstract val jextract: Property - /** * The version of PanamaPort (`io.github.vova7878.panama:Core`), the FFM backport an Android module's * bindings use; the plugin adds it as an `implementation` dependency. It needs `minSdk` 26. diff --git a/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt index fe8f9bd..75156c7 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt @@ -42,9 +42,6 @@ abstract class JniSpec @Inject constructor(objects: ObjectFactory) { /** An archive to link instead of the library's, e.g. one built elsewhere. */ abstract val archive: RegularFileProperty - /** A JDK home that ships `include/jni.h`. Defaults to a scan of installed JDKs. */ - abstract val jdkHome: DirectoryProperty - /** Extra options for every target's link command. */ abstract val linkerOptions: ListProperty diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanConfig.kt new file mode 100644 index 0000000..263c18a --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanConfig.kt @@ -0,0 +1,109 @@ +package io.github.lemcoder.dsl + +import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.model.ConfigField +import io.github.lemcoder.model.ConfigSource +import io.github.lemcoder.model.ConfigValue +import io.github.lemcoder.model.Executable +import org.gradle.api.Project +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemLocationProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory +import java.io.File +import javax.inject.Inject + +/** + * `konan { config { } }`: the paths the plugin needs from the machine β€” the Kotlin/Native distribution and + * the programs it runs. The one place they come from; nothing is searched for. + * + * Each is read from this block, else the Gradle property `konan.config.` (a build's + * `gradle.properties`, `~/.gradle/gradle.properties` or `-P`), else an environment variable: + * + * | Field | Environment variable | Needed by | + * |-------------|----------------------|-----------| + * | `konanHome` | `KONAN_HOME` | anything that compiles, links or generates bindings | + * | `jextract` | `KONAN_JEXTRACT` | `ffm()` | + * | `cmake` | `KONAN_CMAKE` | `cmake { }` | + * | `make` | `KONAN_MAKE` | `make { }` | + * | `cargo` | `KONAN_CARGO` | `cargo { }` | + * | `zig` | `KONAN_ZIG` | `zig { }` | + * + * Values are absolute paths. All are optional until a task that needs one runs; it then fails saying + * which value it needs and how to set it. `gradlew konanConfig` prints them all. + */ +abstract class KonanConfig @Inject constructor(private val providers: ProviderFactory) { + /** The Kotlin/Native distribution: `kotlin-native-prebuilt--`. See [kotlinNativeDistribution]. */ + abstract val konanHome: DirectoryProperty + + /** The jextract program, for `ffm()`. */ + abstract val jextract: RegularFileProperty + + /** The cmake program, for `cmake { }` blocks. */ + abstract val cmake: RegularFileProperty + + /** The make program, for `make { }` blocks. */ + abstract val make: RegularFileProperty + + /** The cargo program, for `cargo { }`. */ + abstract val cargo: RegularFileProperty + + /** The zig program, for `zig { }`. */ + abstract val zig: RegularFileProperty + + /** + * Where the Kotlin plugin installs Kotlin/Native for this host: `konanHome.set(kotlinNativeDistribution())`. + * The project's Kotlin version when it applies the Kotlin plugin, else the plugin's default. Computed, + * not searched for; a Kotlin Multiplatform build of that version with a native target downloads it. + */ + fun kotlinNativeDistribution(): File = KonanDistribution.defaultHome(kotlinVersion()) + + /** The Kotlin plugin's version, set when the project applies it. */ + internal var kotlinPluginVersion: String? = null + + private fun kotlinVersion(): String = kotlinPluginVersion ?: KonanDistribution.defaultVersion + + /** + * [field] for the task [usedBy]: this block, else the Gradle property, else the environment variable, + * else unset. Always present; [ConfigValue.require] checks it when the task runs. + */ + internal fun readConfigField(field: ConfigField, usedBy: String): Provider { + usages.getOrPut(field) { sortedSetOf() } += usedBy + return resolve(field, usedBy) + } + + /** [field] as [readConfigField] reads it, without counting [usedBy] as needing it. */ + internal fun resolve(field: ConfigField, usedBy: String): Provider { + val hint = if (field == ConfigField.KONAN_HOME) konanHomeHint() else null + fun value(source: ConfigSource) = { raw: String -> ConfigValue(field, raw, source, usedBy, hint) } + return property(field).map { it.asFile.absolutePath }.map(value(ConfigSource.DSL)) + .orElse(providers.gradleProperty(field.gradleProperty).map(value(ConfigSource.GRADLE_PROPERTY))) + .orElse(providers.environmentVariable(field.environmentVariable).map(value(ConfigSource.ENVIRONMENT))) + .orElse(ConfigValue(field, null, null, usedBy, hint)) + } + + /** [field] as the program an external build step runs. */ + internal fun executable(field: ConfigField, usedBy: String): Provider = + readConfigField(field, usedBy).map { Executable.Configured(it) } + + /** The tasks that asked for each field, for `konanConfig`. */ + internal val usages = mutableMapOf>() + + private fun property(field: ConfigField): FileSystemLocationProperty<*> = when (field) { + ConfigField.KONAN_HOME -> konanHome + ConfigField.JEXTRACT -> jextract + ConfigField.CMAKE -> cmake + ConfigField.MAKE -> make + ConfigField.CARGO -> cargo + ConfigField.ZIG -> zig + } + + /** Not whether it is installed: that is known when the task runs, not when the configuration cache stores. */ + private fun konanHomeHint(): String = + "Kotlin/Native ${kotlinVersion()}, this project's, installs to ${KonanDistribution.defaultHome(kotlinVersion())}:\n" + + " konan { config { konanHome.set(kotlinNativeDistribution()) } }" +} + +/** The project's `konan { config { } }`. */ +internal val Project.konanConfig: KonanConfig get() = extensions.getByType(KonanExtension::class.java).config diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt index 003c618..7359772 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -2,8 +2,8 @@ package io.github.lemcoder.dsl import org.gradle.api.Action import org.gradle.api.NamedDomainObjectContainer -import org.gradle.api.file.DirectoryProperty import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property import javax.inject.Inject /** @@ -27,11 +27,18 @@ import javax.inject.Inject * [BindingSpec.ffm] β€” the JVM through jextract's FFM bindings. */ abstract class KonanExtension @Inject constructor(objects: ObjectFactory) { + /** The paths the plugin needs from the machine: the Kotlin/Native distribution and the programs it runs. */ + val config: KonanConfig = objects.newInstance(KonanConfig::class.java) + + fun config(action: Action) = action.execute(config) + /** - * The Kotlin/Native distribution that compiles libraries and links JNI stubs. Defaults to - * `$KONAN_HOME`, else the newest `~/.konan/kotlin-native-prebuilt-*`, found when a task runs. + * The Android API level whose platform libraries the plugin's Android links see: `libaaudio`, + * `libcamera2ndk` and the other later ones are missing from the API 21 Kotlin/Native links against. + * Defaults to the module's lowest `minSdk`, else Kotlin/Native's; Kotlin/Native's NDK stops at 29, so + * a higher level links against the highest it has. */ - abstract val konanHome: DirectoryProperty + abstract val androidApiLevel: Property /** Native libraries this project builds or brings in, one archive per target. */ val libraries: NamedDomainObjectContainer = diff --git a/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt index 86e9c32..c7dbbb0 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt @@ -1,6 +1,5 @@ package io.github.lemcoder.dsl -import io.github.lemcoder.model.Executable import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget import org.gradle.api.file.ConfigurableFileCollection @@ -23,7 +22,6 @@ import java.io.File */ abstract class MakeSettings { init { - executable.convention(Executable.OnPath("make")) toolchain.convention(ExternalToolchain.AUTO) jobs.convention(Runtime.getRuntime().availableProcessors()) } @@ -46,9 +44,6 @@ abstract class MakeSettings { /** `-j`. Defaults to the number of processors. */ abstract val jobs: Property - /** The `make` program. Defaults to `make` on the PATH or in the usual install locations. */ - abstract val executable: Property - /** Whose compilers make gets: Kotlin/Native's when cross-compiling, by default. */ abstract val toolchain: Property diff --git a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt index be3a307..d61fc45 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt @@ -16,7 +16,7 @@ import javax.inject.Inject /** * A C/C++ library built into a static archive per target, `//lib.a`: by - * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([zig], [cargo], [make], [external]), or built + * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([cmake], [zig], [cargo], [make], [external]), or built * elsewhere ([prebuilt]). */ abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { @@ -84,6 +84,19 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o action.execute(make) } + /** + * Builds the library with the project's CMakeLists, once per target: the contract as cache entries, + * `CMAKE_ARCHIVE_OUTPUT_DIRECTORY` set to where the archive must land, and Kotlin/Native's compilers + * as a toolchain file when cross-compiling β€” `add_library(${KONAN_LIBRARY_NAME} STATIC …)` is all the + * CMakeLists needs. + */ + val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) + + fun cmake(action: Action) { + selectedProviders += LibraryProvider.External.CMake + action.execute(cmake) + } + /** Builds the library from a Rust crate (`crate-type = ["staticlib"]`) with `cargo build`, once per target. */ val cargo: CargoSettings = objects.newInstance(CargoSettings::class.java) @@ -107,6 +120,7 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o zig.projectDirectory.convention(sourceDirectory) zig.artifactName.convention(name) cargo.crateName.convention(name) + cmake.path.convention(sourceDirectory.file("CMakeLists.txt")) targets.convention(prebuilt.archives.keySet().map { it.toList() }) } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt index 0e308e4..3ce68d6 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt @@ -1,6 +1,5 @@ package io.github.lemcoder.dsl -import io.github.lemcoder.model.Executable import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property @@ -11,16 +10,12 @@ import org.gradle.api.provider.Property */ abstract class ZigSettings { init { - executable.convention(Executable.OnPath("zig")) optimize.convention(ZigOptimize.RELEASE_FAST) } /** The directory holding `build.zig`. Defaults to the library's `sourceDirectory`. */ abstract val projectDirectory: DirectoryProperty - /** The `zig` program. Defaults to `zig` on the PATH or in the usual install locations. */ - abstract val executable: Property - /** * `-Doptimize`. Defaults to [ZigOptimize.RELEASE_FAST]: Zig's `DEBUG` and `RELEASE_SAFE` compile C with * UBSan traps, whose runtime the Kotlin/Native and JNI links do not have. diff --git a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt index 2b272e3..ebe510c 100644 --- a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt @@ -1,6 +1,8 @@ package io.github.lemcoder.external import io.github.lemcoder.dsl.CMakeSettings +import io.github.lemcoder.dsl.konanConfig +import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.ToolchainVariable import org.gradle.api.Project @@ -10,8 +12,9 @@ import org.gradle.api.provider.Provider /** * The CMake preset of an external build: configure, then build, the project's CMakeLists for one target. * The contract also goes in as cache entries β€” what a CMakeLists conventionally reads β€” and - * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` lands the library in the context's output directory, so the - * CMakeLists needs no knowledge of the layout. Used for the JNI stub and for the library FFM loads. + * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` and `CMAKE_ARCHIVE_OUTPUT_DIRECTORY` land the library in the context's + * output directory, so the CMakeLists needs no knowledge of the layout. Used for a library's archive, the + * JNI stub and the library FFM loads. * * Both commands are one task, which always runs: CMake tracks its own sources, and a changed configure * argument then always reaches the build. @@ -28,6 +31,8 @@ internal fun ExternalBuildTask.addCMakeCommands( ) { val perTarget = cmake.targetSettings[context.target] val preset = perTarget?.preset?.orElse(cmake.preset) ?: cmake.preset + // Read before command { }, whose receiver has neither the task's path nor its project. + val cmakeExecutable = project.konanConfig.executable(ConfigField.CMAKE, path) check(cmake.path.isPresent || preset.isPresent) { "$owner needs a path or a preset." } val sourceDirectory = cmake.path.map { it.asFile.parentFile } // With a preset the binary directory is the preset's to choose, and `-B` cannot override it; the @@ -40,7 +45,7 @@ internal fun ExternalBuildTask.addCMakeCommands( ) command { - executable.set(cmake.executable) + executable.set(cmakeExecutable) arguments.addAll( preset.map { listOf("--preset", it) }.orElse( sourceDirectory.zip(buildDirectory) { source, build -> listOf("-S", source.absolutePath, "-B", build.asFile.absolutePath) } @@ -53,14 +58,16 @@ internal fun ExternalBuildTask.addCMakeCommands( } context.environment.filterKeys { it !is ToolchainVariable } .forEach { (variable, value) -> arguments.add(value.map { "-D${variable.variableName}=$it" }) } + // Shared libraries go to the library directory, static ones to the archive directory: both land in place. arguments.add(context.outputDirectory.map { "-DCMAKE_LIBRARY_OUTPUT_DIRECTORY=${it.asFile.absolutePath}" }) + arguments.add(context.outputDirectory.map { "-DCMAKE_ARCHIVE_OUTPUT_DIRECTORY=${it.asFile.absolutePath}" }) arguments.addAll(cmake.arguments) perTarget?.let { arguments.addAll(it.arguments) } // `--preset` resolves CMakePresets.json from the working directory. workingDirectory.set(sourceDirectory.map { it.absolutePath }) } command { - executable.set(cmake.executable) + executable.set(cmakeExecutable) arguments.addAll(buildDirectory.map { listOf("--build", it.asFile.absolutePath) }) arguments.addAll(cmake.buildTargets.map { targets -> targets.flatMap { listOf("--target", it) } }) arguments.add("--parallel") diff --git a/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt b/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt index 591439f..09a53b4 100644 --- a/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt @@ -1,14 +1,17 @@ package io.github.lemcoder.external import io.github.lemcoder.dsl.MakeSettings +import io.github.lemcoder.dsl.konanConfig +import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.ToolchainVariable /** The Make preset of an external build: one `make` run for the context's target; see [MakeSettings]. */ internal fun ExternalBuildTask.addMakeCommands(make: MakeSettings, context: ExternalBuildContext, owner: String) { check(make.directory.isPresent) { "$owner needs a directory." } + val makeExecutable = project.konanConfig.executable(ConfigField.MAKE, path) command { - executable.set(make.executable) + executable.set(makeExecutable) arguments.addAll(make.directory.map { listOf("-C", it.asFile.absolutePath) }) arguments.addAll(make.makefile.map { listOf("-f", it.asFile.absolutePath) }.orElse(emptyList())) arguments.add(make.jobs.map { "-j$it" }) diff --git a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt index fb9df82..e23944f 100644 --- a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt @@ -32,7 +32,14 @@ internal sealed interface FfmLibraryBuild { data object KonanLink : FfmLibraryBuild { override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { val artifacts = inputs.artifacts - val built = artifacts?.targets?.getOrElse(emptyList()).orEmpty() + if (artifacts == null) { + // Nothing to link: a platform library (libaaudio), or one the app gets some other way. + project.logger.info( + "konan.bindings.${binding.name}.ffm: no library(), so the bindings load lib${inputs.libraryName} as the system provides it." + ) + return emptyList() + } + val built = artifacts.targets.getOrElse(emptyList()) val (linked, missing) = inputs.targets.partition { it in built } if (missing.isNotEmpty()) { project.logger.warn( @@ -40,7 +47,7 @@ internal sealed interface FfmLibraryBuild { "is linked there β€” name one with library(…), or use ffm { external { } } or ffm { prebuilt { } }." ) } - return linked.map { target -> registerLink(project, konan, binding, artifacts!!, inputs.libraryName, target) } + return linked.map { target -> registerLink(project, konan, binding, artifacts, inputs.libraryName, target) } } private fun registerLink( @@ -54,7 +61,7 @@ internal sealed interface FfmLibraryBuild { group = "konan" description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." dependsOn(artifacts.builtBy) - konanHome.set(konan.konanHome) + androidApiLevel.set(konan.androidApiLevel) this.target.set(target) libraryBaseName.set(libraryName) staticLibrary.set(artifacts.archive(target)) diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index 4ac1a24..9a3f61b 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -1,7 +1,9 @@ package io.github.lemcoder.index -import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.kotlin.distribution import io.github.lemcoder.kotlin.runIndexExporter +import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeTarget import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection @@ -30,7 +32,7 @@ import javax.inject.Inject @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") abstract class GenerateFromNativeIndexTask @Inject constructor( private val exec: ExecOperations, -) : DefaultTask() { +) : DefaultTask(), UsesKonanDistribution { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) abstract val defFile: RegularFileProperty @@ -48,9 +50,11 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @get:Input abstract val target: Property - /** Defaults to the installed distribution of [KonanDistribution.pinnedVersion], found at execution. */ - @get:Internal - abstract val konanHome: DirectoryProperty + /** One of [io.github.lemcoder.kotlin.KonanDistribution.supportedVersions]: the exporter links against its compiler. */ + @get:Internal abstract override val konanHome: Property + + /** The jextract program, when [request] is [GeneratorRequest.Jextract]; checked when the task runs. */ + @get:Internal abstract val jextract: Property /** What to generate, with that generator's settings, e.g. `GeneratorRequest.Dump`. */ @get:Input @@ -63,7 +67,9 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @TaskAction fun generate() { exec.runIndexExporter( - request = request.get(), + request = request.get().let { request -> + if (request is GeneratorRequest.Jextract && jextract.isPresent) request.copy(jextract = jextract.get().require()) else request + }, outputDirectory = outputDirectory.get().asFile, cinteropArgs = buildList { val def = defFile.get().asFile @@ -76,7 +82,7 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } addAll(extraCinteropArgs.getOrElse(emptyList())) }, - konanHome = konanHome.orNull?.asFile, + distribution = distribution(), workDir = temporaryDir, ) } diff --git a/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt b/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt index 721f6e4..a91ac35 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt @@ -1,8 +1,11 @@ package io.github.lemcoder.jni import io.github.lemcoder.index.GeneratorRequest +import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.kotlin.distribution import io.github.lemcoder.kotlin.runIndexExporter import io.github.lemcoder.model.BindingVisibility +import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.stubLibraryBaseName import org.gradle.api.DefaultTask @@ -38,7 +41,9 @@ import javax.inject.Inject @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") internal abstract class GenerateJniBindingsTask @Inject constructor( private val exec: ExecOperations, -) : DefaultTask() { +) : DefaultTask(), UsesKonanDistribution { + @get:Internal abstract override val konanHome: Property + @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) abstract val defFile: RegularFileProperty @@ -75,6 +80,13 @@ internal abstract class GenerateJniBindingsTask @Inject constructor( @get:Internal val stubLibraryBaseName: Provider get() = packageName.map { stubLibraryBaseName(it) } + /** + * Directory holding `jni.h` β€” Android's, which is VM-neutral and Apache-licensed β€” for compiling the stub + * for a desktop JVM without a JDK. Android builds use the NDK sysroot's. + */ + @get:Internal + val jniIncludeDirectory: Provider get() = outputDirectory.dir(INCLUDE_DIR) + /** Directory holding the generated Kotlin bindings. */ @get:Internal val kotlinSourceDirectory: Provider get() = outputDirectory.dir(KOTLIN_DIR) @@ -93,13 +105,19 @@ internal abstract class GenerateJniBindingsTask @Inject constructor( includes.filter { it.exists() }.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } }, + distribution = distribution(), workDir = temporaryDir, ) + val header = outputDirectory.get().asFile.resolve("$INCLUDE_DIR/jni.h").apply { parentFile.mkdirs() } + GenerateJniBindingsTask::class.java.getResourceAsStream("/io/github/lemcoder/jni/jni.h").use { input -> + header.outputStream().use { checkNotNull(input) { "jni.h missing from the plugin jar" }.copyTo(it) } + } } private companion object { // The jni generator's layout: kotlin//.kt and c/.c. const val C_DIR = "c" const val KOTLIN_DIR = "kotlin" + const val INCLUDE_DIR = "include" } } diff --git a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt index c9d6837..30c0760 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt @@ -14,7 +14,6 @@ import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.selectOne -import io.github.lemcoder.toolchain.Jdk import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider @@ -61,7 +60,6 @@ internal sealed interface JniStubBuild { final override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs): List> { val bindingName = binding.name val jni = binding.jni - val javaHome = jni.jdkHome.map { it.asFile.absolutePath }.orElse(project.provider { Jdk.detectHome().absolutePath }) val targets = targets(binding, inputs) val tasks = project.registerExternalBuilds( taskPrefix = taskPrefix, @@ -78,7 +76,7 @@ internal sealed interface JniStubBuild { ExternalStubVariable.OUTPUT_DIR to output.map { it.asFile.absolutePath }, ExternalStubVariable.TARGET to project.provider { target.konanName }, ExternalStubVariable.ABI to project.provider { target.libraryDirectoryName }, - ExternalStubVariable.JAVA_HOME to javaHome, + ExternalStubVariable.JNI_INCLUDE_DIR to inputs.generate.flatMap { it.jniIncludeDirectory }.map { it.asFile.absolutePath }, ), ) }, diff --git a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt index 49655d5..55229ab 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt @@ -7,7 +7,6 @@ import io.github.lemcoder.model.DefFile import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName -import io.github.lemcoder.toolchain.Jdk import io.github.lemcoder.util.cleanTask import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider @@ -26,11 +25,6 @@ internal fun registerKonanLink( val bindingName = binding.name val generate = inputs.generate val artifacts = inputs.artifacts - val jniIncludeDirs = project.providers.provider { - binding.jni.jdkHome.orNull?.asFile - ?.let { Jdk.jniIncludeDirectoriesOf(it) ?: error("No include/jni.h under $it") } - ?: Jdk.detectJniIncludeDirectories() - } return inputs.targets.map { target -> // Where a consumer looks for the library: the host's, the one a JVM running this build loads. if (target == NativeTarget.host) binding.jni.libraryDirectory.convention(binding.jni.libraryDirectoryFor(target)) @@ -41,7 +35,7 @@ internal fun registerKonanLink( dependsOn(generate) artifacts?.let { dependsOn(it.builtBy) } mustRunAfter(project.cleanTask()) - konanHome.set(konan.konanHome) + androidApiLevel.set(konan.androidApiLevel) this.target.set(target) libraryBaseName.set(generate.flatMap { it.stubLibraryBaseName }) stubCFile.set(generate.flatMap { it.stubSourceFile }) @@ -50,20 +44,21 @@ internal fun registerKonanLink( // project, build/ included, an input of this task. includePaths.add(binding.defFile.map { it.asFile.parentFile.absolutePath }) staticLibrary.set(project.layout.file(project.provider { archive(binding, artifacts, target) })) - if (!target.isAndroid) this.jniIncludeDirs.from(jniIncludeDirs) + if (!target.isAndroid) jniIncludeDirs.from(generate.flatMap { it.jniIncludeDirectory }) additionalLinkerArgs.set( binding.jni.linkerOptions.zip(binding.jni.targetLinkerOptions) { common, perTarget -> - common + perTarget[target].orEmpty() + defLinkerOpts(binding) + common + perTarget[target].orEmpty() } ) outputDirectory.set(binding.jni.libraryDirectoryFor(target)) // From the task's own property: a closure over the artifacts would drag their TaskProvider into // the configuration cache. + // Linker options alone are enough: `-laaudio` links the stub against a platform library. onlyIf { - staticLibrary.isPresent || run { + staticLibrary.isPresent || additionalLinkerArgs.getOrElse(emptyList()).isNotEmpty() || run { logger.lifecycle( - "konan.bindings.$bindingName: no library(), jni.archive or staticLibraries in the .def, so only " + - "the JNI bindings and the .c stub were generated β€” link them from your own build." + "konan.bindings.$bindingName: no library(), jni.archive, staticLibraries or linkerOpts in the .def, so " + + "only the JNI bindings and the .c stub were generated β€” link them from your own build." ) false } @@ -72,6 +67,10 @@ internal fun registerKonanLink( } } +/** The `.def`'s `linkerOpts`. */ +private fun defLinkerOpts(binding: BindingSpec): List = + binding.defFile.orNull?.asFile?.takeIf { it.isFile }?.let { DefFile.parse(it).linkerOpts }.orEmpty() + /** * The archive to link: `jni.archive` if set, else the library's for [target], else the `.def`'s * `staticLibraries`. diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt b/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt index 7d28598..561ef7a 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt @@ -4,6 +4,7 @@ import io.github.lemcoder.index.ExporterInvocation import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.javaExecCapture +import org.gradle.api.InvalidUserDataException import org.gradle.process.ExecOperations import java.io.File import java.nio.file.Files @@ -18,31 +19,33 @@ import java.nio.file.StandardCopyOption * cinterop may already have loaded it there. Not in a Gradle worker either: that process carries Gradle's * Kotlin stdlib, which would meet the one bundled in the compiler jar. * - * @param konanHome the distribution to run in; by default the installed one of [KonanDistribution.pinnedVersion]. + * The plugin carries one exporter per Kotlin/Native version, each compiled against that version's compiler, + * and runs the one matching [distribution]; a version it has none for is an error. + * + * @param distribution the distribution to run in, one of [KonanDistribution.supportedVersions]. * @param workDir where the exporter jar is extracted, e.g. the task's temporary directory. */ internal fun ExecOperations.runIndexExporter( request: GeneratorRequest, outputDirectory: File, cinteropArgs: List, - konanHome: File? = null, + distribution: KonanDistribution, workDir: File, ) { - val distribution = konanHome?.let(::KonanDistribution) ?: KonanDistribution.pinned() ?: error( - "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed; generating bindings needs exactly that " + - "version. A Kotlin Multiplatform build of ${KonanDistribution.pinnedVersion} with a native target downloads it." - ) val home = distribution.home check(distribution.compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } - val exporterJar = workDir.resolve(EXPORTER_JAR) + val version = distribution.version + if (version !in KonanDistribution.supportedVersions) throw unsupported(distribution) + val jarName = "native-index-exporter-$version.jar" + val exporterJar = workDir.resolve(jarName) workDir.mkdirs() - KonanDistribution::class.java.getResourceAsStream("/io/github/lemcoder/index/$EXPORTER_JAR").use { input -> - checkNotNull(input) { "Index exporter missing from the plugin jar: $EXPORTER_JAR" } + KonanDistribution::class.java.getResourceAsStream("/io/github/lemcoder/index/exporters/$jarName").use { input -> + checkNotNull(input) { "Index exporter missing from the plugin jar: $jarName" } Files.copy(input, exporterJar.toPath(), StandardCopyOption.REPLACE_EXISTING) } - val invocation = ExporterInvocation(request, outputDirectory, listOf("-Xkonan-home", home.absolutePath) + cinteropArgs) + val invocation = ExporterInvocation(request, outputDirectory, cinteropArgs) val result = javaExecCapture { classpath(distribution.compilerJar, exporterJar) mainClass.set(EXPORTER_MAIN) @@ -54,5 +57,14 @@ internal fun ExecOperations.runIndexExporter( check(result is ExecCaptureResult.Success) { "Generating bindings ($request) failed:\n${result.output}" } } -private const val EXPORTER_JAR = "native-index-exporter.jar" +private fun unsupported(distribution: KonanDistribution) = InvalidUserDataException( + buildString { + appendLine("konan.config.konanHome ${distribution.description} is Kotlin/Native ${distribution.version}, which this plugin cannot generate bindings with.") + appendLine("Point it at one of the versions it supports:") + KonanDistribution.supportedVersions.groupBy { it.substringBeforeLast('.') }.values + .forEach { appendLine(" " + it.joinToString(", ")) } + append("Its default location is ${KonanDistribution.defaultHome("")}.") + } +) + private const val EXPORTER_MAIN = "io.github.lemcoder.index.exporter.NativeIndexExporterKt" diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt index 107a16a..a704971 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -1,20 +1,45 @@ package io.github.lemcoder.kotlin +import io.github.lemcoder.model.Architecture +import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.TargetFamily import io.github.lemcoder.toolchain.Xcode import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.execCapture +import org.gradle.api.InvalidUserDataException +import org.gradle.api.Task +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Internal import org.gradle.process.ExecOperations import java.io.File +import java.util.Properties +import java.util.jar.JarFile /** * A Kotlin/Native distribution β€” `kotlin-native-prebuilt-*` β€” and the tools in it the plugin runs: clang * and llvm-ar through `run_konan`, with the target's sysroot, and cinterop's compiler jar. The one * place the distribution's layout is known. */ -internal class KonanDistribution(val home: File) { +internal class KonanDistribution( + val home: File, + /** Where [home] came from, for errors: `/opt/x (Gradle property konan.config.konanHome)`. */ + val description: String = home.path, +) { + /** [konanHome] checked, and checked to be a distribution. Call it when the task runs. */ + constructor(konanHome: ConfigValue) : this(konanHome.require(), konanHome.describe()) { + if (!runKonan.isFile) { + throw InvalidUserDataException("konan.config.konanHome $description is not a Kotlin/Native distribution: it has no ${runKonan.relativeTo(home)}.") + } + } + + /** The Kotlin/Native version, as its compiler jar records it. */ + val version: String by lazy { + JarFile(compilerJar).use { it.manifest?.mainAttributes?.getValue("Implementation-Version") } + ?: throw InvalidUserDataException("konan.config.konanHome $description has no version in ${compilerJar.relativeTo(home)}.") + } + /** `run_konan`, which runs the distribution's LLVM tools the way the Kotlin/Native compiler does. */ val runKonan: File get() = home.resolve(if (HostOs.current == HostOs.WINDOWS) "bin/run_konan.bat" else "bin/run_konan") @@ -56,6 +81,25 @@ internal class KonanDistribution(val home: File) { return line.removeSuffix(" --version").split(' ').filter { it.isNotEmpty() } } + /** + * [target]'s sysroot at Android API [apiLevel], or the highest below it the NDK has: the one `run_konan` + * links against, at another level. Kotlin/Native's level, 21, predates platform libraries such as + * `libaaudio`; its NDK stops at 29. + */ + fun androidSysroot(target: NativeTarget, apiLevel: Int, exec: ExecOperations): File { + val default = clangCommand(target, ClangDriver.C, exec).firstOrNull { it.startsWith("--sysroot=") } + ?.let { File(it.removePrefix("--sysroot=")) } + ?: error("run_konan passes no --sysroot for ${target.konanName}") + // /android-/arch- + val ndk = default.parentFile.parentFile + val available = ndk.list().orEmpty() + .mapNotNull { it.removePrefix("android-").takeIf { _ -> it.startsWith("android-") }?.toIntOrNull() } + .filter { ndk.resolve("android-$it").resolve(default.name).isDirectory } + val level = available.filter { it <= apiLevel }.maxOrNull() + ?: error("Kotlin/Native's NDK has no API level up to $apiLevel for ${target.konanName}; it has ${available.sorted().joinToString()}") + return ndk.resolve("android-$level").resolve(default.name) + } + /** * Downloads what [target] needs β€” its sysroot and toolchain under `~/.konan/dependencies` β€” when * missing. `run_konan` uses them but never fetches them; the compiler does when it links, so a trivial @@ -88,44 +132,55 @@ internal class KonanDistribution(val home: File) { } companion object { - private const val PREBUILT_PREFIX = "kotlin-native-prebuilt-" - - private val konanDirectory: File get() = File(System.getProperty("user.home"), ".konan") - - const val MISSING: String = - "No Kotlin/Native distribution found. The Kotlin plugin downloads one when it first compiles " + - "a native target, so build one of those first, or set konan.konanHome or KONAN_HOME." - - /** `$KONAN_HOME`, else the newest distribution under `~/.konan`; null when none is installed yet. */ - fun findHome(): File? { - System.getenv("KONAN_HOME")?.let { return File(it) } - return konanDirectory.listFiles { f -> f.isDirectory && f.name.startsWith(PREBUILT_PREFIX) }?.maxByOrNull { it.name } - } - /** - * [configured], else [findHome]. Called when a task runs: a convention resolved earlier β€” when the - * configuration cache stores β€” may predate the Kotlin plugin's download of the distribution. + * Where the Kotlin plugin installs Kotlin/Native [version] for this host: + * `kotlin-native-prebuilt--` under `$KONAN_DATA_DIR`, else `~/.konan`. Computed, not + * searched for β€” it may not exist yet. */ - fun resolve(configured: File?): KonanDistribution = KonanDistribution(configured ?: findHome() ?: error(MISSING)) + fun defaultHome(version: String = defaultVersion): File { + val os = when (HostOs.current) { + HostOs.MACOS -> "macos" + HostOs.LINUX -> "linux" + HostOs.WINDOWS -> "windows" + } + val arch = if (HostOs.architecture == Architecture.ARM64) "aarch64" else "x86_64" + val data = System.getenv("KONAN_DATA_DIR")?.let(::File) ?: File(System.getProperty("user.home"), ".konan") + return data.resolve("kotlin-native-prebuilt-$os-$arch-$version") + } - /** - * The version the index exporter was compiled against, and so the one it must run in: it links - * against that version's compiler jar. `kotlinNativeVersion` in the plugin build. - */ - val pinnedVersion: String by lazy { - java.util.Properties().apply { + private val properties by lazy { + Properties().apply { KonanDistribution::class.java.getResourceAsStream("/io/github/lemcoder/index/kotlin-native.properties") .use { load(checkNotNull(it)) } - }.getProperty("version") + } } - /** The installed distribution of [pinnedVersion], if any. */ - fun pinned(): KonanDistribution? = - konanDirectory.listFiles { f -> f.isDirectory && f.name.startsWith(PREBUILT_PREFIX) && f.name.endsWith("-$pinnedVersion") } - ?.singleOrNull() - ?.let(::KonanDistribution) + /** + * The versions bindings can be generated with: the index exporter is compiled against each one's + * compiler and runs only in that distribution. `kotlinNativeVersions` in the plugin build. + */ + val supportedVersions: List by lazy { properties.getProperty("supported").split(',') } + + /** The version used where the project's Kotlin version is unknown. `kotlinNativeVersion` in the plugin build. */ + val defaultVersion: String by lazy { properties.getProperty("version") } + + /** The distribution of [version] at [defaultHome], if installed. */ + fun installed(version: String = defaultVersion): KonanDistribution? = + defaultHome(version).takeIf { it.isDirectory }?.let(::KonanDistribution) } } /** The clang drivers `run_konan` runs. */ internal enum class ClangDriver(val toolName: String) { C("clang"), CXX("clang++") } + +/** + * A task that runs Kotlin/Native's tools. The plugin sets [konanHome] from `konan.config` for every such + * task; it is checked when the task runs. + */ +interface UsesKonanDistribution : Task { + /** Not an input: where the distribution is installed is the machine's business. */ + @get:Internal val konanHome: Property +} + +/** The distribution [UsesKonanDistribution.konanHome] names, checked. Call it when the task runs. */ +internal fun UsesKonanDistribution.distribution(): KonanDistribution = KonanDistribution(konanHome.get()) diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt index 41d103c..08bd413 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt @@ -1,7 +1,7 @@ package io.github.lemcoder.kotlin -import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.model.Architecture +import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.TargetFamily @@ -31,9 +31,10 @@ internal class KonanToolchain(val task: TaskProvider, direct val cc: Provider = directory.map { it.file("bin/${script("cc")}").asFile } val cxx: Provider = directory.map { it.file("bin/${script("c++")}").asFile } val ar: Provider = directory.map { it.file("bin/${script("ar")}").asFile } + val ranlib: Provider = directory.map { it.file("bin/${script("ranlib")}").asFile } val cmakeToolchainFile: Provider = directory.map { it.file("toolchain.cmake").asFile } - val files: ToolchainFiles = ToolchainFiles(cc, cxx, ar, cmakeToolchainFile) + val files: ToolchainFiles = ToolchainFiles(cc, cxx, ar, ranlib, cmakeToolchainFile) companion object { /** [target]'s toolchain, whose task is registered by the first build that asks for it. */ @@ -43,13 +44,11 @@ internal class KonanToolchain(val task: TaskProvider, direct val task = if (name in project.tasks.names) { project.tasks.named(name, KonanToolchainTask::class.java) } else { - val konan = project.extensions.getByType(KonanExtension::class.java) project.tasks.register(name, KonanToolchainTask::class.java) { group = "konan" description = "Write Kotlin/Native's compilers for ${target.konanName} as files external builds can use." - konanHome.set(konan.konanHome) - // An input so another distribution regenerates the wrappers; resolved again when the task runs. - distribution.set(konan.konanHome.map { it.asFile.absolutePath }.orElse(project.provider { KonanDistribution.findHome()?.absolutePath ?: "" })) + // An input so another distribution regenerates the wrappers. + distribution.set(konanHome.map { it.raw.orEmpty() }) this.target.set(target) outputDirectory.set(directory) } @@ -63,15 +62,15 @@ internal class KonanToolchain(val task: TaskProvider, direct /** Writes [KonanToolchain]'s files for [target]. */ @DisableCachingByDefault(because = "The files name absolute paths into the local Kotlin/Native distribution") -internal abstract class KonanToolchainTask @Inject constructor(private val exec: ExecOperations) : DefaultTask() { - @get:Internal abstract val konanHome: DirectoryProperty +internal abstract class KonanToolchainTask @Inject constructor(private val exec: ExecOperations) : DefaultTask(), UsesKonanDistribution { + @get:Internal abstract override val konanHome: Property @get:Input abstract val distribution: Property @get:Input abstract val target: Property @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction fun write() { - val konan = KonanDistribution.resolve(konanHome.orNull?.asFile) + val konan = distribution() val target = target.get() konan.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) val cc = konan.clangCommand(target, ClangDriver.C, exec) @@ -89,7 +88,8 @@ internal abstract class KonanToolchainTask @Inject constructor(private val exec: val ccFile = script("cc", driver(target, cc, link, windows)) val cxxFile = script("c++", driver(target, cxx, link, windows)) val arFile = script("ar", if (windows) "@\"$ar\" %*\r\n" else "#!/bin/sh\nexec ${quote(ar)} \"$@\"\n") - directory.resolve("toolchain.cmake").writeText(cmakeToolchain(target, cc, ccFile, cxxFile, arFile)) + val ranlibFile = script("ranlib", if (windows) "@\"$ar\" s %*\r\n" else "#!/bin/sh\nexec ${quote(ar)} s \"$@\"\n") + directory.resolve("toolchain.cmake").writeText(cmakeToolchain(target, cc, ccFile, cxxFile, arFile, ranlibFile)) } /** @@ -118,7 +118,7 @@ internal abstract class KonanToolchainTask @Inject constructor(private val exec: * looks for an NDK, and the compilers already target Android. Apple targets name the SDK clang uses, * or CMake would add the host's after it. */ - private fun cmakeToolchain(target: NativeTarget, cc: List, ccFile: File, cxxFile: File, arFile: File): String { + private fun cmakeToolchain(target: NativeTarget, cc: List, ccFile: File, cxxFile: File, arFile: File, ranlibFile: File): String { val system = when (target.family) { TargetFamily.LINUX, TargetFamily.ANDROID -> "Linux" TargetFamily.MINGW -> "Windows" @@ -139,6 +139,7 @@ internal abstract class KonanToolchainTask @Inject constructor(private val exec: appendLine("set(CMAKE_C_COMPILER \"${path(ccFile)}\")") appendLine("set(CMAKE_CXX_COMPILER \"${path(cxxFile)}\")") appendLine("set(CMAKE_AR \"${path(arFile)}\" CACHE FILEPATH \"Archiver\")") + appendLine("set(CMAKE_RANLIB \"${path(ranlibFile)}\" CACHE FILEPATH \"Archive indexer\")") sysroot?.let { appendLine("set(CMAKE_OSX_SYSROOT \"$it\" CACHE PATH \"SDK\")") } // A cross toolchain may not link executables for the build machine to run; libraries are what we build. appendLine("set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)") diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index e8754e0..a81e80c 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -6,13 +6,16 @@ import io.github.lemcoder.model.NativeTarget import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider +import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.TaskProvider -import java.io.File import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion +import java.io.File /** * Everything that touches the Kotlin Gradle plugin's types. Called only from `plugins.withId` callbacks @@ -22,38 +25,71 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget internal object KotlinGradlePlugin { private val JVM_PLATFORMS = setOf(KotlinPlatformType.jvm, KotlinPlatformType.androidJvm) + /** The Kotlin plugin's version, which is the Kotlin/Native version it downloads. */ + fun version(project: Project): String = project.getKotlinPluginVersion() + /** Generated bindings are sources of a `kotlin("jvm")` project's main source set. */ fun addJvmSources(project: Project, sources: Provider) { project.extensions.getByType(KotlinJvmProjectExtension::class.java).sourceSets.getByName("main").kotlin.srcDir(sources) } /** …and of the main compilation of every JVM and Android target of a multiplatform project. */ - fun addKmpJvmSources(project: Project, sources: Provider, generate: TaskProvider<*>, onJvmTargets: () -> Boolean) { + fun addKmpJvmSources(project: Project, sources: Provider, generate: TaskProvider<*>, enabled: () -> Boolean) { // Decided when the sources are read, not now: targets are usually declared before konan { }. A - // provider of our own carries no producer, so the jvm() compile's dependency is spelled out too. - val jvmSources = project.provider { if (onJvmTargets()) listOf(sources.get()) else emptyList() } - val jvmGenerate = project.provider { if (onJvmTargets()) listOf(generate) else emptyList() } + // provider of our own carries no producer, so the compile's dependency is spelled out too. + val lazySources = project.provider { if (enabled()) listOf(sources.get()) else emptyList() } + val lazyGenerate = project.provider { if (enabled()) listOf(generate) else emptyList() } project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets .matching { it.platformType in JVM_PLATFORMS } .configureEach { - val jvm = platformType == KotlinPlatformType.jvm compilations.matching { it.name == "main" }.configureEach { - defaultSourceSet.kotlin.srcDir(if (jvm) jvmSources else sources) - if (jvm) compileTaskProvider.configure { dependsOn(jvmGenerate) } + defaultSourceSet.kotlin.srcDir(lazySources) + compileTaskProvider.configure { dependsOn(lazyGenerate) } } } } + /** Whether a multiplatform project has a `jvm()` target. */ + fun hasKmpPlainJvmTarget(project: Project): Boolean = + project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets.any { it.platformType == KotlinPlatformType.jvm } + + /** Whether a multiplatform project has an Android target. */ + fun hasKmpAndroidTarget(project: Project): Boolean = + project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets.any { it.platformType == KotlinPlatformType.androidJvm } + + /** + * Makes every Android target's compilations wait for [task] when [enabled]: their Kotlin reads the + * Java the task generates, which reaches them through AGP's variant API with no producer attached. + */ + fun dependOnForKmpAndroid(project: Project, task: TaskProvider<*>, enabled: () -> Boolean) { + val lazyTask = project.provider { if (enabled()) listOf(task) else emptyList() } + project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets + .matching { it.platformType == KotlinPlatformType.androidJvm } + .configureEach { compilations.configureEach { compileTaskProvider.configure { dependsOn(lazyTask) } } } + } + + /** An `implementation` dependency of a multiplatform project's `androidMain`. */ + fun addKmpAndroidDependency(project: Project, notation: Provider) { + project.extensions.getByType(KotlinMultiplatformExtension::class.java).sourceSets + .matching { it.name == "androidMain" } + .configureEach { dependencies { implementation(notation) } } + } + /** * Java sources of every JVM target's main compilation in a multiplatform project: the Gradle source set - * Kotlin creates for it, `Main`, compiled by `compileMainJava`. + * Kotlin creates for it, `Main`, compiled by `compileMainJava`. Before Kotlin 2.1.20 a + * `jvm()` target has none unless the build calls `withJava()`, so the plugin calls it; its Java is then + * the project's `main` source set, compiled by `compileJava`. */ fun addKmpJvmJava(project: Project, sources: Provider) { val sourceSets = project.extensions.getByType(SourceSetContainer::class.java) + val javaByDefault = KotlinVersion.fromString(version(project)) >= KotlinVersion(2, 1, 20) project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets .matching { it.platformType == KotlinPlatformType.jvm } .configureEach { - val name = "${targetName}Main" + @Suppress("DEPRECATION") + if (!javaByDefault) (this as KotlinJvmTarget).withJava() + val name = if (javaByDefault) "${targetName}Main" else SourceSet.MAIN_SOURCE_SET_NAME sourceSets.matching { it.name == name }.configureEach { java.srcDir(sources) } } } @@ -102,3 +138,9 @@ internal object KotlinGradlePlugin { } } } + +/** `2.1.20`, `2.2.0-RC` as comparable numbers; a pre-release counts as its release. */ +private fun KotlinVersion.Companion.fromString(version: String): KotlinVersion { + val parts = version.split('.', '-').take(3).map { part -> part.takeWhile(Char::isDigit).toIntOrNull() ?: 0 } + return KotlinVersion(parts[0], parts.getOrElse(1) { 0 }, parts.getOrElse(2) { 0 }) +} diff --git a/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt b/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt index ec0ce10..39bbcd5 100644 --- a/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt @@ -2,7 +2,9 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.CargoProfile import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.dsl.konanConfig import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.toolchain.rustTriple import org.gradle.api.Project @@ -18,8 +20,9 @@ internal fun addCargoCommands(task: ExternalBuildTask, project: Project, library val targetDirectory = project.layout.buildDirectory.dir("konan/libraries/${library.name}/cargo") val manifest = cargo.manifest.orElse(library.sourceDirectory.file("Cargo.toml")) + val cargoExecutable = project.konanConfig.executable(ConfigField.CARGO, task.path) task.command { - executable.set(cargo.executable) + executable.set(cargoExecutable) arguments.addAll("build", "--lib", "--target", triple) arguments.addAll(manifest.map { listOf("--manifest-path", it.asFile.absolutePath) }) arguments.addAll(targetDirectory.map { listOf("--target-dir", it.asFile.absolutePath) }) diff --git a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt index 9a088db..81e625f 100644 --- a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt @@ -1,6 +1,8 @@ package io.github.lemcoder.library -import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.kotlin.distribution +import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture @@ -33,7 +35,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( private val exec: ExecOperations, private val fs: FileSystemOperations, private val objects: ObjectFactory, -) : DefaultTask() { +) : DefaultTask(), UsesKonanDistribution { /** Destination of compiled `.o` object files */ @get:InputFiles @@ -50,9 +52,8 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( @get:PathSensitive(RELATIVE) abstract val includeDirs: ConfigurableFileCollection - /** The Kotlin/Native distribution whose clang compiles; resolved when the task runs. */ - @get:Internal - abstract val konanHome: DirectoryProperty + /** The Kotlin/Native distribution whose clang compiles. */ + @get:Internal abstract override val konanHome: Property /** Targets to compile for */ @get:Input @@ -74,7 +75,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( @TaskAction fun compileAndLink() { val workingDir = workingDir.asFile.getOrElse(temporaryDir) - val distribution = KonanDistribution.resolve(konanHome.orNull?.asFile) + val distribution = distribution() targets.get().forEach { target -> distribution.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) // prepare output dirs diff --git a/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt index cbcc772..89698e0 100644 --- a/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt @@ -36,7 +36,6 @@ internal fun registerKonanClang(project: Project, konan: KonanExtension, library libName.set(library.name) arguments.addAll(DEFAULT_COMPILER_OPTIONS) arguments.addAll(library.compilerOptions) - konanHome.set(konan.konanHome) onlyIf { targets.get().isNotEmpty() } } diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt index 4482ba6..db7262c 100644 --- a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt +++ b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt @@ -3,6 +3,7 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.NativeLibrarySpec import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.external.addCMakeCommands import io.github.lemcoder.external.addMakeCommands import io.github.lemcoder.external.addSteps import io.github.lemcoder.external.registerExternalBuilds @@ -88,6 +89,22 @@ internal sealed interface LibraryProvider { override fun toolchain(library: NativeLibrarySpec) = library.external.toolchain.get() } + /** `cmake { }`: the project's CMakeLists, building a static library per target. */ + data object CMake : External() { + override val taskPrefix = "cmakeBuild" + override val toolName = "CMake" + override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) { + val cmake = library.cmake + task.addCMakeCommands( + project, cmake, context, "konan.libraries.${library.name}.cmake", + project.layout.buildDirectory.dir("konan/libraries/${library.name}/cmake"), + ) + // A build that ignores CMAKE_ARCHIVE_OUTPUT_DIRECTORY: its archive is copied into place. + task.producedLibrary.set(cmake.libraryDirectory.zip(task.libraryFileName) { directory, file -> directory.file(file) }) + } + override fun toolchain(library: NativeLibrarySpec) = library.cmake.toolchain.get() + } + /** `make { }`: the project's Makefile, with Kotlin/Native's compilers when cross-compiling. */ data object Make : External() { override val taskPrefix = "makeBuild" diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt b/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt index 6588425..b86b6c4 100644 --- a/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt @@ -1,7 +1,9 @@ package io.github.lemcoder.library import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.dsl.konanConfig import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.TargetFamily import io.github.lemcoder.toolchain.zigTriple @@ -19,8 +21,9 @@ internal fun addZigCommands(task: ExternalBuildTask, project: Project, library: val work = project.layout.buildDirectory.dir("konan/libraries/${library.name}/zig") val prefix = work.map { it.dir(target.konanName) } + val zigExecutable = project.konanConfig.executable(ConfigField.ZIG, task.path) task.command { - executable.set(zig.executable) + executable.set(zigExecutable) arguments.addAll(prefix.map { listOf("build", "--prefix", it.asFile.absolutePath) }) arguments.addAll(work.map { listOf("--cache-dir", it.dir("cache").asFile.absolutePath) }) arguments.add("-Dtarget=${target.zigTriple()}") diff --git a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt index 39fb274..d29a991 100644 --- a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt @@ -1,6 +1,8 @@ package io.github.lemcoder.link -import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.kotlin.distribution +import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture import org.gradle.api.DefaultTask @@ -24,15 +26,15 @@ import javax.inject.Inject /** * Links a target's static archive into a shared library with Kotlin/Native's clang: with [stubCFile], the - * generated JNI stub compiled against it (JNI); without, the whole archive on its own, every symbol - * exported (FFM). + * generated JNI stub compiled against it (JNI) β€” or against [additionalLinkerArgs] alone, for a platform + * library; without, the whole archive on its own, every symbol exported (FFM). */ @DisableCachingByDefault(because = "Native link output depends on the local toolchain; cheap to rebuild") internal abstract class LinkSharedLibraryTask @Inject constructor( private val exec: ExecOperations, -) : DefaultTask() { +) : DefaultTask(), UsesKonanDistribution { /** The Kotlin/Native distribution whose clang links; resolved when the task runs. */ - @get:Internal abstract val konanHome: DirectoryProperty + @get:Internal abstract override val konanHome: Property @get:Input abstract val target: Property /** Base name of the library, e.g. `examplestubs` for `libexamplestubs.so`. */ @@ -46,17 +48,20 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( /** Further include roots, as paths: ones that must not be tracked as inputs, like the `.def`'s directory. */ @get:Input @get:Optional abstract val includePaths: ListProperty - /** The JDK's `include` directories, for `jni.h`; Android gets it from the NDK sysroot instead. */ + /** The directory holding the generator's `jni.h`; Android gets it from the NDK sysroot instead. */ @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) abstract val jniIncludeDirs: ConfigurableFileCollection - @get:InputFile @get:PathSensitive(PathSensitivity.NONE) abstract val staticLibrary: RegularFileProperty + @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.NONE) abstract val staticLibrary: RegularFileProperty @get:Input @get:Optional abstract val additionalLinkerArgs: ListProperty + + /** For Android targets, the API level whose sysroot to link against instead of Kotlin/Native's. */ + @get:Input @get:Optional abstract val androidApiLevel: Property @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction fun run() { - val distribution = KonanDistribution.resolve(konanHome.orNull?.asFile) + val distribution = distribution() check(distribution.runKonan.isFile) { "run_konan not found: ${distribution.runKonan}" } val nativeTarget = target.get() distribution.ensureDependencies(nativeTarget, exec, temporaryDir.resolve("dependencies")) @@ -64,7 +69,7 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( val outDir = outputDirectory.get().asFile outDir.deleteRecursively(); outDir.mkdirs() val outLib = outDir.resolve(nativeTarget.sharedLibraryName(libraryBaseName.get())) - val archive = staticLibrary.get().asFile.absolutePath + val archive = staticLibrary.orNull?.asFile?.absolutePath val stub = stubCFile.orNull?.asFile val arguments = buildList { @@ -74,13 +79,15 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( // On Android jni.h comes from the NDK sysroot. if (!nativeTarget.isAndroid) jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } addAll(distribution.linkArguments(nativeTarget, exec)) + // After run_konan's own --sysroot, so this one wins. + if (nativeTarget.isAndroid) androidApiLevel.orNull?.let { add("--sysroot=${distribution.androidSysroot(nativeTarget, it, exec)}") } if (stub != null) { add(stub.absolutePath) - add(archive) - } else if (nativeTarget.family.isApple) { - add("-Wl,-force_load,$archive") + archive?.let(::add) } else { - add("-Wl,--whole-archive"); add(archive); add("-Wl,--no-whole-archive") + val whole = checkNotNull(archive) { "Nothing to link: neither an archive nor a JNI stub" } + if (nativeTarget.family.isApple) add("-Wl,-force_load,$whole") + else { add("-Wl,--whole-archive"); add(whole); add("-Wl,--no-whole-archive") } } add("-o"); add(outLib.absolutePath) // A C++ runtime, extra -L, frameworks: all caller-supplied. Adding konan's own NDK lib diff --git a/src/main/kotlin/io/github/lemcoder/model/ConfigField.kt b/src/main/kotlin/io/github/lemcoder/model/ConfigField.kt new file mode 100644 index 0000000..26671de --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/ConfigField.kt @@ -0,0 +1,87 @@ +package io.github.lemcoder.model + +import org.gradle.api.InvalidUserDataException +import java.io.File +import java.io.Serializable + +/** + * A path the plugin needs from the machine it runs on: `konan { config { } }`, else the Gradle property + * [gradleProperty], else the environment variable [environmentVariable]. Nothing is searched for: unset + * is an error, raised by the task that needs the value when it runs. + */ +enum class ConfigField(val dslName: String, val environmentVariable: String, val kind: Kind, val example: String) { + KONAN_HOME("konanHome", "KONAN_HOME", Kind.DIRECTORY, "/path/to/kotlin-native-prebuilt--"), + JEXTRACT("jextract", "KONAN_JEXTRACT", Kind.EXECUTABLE, "/path/to/jextract"), + CMAKE("cmake", "KONAN_CMAKE", Kind.EXECUTABLE, "/path/to/cmake"), + MAKE("make", "KONAN_MAKE", Kind.EXECUTABLE, "/path/to/make"), + CARGO("cargo", "KONAN_CARGO", Kind.EXECUTABLE, "/path/to/cargo"), + ZIG("zig", "KONAN_ZIG", Kind.EXECUTABLE, "/path/to/zig"), + ; + + val gradleProperty: String get() = "konan.config.$dslName" + + enum class Kind { DIRECTORY, EXECUTABLE } +} + +/** Where a [ConfigValue] came from, in the order they are tried. */ +enum class ConfigSource(val description: String) { + DSL("build script"), + GRADLE_PROPERTY("Gradle property"), + ENVIRONMENT("environment variable"), +} + +/** + * One [field] as resolved for [usedBy], a task path: its [raw] value and [source], or neither when it is + * unset. Resolved lazily and checked by [require] when the task runs, so a build that never runs such a + * task never needs the value. + */ +data class ConfigValue( + val field: ConfigField, + val raw: String?, + val source: ConfigSource?, + val usedBy: String, + /** Extra lines for the error, e.g. the default the build could use. */ + val hint: String? = null, +) : Serializable { + /** The file named, checked to exist as the field's kind; otherwise an error saying how to set it. */ + fun require(): File { + problem()?.let(::fail) + return File(checkNotNull(raw)) + } + + /** What is wrong with the value, or null if it names an existing file of the field's kind. */ + fun problem(): String? { + val file = File(raw ?: return "is not set") + return when { + !file.isAbsolute -> "is not an absolute path" + !file.exists() -> "does not exist" + field.kind == ConfigField.Kind.DIRECTORY && !file.isDirectory -> "is not a directory" + field.kind == ConfigField.Kind.EXECUTABLE && !(file.isFile && file.canExecute()) -> "is not an executable file" + else -> null + } + } + + /** The current value and its origin, for reports: `/opt/x (Gradle property konan.config.x)`. */ + fun describe(): String = when (source) { + null -> "unset" + ConfigSource.DSL -> "$raw (${source.description})" + ConfigSource.GRADLE_PROPERTY -> "$raw (${source.description} ${field.gradleProperty})" + ConfigSource.ENVIRONMENT -> "$raw (${source.description} ${field.environmentVariable})" + } + + private fun fail(problem: String): Nothing { + val name = "konan.config.${field.dslName}" + val headline = if (raw == null) "$name $problem β€” $usedBy needs it." else "$name ${describe()} $problem β€” $usedBy needs it." + throw InvalidUserDataException( + buildString { + appendLine(headline) + appendLine("Set it to an absolute path in one of these; the first one set wins:") + appendLine(" 1. build script konan { config { ${field.dslName}.set(file(\"${field.example}\")) } }") + appendLine(" 2. gradle.properties ${field.gradleProperty}=${field.example}") + appendLine(" (~/.gradle/gradle.properties sets it for every build on this machine)") + append(" 3. environment ${field.environmentVariable}=${field.example}") + hint?.let { appendLine(); append(it) } + } + ) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/model/DefFile.kt b/src/main/kotlin/io/github/lemcoder/model/DefFile.kt index f783c36..e813024 100644 --- a/src/main/kotlin/io/github/lemcoder/model/DefFile.kt +++ b/src/main/kotlin/io/github/lemcoder/model/DefFile.kt @@ -10,11 +10,14 @@ import java.io.File * @property staticLibraries archives to link into the JNI library. On the native side cinterop embeds * these into the klib; here they decide whether there is anything to link at all β€” a def without * them yields bindings and a `.c` stub for someone else's build to compile. + * @property linkerOpts options for linking against the C library β€” `-laaudio` for a platform library, + * which needs no archive at all. Added to the JNI library's link, which they make worth running. */ internal data class DefFile( val packageName: String?, val staticLibraries: List, val libraryPaths: List, + val linkerOpts: List = emptyList(), ) { /** Resolves [staticLibraries] against [libraryPaths], relative to [base] for relative paths. */ fun resolveStaticLibraries(base: File): List = staticLibraries.mapNotNull { name -> @@ -39,6 +42,7 @@ internal data class DefFile( packageName = properties["package"], staticLibraries = tokens("staticLibraries"), libraryPaths = tokens("libraryPaths"), + linkerOpts = tokens("linkerOpts"), ) } } diff --git a/src/main/kotlin/io/github/lemcoder/model/Executable.kt b/src/main/kotlin/io/github/lemcoder/model/Executable.kt index 7516c77..6c6207b 100644 --- a/src/main/kotlin/io/github/lemcoder/model/Executable.kt +++ b/src/main/kotlin/io/github/lemcoder/model/Executable.kt @@ -2,14 +2,17 @@ package io.github.lemcoder.model import java.io.Serializable -/** A program an external build runs: a file, or a name looked up when the build runs. */ +/** A program an external build step runs: a file, or a name the build's `PATH` resolves. */ sealed class Executable : Serializable { /** This exact file. */ data class File(val file: java.io.File) : Executable() /** - * Found on the `PATH` when the build runs, then in the usual install locations: the Gradle daemon - * does not inherit a login shell's `PATH`, so a Homebrew or MacPorts install is otherwise invisible. + * Run by name, resolved by the `PATH` of the Gradle daemon β€” which does not inherit a login shell's, so + * prefer [File] for anything outside the system directories. */ data class OnPath(val name: String) : Executable() + + /** A program from `konan { config { } }`, checked when the step runs. */ + internal data class Configured(val value: ConfigValue) : Executable() } diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt index cf3f8bc..11e8fa7 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt +++ b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt @@ -45,8 +45,8 @@ internal enum class ExternalStubVariable(override val variableName: String) : Ex /** The target's Android ABI, e.g. `arm64-v8a`; the target's name elsewhere. */ ABI("KONAN_JNI_ABI"), - /** A JDK that ships `include/jni.h`. */ - JAVA_HOME("JAVA_HOME"), + /** The directory holding `jni.h`, which the plugin ships: no JDK needed. */ + JNI_INCLUDE_DIR("KONAN_JNI_INCLUDE_DIR"), } /** The contract of a build that makes the shared library FFM bindings load (`ffm { external { } }`). */ @@ -78,6 +78,9 @@ internal enum class ToolchainVariable(override val variableName: String) : Exter /** llvm-ar. */ AR("AR"), + /** llvm-ar's `s`, as `ranlib`: a host ranlib β€” Apple's, say β€” drops objects it cannot read. */ + RANLIB("RANLIB"), + /** A CMake toolchain file naming all three, with the target's system and processor. */ CMAKE_TOOLCHAIN_FILE("KONAN_CMAKE_TOOLCHAIN_FILE"), } @@ -98,6 +101,7 @@ internal class ToolchainFiles( val cc: Provider, val cxx: Provider, val ar: Provider, + val ranlib: Provider, val cmakeToolchainFile: Provider, ) { /** The [ToolchainVariable]s, under the standard names. */ @@ -105,6 +109,7 @@ internal class ToolchainFiles( ToolchainVariable.CC to cc.map { it.absolutePath }, ToolchainVariable.CXX to cxx.map { it.absolutePath }, ToolchainVariable.AR to ar.map { it.absolutePath }, + ToolchainVariable.RANLIB to ranlib.map { it.absolutePath }, ToolchainVariable.CMAKE_TOOLCHAIN_FILE to cmakeToolchainFile.map { it.absolutePath }, ) } diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt b/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt index 188ff8f..8e975b8 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt +++ b/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt @@ -1,25 +1,10 @@ package io.github.lemcoder.toolchain import io.github.lemcoder.model.Executable -import io.github.lemcoder.model.HostOs -import java.io.File - -/** Where installs live that a daemon's `PATH` misses. */ -private val FALLBACK_DIRECTORIES = listOf("/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin", "/usr/bin") - -private val WINDOWS_EXTENSIONS = listOf("", ".exe", ".cmd", ".bat") /** The command line's first element for this executable. Call it when the build runs, not before. */ internal fun Executable.command(): String = when (this) { is Executable.File -> file.absolutePath - // Unresolved, the bare name still runs if the process's own PATH has it, and fails naming it if not. - is Executable.OnPath -> find(name)?.absolutePath ?: name -} - -private fun find(name: String): File? { - val directories = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter { it.isNotEmpty() } + FALLBACK_DIRECTORIES - val extensions = if (HostOs.current == HostOs.WINDOWS) WINDOWS_EXTENSIONS else listOf("") - return directories.asSequence() - .flatMap { directory -> extensions.asSequence().map { File(directory, name + it) } } - .firstOrNull { it.isFile && it.canExecute() } + is Executable.OnPath -> name + is Executable.Configured -> value.require().absolutePath } diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt b/src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt deleted file mode 100644 index c857560..0000000 --- a/src/main/kotlin/io/github/lemcoder/toolchain/Jdk.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.github.lemcoder.toolchain - -import io.github.lemcoder.model.HostOs -import java.io.File - -/** - * A JDK that ships `include/jni.h`, which the JNI stub compiles against. The JVM running Gradle is not a - * reliable one: IDE-bundled JBRs β€” Android Studio's, often the Gradle daemon β€” strip `include/`. - */ -internal object Jdk { - private const val MISSING = "No JDK with include/jni.h found. Set jni.jdkHome." - - /** `include` and its OS subdirectory under [jdkHome], if it ships jni.h; the macOS `Contents/Home` layout too. */ - fun jniIncludeDirectoriesOf(jdkHome: File): List? = - homeWithHeaders(jdkHome)?.let { listOf(it.resolve("include"), it.resolve("include/${HostOs.current.jniIncludeSubdirectory}")) } - - /** [jniIncludeDirectoriesOf] the first installed JDK that has them. */ - fun detectJniIncludeDirectories(): List = - candidates().firstNotNullOfOrNull(::jniIncludeDirectoriesOf) ?: error(MISSING) - - /** The home of the JDK [detectJniIncludeDirectories] uses, for an external build's `JAVA_HOME`. */ - fun detectHome(): File = candidates().firstNotNullOfOrNull(::homeWithHeaders) ?: error(MISSING) - - private fun homeWithHeaders(jdkHome: File): File? = - listOf(jdkHome, jdkHome.resolve("Contents/Home")).firstOrNull { it.resolve("include/jni.h").isFile } - - /** JDK homes worth checking, most specific first. */ - private fun candidates(): List = buildList { - System.getenv("JNI_HOME")?.let { add(File(it)) } - System.getenv("JAVA_HOME")?.let { add(File(it)) } - System.getProperty("java.home")?.let { add(File(it)) } - File(System.getProperty("user.home"), "Library/Java/JavaVirtualMachines").listFiles()?.let { addAll(it) } - File("/Library/Java/JavaVirtualMachines").listFiles()?.let { addAll(it) } - File("/usr/lib/jvm").listFiles()?.let { addAll(it) } - } - - private val HostOs.jniIncludeSubdirectory: String - get() = when (this) { - HostOs.MACOS -> "darwin" - HostOs.LINUX -> "linux" - HostOs.WINDOWS -> "win32" - } -} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt index 63f7780..13fe27b 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt @@ -32,8 +32,14 @@ internal object AgpWiring { link: TaskProvider, enabled: () -> Boolean, ) { + val relative = sources.get().asFile.relativeTo(project.projectDir).path + val ordering = { + project.tasks.matching { it.name == "preBuild" }.configureEach { if (enabled()) dependsOn(generate, link) } + project.tasks + .matching { (it.name.startsWith("compile") && (it.name.contains("Kotlin") || it.name.contains("Java"))) || it.name == "compileJava" } + .configureEach { if (enabled()) dependsOn(generate) } + } project.withPlugin(GradlePluginId.ANDROID_BASE) { - val relative = sources.get().asFile.relativeTo(project.projectDir).path project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { variant -> if (!enabled()) return@onVariants project.file(relative).mkdirs() @@ -43,11 +49,38 @@ internal object AgpWiring { SourceKind.JAVA -> variant.sources.java?.addStaticSourceDirectory(relative) } } - project.tasks.matching { it.name == "preBuild" }.configureEach { if (enabled()) dependsOn(generate, link) } - project.tasks - .matching { (it.name.startsWith("compile") && (it.name.contains("Kotlin") || it.name.contains("JavaWithJavac"))) || it.name == "compileJava" } - .configureEach { if (enabled()) dependsOn(generate) } + ordering() + } + // The multiplatform Android library plugin: Kotlin sources reach it through its Kotlin target; Java + // ones need the target's withJava(). + if (kind == SourceKind.JAVA) project.withPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) { + project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java)?.onVariants { variant -> + if (!enabled()) return@onVariants + project.file(relative).mkdirs() + val java = variant.sources.java + if (java == null) { + project.logger.warn("${project.path}: the Android target compiles no Java β€” call withJava() in kotlin { android { } } for the FFM bindings.") + } else { + java.addStaticSourceDirectory(relative) + } + } + ordering() + } + } + + /** + * The lowest `minSdk` among the module's variants, once AGP has computed them; absent outside Android. + * The API level Android links target, so they see every platform library the app may call. + */ + fun minSdk(project: Project): Provider { + val levels = mutableSetOf() + project.withPlugin(GradlePluginId.ANDROID_BASE) { + project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { levels += it.minSdk.apiLevel } + } + project.withPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) { + project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java)?.onVariants { levels += it.minSdk.apiLevel } } + return project.provider { levels.minOrNull() } } /** diff --git a/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt b/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt new file mode 100644 index 0000000..edc4f51 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt @@ -0,0 +1,64 @@ +package io.github.lemcoder.wiring + +import io.github.lemcoder.dsl.KonanConfig +import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.model.ConfigField +import io.github.lemcoder.model.ConfigValue +import org.gradle.api.DefaultTask +import org.gradle.api.Project +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +/** `konanConfig`: every `konan.config` field, where its value comes from, and which tasks need it. */ +internal fun Project.registerConfigReport(config: KonanConfig) { + tasks.register("konanConfig", KonanConfigReportTask::class.java) { + group = "konan" + description = "Print konan.config: each path, where it is set, and which tasks need it." + val reportPath = path + ConfigField.entries.forEach { values.add(config.resolve(it, reportPath)) } + projectPath.set(project.path) + usages.set( + project.provider { + // Tasks read config as they are configured; realize them so every reader has asked. + project.tasks.withType(UsesKonanDistribution::class.java).toList() + project.tasks.withType(ExternalBuildTask::class.java).toList() + config.usages.mapKeys { it.key.name }.mapValues { it.value.toList() } + } + ) + outputs.upToDateWhen { false } + } +} + +@DisableCachingByDefault(because = "Prints a report") +internal abstract class KonanConfigReportTask : DefaultTask() { + @get:Input abstract val values: ListProperty + @get:Input abstract val usages: MapProperty> + @get:Input abstract val projectPath: Property + + @TaskAction + fun report() { + val usages = usages.get() + val width = ConfigField.entries.maxOf { it.dslName.length } + val lines = values.get().map { value -> + val users = usages[value.field.name].orEmpty() + val problem = value.problem() + val status = when { + users.isEmpty() -> "not needed here" + problem == null -> "ok" + else -> "NEEDED, ${problem.removePrefix("is ")}" + } + val needs = if (users.isEmpty()) "" else "\n${" ".repeat(width + 4)}needed by ${users.joinToString()}" + " ${value.field.dslName.padEnd(width)} ${value.describe()} β€” $status$needs" + } + logger.lifecycle((listOf("konan.config for ${projectPath.get()}:") + lines).joinToString("\n")) + val missing = values.get().filter { usages[it.field.name].orEmpty().isNotEmpty() && it.problem() != null } + if (missing.isNotEmpty()) { + logger.lifecycle("\nSet a field in konan { config { } }, as the Gradle property konan.config. or as its environment variable; `gradlew ` prints how for each.") + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt b/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt index 7bccfc2..c5e5826 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt @@ -6,6 +6,8 @@ import org.gradle.api.Project internal enum class GradlePluginId(val id: String) { KOTLIN_JVM("org.jetbrains.kotlin.jvm"), KOTLIN_MULTIPLATFORM("org.jetbrains.kotlin.multiplatform"), + /** Consumes as Android does; only its version matters, for `kotlinNativeDistribution()`. */ + KOTLIN_ANDROID("org.jetbrains.kotlin.android"), /** Every AGP application and library plugin; the KMP Android plugin does not apply it. */ ANDROID_BASE("com.android.base"), ANDROID_KMP_LIBRARY("com.android.kotlin.multiplatform.library"), diff --git a/src/main/resources/io/github/lemcoder/index/kotlin-native.properties b/src/main/resources/io/github/lemcoder/index/kotlin-native.properties index 431a3bd..5937a0d 100644 --- a/src/main/resources/io/github/lemcoder/index/kotlin-native.properties +++ b/src/main/resources/io/github/lemcoder/index/kotlin-native.properties @@ -1 +1,2 @@ version=${kotlinNativeVersion} +supported=${kotlinNativeVersions} diff --git a/src/main/resources/io/github/lemcoder/jni/jni.h b/src/main/resources/io/github/lemcoder/jni/jni.h new file mode 100644 index 0000000..4c34313 --- /dev/null +++ b/src/main/resources/io/github/lemcoder/jni/jni.h @@ -0,0 +1,1143 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * JNI specification, as defined by Sun: + * http://java.sun.com/javase/6/docs/technotes/guides/jni/spec/jniTOC.html + * + * Everything here is expected to be VM-neutral. + */ + +#ifndef JNI_H_ +#define JNI_H_ + +#include +#include + +/* Primitive types that match up with Java equivalents. */ +typedef uint8_t jboolean; /* unsigned 8 bits */ +typedef int8_t jbyte; /* signed 8 bits */ +typedef uint16_t jchar; /* unsigned 16 bits */ +typedef int16_t jshort; /* signed 16 bits */ +typedef int32_t jint; /* signed 32 bits */ +typedef int64_t jlong; /* signed 64 bits */ +typedef float jfloat; /* 32-bit IEEE 754 */ +typedef double jdouble; /* 64-bit IEEE 754 */ + +/* "cardinal indices and sizes" */ +typedef jint jsize; + +#ifdef __cplusplus +/* + * Reference types, in C++ + */ +class _jobject {}; +class _jclass : public _jobject {}; +class _jstring : public _jobject {}; +class _jarray : public _jobject {}; +class _jobjectArray : public _jarray {}; +class _jbooleanArray : public _jarray {}; +class _jbyteArray : public _jarray {}; +class _jcharArray : public _jarray {}; +class _jshortArray : public _jarray {}; +class _jintArray : public _jarray {}; +class _jlongArray : public _jarray {}; +class _jfloatArray : public _jarray {}; +class _jdoubleArray : public _jarray {}; +class _jthrowable : public _jobject {}; + +typedef _jobject* jobject; +typedef _jclass* jclass; +typedef _jstring* jstring; +typedef _jarray* jarray; +typedef _jobjectArray* jobjectArray; +typedef _jbooleanArray* jbooleanArray; +typedef _jbyteArray* jbyteArray; +typedef _jcharArray* jcharArray; +typedef _jshortArray* jshortArray; +typedef _jintArray* jintArray; +typedef _jlongArray* jlongArray; +typedef _jfloatArray* jfloatArray; +typedef _jdoubleArray* jdoubleArray; +typedef _jthrowable* jthrowable; +typedef _jobject* jweak; + + +#else /* not __cplusplus */ + +/* + * Reference types, in C. + */ +typedef void* jobject; +typedef jobject jclass; +typedef jobject jstring; +typedef jobject jarray; +typedef jarray jobjectArray; +typedef jarray jbooleanArray; +typedef jarray jbyteArray; +typedef jarray jcharArray; +typedef jarray jshortArray; +typedef jarray jintArray; +typedef jarray jlongArray; +typedef jarray jfloatArray; +typedef jarray jdoubleArray; +typedef jobject jthrowable; +typedef jobject jweak; + +#endif /* not __cplusplus */ + +struct _jfieldID; /* opaque structure */ +typedef struct _jfieldID* jfieldID; /* field IDs */ + +struct _jmethodID; /* opaque structure */ +typedef struct _jmethodID* jmethodID; /* method IDs */ + +struct JNIInvokeInterface; + +typedef union jvalue { + jboolean z; + jbyte b; + jchar c; + jshort s; + jint i; + jlong j; + jfloat f; + jdouble d; + jobject l; +} jvalue; + +typedef enum jobjectRefType { + JNIInvalidRefType = 0, + JNILocalRefType = 1, + JNIGlobalRefType = 2, + JNIWeakGlobalRefType = 3 +} jobjectRefType; + +typedef struct { + const char* name; + const char* signature; + void* fnPtr; +} JNINativeMethod; + +struct _JNIEnv; +struct _JavaVM; +typedef const struct JNINativeInterface* C_JNIEnv; + +#if defined(__cplusplus) +typedef _JNIEnv JNIEnv; +typedef _JavaVM JavaVM; +#else +typedef const struct JNINativeInterface* JNIEnv; +typedef const struct JNIInvokeInterface* JavaVM; +#endif + +/* + * Table of interface function pointers. + */ +struct JNINativeInterface { + void* reserved0; + void* reserved1; + void* reserved2; + void* reserved3; + + jint (*GetVersion)(JNIEnv *); + + jclass (*DefineClass)(JNIEnv*, const char*, jobject, const jbyte*, + jsize); + jclass (*FindClass)(JNIEnv*, const char*); + + jmethodID (*FromReflectedMethod)(JNIEnv*, jobject); + jfieldID (*FromReflectedField)(JNIEnv*, jobject); + /* spec doesn't show jboolean parameter */ + jobject (*ToReflectedMethod)(JNIEnv*, jclass, jmethodID, jboolean); + + jclass (*GetSuperclass)(JNIEnv*, jclass); + jboolean (*IsAssignableFrom)(JNIEnv*, jclass, jclass); + + /* spec doesn't show jboolean parameter */ + jobject (*ToReflectedField)(JNIEnv*, jclass, jfieldID, jboolean); + + jint (*Throw)(JNIEnv*, jthrowable); + jint (*ThrowNew)(JNIEnv *, jclass, const char *); + jthrowable (*ExceptionOccurred)(JNIEnv*); + void (*ExceptionDescribe)(JNIEnv*); + void (*ExceptionClear)(JNIEnv*); + void (*FatalError)(JNIEnv*, const char*); + + jint (*PushLocalFrame)(JNIEnv*, jint); + jobject (*PopLocalFrame)(JNIEnv*, jobject); + + jobject (*NewGlobalRef)(JNIEnv*, jobject); + void (*DeleteGlobalRef)(JNIEnv*, jobject); + void (*DeleteLocalRef)(JNIEnv*, jobject); + jboolean (*IsSameObject)(JNIEnv*, jobject, jobject); + + jobject (*NewLocalRef)(JNIEnv*, jobject); + jint (*EnsureLocalCapacity)(JNIEnv*, jint); + + jobject (*AllocObject)(JNIEnv*, jclass); + jobject (*NewObject)(JNIEnv*, jclass, jmethodID, ...); + jobject (*NewObjectV)(JNIEnv*, jclass, jmethodID, va_list); + jobject (*NewObjectA)(JNIEnv*, jclass, jmethodID, const jvalue*); + + jclass (*GetObjectClass)(JNIEnv*, jobject); + jboolean (*IsInstanceOf)(JNIEnv*, jobject, jclass); + jmethodID (*GetMethodID)(JNIEnv*, jclass, const char*, const char*); + + jobject (*CallObjectMethod)(JNIEnv*, jobject, jmethodID, ...); + jobject (*CallObjectMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jobject (*CallObjectMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jboolean (*CallBooleanMethod)(JNIEnv*, jobject, jmethodID, ...); + jboolean (*CallBooleanMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jboolean (*CallBooleanMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jbyte (*CallByteMethod)(JNIEnv*, jobject, jmethodID, ...); + jbyte (*CallByteMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jbyte (*CallByteMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jchar (*CallCharMethod)(JNIEnv*, jobject, jmethodID, ...); + jchar (*CallCharMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jchar (*CallCharMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jshort (*CallShortMethod)(JNIEnv*, jobject, jmethodID, ...); + jshort (*CallShortMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jshort (*CallShortMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jint (*CallIntMethod)(JNIEnv*, jobject, jmethodID, ...); + jint (*CallIntMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jint (*CallIntMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jlong (*CallLongMethod)(JNIEnv*, jobject, jmethodID, ...); + jlong (*CallLongMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jlong (*CallLongMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jfloat (*CallFloatMethod)(JNIEnv*, jobject, jmethodID, ...); + jfloat (*CallFloatMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jfloat (*CallFloatMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + jdouble (*CallDoubleMethod)(JNIEnv*, jobject, jmethodID, ...); + jdouble (*CallDoubleMethodV)(JNIEnv*, jobject, jmethodID, va_list); + jdouble (*CallDoubleMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + void (*CallVoidMethod)(JNIEnv*, jobject, jmethodID, ...); + void (*CallVoidMethodV)(JNIEnv*, jobject, jmethodID, va_list); + void (*CallVoidMethodA)(JNIEnv*, jobject, jmethodID, const jvalue*); + + jobject (*CallNonvirtualObjectMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jobject (*CallNonvirtualObjectMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jobject (*CallNonvirtualObjectMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jboolean (*CallNonvirtualBooleanMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jboolean (*CallNonvirtualBooleanMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jboolean (*CallNonvirtualBooleanMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jbyte (*CallNonvirtualByteMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jbyte (*CallNonvirtualByteMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jbyte (*CallNonvirtualByteMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jchar (*CallNonvirtualCharMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jchar (*CallNonvirtualCharMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jchar (*CallNonvirtualCharMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jshort (*CallNonvirtualShortMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jshort (*CallNonvirtualShortMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jshort (*CallNonvirtualShortMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jint (*CallNonvirtualIntMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jint (*CallNonvirtualIntMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jint (*CallNonvirtualIntMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jlong (*CallNonvirtualLongMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jlong (*CallNonvirtualLongMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jlong (*CallNonvirtualLongMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jfloat (*CallNonvirtualFloatMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jfloat (*CallNonvirtualFloatMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jfloat (*CallNonvirtualFloatMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + jdouble (*CallNonvirtualDoubleMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + jdouble (*CallNonvirtualDoubleMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + jdouble (*CallNonvirtualDoubleMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + void (*CallNonvirtualVoidMethod)(JNIEnv*, jobject, jclass, + jmethodID, ...); + void (*CallNonvirtualVoidMethodV)(JNIEnv*, jobject, jclass, + jmethodID, va_list); + void (*CallNonvirtualVoidMethodA)(JNIEnv*, jobject, jclass, + jmethodID, const jvalue*); + + jfieldID (*GetFieldID)(JNIEnv*, jclass, const char*, const char*); + + jobject (*GetObjectField)(JNIEnv*, jobject, jfieldID); + jboolean (*GetBooleanField)(JNIEnv*, jobject, jfieldID); + jbyte (*GetByteField)(JNIEnv*, jobject, jfieldID); + jchar (*GetCharField)(JNIEnv*, jobject, jfieldID); + jshort (*GetShortField)(JNIEnv*, jobject, jfieldID); + jint (*GetIntField)(JNIEnv*, jobject, jfieldID); + jlong (*GetLongField)(JNIEnv*, jobject, jfieldID); + jfloat (*GetFloatField)(JNIEnv*, jobject, jfieldID); + jdouble (*GetDoubleField)(JNIEnv*, jobject, jfieldID); + + void (*SetObjectField)(JNIEnv*, jobject, jfieldID, jobject); + void (*SetBooleanField)(JNIEnv*, jobject, jfieldID, jboolean); + void (*SetByteField)(JNIEnv*, jobject, jfieldID, jbyte); + void (*SetCharField)(JNIEnv*, jobject, jfieldID, jchar); + void (*SetShortField)(JNIEnv*, jobject, jfieldID, jshort); + void (*SetIntField)(JNIEnv*, jobject, jfieldID, jint); + void (*SetLongField)(JNIEnv*, jobject, jfieldID, jlong); + void (*SetFloatField)(JNIEnv*, jobject, jfieldID, jfloat); + void (*SetDoubleField)(JNIEnv*, jobject, jfieldID, jdouble); + + jmethodID (*GetStaticMethodID)(JNIEnv*, jclass, const char*, const char*); + + jobject (*CallStaticObjectMethod)(JNIEnv*, jclass, jmethodID, ...); + jobject (*CallStaticObjectMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jobject (*CallStaticObjectMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jboolean (*CallStaticBooleanMethod)(JNIEnv*, jclass, jmethodID, ...); + jboolean (*CallStaticBooleanMethodV)(JNIEnv*, jclass, jmethodID, + va_list); + jboolean (*CallStaticBooleanMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jbyte (*CallStaticByteMethod)(JNIEnv*, jclass, jmethodID, ...); + jbyte (*CallStaticByteMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jbyte (*CallStaticByteMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jchar (*CallStaticCharMethod)(JNIEnv*, jclass, jmethodID, ...); + jchar (*CallStaticCharMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jchar (*CallStaticCharMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jshort (*CallStaticShortMethod)(JNIEnv*, jclass, jmethodID, ...); + jshort (*CallStaticShortMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jshort (*CallStaticShortMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jint (*CallStaticIntMethod)(JNIEnv*, jclass, jmethodID, ...); + jint (*CallStaticIntMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jint (*CallStaticIntMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jlong (*CallStaticLongMethod)(JNIEnv*, jclass, jmethodID, ...); + jlong (*CallStaticLongMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jlong (*CallStaticLongMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jfloat (*CallStaticFloatMethod)(JNIEnv*, jclass, jmethodID, ...); + jfloat (*CallStaticFloatMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jfloat (*CallStaticFloatMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + jdouble (*CallStaticDoubleMethod)(JNIEnv*, jclass, jmethodID, ...); + jdouble (*CallStaticDoubleMethodV)(JNIEnv*, jclass, jmethodID, va_list); + jdouble (*CallStaticDoubleMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + void (*CallStaticVoidMethod)(JNIEnv*, jclass, jmethodID, ...); + void (*CallStaticVoidMethodV)(JNIEnv*, jclass, jmethodID, va_list); + void (*CallStaticVoidMethodA)(JNIEnv*, jclass, jmethodID, const jvalue*); + + jfieldID (*GetStaticFieldID)(JNIEnv*, jclass, const char*, + const char*); + + jobject (*GetStaticObjectField)(JNIEnv*, jclass, jfieldID); + jboolean (*GetStaticBooleanField)(JNIEnv*, jclass, jfieldID); + jbyte (*GetStaticByteField)(JNIEnv*, jclass, jfieldID); + jchar (*GetStaticCharField)(JNIEnv*, jclass, jfieldID); + jshort (*GetStaticShortField)(JNIEnv*, jclass, jfieldID); + jint (*GetStaticIntField)(JNIEnv*, jclass, jfieldID); + jlong (*GetStaticLongField)(JNIEnv*, jclass, jfieldID); + jfloat (*GetStaticFloatField)(JNIEnv*, jclass, jfieldID); + jdouble (*GetStaticDoubleField)(JNIEnv*, jclass, jfieldID); + + void (*SetStaticObjectField)(JNIEnv*, jclass, jfieldID, jobject); + void (*SetStaticBooleanField)(JNIEnv*, jclass, jfieldID, jboolean); + void (*SetStaticByteField)(JNIEnv*, jclass, jfieldID, jbyte); + void (*SetStaticCharField)(JNIEnv*, jclass, jfieldID, jchar); + void (*SetStaticShortField)(JNIEnv*, jclass, jfieldID, jshort); + void (*SetStaticIntField)(JNIEnv*, jclass, jfieldID, jint); + void (*SetStaticLongField)(JNIEnv*, jclass, jfieldID, jlong); + void (*SetStaticFloatField)(JNIEnv*, jclass, jfieldID, jfloat); + void (*SetStaticDoubleField)(JNIEnv*, jclass, jfieldID, jdouble); + + jstring (*NewString)(JNIEnv*, const jchar*, jsize); + jsize (*GetStringLength)(JNIEnv*, jstring); + const jchar* (*GetStringChars)(JNIEnv*, jstring, jboolean*); + void (*ReleaseStringChars)(JNIEnv*, jstring, const jchar*); + jstring (*NewStringUTF)(JNIEnv*, const char*); + jsize (*GetStringUTFLength)(JNIEnv*, jstring); + /* JNI spec says this returns const jbyte*, but that's inconsistent */ + const char* (*GetStringUTFChars)(JNIEnv*, jstring, jboolean*); + void (*ReleaseStringUTFChars)(JNIEnv*, jstring, const char*); + jsize (*GetArrayLength)(JNIEnv*, jarray); + jobjectArray (*NewObjectArray)(JNIEnv*, jsize, jclass, jobject); + jobject (*GetObjectArrayElement)(JNIEnv*, jobjectArray, jsize); + void (*SetObjectArrayElement)(JNIEnv*, jobjectArray, jsize, jobject); + + jbooleanArray (*NewBooleanArray)(JNIEnv*, jsize); + jbyteArray (*NewByteArray)(JNIEnv*, jsize); + jcharArray (*NewCharArray)(JNIEnv*, jsize); + jshortArray (*NewShortArray)(JNIEnv*, jsize); + jintArray (*NewIntArray)(JNIEnv*, jsize); + jlongArray (*NewLongArray)(JNIEnv*, jsize); + jfloatArray (*NewFloatArray)(JNIEnv*, jsize); + jdoubleArray (*NewDoubleArray)(JNIEnv*, jsize); + + jboolean* (*GetBooleanArrayElements)(JNIEnv*, jbooleanArray, jboolean*); + jbyte* (*GetByteArrayElements)(JNIEnv*, jbyteArray, jboolean*); + jchar* (*GetCharArrayElements)(JNIEnv*, jcharArray, jboolean*); + jshort* (*GetShortArrayElements)(JNIEnv*, jshortArray, jboolean*); + jint* (*GetIntArrayElements)(JNIEnv*, jintArray, jboolean*); + jlong* (*GetLongArrayElements)(JNIEnv*, jlongArray, jboolean*); + jfloat* (*GetFloatArrayElements)(JNIEnv*, jfloatArray, jboolean*); + jdouble* (*GetDoubleArrayElements)(JNIEnv*, jdoubleArray, jboolean*); + + void (*ReleaseBooleanArrayElements)(JNIEnv*, jbooleanArray, + jboolean*, jint); + void (*ReleaseByteArrayElements)(JNIEnv*, jbyteArray, + jbyte*, jint); + void (*ReleaseCharArrayElements)(JNIEnv*, jcharArray, + jchar*, jint); + void (*ReleaseShortArrayElements)(JNIEnv*, jshortArray, + jshort*, jint); + void (*ReleaseIntArrayElements)(JNIEnv*, jintArray, + jint*, jint); + void (*ReleaseLongArrayElements)(JNIEnv*, jlongArray, + jlong*, jint); + void (*ReleaseFloatArrayElements)(JNIEnv*, jfloatArray, + jfloat*, jint); + void (*ReleaseDoubleArrayElements)(JNIEnv*, jdoubleArray, + jdouble*, jint); + + void (*GetBooleanArrayRegion)(JNIEnv*, jbooleanArray, + jsize, jsize, jboolean*); + void (*GetByteArrayRegion)(JNIEnv*, jbyteArray, + jsize, jsize, jbyte*); + void (*GetCharArrayRegion)(JNIEnv*, jcharArray, + jsize, jsize, jchar*); + void (*GetShortArrayRegion)(JNIEnv*, jshortArray, + jsize, jsize, jshort*); + void (*GetIntArrayRegion)(JNIEnv*, jintArray, + jsize, jsize, jint*); + void (*GetLongArrayRegion)(JNIEnv*, jlongArray, + jsize, jsize, jlong*); + void (*GetFloatArrayRegion)(JNIEnv*, jfloatArray, + jsize, jsize, jfloat*); + void (*GetDoubleArrayRegion)(JNIEnv*, jdoubleArray, + jsize, jsize, jdouble*); + + /* spec shows these without const; some jni.h do, some don't */ + void (*SetBooleanArrayRegion)(JNIEnv*, jbooleanArray, + jsize, jsize, const jboolean*); + void (*SetByteArrayRegion)(JNIEnv*, jbyteArray, + jsize, jsize, const jbyte*); + void (*SetCharArrayRegion)(JNIEnv*, jcharArray, + jsize, jsize, const jchar*); + void (*SetShortArrayRegion)(JNIEnv*, jshortArray, + jsize, jsize, const jshort*); + void (*SetIntArrayRegion)(JNIEnv*, jintArray, + jsize, jsize, const jint*); + void (*SetLongArrayRegion)(JNIEnv*, jlongArray, + jsize, jsize, const jlong*); + void (*SetFloatArrayRegion)(JNIEnv*, jfloatArray, + jsize, jsize, const jfloat*); + void (*SetDoubleArrayRegion)(JNIEnv*, jdoubleArray, + jsize, jsize, const jdouble*); + + jint (*RegisterNatives)(JNIEnv*, jclass, const JNINativeMethod*, + jint); + jint (*UnregisterNatives)(JNIEnv*, jclass); + jint (*MonitorEnter)(JNIEnv*, jobject); + jint (*MonitorExit)(JNIEnv*, jobject); + jint (*GetJavaVM)(JNIEnv*, JavaVM**); + + void (*GetStringRegion)(JNIEnv*, jstring, jsize, jsize, jchar*); + void (*GetStringUTFRegion)(JNIEnv*, jstring, jsize, jsize, char*); + + void* (*GetPrimitiveArrayCritical)(JNIEnv*, jarray, jboolean*); + void (*ReleasePrimitiveArrayCritical)(JNIEnv*, jarray, void*, jint); + + const jchar* (*GetStringCritical)(JNIEnv*, jstring, jboolean*); + void (*ReleaseStringCritical)(JNIEnv*, jstring, const jchar*); + + jweak (*NewWeakGlobalRef)(JNIEnv*, jobject); + void (*DeleteWeakGlobalRef)(JNIEnv*, jweak); + + jboolean (*ExceptionCheck)(JNIEnv*); + + jobject (*NewDirectByteBuffer)(JNIEnv*, void*, jlong); + void* (*GetDirectBufferAddress)(JNIEnv*, jobject); + jlong (*GetDirectBufferCapacity)(JNIEnv*, jobject); + + /* added in JNI 1.6 */ + jobjectRefType (*GetObjectRefType)(JNIEnv*, jobject); +}; + +/* + * C++ object wrapper. + * + * This is usually overlaid on a C struct whose first element is a + * JNINativeInterface*. We rely somewhat on compiler behavior. + */ +struct _JNIEnv { + /* do not rename this; it does not seem to be entirely opaque */ + const struct JNINativeInterface* functions; + +#if defined(__cplusplus) + + jint GetVersion() + { return functions->GetVersion(this); } + + jclass DefineClass(const char *name, jobject loader, const jbyte* buf, + jsize bufLen) + { return functions->DefineClass(this, name, loader, buf, bufLen); } + + jclass FindClass(const char* name) + { return functions->FindClass(this, name); } + + jmethodID FromReflectedMethod(jobject method) + { return functions->FromReflectedMethod(this, method); } + + jfieldID FromReflectedField(jobject field) + { return functions->FromReflectedField(this, field); } + + jobject ToReflectedMethod(jclass cls, jmethodID methodID, jboolean isStatic) + { return functions->ToReflectedMethod(this, cls, methodID, isStatic); } + + jclass GetSuperclass(jclass clazz) + { return functions->GetSuperclass(this, clazz); } + + jboolean IsAssignableFrom(jclass clazz1, jclass clazz2) + { return functions->IsAssignableFrom(this, clazz1, clazz2); } + + jobject ToReflectedField(jclass cls, jfieldID fieldID, jboolean isStatic) + { return functions->ToReflectedField(this, cls, fieldID, isStatic); } + + jint Throw(jthrowable obj) + { return functions->Throw(this, obj); } + + jint ThrowNew(jclass clazz, const char* message) + { return functions->ThrowNew(this, clazz, message); } + + jthrowable ExceptionOccurred() + { return functions->ExceptionOccurred(this); } + + void ExceptionDescribe() + { functions->ExceptionDescribe(this); } + + void ExceptionClear() + { functions->ExceptionClear(this); } + + void FatalError(const char* msg) + { functions->FatalError(this, msg); } + + jint PushLocalFrame(jint capacity) + { return functions->PushLocalFrame(this, capacity); } + + jobject PopLocalFrame(jobject result) + { return functions->PopLocalFrame(this, result); } + + jobject NewGlobalRef(jobject obj) + { return functions->NewGlobalRef(this, obj); } + + void DeleteGlobalRef(jobject globalRef) + { functions->DeleteGlobalRef(this, globalRef); } + + void DeleteLocalRef(jobject localRef) + { functions->DeleteLocalRef(this, localRef); } + + jboolean IsSameObject(jobject ref1, jobject ref2) + { return functions->IsSameObject(this, ref1, ref2); } + + jobject NewLocalRef(jobject ref) + { return functions->NewLocalRef(this, ref); } + + jint EnsureLocalCapacity(jint capacity) + { return functions->EnsureLocalCapacity(this, capacity); } + + jobject AllocObject(jclass clazz) + { return functions->AllocObject(this, clazz); } + + jobject NewObject(jclass clazz, jmethodID methodID, ...) + { + va_list args; + va_start(args, methodID); + jobject result = functions->NewObjectV(this, clazz, methodID, args); + va_end(args); + return result; + } + + jobject NewObjectV(jclass clazz, jmethodID methodID, va_list args) + { return functions->NewObjectV(this, clazz, methodID, args); } + + jobject NewObjectA(jclass clazz, jmethodID methodID, const jvalue* args) + { return functions->NewObjectA(this, clazz, methodID, args); } + + jclass GetObjectClass(jobject obj) + { return functions->GetObjectClass(this, obj); } + + jboolean IsInstanceOf(jobject obj, jclass clazz) + { return functions->IsInstanceOf(this, obj, clazz); } + + jmethodID GetMethodID(jclass clazz, const char* name, const char* sig) + { return functions->GetMethodID(this, clazz, name, sig); } + +#define CALL_TYPE_METHOD(_jtype, _jname) \ + _jtype Call##_jname##Method(jobject obj, jmethodID methodID, ...) \ + { \ + _jtype result; \ + va_list args; \ + va_start(args, methodID); \ + result = functions->Call##_jname##MethodV(this, obj, methodID, \ + args); \ + va_end(args); \ + return result; \ + } +#define CALL_TYPE_METHODV(_jtype, _jname) \ + _jtype Call##_jname##MethodV(jobject obj, jmethodID methodID, \ + va_list args) \ + { return functions->Call##_jname##MethodV(this, obj, methodID, args); } +#define CALL_TYPE_METHODA(_jtype, _jname) \ + _jtype Call##_jname##MethodA(jobject obj, jmethodID methodID, \ + const jvalue* args) \ + { return functions->Call##_jname##MethodA(this, obj, methodID, args); } + +#define CALL_TYPE(_jtype, _jname) \ + CALL_TYPE_METHOD(_jtype, _jname) \ + CALL_TYPE_METHODV(_jtype, _jname) \ + CALL_TYPE_METHODA(_jtype, _jname) + + CALL_TYPE(jobject, Object) + CALL_TYPE(jboolean, Boolean) + CALL_TYPE(jbyte, Byte) + CALL_TYPE(jchar, Char) + CALL_TYPE(jshort, Short) + CALL_TYPE(jint, Int) + CALL_TYPE(jlong, Long) + CALL_TYPE(jfloat, Float) + CALL_TYPE(jdouble, Double) + + void CallVoidMethod(jobject obj, jmethodID methodID, ...) + { + va_list args; + va_start(args, methodID); + functions->CallVoidMethodV(this, obj, methodID, args); + va_end(args); + } + void CallVoidMethodV(jobject obj, jmethodID methodID, va_list args) + { functions->CallVoidMethodV(this, obj, methodID, args); } + void CallVoidMethodA(jobject obj, jmethodID methodID, const jvalue* args) + { functions->CallVoidMethodA(this, obj, methodID, args); } + +#define CALL_NONVIRT_TYPE_METHOD(_jtype, _jname) \ + _jtype CallNonvirtual##_jname##Method(jobject obj, jclass clazz, \ + jmethodID methodID, ...) \ + { \ + _jtype result; \ + va_list args; \ + va_start(args, methodID); \ + result = functions->CallNonvirtual##_jname##MethodV(this, obj, \ + clazz, methodID, args); \ + va_end(args); \ + return result; \ + } +#define CALL_NONVIRT_TYPE_METHODV(_jtype, _jname) \ + _jtype CallNonvirtual##_jname##MethodV(jobject obj, jclass clazz, \ + jmethodID methodID, va_list args) \ + { return functions->CallNonvirtual##_jname##MethodV(this, obj, clazz, \ + methodID, args); } +#define CALL_NONVIRT_TYPE_METHODA(_jtype, _jname) \ + _jtype CallNonvirtual##_jname##MethodA(jobject obj, jclass clazz, \ + jmethodID methodID, const jvalue* args) \ + { return functions->CallNonvirtual##_jname##MethodA(this, obj, clazz, \ + methodID, args); } + +#define CALL_NONVIRT_TYPE(_jtype, _jname) \ + CALL_NONVIRT_TYPE_METHOD(_jtype, _jname) \ + CALL_NONVIRT_TYPE_METHODV(_jtype, _jname) \ + CALL_NONVIRT_TYPE_METHODA(_jtype, _jname) + + CALL_NONVIRT_TYPE(jobject, Object) + CALL_NONVIRT_TYPE(jboolean, Boolean) + CALL_NONVIRT_TYPE(jbyte, Byte) + CALL_NONVIRT_TYPE(jchar, Char) + CALL_NONVIRT_TYPE(jshort, Short) + CALL_NONVIRT_TYPE(jint, Int) + CALL_NONVIRT_TYPE(jlong, Long) + CALL_NONVIRT_TYPE(jfloat, Float) + CALL_NONVIRT_TYPE(jdouble, Double) + + void CallNonvirtualVoidMethod(jobject obj, jclass clazz, + jmethodID methodID, ...) + { + va_list args; + va_start(args, methodID); + functions->CallNonvirtualVoidMethodV(this, obj, clazz, methodID, args); + va_end(args); + } + void CallNonvirtualVoidMethodV(jobject obj, jclass clazz, + jmethodID methodID, va_list args) + { functions->CallNonvirtualVoidMethodV(this, obj, clazz, methodID, args); } + void CallNonvirtualVoidMethodA(jobject obj, jclass clazz, + jmethodID methodID, const jvalue* args) + { functions->CallNonvirtualVoidMethodA(this, obj, clazz, methodID, args); } + + jfieldID GetFieldID(jclass clazz, const char* name, const char* sig) + { return functions->GetFieldID(this, clazz, name, sig); } + + jobject GetObjectField(jobject obj, jfieldID fieldID) + { return functions->GetObjectField(this, obj, fieldID); } + jboolean GetBooleanField(jobject obj, jfieldID fieldID) + { return functions->GetBooleanField(this, obj, fieldID); } + jbyte GetByteField(jobject obj, jfieldID fieldID) + { return functions->GetByteField(this, obj, fieldID); } + jchar GetCharField(jobject obj, jfieldID fieldID) + { return functions->GetCharField(this, obj, fieldID); } + jshort GetShortField(jobject obj, jfieldID fieldID) + { return functions->GetShortField(this, obj, fieldID); } + jint GetIntField(jobject obj, jfieldID fieldID) + { return functions->GetIntField(this, obj, fieldID); } + jlong GetLongField(jobject obj, jfieldID fieldID) + { return functions->GetLongField(this, obj, fieldID); } + jfloat GetFloatField(jobject obj, jfieldID fieldID) + { return functions->GetFloatField(this, obj, fieldID); } + jdouble GetDoubleField(jobject obj, jfieldID fieldID) + { return functions->GetDoubleField(this, obj, fieldID); } + + void SetObjectField(jobject obj, jfieldID fieldID, jobject value) + { functions->SetObjectField(this, obj, fieldID, value); } + void SetBooleanField(jobject obj, jfieldID fieldID, jboolean value) + { functions->SetBooleanField(this, obj, fieldID, value); } + void SetByteField(jobject obj, jfieldID fieldID, jbyte value) + { functions->SetByteField(this, obj, fieldID, value); } + void SetCharField(jobject obj, jfieldID fieldID, jchar value) + { functions->SetCharField(this, obj, fieldID, value); } + void SetShortField(jobject obj, jfieldID fieldID, jshort value) + { functions->SetShortField(this, obj, fieldID, value); } + void SetIntField(jobject obj, jfieldID fieldID, jint value) + { functions->SetIntField(this, obj, fieldID, value); } + void SetLongField(jobject obj, jfieldID fieldID, jlong value) + { functions->SetLongField(this, obj, fieldID, value); } + void SetFloatField(jobject obj, jfieldID fieldID, jfloat value) + { functions->SetFloatField(this, obj, fieldID, value); } + void SetDoubleField(jobject obj, jfieldID fieldID, jdouble value) + { functions->SetDoubleField(this, obj, fieldID, value); } + + jmethodID GetStaticMethodID(jclass clazz, const char* name, const char* sig) + { return functions->GetStaticMethodID(this, clazz, name, sig); } + +#define CALL_STATIC_TYPE_METHOD(_jtype, _jname) \ + _jtype CallStatic##_jname##Method(jclass clazz, jmethodID methodID, \ + ...) \ + { \ + _jtype result; \ + va_list args; \ + va_start(args, methodID); \ + result = functions->CallStatic##_jname##MethodV(this, clazz, \ + methodID, args); \ + va_end(args); \ + return result; \ + } +#define CALL_STATIC_TYPE_METHODV(_jtype, _jname) \ + _jtype CallStatic##_jname##MethodV(jclass clazz, jmethodID methodID, \ + va_list args) \ + { return functions->CallStatic##_jname##MethodV(this, clazz, methodID, \ + args); } +#define CALL_STATIC_TYPE_METHODA(_jtype, _jname) \ + _jtype CallStatic##_jname##MethodA(jclass clazz, jmethodID methodID, \ + const jvalue* args) \ + { return functions->CallStatic##_jname##MethodA(this, clazz, methodID, \ + args); } + +#define CALL_STATIC_TYPE(_jtype, _jname) \ + CALL_STATIC_TYPE_METHOD(_jtype, _jname) \ + CALL_STATIC_TYPE_METHODV(_jtype, _jname) \ + CALL_STATIC_TYPE_METHODA(_jtype, _jname) + + CALL_STATIC_TYPE(jobject, Object) + CALL_STATIC_TYPE(jboolean, Boolean) + CALL_STATIC_TYPE(jbyte, Byte) + CALL_STATIC_TYPE(jchar, Char) + CALL_STATIC_TYPE(jshort, Short) + CALL_STATIC_TYPE(jint, Int) + CALL_STATIC_TYPE(jlong, Long) + CALL_STATIC_TYPE(jfloat, Float) + CALL_STATIC_TYPE(jdouble, Double) + + void CallStaticVoidMethod(jclass clazz, jmethodID methodID, ...) + { + va_list args; + va_start(args, methodID); + functions->CallStaticVoidMethodV(this, clazz, methodID, args); + va_end(args); + } + void CallStaticVoidMethodV(jclass clazz, jmethodID methodID, va_list args) + { functions->CallStaticVoidMethodV(this, clazz, methodID, args); } + void CallStaticVoidMethodA(jclass clazz, jmethodID methodID, const jvalue* args) + { functions->CallStaticVoidMethodA(this, clazz, methodID, args); } + + jfieldID GetStaticFieldID(jclass clazz, const char* name, const char* sig) + { return functions->GetStaticFieldID(this, clazz, name, sig); } + + jobject GetStaticObjectField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticObjectField(this, clazz, fieldID); } + jboolean GetStaticBooleanField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticBooleanField(this, clazz, fieldID); } + jbyte GetStaticByteField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticByteField(this, clazz, fieldID); } + jchar GetStaticCharField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticCharField(this, clazz, fieldID); } + jshort GetStaticShortField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticShortField(this, clazz, fieldID); } + jint GetStaticIntField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticIntField(this, clazz, fieldID); } + jlong GetStaticLongField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticLongField(this, clazz, fieldID); } + jfloat GetStaticFloatField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticFloatField(this, clazz, fieldID); } + jdouble GetStaticDoubleField(jclass clazz, jfieldID fieldID) + { return functions->GetStaticDoubleField(this, clazz, fieldID); } + + void SetStaticObjectField(jclass clazz, jfieldID fieldID, jobject value) + { functions->SetStaticObjectField(this, clazz, fieldID, value); } + void SetStaticBooleanField(jclass clazz, jfieldID fieldID, jboolean value) + { functions->SetStaticBooleanField(this, clazz, fieldID, value); } + void SetStaticByteField(jclass clazz, jfieldID fieldID, jbyte value) + { functions->SetStaticByteField(this, clazz, fieldID, value); } + void SetStaticCharField(jclass clazz, jfieldID fieldID, jchar value) + { functions->SetStaticCharField(this, clazz, fieldID, value); } + void SetStaticShortField(jclass clazz, jfieldID fieldID, jshort value) + { functions->SetStaticShortField(this, clazz, fieldID, value); } + void SetStaticIntField(jclass clazz, jfieldID fieldID, jint value) + { functions->SetStaticIntField(this, clazz, fieldID, value); } + void SetStaticLongField(jclass clazz, jfieldID fieldID, jlong value) + { functions->SetStaticLongField(this, clazz, fieldID, value); } + void SetStaticFloatField(jclass clazz, jfieldID fieldID, jfloat value) + { functions->SetStaticFloatField(this, clazz, fieldID, value); } + void SetStaticDoubleField(jclass clazz, jfieldID fieldID, jdouble value) + { functions->SetStaticDoubleField(this, clazz, fieldID, value); } + + jstring NewString(const jchar* unicodeChars, jsize len) + { return functions->NewString(this, unicodeChars, len); } + + jsize GetStringLength(jstring string) + { return functions->GetStringLength(this, string); } + + const jchar* GetStringChars(jstring string, jboolean* isCopy) + { return functions->GetStringChars(this, string, isCopy); } + + void ReleaseStringChars(jstring string, const jchar* chars) + { functions->ReleaseStringChars(this, string, chars); } + + jstring NewStringUTF(const char* bytes) + { return functions->NewStringUTF(this, bytes); } + + jsize GetStringUTFLength(jstring string) + { return functions->GetStringUTFLength(this, string); } + + const char* GetStringUTFChars(jstring string, jboolean* isCopy) + { return functions->GetStringUTFChars(this, string, isCopy); } + + void ReleaseStringUTFChars(jstring string, const char* utf) + { functions->ReleaseStringUTFChars(this, string, utf); } + + jsize GetArrayLength(jarray array) + { return functions->GetArrayLength(this, array); } + + jobjectArray NewObjectArray(jsize length, jclass elementClass, + jobject initialElement) + { return functions->NewObjectArray(this, length, elementClass, + initialElement); } + + jobject GetObjectArrayElement(jobjectArray array, jsize index) + { return functions->GetObjectArrayElement(this, array, index); } + + void SetObjectArrayElement(jobjectArray array, jsize index, jobject value) + { functions->SetObjectArrayElement(this, array, index, value); } + + jbooleanArray NewBooleanArray(jsize length) + { return functions->NewBooleanArray(this, length); } + jbyteArray NewByteArray(jsize length) + { return functions->NewByteArray(this, length); } + jcharArray NewCharArray(jsize length) + { return functions->NewCharArray(this, length); } + jshortArray NewShortArray(jsize length) + { return functions->NewShortArray(this, length); } + jintArray NewIntArray(jsize length) + { return functions->NewIntArray(this, length); } + jlongArray NewLongArray(jsize length) + { return functions->NewLongArray(this, length); } + jfloatArray NewFloatArray(jsize length) + { return functions->NewFloatArray(this, length); } + jdoubleArray NewDoubleArray(jsize length) + { return functions->NewDoubleArray(this, length); } + + jboolean* GetBooleanArrayElements(jbooleanArray array, jboolean* isCopy) + { return functions->GetBooleanArrayElements(this, array, isCopy); } + jbyte* GetByteArrayElements(jbyteArray array, jboolean* isCopy) + { return functions->GetByteArrayElements(this, array, isCopy); } + jchar* GetCharArrayElements(jcharArray array, jboolean* isCopy) + { return functions->GetCharArrayElements(this, array, isCopy); } + jshort* GetShortArrayElements(jshortArray array, jboolean* isCopy) + { return functions->GetShortArrayElements(this, array, isCopy); } + jint* GetIntArrayElements(jintArray array, jboolean* isCopy) + { return functions->GetIntArrayElements(this, array, isCopy); } + jlong* GetLongArrayElements(jlongArray array, jboolean* isCopy) + { return functions->GetLongArrayElements(this, array, isCopy); } + jfloat* GetFloatArrayElements(jfloatArray array, jboolean* isCopy) + { return functions->GetFloatArrayElements(this, array, isCopy); } + jdouble* GetDoubleArrayElements(jdoubleArray array, jboolean* isCopy) + { return functions->GetDoubleArrayElements(this, array, isCopy); } + + void ReleaseBooleanArrayElements(jbooleanArray array, jboolean* elems, + jint mode) + { functions->ReleaseBooleanArrayElements(this, array, elems, mode); } + void ReleaseByteArrayElements(jbyteArray array, jbyte* elems, + jint mode) + { functions->ReleaseByteArrayElements(this, array, elems, mode); } + void ReleaseCharArrayElements(jcharArray array, jchar* elems, + jint mode) + { functions->ReleaseCharArrayElements(this, array, elems, mode); } + void ReleaseShortArrayElements(jshortArray array, jshort* elems, + jint mode) + { functions->ReleaseShortArrayElements(this, array, elems, mode); } + void ReleaseIntArrayElements(jintArray array, jint* elems, + jint mode) + { functions->ReleaseIntArrayElements(this, array, elems, mode); } + void ReleaseLongArrayElements(jlongArray array, jlong* elems, + jint mode) + { functions->ReleaseLongArrayElements(this, array, elems, mode); } + void ReleaseFloatArrayElements(jfloatArray array, jfloat* elems, + jint mode) + { functions->ReleaseFloatArrayElements(this, array, elems, mode); } + void ReleaseDoubleArrayElements(jdoubleArray array, jdouble* elems, + jint mode) + { functions->ReleaseDoubleArrayElements(this, array, elems, mode); } + + void GetBooleanArrayRegion(jbooleanArray array, jsize start, jsize len, + jboolean* buf) + { functions->GetBooleanArrayRegion(this, array, start, len, buf); } + void GetByteArrayRegion(jbyteArray array, jsize start, jsize len, + jbyte* buf) + { functions->GetByteArrayRegion(this, array, start, len, buf); } + void GetCharArrayRegion(jcharArray array, jsize start, jsize len, + jchar* buf) + { functions->GetCharArrayRegion(this, array, start, len, buf); } + void GetShortArrayRegion(jshortArray array, jsize start, jsize len, + jshort* buf) + { functions->GetShortArrayRegion(this, array, start, len, buf); } + void GetIntArrayRegion(jintArray array, jsize start, jsize len, + jint* buf) + { functions->GetIntArrayRegion(this, array, start, len, buf); } + void GetLongArrayRegion(jlongArray array, jsize start, jsize len, + jlong* buf) + { functions->GetLongArrayRegion(this, array, start, len, buf); } + void GetFloatArrayRegion(jfloatArray array, jsize start, jsize len, + jfloat* buf) + { functions->GetFloatArrayRegion(this, array, start, len, buf); } + void GetDoubleArrayRegion(jdoubleArray array, jsize start, jsize len, + jdouble* buf) + { functions->GetDoubleArrayRegion(this, array, start, len, buf); } + + void SetBooleanArrayRegion(jbooleanArray array, jsize start, jsize len, + const jboolean* buf) + { functions->SetBooleanArrayRegion(this, array, start, len, buf); } + void SetByteArrayRegion(jbyteArray array, jsize start, jsize len, + const jbyte* buf) + { functions->SetByteArrayRegion(this, array, start, len, buf); } + void SetCharArrayRegion(jcharArray array, jsize start, jsize len, + const jchar* buf) + { functions->SetCharArrayRegion(this, array, start, len, buf); } + void SetShortArrayRegion(jshortArray array, jsize start, jsize len, + const jshort* buf) + { functions->SetShortArrayRegion(this, array, start, len, buf); } + void SetIntArrayRegion(jintArray array, jsize start, jsize len, + const jint* buf) + { functions->SetIntArrayRegion(this, array, start, len, buf); } + void SetLongArrayRegion(jlongArray array, jsize start, jsize len, + const jlong* buf) + { functions->SetLongArrayRegion(this, array, start, len, buf); } + void SetFloatArrayRegion(jfloatArray array, jsize start, jsize len, + const jfloat* buf) + { functions->SetFloatArrayRegion(this, array, start, len, buf); } + void SetDoubleArrayRegion(jdoubleArray array, jsize start, jsize len, + const jdouble* buf) + { functions->SetDoubleArrayRegion(this, array, start, len, buf); } + + jint RegisterNatives(jclass clazz, const JNINativeMethod* methods, + jint nMethods) + { return functions->RegisterNatives(this, clazz, methods, nMethods); } + + jint UnregisterNatives(jclass clazz) + { return functions->UnregisterNatives(this, clazz); } + + jint MonitorEnter(jobject obj) + { return functions->MonitorEnter(this, obj); } + + jint MonitorExit(jobject obj) + { return functions->MonitorExit(this, obj); } + + jint GetJavaVM(JavaVM** vm) + { return functions->GetJavaVM(this, vm); } + + void GetStringRegion(jstring str, jsize start, jsize len, jchar* buf) + { functions->GetStringRegion(this, str, start, len, buf); } + + void GetStringUTFRegion(jstring str, jsize start, jsize len, char* buf) + { return functions->GetStringUTFRegion(this, str, start, len, buf); } + + void* GetPrimitiveArrayCritical(jarray array, jboolean* isCopy) + { return functions->GetPrimitiveArrayCritical(this, array, isCopy); } + + void ReleasePrimitiveArrayCritical(jarray array, void* carray, jint mode) + { functions->ReleasePrimitiveArrayCritical(this, array, carray, mode); } + + const jchar* GetStringCritical(jstring string, jboolean* isCopy) + { return functions->GetStringCritical(this, string, isCopy); } + + void ReleaseStringCritical(jstring string, const jchar* carray) + { functions->ReleaseStringCritical(this, string, carray); } + + jweak NewWeakGlobalRef(jobject obj) + { return functions->NewWeakGlobalRef(this, obj); } + + void DeleteWeakGlobalRef(jweak obj) + { functions->DeleteWeakGlobalRef(this, obj); } + + jboolean ExceptionCheck() + { return functions->ExceptionCheck(this); } + + jobject NewDirectByteBuffer(void* address, jlong capacity) + { return functions->NewDirectByteBuffer(this, address, capacity); } + + void* GetDirectBufferAddress(jobject buf) + { return functions->GetDirectBufferAddress(this, buf); } + + jlong GetDirectBufferCapacity(jobject buf) + { return functions->GetDirectBufferCapacity(this, buf); } + + /* added in JNI 1.6 */ + jobjectRefType GetObjectRefType(jobject obj) + { return functions->GetObjectRefType(this, obj); } +#endif /*__cplusplus*/ +}; + + +/* + * JNI invocation interface. + */ +struct JNIInvokeInterface { + void* reserved0; + void* reserved1; + void* reserved2; + + jint (*DestroyJavaVM)(JavaVM*); + jint (*AttachCurrentThread)(JavaVM*, JNIEnv**, void*); + jint (*DetachCurrentThread)(JavaVM*); + jint (*GetEnv)(JavaVM*, void**, jint); + jint (*AttachCurrentThreadAsDaemon)(JavaVM*, JNIEnv**, void*); +}; + +/* + * C++ version. + */ +struct _JavaVM { + const struct JNIInvokeInterface* functions; + +#if defined(__cplusplus) + jint DestroyJavaVM() + { return functions->DestroyJavaVM(this); } + jint AttachCurrentThread(JNIEnv** p_env, void* thr_args) + { return functions->AttachCurrentThread(this, p_env, thr_args); } + jint DetachCurrentThread() + { return functions->DetachCurrentThread(this); } + jint GetEnv(void** env, jint version) + { return functions->GetEnv(this, env, version); } + jint AttachCurrentThreadAsDaemon(JNIEnv** p_env, void* thr_args) + { return functions->AttachCurrentThreadAsDaemon(this, p_env, thr_args); } +#endif /*__cplusplus*/ +}; + +struct JavaVMAttachArgs { + jint version; /* must be >= JNI_VERSION_1_2 */ + const char* name; /* NULL or name of thread as modified UTF-8 str */ + jobject group; /* global ref of a ThreadGroup object, or NULL */ +}; +typedef struct JavaVMAttachArgs JavaVMAttachArgs; + +/* + * JNI 1.2+ initialization. (As of 1.6, the pre-1.2 structures are no + * longer supported.) + */ +typedef struct JavaVMOption { + const char* optionString; + void* extraInfo; +} JavaVMOption; + +typedef struct JavaVMInitArgs { + jint version; /* use JNI_VERSION_1_2 or later */ + + jint nOptions; + JavaVMOption* options; + jboolean ignoreUnrecognized; +} JavaVMInitArgs; + +#ifdef __cplusplus +extern "C" { +#endif +/* + * VM initialization functions. + * + * Note these are the only symbols exported for JNI by the VM. + */ +jint JNI_GetDefaultJavaVMInitArgs(void*); +jint JNI_CreateJavaVM(JavaVM**, JNIEnv**, void*); +jint JNI_GetCreatedJavaVMs(JavaVM**, jsize, jsize*); + +#define JNIIMPORT +#define JNIEXPORT __attribute__ ((visibility ("default"))) +#define JNICALL + +/* + * Prototypes for functions exported by loadable shared libs. These are + * called by JNI, not provided by JNI. + */ +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved); +JNIEXPORT void JNI_OnUnload(JavaVM* vm, void* reserved); + +#ifdef __cplusplus +} +#endif + + +/* + * Manifest constants. + */ +#define JNI_FALSE 0 +#define JNI_TRUE 1 + +#define JNI_VERSION_1_1 0x00010001 +#define JNI_VERSION_1_2 0x00010002 +#define JNI_VERSION_1_4 0x00010004 +#define JNI_VERSION_1_6 0x00010006 + +#define JNI_OK (0) /* no error */ +#define JNI_ERR (-1) /* generic error */ +#define JNI_EDETACHED (-2) /* thread detached from the VM */ +#define JNI_EVERSION (-3) /* JNI version error */ +#define JNI_ENOMEM (-4) /* Out of memory */ +#define JNI_EEXIST (-5) /* VM already created */ +#define JNI_EINVAL (-6) /* Invalid argument */ + +#define JNI_COMMIT 1 /* copy content, do not free buffer */ +#define JNI_ABORT 2 /* free buffer w/o copying back */ + +#endif /* JNI_H_ */ diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 5fd7a3d..154ecb3 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -6,9 +6,13 @@ import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File +import java.util.jar.Attributes +import java.util.jar.JarOutputStream +import java.util.jar.Manifest import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertContains import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -22,7 +26,7 @@ class KonanPluginFunctionalTest { private val host = NativeTarget.host private val hostSuffix = host.konanName.replaceFirstChar { it.uppercase() } - private fun runner(vararg args: String) = GradleRunner.create() + private fun runner(vararg args: String) = testRunner() .withProjectDir(projectDir) .withPluginClasspath() .withArguments(*args) @@ -37,10 +41,17 @@ class KonanPluginFunctionalTest { File(projectDir, "build.gradle.kts").writeText( "import io.github.lemcoder.model.NativeTarget\nimport io.github.lemcoder.model.Executable\n\n" + "plugins {\n${plugins.trimIndent().prependIndent(" ")}\n}\n\n" + - "konan {\n konanHome.set(file(\"${konanStub()}\"))\n${konan.trimIndent().prependIndent(" ")}\n}\n" + "konan {\n config.konanHome.set(file(\"${konanStub()}\"))\n$installedTools${konan.trimIndent().prependIndent(" ")}\n}\n" ) } + /** make and cmake as this machine has them, for fixtures that run them; the plugin no longer looks. */ + private val installedTools: String = listOf("make", "cmake").joinToString("") { name -> + val directories = System.getenv("PATH").orEmpty().split(File.pathSeparator) + listOf("/opt/homebrew/bin", "/usr/local/bin", "/usr/bin") + directories.map { File(it, name) }.firstOrNull { it.canExecute() } + ?.let { " config.$name.set(file(\"${it.absolutePath.replace("\\", "/")}\"))\n" }.orEmpty() + } + private val mymath = """ val mymath = libraries.create("mymath") { targets(NativeTarget.${host.name}) } bindings { @@ -51,6 +62,54 @@ class KonanPluginFunctionalTest { } """.trimIndent() + @Test + fun `config reads the build script, then Gradle properties, then the environment`() { + write( + """id("io.github.lemcoder.konanplugin")""", + """ + config.jextract.set(file("/from/dsl/jextract")) + libraries { create("mymath") { targets(NativeTarget.LINUX_X64); cmake { } } } + """, + ) + + val output = runner("konanConfig", "-Pkonan.config.jextract=/from/property/jextract", "-Pkonan.config.zig=/from/property/zig") + .withEnvironment(System.getenv() + mapOf("KONAN_ZIG" to "/from/env/zig", "KONAN_CARGO" to "/from/env/cargo")) + .build().output + + assertContains(output, "jextract /from/dsl/jextract (build script) β€” not needed here") + assertContains(output, "zig /from/property/zig (Gradle property konan.config.zig) β€” not needed here") + assertContains(output, "cargo /from/env/cargo (environment variable KONAN_CARGO) β€” not needed here") + assertContains(output, "needed by :cmakeBuildMymathLinux_x64") + } + + @Test + fun `a task needing an unset path says how to set it`() { + write("""id("io.github.lemcoder.konanplugin")""", """libraries { create("mymath") { targets(NativeTarget.${host.name}); cargo { } } }""") + + val output = runner("cargoBuildMymath$hostSuffix").withEnvironment(System.getenv() - "KONAN_CARGO").buildAndFail().output + + assertContains(output, "konan.config.cargo is not set β€” :cargoBuildMymath$hostSuffix needs it.") + assertContains(output, "KONAN_CARGO=/path/to/cargo") + } + + @Test + fun `binding generation refuses a Kotlin-Native version it has no exporter for`() { + write("""id("io.github.lemcoder.konanplugin")""", """bindings { create("mymath") { defFile.set(file("mymath.def")) } }""") + // A distribution as far as the plugin looks: run_konan, and a compiler jar that says 1.9.24. + File(konanStub(), "bin/run_konan").apply { parentFile.mkdirs(); writeText("#!/bin/sh\n"); setExecutable(true) } + val manifest = Manifest().apply { + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" + mainAttributes[Attributes.Name.IMPLEMENTATION_VERSION] = "1.9.24" + } + File(konanStub(), "konan/lib/kotlin-native-compiler-embeddable.jar").apply { parentFile.mkdirs() } + .outputStream().use { JarOutputStream(it, manifest).close() } + + val output = runner("generateJniMymath").buildAndFail().output + + assertContains(output, "is Kotlin/Native 1.9.24, which this plugin cannot generate bindings with.") + assertContains(output, "2.0.0, 2.0.10, 2.0.20, 2.0.21") + } + @Test fun `a library compiles from conventions alone`() { write("""id("io.github.lemcoder.konanplugin")""", """libraries { create("mymath") { targets(NativeTarget.MACOS_ARM64) } }""") @@ -156,8 +215,8 @@ class KonanPluginFunctionalTest { /** The installed Kotlin/Native distribution, for tests that really cross-compile with it. */ private fun realKonanHome(): String { - assumeTrue(KonanDistribution.pinned() != null, "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed") - return KonanDistribution.pinned()!!.home.absolutePath.replace("\\", "/") + assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") + return KonanDistribution.installed()!!.home.absolutePath.replace("\\", "/") } @Test @@ -175,7 +234,7 @@ class KonanPluginFunctionalTest { write( """id("io.github.lemcoder.konanplugin")""", """ - konanHome.set(file("$home")) + config.konanHome.set(file("$home")) libraries { create("mymath") { targets(NativeTarget.LINUX_X64) @@ -194,6 +253,42 @@ class KonanPluginFunctionalTest { assertTrue(elf > 0 && archive[elf + 18] == 0x3e.toByte(), "expected an x86-64 ELF member:\n${result.output}") } + @Test + fun `CMake builds a library's archive for the host and, cross-compiled, for linux_x64`() { + val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } + assumeTrue(cmake != null, "needs cmake") + assumeTrue(host != NativeTarget.LINUX_X64, "needs a host other than linux_x64") + val home = realKonanHome() + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") + File(projectDir, "native/CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.16) + project(mymath C) + add_library(${'$'}{KONAN_LIBRARY_NAME} STATIC mymath.c) + """.trimIndent() + ) + write( + """id("io.github.lemcoder.konanplugin")""", + """ + config.konanHome.set(file("$home")) + libraries { + create("mymath") { + targets(NativeTarget.${host.name}, NativeTarget.LINUX_X64) + cmake { } + } + } + """, + ) + + val result = runner("compileNativeLibraryMymath").build() + + assertTrue(File(projectDir, "build/konan/libraries/mymath/${host.konanName}/libmymath.a").isFile, result.output) + val linux = File(projectDir, "build/konan/libraries/mymath/linux_x64/libmymath.a").readBytes() + val elf = linux.indexOfSequence(byteArrayOf(0x7f, 'E'.code.toByte(), 'L'.code.toByte(), 'F'.code.toByte())) + assertTrue(elf > 0 && linux[elf + 18] == 0x3e.toByte(), "expected an x86-64 ELF member:\n${result.output}") + } + @Test fun `CMake cross-compiles and links a JNI library through Kotlin-Native's toolchain file`() { val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } @@ -212,7 +307,7 @@ class KonanPluginFunctionalTest { write( """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", """ - konanHome.set(file("$home")) + config.konanHome.set(file("$home")) bindings { create("mymath") { defFile.set(file("mymath.def")) @@ -244,11 +339,11 @@ class KonanPluginFunctionalTest { write( """id("io.github.lemcoder.konanplugin")""", """ + config.cargo.set(file("${cargo.absolutePath.replace("\\", "/")}")) libraries { create("mymath") { targets(NativeTarget.${host.name}) cargo { - executable.set(Executable.File(file("${cargo.absolutePath.replace("\\", "/")}"))) crateName.set("my-math") } } @@ -285,10 +380,11 @@ class KonanPluginFunctionalTest { write( """id("io.github.lemcoder.konanplugin")""", """ + config.zig.set(file("${zig.replace("\\", "/")}")) libraries { create("mymath") { targets(NativeTarget.MACOS_ARM64, NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) - zig { executable.set(Executable.File(file("${zig.replace("\\", "/")}"))) } + zig { } } } """, @@ -423,9 +519,12 @@ class KonanPluginFunctionalTest { val result = runner("jvmJar", "--dry-run").withPluginClasspathAndKgp().build() - listOf(":generateFfmMymath", ":compileJvmMainJava", ":linkFfmMymath$hostSuffix", ":bundleFfmMymath").forEach { + listOf(":generateFfmMymath", ":linkFfmMymath$hostSuffix", ":bundleFfmMymath").forEach { assertTrue("$it SKIPPED" in result.output, "expected $it:\n${result.output}") } + // jvm()'s own Java source set from Kotlin 2.1.20; before, withJava()'s, which is main. + val compileJava = listOf(":compileJvmMainJava", ":compileJava").firstOrNull { "$it SKIPPED" in result.output } + assertTrue(compileJava != null && result.output.indexOf(compileJava) > result.output.indexOf(":generateFfmMymath"), result.output) assertFalse(":generateJniMymath" in result.output, "JNI is replaced on jvm():\n${result.output}") } @@ -473,9 +572,12 @@ class KonanPluginFunctionalTest { fun `CMake receives the contract as cache entries and builds into the plugin's directory`() { val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } assumeTrue(cmake != null, "needs cmake") + // The generator runs in the real distribution. + val home = realKonanHome() write( """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", """ + config.konanHome.set(file("$home")) bindings { create("mymath") { defFile.set(file("mymath.def")) @@ -488,7 +590,7 @@ class KonanPluginFunctionalTest { """ cmake_minimum_required(VERSION 3.16) project(contract NONE) - file(WRITE "${'$'}{CMAKE_LIBRARY_OUTPUT_DIRECTORY}/contract.txt" "${'$'}{KONAN_JNI_LIB_NAME}|${'$'}{KONAN_JNI_STUB_DIR}") + file(WRITE "${'$'}{CMAKE_LIBRARY_OUTPUT_DIRECTORY}/contract.txt" "${'$'}{KONAN_JNI_LIB_NAME}|${'$'}{KONAN_JNI_STUB_DIR}|${'$'}{KONAN_JNI_INCLUDE_DIR}") """.trimIndent() ) @@ -497,14 +599,17 @@ class KonanPluginFunctionalTest { val contract = File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.konanName}/contract.txt").readText().split('|') assertTrue(contract[0].isNotEmpty(), "library name: $contract") assertTrue(File(contract[1]).isDirectory, "stub directory: $contract") + assertTrue(File(contract[2], "jni.h").isFile, "the plugin's jni.h, no JDK: $contract") } @Test fun `a generic external build runs its steps with the contract and its library is copied into place`() { assumeTrue(File("/bin/sh").canExecute(), "needs /bin/sh") + val home = realKonanHome() write( """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", """ + config.konanHome.set(file("$home")) bindings { create("mymath") { defFile.set(file("mymath.def")) @@ -642,7 +747,7 @@ class KonanPluginFunctionalTest { @Test fun `a multiplatform project binds through KGP's cinterop, after compiling the library`() { - assumeTrue(KonanDistribution.pinned() != null, "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed") + assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") // KMP's DSL names targets in camelCase: macos_arm64 -> macosArm64. val dslName = host.konanName.split('_').mapIndexed { i, part -> if (i == 0) part else part.replaceFirstChar { it.uppercase() } }.joinToString("") val targetSuffix = dslName.replaceFirstChar { it.uppercase() } @@ -657,7 +762,7 @@ class KonanPluginFunctionalTest { write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) // The real distribution, not the stub: this one compiles. File(projectDir, "build.gradle.kts").appendText( - "\nkonan.konanHome.set(file(\"${KonanDistribution.pinned()!!.home.absolutePath.replace("\\", "/")}\"))\n" + + "\nkonan.config.konanHome.set(file(\"${KonanDistribution.installed()!!.home.absolutePath.replace("\\", "/")}\"))\n" + "kotlin { $dslName() }\nrepositories { mavenCentral() }\n" ) File(projectDir, "mymath.def").writeText("headers = mymath.h\npackage = example\n") diff --git a/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt index 2e3360f..c96da17 100644 --- a/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt +++ b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt @@ -18,4 +18,12 @@ internal fun GradleRunner.withPluginClasspathAndKgp(): GradleRunner { return withPluginClasspath((plugin + kgp).filter { it.isNotEmpty() }.map(::File)) } +/** + * A runner on the Gradle version `-PtestGradleVersion` names, else the one building the plugin: how the + * suite checks the oldest Gradle a supported Kotlin version runs on. + */ +internal fun testRunner(): GradleRunner = GradleRunner.create().apply { + System.getProperty("test.gradle.version")?.takeIf { it.isNotEmpty() }?.let(::withGradleVersion) +} + private object TestKitSupport diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index 2ae83ff..194f427 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -1,20 +1,26 @@ package io.github.lemcoder.index import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.testRunner import org.gradle.testkit.runner.GradleRunner import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.TestFactory import java.io.File import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * Runs [GenerateFromNativeIndexTask] in a TestKit build: cinterop's own indexer in the pinned Kotlin/Native + * Runs [GenerateFromNativeIndexTask] in a TestKit build: cinterop's own indexer in the default Kotlin/Native * distribution, then the `dump` generator over the index it built. * * Skipped when that distribution is not installed; any Kotlin Multiplatform build of - * [KonanDistribution.pinnedVersion] downloads it. + * [KonanDistribution.defaultVersion] downloads it. Every other supported version installed under `~/.konan` + * must generate the same JNI bindings. */ class GenerateFromNativeIndexTest { @@ -50,8 +56,32 @@ class GenerateFromNativeIndexTest { assertContains(kotlin, "internal external fun sample_name(): String?") assertContains(kotlin, "internal external fun sample_buffer(handle: Long, c: Int, s: Int): ByteArray") assertContains(kotlin, "internal external fun sample_bits(f: ByteArray): ByteArray") - assertContains(kotlin, "internal external fun sample_sort(base: Long, n: Long, cmp: Long): Unit") + assertContains(kotlin, "internal external fun sample_sort(base: java.nio.Buffer?, n: Long, cmp: Long): Unit") + // A typedef'd void* is a handle, not memory. + assertContains(kotlin, "internal external fun sample_release(handle: Long): Unit") assertContains(kotlin, "// - sample_log: variadic") + assertContains(kotlin, "internal external fun sample_open(out: LongArray?): Int") + } + + @Test + fun `the jni generator turns enum and macro constants into const vals`() { + val kotlin = jni().resolve("kotlin/sample/sample.kt").readText() + assertContains(kotlin, "internal const val GREEN: Int = 5") + assertContains(kotlin, "internal const val LARGE: Int = 100") + assertContains(kotlin, "internal const val SAMPLE_VERSION: Int = 3") + assertContains(kotlin, "internal const val SAMPLE_MASK: Int = 16") + assertContains(kotlin, "internal const val SAMPLE_PI: Double = 3.14159") + assertContains(kotlin, "internal const val SAMPLE_NAME: String = \"sample\"") + } + + @Test + fun `the jni stub copies pointer out-parameters through a pointer array`() { + val c = jni().resolve("c/samplestubs.c").readText() + assertContains(c, "for (jsize k = 0; k < n0; k++) t0[k] = (void*)(intptr_t)a0[k];") + assertContains(c, "sample_open(p0 ? (struct opaque **)t0 : 0);") + assertContains(c, "for (jsize k = 0; k < n0; k++) a0[k] = (jlong)(intptr_t)t0[k];") + // A const pointee is read, not written back. + assertContains(c, "if (p0) (*env)->ReleaseLongArrayElements(env, p0, a0, JNI_ABORT);") } @Test @@ -63,7 +93,8 @@ class GenerateFromNativeIndexTest { assertContains(c, "if (p0) (*env)->ReleaseIntArrayElements(env, p0, a0, JNI_ABORT);") assertContains(c, "jstring result = r ? (*env)->NewStringUTF(env, r) : 0;") assertContains(c, "if (!p0 || (*env)->GetArrayLength(env, p0) < (jsize)sizeof(struct flags)) {") - assertContains(c, "sample_sort((void *)(intptr_t)p0, p1, (compare_fn)(intptr_t)p2);") + assertContains(c, "void* d0 = p0 ? (*env)->GetDirectBufferAddress(env, p0) : 0;") + assertContains(c, "sample_sort((void *)d0, p1, (compare_fn)(intptr_t)p2);") } @Test @@ -84,33 +115,56 @@ class GenerateFromNativeIndexTest { .forEach { assertContains(header, it) } } + @TestFactory + fun `every installed Kotlin-Native version generates the same JNI bindings`(): List = + KonanDistribution.supportedVersions + .filter { it != KonanDistribution.defaultVersion && KonanDistribution.installed(it) != null } + .map { version -> + dynamicTest(version) { + assumeInstalled() + val expected = generated().getValue("jni_sample") + val actual = generated(version).getValue("jni_sample") + assertEquals(files(expected).keys, files(actual).keys) + files(expected).forEach { (path, text) -> assertEquals(text, files(actual).getValue(path), "$path on $version") } + + val dump = generated(version).getValue("sample").resolve("index.txt").readText() + assertContains(dump, "field next: ptr typedef node_t `node_t *` offset=64 size=8 align=8") + assertContains(dump, "const SAMPLE_MASK: int32u(unsigned int) = 16") + } + } + + private fun files(directory: File): Map = + directory.walk().filter { it.isFile }.associate { it.relativeTo(directory).invariantSeparatorsPath to it.readText() } + private fun dump(name: String): String { assumeInstalled() - return generated.getValue(name).resolve("index.txt").readText() + return generated().getValue(name).resolve("index.txt").readText() } private fun jni(): File { assumeInstalled() - return generated.getValue("jni_sample") + return generated().getValue("jni_sample") } private fun assumeInstalled() = - assumeTrue(KonanDistribution.pinned() != null, "Kotlin/Native ${KonanDistribution.pinnedVersion} is not installed") + assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") private companion object { val sampleDef = File(checkNotNull(GenerateFromNativeIndexTest::class.java.classLoader.getResource("nativeindex/sample.def")).toURI()) val mymathDef = File("examples/jvm/src/main/nativeInterop/mymath.def").absoluteFile val mymathHeaders = File("examples/jvm/native").absoluteFile - /** One build runs every generator the tests read: output directory by task name. */ - val generated: Map by lazy { + private val builds = mutableMapOf>() + + /** One build per version runs every generator the tests read: output directory by task name. */ + fun generated(version: String = KonanDistribution.defaultVersion): Map = builds.getOrPut(version) { val projectDir = project( exportTask(GeneratorRequest.Dump, "mymath", mymathDef, mymathHeaders), exportTask(GeneratorRequest.Dump, "sample", sampleDef, sampleDef.parentFile), exportTask(GeneratorRequest.Jni(stubLibrary = "samplestubs"), "jni_sample", sampleDef, sampleDef.parentFile), ) val names = listOf("mymath", "sample", "jni_sample") - runner(projectDir, *names.map { "generate_$it" }.toTypedArray()).build() + runner(projectDir, *names.map { "generate_$it" }.toTypedArray(), version = version).build() names.associateWith { projectDir.resolve("build/generated/$it") } } @@ -148,10 +202,11 @@ class GenerateFromNativeIndexTest { return projectDir } - fun runner(projectDir: File, vararg tasks: String): GradleRunner = GradleRunner.create() - .withProjectDir(projectDir) - .withPluginClasspath() - .withArguments(*tasks, "--stacktrace") + fun runner(projectDir: File, vararg tasks: String, version: String = KonanDistribution.defaultVersion): GradleRunner = + testRunner() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(*tasks, "--stacktrace", "-Pkonan.config.konanHome=${KonanDistribution.installed(version)?.home?.absolutePath}") fun path(file: File) = file.absolutePath.replace("\\", "/") } diff --git a/src/test/kotlin/io/github/lemcoder/model/ConfigValueTest.kt b/src/test/kotlin/io/github/lemcoder/model/ConfigValueTest.kt new file mode 100644 index 0000000..a534c7c --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/model/ConfigValueTest.kt @@ -0,0 +1,36 @@ +package io.github.lemcoder.model + +import org.gradle.api.InvalidUserDataException +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ConfigValueTest { + @Test + fun `an unset field says how to set it, in lookup order`() { + val message = assertFailsWith { + ConfigValue(ConfigField.JEXTRACT, null, null, ":generateFfmMymath").require() + }.message.orEmpty() + assertContains(message, "konan.config.jextract is not set β€” :generateFfmMymath needs it.") + assertContains(message, "1. build script konan { config { jextract.set(file(\"/path/to/jextract\")) } }") + assertContains(message, "2. gradle.properties konan.config.jextract=/path/to/jextract") + assertContains(message, "3. environment KONAN_JEXTRACT=/path/to/jextract") + } + + @Test + fun `a value names where it came from and what is wrong with it`() { + val relative = ConfigValue(ConfigField.CMAKE, "./cmake", ConfigSource.GRADLE_PROPERTY, ":cmakeBuild") + assertEquals("is not an absolute path", relative.problem()) + assertContains( + assertFailsWith { relative.require() }.message.orEmpty(), + "konan.config.cmake ./cmake (Gradle property konan.config.cmake) is not an absolute path β€” :cmakeBuild needs it.", + ) + val directory = createTempDirectory("config").toFile() + assertEquals("is not an executable file", ConfigValue(ConfigField.ZIG, directory.absolutePath, ConfigSource.ENVIRONMENT, ":z").problem()) + assertEquals("is not a directory", ConfigValue(ConfigField.KONAN_HOME, File(directory, "f").apply { writeText("") }.absolutePath, ConfigSource.DSL, ":k").problem()) + assertEquals(directory, ConfigValue(ConfigField.KONAN_HOME, directory.absolutePath, ConfigSource.DSL, ":k").require()) + } +} diff --git a/src/test/kotlin/io/github/lemcoder/model/DefFileTest.kt b/src/test/kotlin/io/github/lemcoder/model/DefFileTest.kt new file mode 100644 index 0000000..28f1f6f --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/model/DefFileTest.kt @@ -0,0 +1,22 @@ +package io.github.lemcoder.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DefFileTest { + @Test + fun `linker options of a platform library are read before the separator`() { + val def = DefFile.parse( + """ + headers = aaudio/AAudio.h + package = aaudio + linkerOpts = -laaudio -llog + --- + linkerOpts = -lnot_a_property + """.trimIndent() + ) + assertEquals("aaudio", def.packageName) + assertEquals(listOf("-laaudio", "-llog"), def.linkerOpts) + assertEquals(emptyList(), def.staticLibraries) + } +} diff --git a/src/test/resources/nativeindex/sample.h b/src/test/resources/nativeindex/sample.h index b4852e2..5bccba9 100644 --- a/src/test/resources/nativeindex/sample.h +++ b/src/test/resources/nativeindex/sample.h @@ -54,3 +54,7 @@ struct buffer sample_buffer(struct opaque *handle, enum color c, size_class s); int sample_log(const char *format, ...); size_t sample_packet_size(const struct packet *p); bits_t sample_bits(struct flags f); +int sample_open(struct opaque **out); +int sample_peek(const struct opaque *const *handles, size_t n); +typedef void *sample_handle; +void sample_release(sample_handle handle); From bb445a7ee1c9e61bf686323c4a7a1fd1a99530a0 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 15 Oct 2026 11:24:13 +0200 Subject: [PATCH 25/57] docs: Kotlin Multiplatform example for iOS, macOS, Linux and Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/kmp: one binding behind an expect/actual API in a multiplatform library β€” KGP's cinterop for iosArm64, iosSimulatorArm64, macosArm64 and linuxX64, generated JNI for the Android target β€” and an Android app over it. Its common tests run on the iOS simulator and macOS; the Linux binaries are cross-linked on macOS. Documents what a multiplatform build needs from the user: cinterop commonization for a shared native source set calling the binding, and that JS and Wasm targets get no bindings (with notes in CLAUDE.md on what binding them would take). The generated JNI loader reads os.name and os.arch null-safely, which Android's nullable System.getProperty warned about. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 ++ README.md | 12 +++ examples/README.md | 8 +- examples/kmp/README.md | 86 +++++++++++++++++++ examples/kmp/androidApp/build.gradle.kts | 28 ++++++ .../androidApp/src/main/AndroidManifest.xml | 17 ++++ .../kotlin/com/example/kmpapp/MainActivity.kt | 27 ++++++ examples/kmp/build.gradle.kts | 7 ++ examples/kmp/gradle.properties | 6 ++ examples/kmp/settings.gradle.kts | 24 ++++++ examples/kmp/shared/build.gradle.kts | 56 ++++++++++++ examples/kmp/shared/native/mymath.c | 4 + examples/kmp/shared/native/mymath.h | 7 ++ .../com/example/shared/MyMath.android.kt | 5 ++ .../kotlin/com/example/shared/MyMath.kt | 5 ++ .../kotlin/com/example/shared/MyMathTest.kt | 13 +++ .../src/nativeInterop/cinterop/mymath.def | 3 + .../kotlin/com/example/shared/Main.kt | 8 ++ .../com/example/shared/MyMath.native.kt | 7 ++ .../index/exporter/NativeLoaderSource.kt | 4 +- 20 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 examples/kmp/README.md create mode 100644 examples/kmp/androidApp/build.gradle.kts create mode 100644 examples/kmp/androidApp/src/main/AndroidManifest.xml create mode 100644 examples/kmp/androidApp/src/main/kotlin/com/example/kmpapp/MainActivity.kt create mode 100644 examples/kmp/build.gradle.kts create mode 100644 examples/kmp/gradle.properties create mode 100644 examples/kmp/settings.gradle.kts create mode 100644 examples/kmp/shared/build.gradle.kts create mode 100644 examples/kmp/shared/native/mymath.c create mode 100644 examples/kmp/shared/native/mymath.h create mode 100644 examples/kmp/shared/src/androidMain/kotlin/com/example/shared/MyMath.android.kt create mode 100644 examples/kmp/shared/src/commonMain/kotlin/com/example/shared/MyMath.kt create mode 100644 examples/kmp/shared/src/commonTest/kotlin/com/example/shared/MyMathTest.kt create mode 100644 examples/kmp/shared/src/nativeInterop/cinterop/mymath.def create mode 100644 examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/Main.kt create mode 100644 examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/MyMath.native.kt diff --git a/CLAUDE.md b/CLAUDE.md index b984cab..0c32b66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,14 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t ## Multiplatform projects +- **JS and Wasm targets are ignored**, deliberately and silently (README says so). If they are ever + bound: Kotlin/Wasm imports another module's functions with `@WasmImport(module, name) external fun`, + the JNI generator's shape; Kotlin/Native's bundled LLVM already compiles `--target=wasm32` and links + with `lld -flavor wasm`, but has no wasm libc (`string.h`, `malloc`: wasi-libc, Zig or Emscripten). + What makes it real work: Kotlin/Wasm's memory is not the C module's, so anything but numbers is copied + through the module's `malloc`; and cinterop indexes LP64 hosts and has no wasm target since 1.9.20, + while wasm32 is ILP32 β€” `long`, pointers and struct offsets need a wasm32 index. Plain Kotlin/JS has + no `@WasmImport` and would go through a JS loader. - **`ffm()` replaces JNI everywhere, generated once per API**: `generateFfm` (JDK) for `kotlin("jvm")` and `jvm()` targets, `generateFfmAndroid` (PanamaPort) for Android modules and multiplatform Android targets. The JDK's Java goes into the Gradle source set Kotlin creates per JVM diff --git a/README.md b/README.md index 138a35d..ca39a5e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ A binding is consumed wherever the project's Kotlin targets need it, with nothin | `ffm()` on the binding, in a `kotlin("jvm")` project or for a multiplatform `jvm()` target | FFM bindings in place of JNI, the library as a shared library | jextract | | `ffm()` on the binding, in an Android module or for a multiplatform Android target | FFM bindings on PanamaPort in place of JNI, the library as `.so` per 64-bit ABI | jextract | +**Calling the cinterop binding from a shared source set** β€” `nativeMain`, `appleMain`, `iosMain` β€” needs KGP +to commonize it: set `kotlin.mpp.enableCInteropCommonization=true` in `gradle.properties`. Without it, +each target's own source set sees the binding, but the shared one fails with *"Unresolved reference"*. + +**JavaScript and WebAssembly targets get no bindings.** A multiplatform project with `js()`, `wasmJs()` or +`wasmWasi()` targets builds as usual, but the plugin leaves those targets alone: nothing is generated or +linked for them, and no error is reported. Declare the API you need as an `expect` in common code, let +the JVM, Android and Kotlin/Native `actual`s call the bindings, and implement the JS and Wasm `actual`s +yourself β€” in Kotlin, or over your own build of the library for the web. + | Property | Type | Default | Description | |-------------------|-------|---------|-------------| | `androidApiLevel` | `Int` | the module's lowest `minSdk`, else Kotlin/Native's, 21 | The API level whose sysroot the plugin's Android links use, so platform libraries such as `libaaudio` or `libcamera2ndk` link. Kotlin/Native's NDK stops at 29; a higher level uses the highest it has. | @@ -577,6 +587,8 @@ On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks au - `examples/jvm` β€” plain JVM app loading the JNI library bundled in its jar. - `examples/android` β€” Android app calling C through the generated bindings. `scripts/run-android.sh` publishes the plugin, builds, installs and launches it in one command. +- `examples/kmp` β€” Kotlin Multiplatform library for iOS, macOS, Linux and Android: one binding, cinterop on Kotlin/Native and JNI on + Android, behind an `expect`/`actual` API, plus an Android app over it. ## Migrating to 2.0 The DSL is new and there is no compatibility shim. diff --git a/examples/README.md b/examples/README.md index 61d06cc..163ff5d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -One C library (`native/mymath.c`: `my_add`, `my_scale`) consumed from three platforms. Each declares it +One C library (`native/mymath.c`: `my_add`, `my_scale`) consumed from each platform the plugin binds. Each declares it the same way β€” `konan { val mymath = libraries.create("mymath"); bindings { create("mymath") { library(mymath) } } }` β€” and gets the binding its Kotlin targets need: @@ -10,8 +10,9 @@ and gets the binding its Kotlin targets need: | [`android/`](android) | same, cross-compiled per ABI, real app | APK with `.so` (arm64-v8a + x86_64) + `MainActivity` | `cd android && ./gradlew installDebug` | | [`android-ffm/`](android-ffm) | same library, FFM instead of JNI: jextract's bindings on PanamaPort, `libmymath.so` per ABI, no stub | FFM on Android, end to end on an emulator (a multiplatform library's Android target works the same, with `withJava()`) | `cd android-ffm && ./gradlew installDebug` | | [`native/`](native) | a library (.a) + a binding β†’ KGP's cinterop | the native path | `../../gradlew -p native runDebugExecutableMacosArm64` | +| [`kmp/`](kmp) | one binding for a multiplatform library: cinterop on iOS, macOS and Linux, JNI on Android, `expect`/`actual` over both | tests on an iOS simulator, macOS and Linux, and an Android app, calling the same C through one common API | `../../gradlew -p kmp :shared:allTests :androidApp:installDebug` | -`jvm` and `native` build the plugin from source via `includeBuild`. `android` needs AGP 9 (Gradle 9.4, +`jvm`, `native` and `kmp` build the plugin from source via `includeBuild`. `android` needs AGP 9 (Gradle 9.4, its own wrapper), so it consumes the plugin from mavenLocal β€” publish it first: `(cd ../.. && ./gradlew publishToMavenLocal)`. @@ -56,5 +57,8 @@ Strings and primitive buffers are marshalled by the binding; opaque pointers arr ## What's verified here vs. needs a device - `jvm` and `native` run to completion; `jvm` also round-trips strings and primitive buffers. +- `kmp` passes its common tests on an iOS simulator and macOS, and β€” cross-linked on macOS β€” in an x86-64 + Ubuntu container; links `Shared.framework` for devices with `my_add` in it; and its app shows C's results + on an Android emulator. - `android` produces and verifies the `.so` (ELF + exported `Java_example_example_my_1add`-style symbols). Running it on Android needs an emulator/device and an AGP module β€” see [`android/README.md`](android/README.md). diff --git a/examples/kmp/README.md b/examples/kmp/README.md new file mode 100644 index 0000000..d79d3ce --- /dev/null +++ b/examples/kmp/README.md @@ -0,0 +1,86 @@ +# Kotlin Multiplatform: iOS, macOS, Linux and Android + +One C library (`shared/native/mymath.c`) behind one common Kotlin API, on iOS, macOS, Linux and Android. + +``` +shared/ Kotlin Multiplatform library: iosArm64, iosSimulatorArm64, macosArm64, linuxX64, android + native/mymath.{c,h} the C library + src/nativeInterop/cinterop/mymath.def + src/commonMain expect fun add / scale + src/nativeMain actuals over KGP's cinterop binding, for every Kotlin/Native target; + main() for the macOS and Linux executables + src/androidMain actuals over the generated JNI binding + src/commonTest calls into C; runs on the iOS simulator, macOS and Linux +androidApp/ Android app over :shared (AGP 9 keeps apps out of multiplatform modules) +``` + +`shared/build.gradle.kts` declares the library and its binding once: + +```kotlin +konan { + config { konanHome.set(kotlinNativeDistribution()) } + val mymath = libraries.create("mymath") { + targets( + NativeTarget.IOS_ARM64, NativeTarget.IOS_SIMULATOR_ARM64, + NativeTarget.MACOS_ARM64, NativeTarget.LINUX_X64, + NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64, + ) + } + bindings { + create("mymath") { + includeDirs.from("native") + library(mymath) + } + } +} +``` + +and each side gets its own binding, in the `.def`'s package `com.example.mymath`: + +| Target | Binding | The C library | +|---|---|---| +| `iosArm64`, `iosSimulatorArm64`, `macosArm64`, `linuxX64` | KGP's cinterop klib (`cinteropMymath`): public, pointers as `CPointer`, needs `@OptIn(ExperimentalForeignApi::class)` | `libmymath.a` embedded in the klib, so linked into `Shared.framework` and the executables | +| `android` | generated JNI bindings in `androidMain` (`generateJniMymath`): `internal`, arrays and buffers marshalled | `libcomexamplemymathstubs.so` per ABI (`linkJniMymath…`), in the AAR's `jniLibs` | + +The Kotlin/Native `actual`s live once, in `nativeMain`. A source set shared by several targets sees the +binding only when KGP commonizes it, so `gradle.properties` sets +`kotlin.mpp.enableCInteropCommonization=true`; without it `compileNativeMainKotlinMetadata` (and the IDE) +fail with *"Unresolved reference 'mymath'"*, while each target's own compilation still works. + +Common code cannot see either binding, so `commonMain` declares `expect fun add(a: Int, b: Int): Int` and +each platform's `actual` calls `com.example.mymath.my_add`. Functions on numbers read the same on both +sides; pointers do not β€” `memScoped`/`CPointer` on iOS, arrays and `ByteBuffer`s on Android. + +## Run + +Builds the plugin from source (`includeBuild("../..")`), with the repository's wrapper: + +```bash +# iOS: the common tests, calling into C on a simulator +../../gradlew -p . :shared:iosSimulatorArm64Test + +# iOS: the framework an Xcode project embeds +../../gradlew -p . :shared:linkDebugFrameworkIosSimulatorArm64 + +# macOS: the tests and the executable +../../gradlew -p . :shared:macosArm64Test :shared:runDebugExecutableMacosArm64 + +# Linux: cross-compiled and linked on macOS β€” Kotlin/Native brings the glibc sysroot; run the ELF on Linux +../../gradlew -p . :shared:linkDebugExecutableLinuxX64 :shared:linkDebugTestLinuxX64 +docker run --rm --platform linux/amd64 -v "$PWD/shared/build/bin/linuxX64":/kn ubuntu:22.04 \ + sh -c '/kn/debugExecutable/shared.kexe && /kn/debugTest/test.kexe' + +# Android: the app, on a device or emulator +../../gradlew -p . :androidApp:installDebug +``` + +Needs Xcode for the iOS and macOS targets, and an Android SDK (`local.properties` or `ANDROID_HOME`). Kotlin/Native's +distribution brings the NDK sysroot the Android library is built against. + +From Swift, the common API is in the framework: + +```swift +import Shared + +let sum = MyMath_iosKt.add(a: 2, b: 3) // 5, computed in C +``` diff --git a/examples/kmp/androidApp/build.gradle.kts b/examples/kmp/androidApp/build.gradle.kts new file mode 100644 index 0000000..ab86329 --- /dev/null +++ b/examples/kmp/androidApp/build.gradle.kts @@ -0,0 +1,28 @@ +// The Android app: AGP 9 does not combine com.android.application with Kotlin Multiplatform, so the app +// is its own module over :shared. The JNI library comes with the shared AAR's jniLibs. + +plugins { + id("com.android.application") // AGP 9 has built-in Kotlin support +} + +android { + namespace = "com.example.kmpapp" + compileSdk = 36 + + defaultConfig { + applicationId = "com.example.kmpapp" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + implementation(project(":shared")) +} diff --git a/examples/kmp/androidApp/src/main/AndroidManifest.xml b/examples/kmp/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e4bc162 --- /dev/null +++ b/examples/kmp/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/examples/kmp/androidApp/src/main/kotlin/com/example/kmpapp/MainActivity.kt b/examples/kmp/androidApp/src/main/kotlin/com/example/kmpapp/MainActivity.kt new file mode 100644 index 0000000..bc3c928 --- /dev/null +++ b/examples/kmp/androidApp/src/main/kotlin/com/example/kmpapp/MainActivity.kt @@ -0,0 +1,27 @@ +package com.example.kmpapp + +import android.app.Activity +import android.os.Bundle +import android.view.Gravity +import android.widget.TextView +import com.example.shared.add +import com.example.shared.scale + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // The shared module's common API, here over JNI into libmymathstubs.so. + val text = buildString { + appendLine("C from Kotlin Multiplatform:") + appendLine("add(2, 3) = ${add(2, 3)}") + appendLine("scale(4.0) = ${scale(4.0)}") + } + + setContentView(TextView(this).apply { + this.text = text + textSize = 20f + gravity = Gravity.CENTER + }) + } +} diff --git a/examples/kmp/build.gradle.kts b/examples/kmp/build.gradle.kts new file mode 100644 index 0000000..a077d12 --- /dev/null +++ b/examples/kmp/build.gradle.kts @@ -0,0 +1,7 @@ +// Declares the plugins once for both modules, so they share one classloader. +plugins { + kotlin("multiplatform") apply false + id("com.android.kotlin.multiplatform.library") apply false + id("com.android.application") apply false + id("io.github.lemcoder.konanplugin") apply false +} diff --git a/examples/kmp/gradle.properties b/examples/kmp/gradle.properties new file mode 100644 index 0000000..5193baa --- /dev/null +++ b/examples/kmp/gradle.properties @@ -0,0 +1,6 @@ +# The Kotlin/Native compiler runs inside the Gradle daemon; Gradle's default 512 MiB heap and 384 MiB +# metaspace run out on repeated links ("OutOfMemoryError: Metaspace"). +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g +android.useAndroidX=true +# nativeMain calls the cinterop binding for several targets at once, which needs KGP to commonize it. +kotlin.mpp.enableCInteropCommonization=true diff --git a/examples/kmp/settings.gradle.kts b/examples/kmp/settings.gradle.kts new file mode 100644 index 0000000..b9d1d98 --- /dev/null +++ b/examples/kmp/settings.gradle.kts @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } + plugins { + kotlin("multiplatform") version "2.4.20" + id("com.android.kotlin.multiplatform.library") version "9.2.1" + id("com.android.application") version "9.2.1" + } + // The konan plugin, built from source. + includeBuild("../..") +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "kmp-example" +include(":shared", ":androidApp") diff --git a/examples/kmp/shared/build.gradle.kts b/examples/kmp/shared/build.gradle.kts new file mode 100644 index 0000000..51b60fc --- /dev/null +++ b/examples/kmp/shared/build.gradle.kts @@ -0,0 +1,56 @@ +// A Kotlin Multiplatform library for iOS, macOS, Linux and Android over one C library. The konan plugin +// builds native/*.c for every target below and binds src/nativeInterop/cinterop/mymath.def to each side: +// KGP's cinterop for the Kotlin/Native targets, generated JNI bindings plus a stub .so per ABI for Android. + +import io.github.lemcoder.model.NativeTarget + +plugins { + kotlin("multiplatform") + id("com.android.kotlin.multiplatform.library") + id("io.github.lemcoder.konanplugin") +} + +kotlin { + android { + namespace = "com.example.shared" + compileSdk = 36 + minSdk = 24 // also the API level the plugin's Android links use + } + + listOf(iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { baseName = "Shared" } + } + // Desktop: a demo executable each. linuxX64 cross-compiles from macOS; Kotlin/Native brings the sysroot. + listOf(macosArm64(), linuxX64()).forEach { + it.binaries.executable { entryPoint = "com.example.shared.main" } + } + + sourceSets { + commonTest.dependencies { implementation(kotlin("test")) } + } +} + +konan { + // The Kotlin/Native version this project's Kotlin plugin installs; or set konan.config.konanHome in + // ~/.gradle/gradle.properties. + config { konanHome.set(kotlinNativeDistribution()) } + + // native/*.c -> libmymath.a for the iPhone, the simulator, macOS, Linux and the Android ABIs. + val mymath = libraries.create("mymath") { + targets( + NativeTarget.IOS_ARM64, NativeTarget.IOS_SIMULATOR_ARM64, + NativeTarget.MACOS_ARM64, NativeTarget.LINUX_X64, + NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64, + ) + } + + // One binding, consumed by both: Kotlin/Native targets get a cinterop klib with libmymath.a embedded + // (so it ends up in Shared.framework and the executables); the Android target gets JNI bindings in androidMain and + // libmymathstubs.so per ABI in the AAR's jniLibs. + bindings { + create("mymath") { + includeDirs.from("native") + library(mymath) + } + } +} diff --git a/examples/kmp/shared/native/mymath.c b/examples/kmp/shared/native/mymath.c new file mode 100644 index 0000000..e24c976 --- /dev/null +++ b/examples/kmp/shared/native/mymath.c @@ -0,0 +1,4 @@ +#include "mymath.h" + +int my_add(int a, int b) { return a + b; } +double my_scale(double x) { return x * 10.0; } diff --git a/examples/kmp/shared/native/mymath.h b/examples/kmp/shared/native/mymath.h new file mode 100644 index 0000000..c951cb1 --- /dev/null +++ b/examples/kmp/shared/native/mymath.h @@ -0,0 +1,7 @@ +#ifndef MYMATH_H +#define MYMATH_H + +int my_add(int a, int b); +double my_scale(double x); + +#endif diff --git a/examples/kmp/shared/src/androidMain/kotlin/com/example/shared/MyMath.android.kt b/examples/kmp/shared/src/androidMain/kotlin/com/example/shared/MyMath.android.kt new file mode 100644 index 0000000..c70e7f5 --- /dev/null +++ b/examples/kmp/shared/src/androidMain/kotlin/com/example/shared/MyMath.android.kt @@ -0,0 +1,5 @@ +package com.example.shared + +// Over the generated JNI bindings (internal external funs named after the C functions). +actual fun add(a: Int, b: Int): Int = com.example.mymath.my_add(a, b) +actual fun scale(x: Double): Double = com.example.mymath.my_scale(x) diff --git a/examples/kmp/shared/src/commonMain/kotlin/com/example/shared/MyMath.kt b/examples/kmp/shared/src/commonMain/kotlin/com/example/shared/MyMath.kt new file mode 100644 index 0000000..f7c2f52 --- /dev/null +++ b/examples/kmp/shared/src/commonMain/kotlin/com/example/shared/MyMath.kt @@ -0,0 +1,5 @@ +package com.example.shared + +// The API common code sees. Each platform implements it over its own generated binding. +expect fun add(a: Int, b: Int): Int +expect fun scale(x: Double): Double diff --git a/examples/kmp/shared/src/commonTest/kotlin/com/example/shared/MyMathTest.kt b/examples/kmp/shared/src/commonTest/kotlin/com/example/shared/MyMathTest.kt new file mode 100644 index 0000000..dc262ec --- /dev/null +++ b/examples/kmp/shared/src/commonTest/kotlin/com/example/shared/MyMathTest.kt @@ -0,0 +1,13 @@ +package com.example.shared + +import kotlin.test.Test +import kotlin.test.assertEquals + +// Calls the C library: `iosSimulatorArm64Test` runs it on an iOS simulator. +class MyMathTest { + @Test + fun addsInC() = assertEquals(5, add(2, 3)) + + @Test + fun scalesInC() = assertEquals(40.0, scale(4.0)) +} diff --git a/examples/kmp/shared/src/nativeInterop/cinterop/mymath.def b/examples/kmp/shared/src/nativeInterop/cinterop/mymath.def new file mode 100644 index 0000000..48f3007 --- /dev/null +++ b/examples/kmp/shared/src/nativeInterop/cinterop/mymath.def @@ -0,0 +1,3 @@ +headers = mymath.h +headerFilter = mymath.h +package = com.example.mymath diff --git a/examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/Main.kt b/examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/Main.kt new file mode 100644 index 0000000..14ecb30 --- /dev/null +++ b/examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/Main.kt @@ -0,0 +1,8 @@ +package com.example.shared + +// The desktop executables' entry point (macosArm64, linuxX64); the iOS framework ignores it. +fun main() { + println("C from Kotlin/Native:") + println("add(2, 3) = ${add(2, 3)}") + println("scale(4.0) = ${scale(4.0)}") +} diff --git a/examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/MyMath.native.kt b/examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/MyMath.native.kt new file mode 100644 index 0000000..27cd3de --- /dev/null +++ b/examples/kmp/shared/src/nativeMain/kotlin/com/example/shared/MyMath.native.kt @@ -0,0 +1,7 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package com.example.shared + +// Over KGP's cinterop binding, which embeds libmymath.a in the klib: linked into every binary. +actual fun add(a: Int, b: Int): Int = com.example.mymath.my_add(a, b) +actual fun scale(x: Double): Double = com.example.mymath.my_scale(x) diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt index 13fb199..559a840 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeLoaderSource.kt @@ -24,8 +24,8 @@ internal object NativeLoaderSource { | try { | System.loadLibrary(name) | } catch (notOnPath: UnsatisfiedLinkError) { - | val os = System.getProperty("os.name").lowercase() - | val arch = when (System.getProperty("os.arch").lowercase()) { + | val os = System.getProperty("os.name").orEmpty().lowercase() + | val arch = when (System.getProperty("os.arch").orEmpty().lowercase()) { | "aarch64", "arm64" -> "arm64" | "amd64", "x86_64" -> "x64" | else -> throw notOnPath From fa7fc30b135f4839a35ddb1d2c93602e5228648f Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 15 Oct 2026 11:26:53 +0200 Subject: [PATCH 26/57] refactor: one set of build systems, one generation task, packages nested as the DSL Build systems: a library's archive, a binding's JNI library and the library FFM loads were three sealed hierarchies (LibraryProvider, JniStubBuild, FfmLibraryBuild) with the same External branch each. They are now one ExternalBuild per build system (CMake, Make, Cargo, Zig, generic steps), registered from an ExternalBuildOutput that carries the only per-owner differences: targets, output directory, file name, extra variables. Every block's settings implement the sealed BuildSettings; calling a block selects its settings, and owners dispatch on PrebuiltSettings or ExternalBuildSettings. External builds read one contract (ExternalVariable): KONAN_LIB_NAME, KONAN_OUTPUT_DIR, KONAN_TARGET, KONAN_ABI for all, KONAN_SOURCE_DIR for libraries, KONAN_JNI_STUB_DIR and KONAN_JNI_INCLUDE_DIR for JNI. This replaces KONAN_LIBRARY_*, KONAN_JNI_LIB_NAME/OUTPUT_DIR/ABI and KONAN_FFM_*. JNI is generated by GenerateFromNativeIndexTask like FFM; the JNI-only task is gone, and the generator's layout is GeneratorRequest.Jni's constants. JNI and FFM share BindingLibrarySpec and BindingLibraryWiring: directories, the link task, target defaulting, the chosen build, the jar bundle. DSL changes: jni.archive is removed (a prebuilt library replaces it); both prebuilt blocks take library(target, file); jni { cmake { libraryDirectory } } is copied from, as for libraries and FFM; cmake target(...) blocks choose a binding library's targets when targets is unset; ffm gains linkerOptionsFor; the build blocks' types (ExternalToolchain, ZigOptimize, CargoProfile) move to io.github.lemcoder.dsl.builds. Packages follow the DSL: dsl, dsl.config, dsl.libraries, dsl.bindings (.jni, .ffm) and dsl.builds; the implementation in libraries and bindings (.jni, .ffm); wiring keeps AGP, plugin ids and the config report. A test now links the JNI library for the host and Android for real: a link reading the stub through a provider without its producer passes a dry run, and Gradle rejects it only when several links execute. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 52 +++-- README.md | 106 +++++----- doc/interop-architecture.md | 34 ++-- .../github/lemcoder/index/ExporterProtocol.kt | 13 +- .../lemcoder/index/exporter/JniGenerator.kt | 4 +- .../kotlin/io/github/lemcoder/KonanPlugin.kt | 4 +- .../{consumer => bindings}/BindingConsumer.kt | 6 +- .../lemcoder/bindings/BindingLibraryWiring.kt | 111 ++++++++++ .../lemcoder/{wiring => bindings}/Bindings.kt | 8 +- .../{consumer => bindings}/JarBundle.kt | 4 +- .../KotlinNativeConsumer.kt | 2 +- .../LinkSharedLibraryTask.kt | 4 +- .../{consumer => bindings/ffm}/FfmConsumer.kt | 64 +++--- .../lemcoder/bindings/ffm/FfmLibraryBuild.kt | 73 +++++++ .../lemcoder/bindings/jni/JniConsumer.kt | 109 ++++++++++ .../{ => bindings}/jni/KonanLinkStubBuild.kt | 45 ++--- .../github/lemcoder/consumer/JniConsumer.kt | 112 ----------- .../kotlin/io/github/lemcoder/dsl/FfmSpec.kt | 118 ----------- .../kotlin/io/github/lemcoder/dsl/JniSpec.kt | 106 ---------- .../io/github/lemcoder/dsl/KonanExtension.kt | 5 +- .../github/lemcoder/dsl/PrebuiltSettings.kt | 17 -- .../dsl/bindings/BindingLibrarySpec.kt | 64 ++++++ .../dsl/{ => bindings}/BindingSpec.kt | 7 +- .../lemcoder/dsl/bindings/ffm/FfmSpec.kt | 41 ++++ .../lemcoder/dsl/bindings/jni/JniSpec.kt | 37 ++++ .../lemcoder/dsl/builds/BuildSettings.kt | 25 +++ .../dsl/{ => builds}/CMakeSettings.kt | 31 ++- .../dsl/{ => builds}/CargoSettings.kt | 5 +- .../dsl/builds/ExternalBuildBlocks.kt | 47 +++++ .../builds}/ExternalToolchain.kt | 4 +- .../lemcoder/dsl/{ => builds}/MakeSettings.kt | 5 +- .../lemcoder/dsl/builds/PrebuiltSettings.kt | 19 ++ .../StepsSettings.kt} | 24 +-- .../lemcoder/dsl/{ => builds}/ZigSettings.kt | 4 +- .../lemcoder/dsl/{ => config}/KonanConfig.kt | 7 +- .../dsl/{ => libraries}/NativeLibrarySpec.kt | 74 ++----- .../github/lemcoder/external/CMakeCommands.kt | 4 +- .../CargoCommands.kt} | 28 ++- .../github/lemcoder/external/ExternalBuild.kt | 162 +++++++++++++++ .../lemcoder/external/ExternalBuilds.kt | 74 ------- .../github/lemcoder/external/MakeCommands.kt | 4 +- .../ZigBuild.kt => external/ZigCommands.kt} | 28 ++- .../io/github/lemcoder/ffm/FfmLibraryBuild.kt | 189 ------------------ .../index/GenerateFromNativeIndexTask.kt | 18 +- .../lemcoder/jni/GenerateJniBindingsTask.kt | 123 ------------ .../io/github/lemcoder/jni/JniStubBuild.kt | 145 -------------- .../lemcoder/kotlin/KotlinGradlePlugin.kt | 11 +- .../CompileNativeLibraryTask.kt | 4 +- .../{library => libraries}/KonanClangBuild.kt | 6 +- .../{wiring => libraries}/Libraries.kt | 7 +- .../lemcoder/libraries/LibraryBuilds.kt | 43 ++++ .../{library => libraries}/PrebuiltLibrary.kt | 10 +- .../lemcoder/library/LibraryProvider.kt | 142 ------------- .../lemcoder/model/ExternalBuildContract.kt | 59 ++---- .../github/lemcoder/model/NativeArtifacts.kt | 2 +- .../io/github/lemcoder/model/Selection.kt | 10 +- .../io/github/lemcoder/wiring/ConfigReport.kt | 3 +- .../github/lemcoder/{ => bindings}/jni/jni.h | 0 .../lemcoder/KonanPluginFunctionalTest.kt | 54 +++-- 59 files changed, 1108 insertions(+), 1409 deletions(-) rename src/main/kotlin/io/github/lemcoder/{consumer => bindings}/BindingConsumer.kt (91%) create mode 100644 src/main/kotlin/io/github/lemcoder/bindings/BindingLibraryWiring.kt rename src/main/kotlin/io/github/lemcoder/{wiring => bindings}/Bindings.kt (84%) rename src/main/kotlin/io/github/lemcoder/{consumer => bindings}/JarBundle.kt (96%) rename src/main/kotlin/io/github/lemcoder/{consumer => bindings}/KotlinNativeConsumer.kt (94%) rename src/main/kotlin/io/github/lemcoder/{link => bindings}/LinkSharedLibraryTask.kt (99%) rename src/main/kotlin/io/github/lemcoder/{consumer => bindings/ffm}/FfmConsumer.kt (73%) create mode 100644 src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmLibraryBuild.kt create mode 100644 src/main/kotlin/io/github/lemcoder/bindings/jni/JniConsumer.kt rename src/main/kotlin/io/github/lemcoder/{ => bindings}/jni/KonanLinkStubBuild.kt (68%) delete mode 100644 src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt rename src/main/kotlin/io/github/lemcoder/dsl/{ => bindings}/BindingSpec.kt (91%) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmSpec.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniSpec.kt create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/builds/BuildSettings.kt rename src/main/kotlin/io/github/lemcoder/dsl/{ => builds}/CMakeSettings.kt (70%) rename src/main/kotlin/io/github/lemcoder/dsl/{ => builds}/CargoSettings.kt (93%) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt rename src/main/kotlin/io/github/lemcoder/{model => dsl/builds}/ExternalToolchain.kt (89%) rename src/main/kotlin/io/github/lemcoder/dsl/{ => builds}/MakeSettings.kt (95%) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/builds/PrebuiltSettings.kt rename src/main/kotlin/io/github/lemcoder/dsl/{ExternalBuildSettings.kt => builds/StepsSettings.kt} (77%) rename src/main/kotlin/io/github/lemcoder/dsl/{ => builds}/ZigSettings.kt (93%) rename src/main/kotlin/io/github/lemcoder/dsl/{ => config}/KonanConfig.kt (98%) rename src/main/kotlin/io/github/lemcoder/dsl/{ => libraries}/NativeLibrarySpec.kt (53%) rename src/main/kotlin/io/github/lemcoder/{library/CargoBuild.kt => external/CargoCommands.kt} (63%) create mode 100644 src/main/kotlin/io/github/lemcoder/external/ExternalBuild.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt rename src/main/kotlin/io/github/lemcoder/{library/ZigBuild.kt => external/ZigCommands.kt} (56%) delete mode 100644 src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt delete mode 100644 src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt rename src/main/kotlin/io/github/lemcoder/{library => libraries}/CompileNativeLibraryTask.kt (99%) rename src/main/kotlin/io/github/lemcoder/{library => libraries}/KonanClangBuild.kt (90%) rename src/main/kotlin/io/github/lemcoder/{wiring => libraries}/Libraries.kt (86%) create mode 100644 src/main/kotlin/io/github/lemcoder/libraries/LibraryBuilds.kt rename src/main/kotlin/io/github/lemcoder/{library => libraries}/PrebuiltLibrary.kt (91%) delete mode 100644 src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt rename src/main/resources/io/github/lemcoder/{ => bindings}/jni/jni.h (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 0c32b66..c18d13a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,15 @@ resolved the moment the plugin is applied, in *every* project that applies it. (`KonanDistribution`) and the index exporter client. So `kotlin-native-utils` is `compileOnly` β€” everything that touches it runs where KGP, which brings it, is applied. `NativeTargetTest` fails when a Kotlin bump adds or retires a target; add the `NativeTarget` entry (and its Zig triple, if any). +- **The `dsl` packages nest as the DSL does**: `dsl` is `konan { }`, then `dsl.config`, `dsl.libraries`, + `dsl.bindings` with `dsl.bindings.jni` and `dsl.bindings.ffm`. Build blocks appear at every level, so + they share `dsl.builds`. A new block goes in the package of the block that contains it. + Never name a source package `build`: `.gitignore`'s `build/` matches it, and `git add` silently skips it. +- **`libraries` and `bindings` hold everything after the DSL**, nested as `dsl.libraries` and + `dsl.bindings` are. `libraries`: a library's registration and builds. `bindings`: a binding's + registration, the consumers (`BindingConsumer`, `KotlinNativeConsumer`) and what JNI and FFM share + (`BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`); `bindings.jni` and `bindings.ffm` + each hold one consumer and its own link. `wiring` keeps AGP, plugin ids and the config report. - **One home per concept.** `model/` holds the domain (targets, naming, the artifact layout, the external-build contract, `selectOne`), `kotlin/` the touchpoints with Kotlin, `toolchain/` the others (Xcode, running executables, Zig). Task names come from `taskName(...)`, OS checks from @@ -39,7 +48,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. Add a path by adding a `ConfigField` entry and a property, never a per-block `executable`. - **Tasks that run Kotlin/Native implement `UsesKonanDistribution`**; the plugin sets their `konanHome` in one `withType().configureEach`, and `distribution()` checks it when the task runs. -- **The plugin ships Android's `jni.h`** (`src/main/resources/io/github/lemcoder/jni/`, Apache 2.0): the +- **The plugin ships Android's `jni.h`** (`src/main/resources/io/github/lemcoder/bindings/jni/`, Apache 2.0): the JNI generator writes it to its `include/` output, the host link and `KONAN_JNI_INCLUDE_DIR` use it. Its JNI is 1.6 β€” enough for the generated stubs. @@ -52,13 +61,19 @@ resolved the moment the plugin is applied, in *every* project that applies it. - **`all`/`whenObjectAdded` fires before `create(name) { … }` runs its configure block.** Reading a binding's `library` or a library's `targets` there gives the default, not what the build declared β€” per-target tasks, consumer detection and KGP's cinterops are deferred to `afterEvaluate` (KGP - accepts cinterops declared there). Both sides follow this split: a `LibraryProvider` registers + accepts cinterops declared there). Both sides follow this split: a library's build registers after evaluation (its block's conventions are set in the DSL constructors); a `BindingConsumer` - (`consumer/`) has `prepare()` for the eager part, then `isUsed()` and `register()` after evaluation. -- **Build systems are sealed hierarchies with a shared `External` branch** β€” `LibraryProvider` for - libraries, `JniStubBuild` for the JNI stub, `FfmLibraryBuild` for the shared library FFM loads. Every external one is `ExternalBuildTask` per target - (`external/ExternalBuilds.kt`); a preset (Zig, CMake) only adds commands. Prefer a preset over a new - task type. + (`bindings/`) has `prepare()` for the eager part, then `isUsed()` and `register()` after evaluation. +- **One set of build systems for every library the plugin makes.** A library's archive, a binding's JNI + library and the library FFM loads are each built by the plugin unless the script calls a block of + `ExternalBuildBlocks` (`cmake`, `make`, `external`; libraries add `zig`, `cargo`, `prebuilt`, FFM + `prebuilt`). Every block's settings live in `dsl.builds` and implement the sealed `BuildSettings`; calling a + block selects its settings object, and owners dispatch on `PrebuiltSettings` or `ExternalBuildSettings`. + `ExternalBuild.of(settings)` (`external/ExternalBuild.kt`) registers + one `ExternalBuildTask` per target from an `ExternalBuildOutput` β€” the only thing that differs between + owners: targets, output directory, file name, extra variables. A build system only adds commands, so a + new one is one `ExternalBuild` subclass and a block, never a new task type. JNI and FFM share + `BindingLibrarySpec` in the DSL and `BindingLibraryWiring` in `bindings/`. - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with *"It is too late to add actions as the callbacks already executed"*, so the jniLibs wiring runs at binding-registration time and tolerates an empty directory. @@ -78,13 +93,18 @@ resolved the moment the plugin is applied, in *every* project that applies it. `konan/bindings//jni/lib/`. This only reproduces on Linux: AGP ignores `.dylib`. - **`Family.dynamicSuffix` has no leading dot.** `"${prefix}$name${suffix}"` yields `libfoostubsdylib`. -- **The stub library name is derived from the binding package** and baked into the generated - `System.loadLibrary`. It, and the stub directory, are reported by the task - (`stubLibraryBaseName`, `stubSourceDirectory`, `kotlinSourceDirectory`) β€” consumers must never - reconstruct them. The external-build contract is `ExternalStubVariable` (`KONAN_JNI_STUB_DIR`, - `KONAN_JNI_LIB_NAME`, `KONAN_JNI_OUTPUT_DIR`, `KONAN_TARGET`, `KONAN_JNI_ABI`, `KONAN_JNI_INCLUDE_DIR`): every - `JniStubBuild.External` gets it as environment in `External.register`; a subclass only adds commands - (CMake also passes it as cache entries). The same values are on `JniSpec` as providers. +- **The stub library name is derived from the binding package** (`stubLibraryBaseName`) and baked into + the generated `System.loadLibrary`; `jni.stubLibraryName` and `jni.stubDirectory` carry it. The + generator's layout (`kotlin/`, `c/`, `include/`) is `GeneratorRequest.Jni`'s constants, shared with the + exporter β€” never spell the directories. JNI is generated by the same `GenerateFromNativeIndexTask` as + FFM; it writes `jni.h` for a JNI request. +- **The external-build contract is one enum, `ExternalVariable`**: `KONAN_LIB_NAME`, `KONAN_OUTPUT_DIR`, + `KONAN_TARGET`, `KONAN_ABI` for every build, `KONAN_SOURCE_DIR` for libraries, `KONAN_JNI_STUB_DIR` and + `KONAN_JNI_INCLUDE_DIR` for JNI. Public: build scripts read it, so renaming one breaks them. +- **A file under a generator's output needs the task's own property.** `generate.flatMap { it.outputDirectory.file(name) }` + carries the producer; `generate.flatMap { it.outputDirectory }.flatMap { it.file(name) }` does not, and + Gradle then rejects the link at execution ("implicit dependency") β€” only when several links run, so a + dry run never shows it. - **`cmake --preset` resolves `CMakePresets.json` from the working directory**, so configure runs from the CMakeLists directory rather than the project root. @@ -112,10 +132,10 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t - **`external { }` defaults to `ExternalToolchain.SYSTEM`**, the presets to `AUTO`: generic steps may not compile anything, and must not need a Kotlin/Native distribution to run. - **Cargo gets the compilers under the `cc` crate's target-specific names** (`CC_`…), not `CC`: - a plain `CC` would also reach build scripts compiled for the host. `registerExternalBuilds` therefore + a plain `CC` would also reach build scripts compiled for the host. `ExternalBuild.register` therefore carries the toolchain in the context (`ToolchainFiles`) and puts the standard names in the environment only where asked. Never register a toolchain task from inside another task's configuration β€” Gradle - forbids it; resolve it in `registerExternalBuilds`. + forbids it; resolve it in `ExternalBuild.register`. - **Task names carry the side**: `Build` for a library, `BuildJni…`, `BuildFfm…` for a binding β€” a library and a binding are usually both called `mymath`. diff --git a/README.md b/README.md index ca39a5e..51ff0d8 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ Android targets need a libc file for the NDK's bionic, which Zig does not bundle ```kotlin create("mymath") { targets(NativeTarget.host, NativeTarget.LINUX_X64, NativeTarget.ANDROID_ARM64) - cmake { } // native/CMakeLists.txt: add_library(${KONAN_LIBRARY_NAME} STATIC …) + cmake { } // native/CMakeLists.txt: add_library(${KONAN_LIB_NAME} STATIC …) } ``` @@ -236,13 +236,13 @@ create("mymath") { `makeBuild` runs make once per target. The compilers and the contract go on make's command line, where they win over the Makefile's own assignments, so a Makefile written against `$(CC)`, `$(AR)` -and `$(KONAN_LIBRARY_OUTPUT_DIR)` cross-compiles unchanged: +and `$(KONAN_OUTPUT_DIR)` cross-compiles unchanged: ```make -OUT := $(KONAN_LIBRARY_OUTPUT_DIR) +OUT := $(KONAN_OUTPUT_DIR) all: $(CC) -c -fPIC mymath.c -o $(OUT)/mymath.o - $(AR) rcs $(OUT)/lib$(KONAN_LIBRARY_NAME).a $(OUT)/mymath.o + $(AR) rcs $(OUT)/lib$(KONAN_LIB_NAME).a $(OUT)/mymath.o ``` Every target builds in the same directory: keep intermediate files per target, as above, or a second @@ -281,8 +281,8 @@ say. They are written per target by `konanToolchain` under `build/konan/ ```kotlin create("mymath") { prebuilt { - archive(NativeTarget.MACOS_ARM64, file("vendor/macos/libmymath.a")) - archive(NativeTarget.MINGW_X64, file("vendor/windows/mymath.lib")) + library(NativeTarget.MACOS_ARM64, file("vendor/macos/libmymath.a")) + library(NativeTarget.MINGW_X64, file("vendor/windows/mymath.lib")) } } ``` @@ -294,14 +294,25 @@ fails configuration. The headers stay the binding's business: point its `include #### Building with anything else β€” `external { }` The same block as `jni { external { } }`, below: a list of `step { }`s run in order once per target by `externalBuild`, with `sources` for up-to-date checks and `producedLibrary(target, file)` -when the build leaves its archive somewhere of its own. Each run gets: +when the build leaves its archive somewhere of its own. + +#### The external-build contract +Every build the project's own build system runs β€” `cmake { }`, `make { }`, `cargo { }`, `zig { }` and +`external { }`, for a library, a binding's `jni` or its `ffm` alike β€” gets the same variables, as +environment variables and, for CMake, as cache entries: | variable | meaning | |---|---| -| `KONAN_LIBRARY_NAME` | the library's name: the archive becomes `lib.a` | -| `KONAN_LIBRARY_OUTPUT_DIR` | where this target's archive must land | -| `KONAN_LIBRARY_SOURCE_DIR` | the library's `sourceDirectory` | -| `KONAN_TARGET` | the Kotlin/Native target, e.g. `linux_x64` | +| `KONAN_LIB_NAME` | the library's name: a library's archive becomes `lib.a`; for `jni` and `ffm`, what the bindings `System.loadLibrary` | +| `KONAN_OUTPUT_DIR` | where this target's library must land: `jniLibs//` for a binding's Android library, which the plugin packages | +| `KONAN_TARGET` | the Kotlin/Native target, e.g. `android_arm64` | +| `KONAN_ABI` | its Android ABI, e.g. `arm64-v8a`; the target's name elsewhere | +| `KONAN_SOURCE_DIR` | a library's `sourceDirectory` β€” libraries only | +| `KONAN_JNI_STUB_DIR` | the directory holding the generated `.c` stub β€” `jni` only | +| `KONAN_JNI_INCLUDE_DIR` | the directory holding `jni.h`, which the plugin ships, so no JDK is needed β€” `jni` only | + +With Kotlin/Native's `toolchain`, also `CC`, `CXX`, `AR`, `RANLIB` and `KONAN_CMAKE_TOOLCHAIN_FILE` (above). +An owner selects at most one build block. ```kotlin create("mymath") { @@ -336,19 +347,18 @@ A C API described by a cinterop `.def`. | Property | Type | Default | Description | |----------------------|--------------------------|---------|-------------| | `visibility` | `BindingVisibility` | `INTERNAL` | `PUBLIC` when the idiomatic wrapper lives in another module. | -| `targets` | `List` | the library's targets a JVM can load β€” the host and Android β€” else the host | Targets to link the JNI library for. | -| `archive` | `File` | the library's | An archive to link instead, e.g. one built elsewhere. | +| `targets` | `List` | `cmake { target(…) }`'s, else the library's targets a JVM can load β€” the host and Android β€” else the host | Targets to link the JNI library for. | | `linkerOptions` | `List` | β€” | Extra options for every target's link. | | `linkerOptionsFor(target, …)` | | β€” | Extra options for one target's link; `-framework` belongs here, since the Android linker rejects it. | | `libraryDirectory` | `Directory` | output | Where the host's JNI library ends up. | | `bundleInJar` | `Boolean` | `true` | Puts each JVM target's library in the jar, where the bindings load it from; see below. | -| `cmake { }` | | β€” | An external CMake build that compiles the stub instead; below. | +| `cmake { }`, `make { }` | | β€” | The project's CMakeLists or Makefile compiles the stub instead; below. | | `external { }` | | β€” | Any other build system that compiles the stub, as commands; below. | | `stubDirectory`, `stubLibraryName`, `libraryDirectoryFor(target)` | read-only | β€” | The external-build contract as providers, for arguments. | A platform library needs no archive: `linkerOpts = -laaudio` in the `.def` (or `jni.linkerOptions`) is -enough for the plugin to link the stub against it. Without a library, `jni.archive`, `staticLibraries` -or linker options, the plugin stops after the Kotlin bindings and the `.c` stub, for another build system +enough for the plugin to link the stub against it. An archive built elsewhere is a library with +`prebuilt { }`. Without a library, `staticLibraries` or linker options, the plugin stops after the Kotlin bindings and the `.c` stub, for another build system to compile. That is the mode for a native library owned by CMake, Bazel or a vendor: linking an archive built by a foreign toolchain with konan's linker is what produces mismatched C++ runtimes and missing compiler-rt. @@ -358,21 +368,10 @@ When the native library belongs to another build system, the plugin drives that linking anything itself, the way AGP's `externalNativeBuild` hands a module's native code to CMake. No link task is registered: one build task per target takes its place, ordered after generation, and `linkJni` depends on them, so "make the JNI library exist" means the same thing either way. A -binding selects at most one of `cmake { }` and `external { }`. - -Every run gets the plugin's contract β€” what only it knows β€” as environment variables (and, with -Kotlin/Native's `toolchain`, `CC`, `CXX`, `AR`; see above): - -| variable | meaning | -|---|---| -| `KONAN_JNI_STUB_DIR` | directory holding the generated `.c` stub | -| `KONAN_JNI_LIB_NAME` | the name the generated bindings will `System.loadLibrary` | -| `KONAN_JNI_OUTPUT_DIR` | where this target's library must land: `jniLibs//` on Android, which the plugin packages | -| `KONAN_TARGET` | the Kotlin/Native target, e.g. `android_arm64` | -| `KONAN_JNI_ABI` | its Android ABI, e.g. `arm64-v8a`; the target's name elsewhere | -| `KONAN_JNI_INCLUDE_DIR` | the directory holding `jni.h`, which the plugin ships β€” no JDK needed | +binding selects at most one of `cmake { }`, `make { }` and `external { }`. -The same values are on `jni` as providers β€” `stubDirectory`, `stubLibraryName`, +Every run gets [the external-build contract](#the-external-build-contract), `KONAN_JNI_STUB_DIR` and +`KONAN_JNI_INCLUDE_DIR` included. The same values are on `jni` as providers β€” `stubDirectory`, `stubLibraryName`, `libraryDirectoryFor(target)` β€” for building arguments. A step's program is an `Executable`: `File(file)`, or `OnPath(name)`, which the Gradle daemon's own `PATH` resolves β€” not a login shell's, so prefer `File` outside the system directories. `cmake { }` and `make { }` run `konan.config`'s. @@ -397,17 +396,18 @@ create("koinference") { `cmakeBuildJni` configures, then builds. It always runs and leaves incrementality to CMake, so a changed configure argument always reaches the build. Android needs a library per ABI, so declare -targets: `target(NativeTarget.ANDROID_ARM64) { preset.set("androidNativeArm64") }`; without any, it -builds once for the host. The contract also arrives as cache entries, and `CMAKE_LIBRARY_OUTPUT_DIRECTORY` -is set to `KONAN_JNI_OUTPUT_DIR`, so the CMakeLists needs no knowledge of the layout: +targets: `target(NativeTarget.ANDROID_ARM64) { preset.set("androidNativeArm64") }` β€” the `target` blocks +are the targets when `jni.targets` is not set. The contract also arrives as cache entries, and +`CMAKE_LIBRARY_OUTPUT_DIRECTORY` is set to `KONAN_OUTPUT_DIR`, so the CMakeLists needs no knowledge of the +layout: ```cmake set(KONAN_JNI_STUB_DIR "" CACHE PATH "") -set(KONAN_JNI_LIB_NAME "" CACHE STRING "") +set(KONAN_LIB_NAME "" CACHE STRING "") file(GLOB JNI_SOURCES "${KONAN_JNI_STUB_DIR}/*.c") add_library(mylib-jni SHARED ${JNI_SOURCES}) -set_target_properties(mylib-jni PROPERTIES OUTPUT_NAME "${KONAN_JNI_LIB_NAME}") +set_target_properties(mylib-jni PROPERTIES OUTPUT_NAME "${KONAN_LIB_NAME}") target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arrive transitively ``` @@ -418,8 +418,8 @@ target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arriv | `buildDirectory` | `Directory` | `/build/`, else under `build/` | Where CMake builds. | | `buildTargets` | `List` | the default target | `--target`s to build. | | `arguments` | `List` | β€” | Extra configure arguments. | -| `libraryDirectory` | `Directory` | `KONAN_JNI_OUTPUT_DIR` | Where the library is, if the build ignores `CMAKE_LIBRARY_OUTPUT_DIRECTORY`. | -| `target(t) { }` | | the host | A per-target build with its own `preset`, `buildDirectory`, `arguments`. | +| `libraryDirectory` | `Directory` | β€” | Where the build leaves the library, if it ignores `CMAKE_LIBRARY_OUTPUT_DIRECTORY`: the plugin copies it into place. | +| `target(t) { }` | | β€” | A per-target build with its own `preset`, `buildDirectory`, `arguments`. | ##### Any build system β€” `jni { external { } }` A list of commands, run in order once per JNI target (`jni.targets`): @@ -438,7 +438,7 @@ jni { } ``` -`externalBuildJni` runs the steps. The build writes the library into `KONAN_JNI_OUTPUT_DIR`, +`externalBuildJni` runs the steps. The build writes the library into `KONAN_OUTPUT_DIR`, or names where it leaves it with `producedLibrary(target, file)`, and the plugin copies it into place under the name the bindings load. With `sources` declared the task is up to date while they and the steps are unchanged; without, the plugin cannot know what the build reads, so it always runs. @@ -507,11 +507,11 @@ with `--enable-native-access`; the library travels in the jar (below), or put `f |--------------------|-------------|----------------------|-------------| | `panamaPortVersion`| `String` | `v0.1.5` | The PanamaPort an Android module's bindings use, added as a dependency. | | `libraryName` | `String` | the library's, else the binding's | What the bindings `System.loadLibrary`. Set it to a prebuilt library's own name. | -| `targets` | `List` | the library's loadable ones, else all loadable | The host on a JVM; 64-bit ABIs on Android. | -| `cmake { }` | | β€” | The project's CMakeLists makes the shared library; below. | +| `targets` | `List` | `cmake { target(…) }`'s, else the library's loadable ones, else all loadable | The host on a JVM; 64-bit ABIs on Android. | +| `cmake { }`, `make { }` | | β€” | The project's CMakeLists or Makefile makes the shared library; below. | | `external { }` | | β€” | Any other build system makes it, as commands; below. | -| `prebuilt { }` | | β€” | Shared libraries built elsewhere: `sharedLibrary(target, file)`; below. | -| `linkerOptions` | `List` | β€” | Extra options for linking the library the bindings load. | +| `prebuilt { }` | | β€” | Shared libraries built elsewhere: `library(target, file)`; below. | +| `linkerOptions` | `List` | β€” | Extra options for linking the library the bindings load; `linkerOptionsFor(target, …)` for one target. | | `libraryDirectory` | `Directory` | output | Where the shared library ends up: the host's, or the `jniLibs` root on Android. | | `bundleInJar` | `Boolean` | `true` | Puts the library in a `kotlin("jvm")` project's jar; see "Libraries in the jar". | @@ -521,7 +521,7 @@ library's archive into it (`linkFfm`). Instead: - `ffm { cmake { } }` β€” the same block as `jni { cmake { } }`, run once per target by `cmakeBuildFfm`: the contract as cache entries, `CMAKE_LIBRARY_OUTPUT_DIRECTORY` set to - where the bindings load the library, so `add_library(${KONAN_FFM_LIB_NAME} SHARED …)` is all the + where the bindings load the library, so `add_library(${KONAN_LIB_NAME} SHARED …)` is all the CMakeLists needs. It builds for its `target(…) { }` blocks, else for `ffm.targets`; on Android, pass the NDK's toolchain per ABI: @@ -536,10 +536,10 @@ library's archive into it (`linkFfm`). Instead: } ``` - `ffm { external { step { } } }` β€” the same block as `jni { external { } }`, run once per target by - `externalBuildFfm`. Each run gets `KONAN_FFM_LIB_NAME`, `KONAN_FFM_OUTPUT_DIR`, - `KONAN_TARGET` and `KONAN_FFM_ABI`, and writes `lib` with the platform's suffix - into the output directory, or names where it leaves it with `producedLibrary(target, file)`. -- `ffm { prebuilt { sharedLibrary(NativeTarget.ANDROID_ARM64, file("vendor/libggml.so")) } }` β€” a vendor's + `externalBuildFfm`. Each run gets [the contract](#the-external-build-contract), and writes + `lib` with the platform's suffix into `KONAN_OUTPUT_DIR`, or names where it leaves it + with `producedLibrary(target, file)`. `ffm { make { } }` works the same way. +- `ffm { prebuilt { library(NativeTarget.ANDROID_ARM64, file("vendor/libggml.so")) } }` β€” a vendor's libraries, placed by `prebuiltFfm`. Set `libraryName` to their own name (`"ggml"`), so libraries that depend on them by that name still find them. @@ -599,7 +599,8 @@ The DSL is new and there is no compatibility shim. `compileNativeLibrary` (which runs `konanClang`). - `jvmInterops { create(…) { … } }` β€” on a compilation or on the project β€” becomes `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` is gone - (the plugin ships `jni.h`), `library` β†’ `archive`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ + (the plugin ships `jni.h`), `library` β†’ a library with `prebuilt { library(target, file) }`, named with + `library(…)`, `additionalLinkerArgs` β†’ `linkerOptions`, `linkerArgsFor` β†’ `linkerOptionsFor`, `externalNativeBuild { cmake { } }` β†’ `cmake { }` (`targets` β†’ `buildTargets`, `abi("arm64-v8a")` β†’ `target(NativeTarget.ANDROID_ARM64)`, `executable` β†’ `konan.config.cmake`, no `$CMAKE`; its tasks are `cmakeBuildJni`), @@ -615,6 +616,15 @@ The DSL is new and there is no compatibility shim. `NativeTarget.of(konanTarget)` (`import io.github.lemcoder.kotlin.of`). `jniHome()` is gone: external builds get `KONAN_JNI_INCLUDE_DIR`. Programs (`zig`, `cmake`, `jextract`) and the distribution come from `konan { config { } }`. +- External builds read one contract: `KONAN_JNI_LIB_NAME`, `KONAN_FFM_LIB_NAME` and `KONAN_LIBRARY_NAME` β†’ + `KONAN_LIB_NAME`; `KONAN_JNI_OUTPUT_DIR`, `KONAN_FFM_OUTPUT_DIR` and `KONAN_LIBRARY_OUTPUT_DIR` β†’ + `KONAN_OUTPUT_DIR`; `KONAN_JNI_ABI` and `KONAN_FFM_ABI` β†’ `KONAN_ABI`; `KONAN_LIBRARY_SOURCE_DIR` β†’ + `KONAN_SOURCE_DIR`. `KONAN_JNI_STUB_DIR`, `KONAN_JNI_INCLUDE_DIR` and `KONAN_TARGET` stay. +- `jni { cmake { libraryDirectory } }` is where the plugin copies the library from, as for libraries and + FFM, not where consumers look; `jni.archive` is gone β€” declare the archive as a `prebuilt { }` library. +- The build blocks' types live in `io.github.lemcoder.dsl.builds` β€” `ExternalToolchain`, `ZigOptimize` and + `CargoProfile` included β€” and share one interface, `BuildSettings`. Both `prebuilt { }` blocks take + `library(target, file)`: `archive(…)` and `sharedLibrary(…)` are gone. - JNI bindings are named after the C functions (`my_add`), not `kniBridge0`; tasks are `generateJni…` and `linkJni…`. diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index 23d5f54..cc88dc6 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -107,10 +107,10 @@ flowchart TD | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `model.NativeTarget`: one entry per Kotlin/Native target (checked by a test against the pinned version), with family, architecture, Android ABI, library naming and Zig triple; `NativeTarget.of(konanTarget)` is the only conversion | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Kotlin/Native's (clang + downloaded sysroots, `KonanDistribution`), also handed to external builds per target as `CC`/`CXX`/`AR` and a CMake toolchain file (`KonanToolchain`, chosen by `ExternalToolchain`); Zig brings its own; the system's for host builds | -| 4 | **Library provider** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | `LibraryProvider`, sealed: **KonanClang** (default, `konanClang`) or **External** β€” one `ExternalBuildTask` per target with the `ExternalLibraryVariable` contract: **CMake** (`cmake { }`), **Zig** (`zig { }`), **Cargo** (`cargo { }`, a Rust `staticlib` per Rust triple), **Make** (`make { }`) and **Generic** (`external { step { } }`, any build system); or **Prebuilt** (`prebuilt { archive(target, file) }`, placed by `prebuilt`); all behind `compileNativeLibrary` | +| 4 | **Library build** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | Kotlin/Native's clang by default (`konanClang`); **Prebuilt** (`prebuilt { library(target, file) }`, placed by `prebuilt`); or an **`ExternalBuild`** β€” one `ExternalBuildTask` per target with the `ExternalVariable` contract: **CMake** (`cmake { }`), **Make** (`make { }`), **Cargo** (`cargo { }`, a Rust `staticlib` per Rust triple), **Zig** (`zig { }`) and **Generic** (`external { step { } }`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | -| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | `JniStubBuild`, a sealed hierarchy: **KonanLink** (`LinkSharedLibraryTask`) or **External** β€” the project's own build, one `ExternalBuildTask` per target running a list of commands with the `ExternalStubVariable` contract as environment: **Generic** (`jni { external { step { } } }`, any build system) and the **CMake** preset (configure + build, contract also as cache entries); for FFM there is no glue, but the shared library it loads is a sealed `FfmLibraryBuild` of the same shape: **KonanLink** (the archive linked whole), **Prebuilt** (`ffm { prebuilt { sharedLibrary(…) } }`), or **External** with the `ExternalFfmVariable` contract β€” **Generic** (`ffm { external { } }`) and the **CMake** preset (`ffm { cmake { } }`). Both CMake presets are one implementation, `external/CMakeCommands.kt` | +| 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | The JNI library: the plugin's link (`LinkSharedLibraryTask`) by default, or the same `ExternalBuild`s as libraries β€” **CMake**, **Make**, **Generic** β€” with `KONAN_JNI_STUB_DIR` and `KONAN_JNI_INCLUDE_DIR` added to the contract. FFM has no glue, but the shared library it loads is built the same way: the archive linked whole by default, **Prebuilt** (`ffm { prebuilt { library(…) } }`), or an `ExternalBuild`. JNI and FFM share `BindingLibrarySpec` and `BindingLibraryWiring` | | 8 | **Packaging and wiring** | Picks static or shared linkage per consumer and puts bindings and binaries where that consumer loads them | Bindings + binaries β†’ source sets, `jniLibs`, cinterop declarations, publications | `BindingConsumer`: **KotlinNativeConsumer** (the KGP adapter), **JniConsumer** (JVM, multiplatform JVM, Android; own link or CMake), **FfmConsumer** (`kotlin("jvm")` only); publications not yet (section D) | How the two paths use them: @@ -186,12 +186,12 @@ cinterop in one multiplatform build (`jvm()` + `macosArm64()`), FFM in a `kotlin and `builtBy`, the lifecycle task `compileNativeLibrary`. Bindings and consumers take a `NativeArtifacts` and never learn which provider ran. Zig names Windows archives `mymath.lib`; its provider renames on copy. -- **A provider is a case of the sealed `LibraryProvider`**, and the selection is the provider itself: - a library's block adds it, two is an error, none means `KonanClang`. Every build system other than - konan's clang is an `External` β€” the generic `external { step { } }` one, or a preset such as Zig β€” - sharing `ExternalBuildTask` with the JNI stub builds: commands per target, the contract as - environment, the produced archive copied to `lib.a`. A new preset is a nested object that adds - its commands; nothing on the consumer side. +- **The selection is the block's settings**: a library's block selects its `BuildSettings`, two is an error, + none means Kotlin/Native's clang. Every build system other than konan's clang is an `ExternalBuild` β€” + the generic `external { step { } }` one, or a preset such as Zig β€” the same classes that build a + binding's JNI or FFM library: commands per target, the contract as environment, the produced library + copied into place. Only an `ExternalBuildOutput` differs per owner. A new preset is one subclass that + adds its commands; nothing on the consumer side. - **The target model has to map to each toolchain's triple.** Kotlin/Native targets become Zig triples pinned to konan's sysroots (glibc 2.19, macOS 12.0, iOS 14.0). The glibc pin is defensive: in this test, unpinned Zig also produced plain `strtol`. Android needs bionic, which Zig does not bundle. @@ -302,12 +302,14 @@ Partly settled by what exists: | `model` | The domain, without tasks or KGP: `NativeTarget` (+ `TargetFamily`, `Architecture`, `HostOs`), `NativeArtifacts` (the archive layout), the external-build contract, `Executable`, `ConfigField`/`ConfigValue` (`konan.config`), `DefFile`, task naming, `selectOne` | | `kotlin` | Every touchpoint with Kotlin, and the only package importing it: `NativeTarget.of(konanTarget)`, `KonanDistribution` (home, pinned version, `run_konan` clang and llvm-ar, NDK), `KotlinGradlePlugin` (KGP), the index exporter client | | `toolchain` | The other toolchains: `Xcode` (compiler-rt), running an `Executable`, Zig triples | - | `dsl` | Everything a build script touches, one file per spec | - | `library` | `LibraryProvider` and its builds: Kotlin/Native's clang, prebuilt, Zig | - | `jni`, `link`, `index` | `JniStubBuild` and its builds, the generate tasks, the shared-library link | - | `external` | `ExternalBuildTask` and the registration every external build shares | - | `consumer` | `BindingConsumer`: Kotlin/Native, JNI, FFM | - | `wiring` | Registration of libraries and bindings, AGP, plugin ids | + | `dsl` | Everything a build script touches, nested as the DSL is: `KonanExtension` (`konan { }`), `dsl.config` (`config { }`), `dsl.libraries` (`libraries { }`), `dsl.bindings` (`bindings { }`, with `BindingLibrarySpec`, what `jni` and `ffm` share), `dsl.bindings.jni`, `dsl.bindings.ffm` | + | `dsl.builds` | The build blocks every level shares β€” `BuildSettings`: CMake, Make, Cargo, Zig, steps, prebuilt β€” their option types, and `ExternalBuildBlocks` | + | `libraries` | A library's registration and build: Kotlin/Native's clang, prebuilt, or an external build | + | `bindings` | Registration of bindings, `BindingConsumer` and the Kotlin/Native one, what JNI and FFM share (`BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`) | + | `bindings.jni`, `bindings.ffm` | Each one's consumer and the plugin's own link | + | `index` | The generate task | + | `external` | `ExternalBuild` (CMake, Make, Cargo, Zig, generic), `ExternalBuildTask`, and their commands | + | `wiring` | AGP, plugin ids, the `konanConfig` report | - **Generator output**: each exporter generator owns its layout under the task's output directory β€” `jni`: `kotlin//.kt` and `c/.c`; `jextract`: `java//`; `dump`: `index.txt`. Kotlin/Native klibs stay where KGP puts them. @@ -320,8 +322,8 @@ Partly settled by what exists: What the generators showed each consumer needs. The plugin wires every row today, in plain and multiplatform projects alike; a multiplatform project with JVM and Android targets gets the FFM bindings -twice, once per API. Each row is a `BindingConsumer`, the binding-side counterpart of -`LibraryProvider`: `prepare()` runs when the binding is added, for wiring that must be eager (AGP's +twice, once per API. Each row is a `BindingConsumer`, the binding-side counterpart of a +library's build: `prepare()` runs when the binding is added, for wiring that must be eager (AGP's variant callbacks, the multiplatform source directory); `isUsed()` decides from the project's plugins and the binding's settings once the build script has run; `register()` then adds the tasks. Consumers take the library only as `NativeArtifacts`. A new consumer is one more entry in `bindingConsumers`. diff --git a/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt index 0a9fec5..ce4e51e 100644 --- a/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt +++ b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt @@ -20,7 +20,18 @@ sealed class GeneratorRequest : Serializable { * @property stubLibrary the library the bindings `System.loadLibrary`; the plugin owns that name. */ data class Jni(val stubLibrary: String, val visibility: BindingVisibility = BindingVisibility.INTERNAL) : - GeneratorRequest() + GeneratorRequest() { + companion object { + /** The Kotlin bindings: `kotlin//.kt`. */ + const val KOTLIN_DIR = "kotlin" + + /** The stub, `c/.c`: what the plugin or the project's own build compiles. */ + const val C_DIR = "c" + + /** `jni.h`, which the plugin ships, for compiling the stub without a JDK. */ + const val INCLUDE_DIR = "include" + } + } /** * FFM bindings, by jextract. diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt index 69ac179..1f4aa56 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -48,11 +48,11 @@ object JniGenerator : BindingGenerator { } } - val kotlinFile = outputDirectory.resolve("kotlin/${packageName.replace('.', '/')}/$facade.kt") + val kotlinFile = outputDirectory.resolve("${GeneratorRequest.Jni.KOTLIN_DIR}/${packageName.replace('.', '/')}/$facade.kt") kotlinFile.parentFile.mkdirs() kotlinFile.writeText(kotlinSource(packageName, facade, stubLibrary, visibility, constants(context.index), bound, skipped)) - val cFile = outputDirectory.resolve("c/$stubLibrary.c") + val cFile = outputDirectory.resolve("${GeneratorRequest.Jni.C_DIR}/$stubLibrary.c") cFile.parentFile.mkdirs() cFile.writeText(cSource(context, jniClassPrefix(packageName, facade), bound)) } diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index d3060b1..34033aa 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -1,14 +1,14 @@ package io.github.lemcoder +import io.github.lemcoder.bindings.registerBindings import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.libraries.registerLibraries import io.github.lemcoder.model.ConfigField import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId -import io.github.lemcoder.wiring.registerBindings import io.github.lemcoder.wiring.registerConfigReport -import io.github.lemcoder.wiring.registerLibraries import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt b/src/main/kotlin/io/github/lemcoder/bindings/BindingConsumer.kt similarity index 91% rename from src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt rename to src/main/kotlin/io/github/lemcoder/bindings/BindingConsumer.kt index 718f12c..bea1754 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/BindingConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/bindings/BindingConsumer.kt @@ -1,7 +1,9 @@ -package io.github.lemcoder.consumer +package io.github.lemcoder.bindings -import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.bindings.ffm.FfmConsumer +import io.github.lemcoder.bindings.jni.JniConsumer import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.bindings.BindingSpec import io.github.lemcoder.model.DefFile import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.wiring.GradlePluginId diff --git a/src/main/kotlin/io/github/lemcoder/bindings/BindingLibraryWiring.kt b/src/main/kotlin/io/github/lemcoder/bindings/BindingLibraryWiring.kt new file mode 100644 index 0000000..6a01e76 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/bindings/BindingLibraryWiring.kt @@ -0,0 +1,111 @@ +package io.github.lemcoder.bindings + +import io.github.lemcoder.dsl.bindings.BindingLibrarySpec +import io.github.lemcoder.dsl.builds.CMakeSettings +import io.github.lemcoder.dsl.builds.ExternalBuildSettings +import io.github.lemcoder.dsl.builds.PrebuiltSettings +import io.github.lemcoder.dsl.builds.blockName +import io.github.lemcoder.external.ExternalBuild +import io.github.lemcoder.external.ExternalBuildOutput +import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.kotlin.KotlinGradlePlugin +import io.github.lemcoder.model.ExternalBuildVariable +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.capitalized +import io.github.lemcoder.model.selectOne +import io.github.lemcoder.model.taskName +import org.gradle.api.Task +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider + +/** + * What JNI and FFM do alike with the library their bindings load ([spec]): its directories under + * `build/konan/bindings///`, the `link` task every build of it hangs off, the build + * the script chose, and its copy in a JVM jar. Created when the binding is added, before its configure block. + */ +internal class BindingLibraryWiring( + private val context: BindingContext, + private val spec: BindingLibrarySpec, + /** `jni` or `ffm`. */ + private val side: String, +) { + private val project = context.project + private val bindingName = context.bindingName + private val owner = "konan.bindings.$bindingName.$side" + private val label = side.uppercase() + private val root: Provider = context.buildDirectory.dir("konan/bindings/$bindingName/$side") + + /** `link`: every build of the library, for every target, whichever built it. */ + val linkAll: TaskProvider = project.tasks.register(taskName("link${side.capitalized()}", bindingName)) { + group = "konan" + description = "Make the '$bindingName' $label library exist for every target, however it is built." + } + + init { + spec.jniLibsRoot.set(root.map { it.dir("jniLibs") }) + spec.hostLibRoot.set(root.map { it.dir("lib") }) + } + + /** `targets`, else those of `cmake { target(…) { } }` blocks; empty when the build script named none. */ + fun declaredTargets(): List = spec.targets.getOrElse(emptyList()).ifEmpty { + (spec.selectedBuilds.singleOrNull() as? CMakeSettings)?.targetSettings?.keys?.toList().orEmpty() + } + + /** + * Registers the build the script chose for [targets] β€” [own], the plugin's link, when it chose none β€” + * under [linkAll], and points `libraryDirectory` at the host's library, else the `jniLibs` root. + */ + fun registerBuild( + targets: List, + libraryName: Provider, + own: () -> List>, + prebuilt: () -> List> = { error("$owner has no prebuilt { } block") }, + extraVariables: (NativeTarget) -> Map> = { emptyMap() }, + configure: ExternalBuildTask.() -> Unit = {}, + ) { + val builds = when (val settings = selectOne(owner, spec.selectedBuilds) { it.blockName }) { + null -> own() + is PrebuiltSettings -> prebuilt() + is ExternalBuildSettings -> ExternalBuild.of(settings).register( + project, + ExternalBuildOutput( + owner = owner, + name = bindingName, + side = side.capitalized(), + targets = targets, + libraryName = libraryName, + libraryFileName = { target -> libraryName.map { target.sharedLibraryName(it) } }, + outputDirectory = spec::libraryDirectoryFor, + workDirectory = root, + description = { target, tool -> "Build the '$bindingName' $label library for ${target.konanName} with $tool." }, + extraVariables = extraVariables, + configure = configure, + ), + ) + } + linkAll.configure { dependsOn(builds) } + val host = NativeTarget.host + spec.libraryDirectory.convention(if (host in targets) spec.libraryDirectoryFor(host) else spec.jniLibsRoot) + } + + /** + * The libraries of [jvmTargets] in the jar of a `kotlin("jvm")` project ([plainJvm]) or a multiplatform + * `jvm()` target ([kmpJvm]), unless `bundleInJar` is off. + */ + fun bundleInJar(jvmTargets: List, libraryName: Provider, plainJvm: Boolean, kmpJvm: Boolean) { + if (!spec.bundleInJar.get() || jvmTargets.isEmpty() || !(plainJvm || kmpJvm)) return + val bundle = project.registerJarBundle( + taskName = taskName("bundle${side.capitalized()}", bindingName), + taskDescription = "Put the '$bindingName' $label libraries in the jar, for the bindings to load.", + libraries = jvmTargets.associateWith { target -> + spec.libraryDirectoryFor(target).zip(libraryName) { directory, name -> directory.file(target.sharedLibraryName(name)) } + }, + destination = root.get().dir("resources").asFile, + builtBy = linkAll, + ) + val resources = bundle.map { it.destinationDir } + if (plainJvm) KotlinGradlePlugin.addJvmResources(project, resources) + if (kmpJvm) KotlinGradlePlugin.addKmpJvmResources(project, resources) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt b/src/main/kotlin/io/github/lemcoder/bindings/Bindings.kt similarity index 84% rename from src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt rename to src/main/kotlin/io/github/lemcoder/bindings/Bindings.kt index 290cebc..394ac3f 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/Bindings.kt +++ b/src/main/kotlin/io/github/lemcoder/bindings/Bindings.kt @@ -1,9 +1,9 @@ -package io.github.lemcoder.wiring +package io.github.lemcoder.bindings -import io.github.lemcoder.consumer.BindingContext -import io.github.lemcoder.consumer.bindingConsumers -import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.bindings.BindingContext +import io.github.lemcoder.bindings.bindingConsumers import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.bindings.BindingSpec import org.gradle.api.Action import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JarBundle.kt b/src/main/kotlin/io/github/lemcoder/bindings/JarBundle.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/consumer/JarBundle.kt rename to src/main/kotlin/io/github/lemcoder/bindings/JarBundle.kt index 37b3cb4..91aca18 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/JarBundle.kt +++ b/src/main/kotlin/io/github/lemcoder/bindings/JarBundle.kt @@ -1,14 +1,14 @@ -package io.github.lemcoder.consumer +package io.github.lemcoder.bindings import io.github.lemcoder.model.BundledLibraries import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.cleanTask +import java.io.File import org.gradle.api.Project import org.gradle.api.file.RegularFile import org.gradle.api.provider.Provider import org.gradle.api.tasks.Sync import org.gradle.api.tasks.TaskProvider -import java.io.File /** * A resources root holding [libraries] as `konan/natives//` β€” where the generated bindings' diff --git a/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt b/src/main/kotlin/io/github/lemcoder/bindings/KotlinNativeConsumer.kt similarity index 94% rename from src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt rename to src/main/kotlin/io/github/lemcoder/bindings/KotlinNativeConsumer.kt index 2b844fe..a529aaa 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/KotlinNativeConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/bindings/KotlinNativeConsumer.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.consumer +package io.github.lemcoder.bindings import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.wiring.GradlePluginId diff --git a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/bindings/LinkSharedLibraryTask.kt similarity index 99% rename from src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt rename to src/main/kotlin/io/github/lemcoder/bindings/LinkSharedLibraryTask.kt index d29a991..0f10178 100644 --- a/src/main/kotlin/io/github/lemcoder/link/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/bindings/LinkSharedLibraryTask.kt @@ -1,10 +1,11 @@ -package io.github.lemcoder.link +package io.github.lemcoder.bindings import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture +import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty @@ -22,7 +23,6 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -import javax.inject.Inject /** * Links a target's static archive into a shared library with Kotlin/Native's clang: with [stubCFile], the diff --git a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmConsumer.kt similarity index 73% rename from src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt rename to src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmConsumer.kt index eb9d5de..f94ea28 100644 --- a/src/main/kotlin/io/github/lemcoder/consumer/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmConsumer.kt @@ -1,7 +1,8 @@ -package io.github.lemcoder.consumer +package io.github.lemcoder.bindings.ffm -import io.github.lemcoder.ffm.FfmLibraryInputs -import io.github.lemcoder.ffm.ffmLibraryBuildFor +import io.github.lemcoder.bindings.BindingConsumer +import io.github.lemcoder.bindings.BindingContext +import io.github.lemcoder.bindings.BindingLibraryWiring import io.github.lemcoder.index.FfmApi import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest @@ -14,15 +15,13 @@ import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId import io.github.lemcoder.wiring.SourceKind import io.github.lemcoder.wiring.withPlugin -import org.gradle.api.Task import org.gradle.api.file.Directory import org.gradle.api.provider.Provider -import org.gradle.api.tasks.SourceSetContainer import org.gradle.api.tasks.TaskProvider /** * FFM bindings: jextract over the same `.def`, and the C library itself as a shared library β€” FFM loads it - * directly, with no stub β€” from the binding's [io.github.lemcoder.ffm.FfmLibraryBuild]. Opted into with + * directly, with no stub β€” linked by the plugin or built as the binding's `ffm` says. Opted into with * `ffm()`, which then replaces JNI wherever JNI would go. * * Generated once per API the project needs: the JDK's `java.lang.foreign` for `kotlin("jvm")` and @@ -35,7 +34,7 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume private val binding = context.binding private val bindingName = context.bindingName - private lateinit var linkAll: TaskProvider + private lateinit var library: BindingLibraryWiring private val isMultiplatform get() = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) @@ -58,12 +57,8 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume override fun prepare() { val ffm = binding.ffm ffm.libraryName.convention(binding.library.map { it.name }.orElse(bindingName)) - ffm.jniLibsRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/ffm/jniLibs")) - ffm.hostLibRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/ffm/lib")) - linkAll = project.tasks.register(taskName("linkFfm", bindingName)) { - group = "konan" - description = "Make the shared library the '$bindingName' FFM bindings load exist for every target, however it is built." - } + library = BindingLibraryWiring(context, ffm, "ffm") + val linkAll = library.linkAll // Eager, as the variant API needs; whether FFM is on is only known in the callbacks. linkFfm // depends on generation, so it orders both the compile and the packaging. val enabled = { ffm.isEnabled } @@ -95,16 +90,18 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume if (android) addAll(NativeTarget.android.filter { it.architecture in LP64 }) } val targets = targets(artifacts?.targets?.getOrElse(emptyList()).orEmpty(), loadable, warnAboutAndroid = android) - val builds = ffmLibraryBuildFor(binding).register(project, context.konan, binding, FfmLibraryInputs(artifacts, targets, libraryName)) - linkAll.configure { dependsOn(builds) } - - ffm.libraryDirectory.convention(if (jvm || !android) ffm.libraryDirectoryFor(NativeTarget.host) else ffm.jniLibsRoot) + library.registerBuild( + targets, + ffm.libraryName, + own = { registerFfmLink(project, context.konan, binding, artifacts, libraryName, targets) }, + prebuilt = { registerFfmPrebuilt(project, binding, libraryName, targets) }, + ) if (android) { val panamaPort = ffm.panamaPortVersion.map { "$PANAMA_PORT:$it" } if (isMultiplatform) KotlinGradlePlugin.addKmpAndroidDependency(project, panamaPort) else project.dependencies.add("implementation", panamaPort) } - if (jvm && generateJvm != null) wireJvm(generateJvm, targets.filter { !it.isAndroid }, libraryName) + if (jvm && generateJvm != null) wireJvm(generateJvm, targets.filter { !it.isAndroid }) } private fun registerGeneration(api: FfmApi, libraryName: String): TaskProvider { @@ -125,35 +122,20 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume target.set(NativeTarget.host) request.set(GeneratorRequest.Jextract(library = libraryName, api = api)) jextract.set(config.readConfigField(ConfigField.JEXTRACT, path)) - packageName.orNull?.let { extraCinteropArgs.addAll("-pkg", it) } + extraCinteropArgs.addAll(packageName.map { listOf("-pkg", it) }) outputDirectory.set(generatedRoot(api)) } - linkAll.configure { dependsOn(task) } + library.linkAll.configure { dependsOn(task) } return task } /** The JDK's bindings into the JVM compilation, and the host libraries into the jar. */ - private fun wireJvm(generate: TaskProvider, jvmTargets: List, libraryName: String) { - val ffm = binding.ffm + private fun wireJvm(generate: TaskProvider, jvmTargets: List) { val javaSources = generate.flatMap { it.outputDirectory.dir("java") } - val bundle = if (ffm.bundleInJar.get() && jvmTargets.isNotEmpty()) { - project.registerJarBundle( - taskName = taskName("bundleFfm", bindingName), - taskDescription = "Put the libraries the '$bindingName' FFM bindings load in the jar.", - libraries = jvmTargets.associateWith { target -> ffm.libraryDirectoryFor(target).map { it.file(target.sharedLibraryName(libraryName)) } }, - destination = context.buildDirectory.dir("konan/bindings/$bindingName/ffm/resources").get().asFile, - builtBy = linkAll, - ).map { it.destinationDir } - } else null - if (context.hasPlugin(GradlePluginId.KOTLIN_JVM)) { - // Plain Gradle source sets: Java is compiled by the java plugin, which kotlin("jvm") applies. - val main = project.extensions.getByType(SourceSetContainer::class.java).getByName("main") - main.java.srcDir(javaSources) - bundle?.let { main.resources.srcDir(it) } - } else { - KotlinGradlePlugin.addKmpJvmJava(project, javaSources) - bundle?.let { KotlinGradlePlugin.addKmpJvmResources(project, it) } - } + val plainJvm = context.hasPlugin(GradlePluginId.KOTLIN_JVM) + // kotlin("jvm") applies the java plugin, which compiles the main source set's Java. + if (plainJvm) KotlinGradlePlugin.addJvmJava(project, javaSources) else KotlinGradlePlugin.addKmpJvmJava(project, javaSources) + library.bundleInJar(jvmTargets, binding.ffm.libraryName, plainJvm = plainJvm, kmpJvm = !plainJvm) } /** @@ -161,7 +143,7 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume * JDK's bindings, 64-bit ABIs for PanamaPort's β€” jextract writes LP64 layouts. */ private fun targets(libraryTargets: List, loadable: List, warnAboutAndroid: Boolean): List { - binding.ffm.targets.getOrElse(emptyList()).takeIf { it.isNotEmpty() }?.let { declared -> + library.declaredTargets().takeIf { it.isNotEmpty() }?.let { declared -> val unsupported = declared - loadable.toSet() check(unsupported.isEmpty()) { "konan.bindings.$bindingName.ffm.targets: ${unsupported.joinToString { it.konanName }} cannot load these bindings here; " + diff --git a/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmLibraryBuild.kt new file mode 100644 index 0000000..4175574 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmLibraryBuild.kt @@ -0,0 +1,73 @@ +package io.github.lemcoder.bindings.ffm + +import io.github.lemcoder.bindings.LinkSharedLibraryTask +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.bindings.BindingSpec +import io.github.lemcoder.libraries.PlacePrebuiltFilesTask +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** + * The FFM library's default build: the binding's library's archive, linked whole into a shared library + * with Kotlin/Native's clang, for each of [targets] it is built for. FFM needs no stub β€” this is the C + * library itself, as `ffm.libraryDirectoryFor(target)/`. + */ +internal fun registerFfmLink( + project: Project, + konan: KonanExtension, + binding: BindingSpec, + artifacts: NativeArtifacts?, + libraryName: String, + targets: List, +): List> { + if (artifacts == null) { + // Nothing to link: a platform library (libaaudio), or one the app gets some other way. + project.logger.info("konan.bindings.${binding.name}.ffm: no library(), so the bindings load lib$libraryName as the system provides it.") + return emptyList() + } + val built = artifacts.targets.getOrElse(emptyList()) + val (linked, missing) = targets.partition { it in built } + if (missing.isNotEmpty()) { + project.logger.warn( + "konan.bindings.${binding.name}.ffm: no library is built for ${missing.joinToString { it.konanName }}, so nothing " + + "is linked there β€” name one with library(…), or use ffm { external { } } or ffm { prebuilt { } }." + ) + } + val ffm = binding.ffm + return linked.map { target -> + project.tasks.register(taskName("linkFfm", binding.name, target), LinkSharedLibraryTask::class.java) { + group = "konan" + description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." + dependsOn(artifacts.builtBy) + androidApiLevel.set(konan.androidApiLevel) + this.target.set(target) + libraryBaseName.set(libraryName) + staticLibrary.set(artifacts.archive(target)) + additionalLinkerArgs.set(ffm.linkerOptions.zip(ffm.targetLinkerOptions) { common, perTarget -> common + perTarget[target].orEmpty() }) + outputDirectory.set(ffm.libraryDirectoryFor(target)) + } + } +} + +/** `ffm { prebuilt { } }`: shared libraries built elsewhere, placed under the name the bindings load. */ +internal fun registerFfmPrebuilt(project: Project, binding: BindingSpec, libraryName: String, targets: List): List> { + val ffm = binding.ffm + val libraries = ffm.prebuilt.libraries.getOrElse(emptyMap()) + val missing = targets.filter { it !in libraries } + check(missing.isEmpty()) { + "konan.bindings.${binding.name}.ffm.prebuilt has no shared library for ${missing.joinToString { it.konanName }}." + } + val placements = targets.associate { target -> + libraries.getValue(target) to ffm.libraryDirectoryFor(target).get().file(target.sharedLibraryName(libraryName)).asFile + } + return listOf( + project.tasks.register(taskName("prebuiltFfm", binding.name), PlacePrebuiltFilesTask::class.java) { + group = "konan" + description = "Place the prebuilt '$libraryName' libraries the '${binding.name}' FFM bindings load." + place(placements) + } + ) +} diff --git a/src/main/kotlin/io/github/lemcoder/bindings/jni/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/bindings/jni/JniConsumer.kt new file mode 100644 index 0000000..70f67bc --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/bindings/jni/JniConsumer.kt @@ -0,0 +1,109 @@ +package io.github.lemcoder.bindings.jni + +import io.github.lemcoder.bindings.BindingConsumer +import io.github.lemcoder.bindings.BindingContext +import io.github.lemcoder.bindings.BindingLibraryWiring +import io.github.lemcoder.index.GenerateFromNativeIndexTask +import io.github.lemcoder.index.GeneratorRequest +import io.github.lemcoder.kotlin.KotlinGradlePlugin +import io.github.lemcoder.model.ExternalVariable +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.stubLibraryBaseName +import io.github.lemcoder.model.taskName +import io.github.lemcoder.util.cleanTask +import io.github.lemcoder.wiring.AgpWiring +import io.github.lemcoder.wiring.GradlePluginId +import io.github.lemcoder.wiring.SourceKind +import io.github.lemcoder.wiring.withPlugin +import org.gradle.api.tasks.TaskProvider + +/** + * JVM and Android targets, through the JNI generator: Kotlin `external fun`s in the main sources, and a + * JNI library per target β€” the generated stub linked with the C library, by the plugin or the project's + * own build. + */ +internal class JniConsumer(private val context: BindingContext) : BindingConsumer { + private val project = context.project + private val binding = context.binding + private val bindingName = context.bindingName + private val jni = binding.jni + + private lateinit var library: BindingLibraryWiring + private lateinit var generate: TaskProvider + + override fun prepare() { + library = BindingLibraryWiring(context, jni, "jni") + generate = registerGeneration() + jni.stubDirectoryProperty.set(generate.flatMap { it.outputDirectory.dir(GeneratorRequest.Jni.C_DIR) }) + jni.stubLibraryNameProperty.set(context.packageName.map(::stubLibraryBaseName)) + val sources = generate.flatMap { it.outputDirectory.dir(GeneratorRequest.Jni.KOTLIN_DIR) } + + // Eager: the variant API must be wired at configuration time. + project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.addKmpJvmSources(project, sources, generate) { !binding.ffm.isEnabled } } + AgpWiring.addSources(project, SourceKind.KOTLIN, sources, generate, library.linkAll) { !replacedByFfm } + AgpWiring.addJniLibs(project, jni.jniLibsRoot, library.linkAll) { !replacedByFfm } + // AGP looks for a `baselineProfiles` directory beside every source root, including the generated + // one, by plain path β€” no task dependency. Matched by name: inert without AGP. + project.tasks.matching { it.name.contains("ArtProfile") }.configureEach { dependsOn(generate) } + } + + /** + * `ffm()` replaces JNI wherever JNI would go β€” JVM, multiplatform and Android alike β€” since both would + * define the same facade class. Read once the build script has run. + */ + private val replacedByFfm get() = binding.ffm.isEnabled + + private val isPlainJvmConsumer get() = context.hasPlugin(GradlePluginId.KOTLIN_JVM) && !replacedByFfm + + private val isKmpJvmConsumer get() = + context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project) && !replacedByFfm + + override fun isUsed(): Boolean = !replacedByFfm && ( + isPlainJvmConsumer || isKmpJvmConsumer || + context.hasPlugin(GradlePluginId.ANDROID_BASE) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY)) + + override fun register() { + if (isPlainJvmConsumer) KotlinGradlePlugin.addJvmSources(project, generate.flatMap { it.outputDirectory.dir(GeneratorRequest.Jni.KOTLIN_DIR) }) + val artifacts = context.artifacts() + val targets = targets(artifacts) + library.registerBuild( + targets, + jni.stubLibraryName, + own = { registerKonanLink(project, context.konan, binding, generate, artifacts, targets) }, + extraVariables = { + mapOf( + ExternalVariable.JNI_STUB_DIR to jni.stubDirectory.map { it.asFile.absolutePath }, + ExternalVariable.JNI_INCLUDE_DIR to generate.flatMap { it.outputDirectory.dir(GeneratorRequest.Jni.INCLUDE_DIR) } + .map { it.asFile.absolutePath }, + ) + }, + configure = { generatedSources.from(jni.stubDirectory) }, + ) + library.bundleInJar(targets.filter { !it.isAndroid }, jni.stubLibraryName, isPlainJvmConsumer, isKmpJvmConsumer) + } + + /** The declared targets, else the library's targets a JVM can load β€” the host and Android β€” else the host. */ + private fun targets(artifacts: NativeArtifacts?): List = library.declaredTargets().ifEmpty { + artifacts?.targets?.getOrElse(emptyList())?.filter { it == NativeTarget.host || it.isAndroid } + ?.ifEmpty { null } + ?: listOf(NativeTarget.host) + } + + private fun registerGeneration(): TaskProvider { + val packageName = context.packageName + return project.tasks.register(taskName("generateJni", bindingName), GenerateFromNativeIndexTask::class.java) { + group = "konan" + description = "Generate the '$bindingName' JNI bindings and stub from its .def." + mustRunAfter(project.cleanTask()) + defFile.set(binding.defFile) + includeDirs.from(binding.includeDirs) + compilerOptions.set(binding.compilerOptions) + // The headers are indexed once, for the host; the bridges are the same on every target. + target.set(NativeTarget.host) + request.set(packageName.zip(jni.visibility) { name, visibility -> GeneratorRequest.Jni(stubLibraryBaseName(name), visibility) }) + extraCinteropArgs.addAll(packageName.map { listOf("-pkg", it) }) + outputDirectory.set(context.buildDirectory.dir("generated/konan/$bindingName/jni")) + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/bindings/jni/KonanLinkStubBuild.kt similarity index 68% rename from src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt rename to src/main/kotlin/io/github/lemcoder/bindings/jni/KonanLinkStubBuild.kt index 55229ab..680c693 100644 --- a/src/main/kotlin/io/github/lemcoder/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/bindings/jni/KonanLinkStubBuild.kt @@ -1,34 +1,35 @@ -package io.github.lemcoder.jni +package io.github.lemcoder.bindings.jni -import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.bindings.LinkSharedLibraryTask import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.link.LinkSharedLibraryTask +import io.github.lemcoder.dsl.bindings.BindingSpec +import io.github.lemcoder.index.GenerateFromNativeIndexTask +import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.model.DefFile import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask +import java.io.File import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider -import java.io.File /** - * [JniStubBuild.KonanLink]: the stub and the library's archive into one shared library per target, with - * Kotlin/Native's toolchain. + * The JNI library's default build: the generated stub and the library's archive into one shared library + * per target, with Kotlin/Native's toolchain. */ internal fun registerKonanLink( project: Project, konan: KonanExtension, binding: BindingSpec, - inputs: JniStubInputs, + generate: TaskProvider, + artifacts: NativeArtifacts?, + targets: List, ): List> { val bindingName = binding.name - val generate = inputs.generate - val artifacts = inputs.artifacts - return inputs.targets.map { target -> - // Where a consumer looks for the library: the host's, the one a JVM running this build loads. - if (target == NativeTarget.host) binding.jni.libraryDirectory.convention(binding.jni.libraryDirectoryFor(target)) - + val jni = binding.jni + val output = generate.flatMap { it.outputDirectory } + return targets.map { target -> project.tasks.register(taskName("linkJni", bindingName, target), LinkSharedLibraryTask::class.java) { group = "konan" description = "Link the '$bindingName' JNI library for ${target.konanName}." @@ -37,27 +38,27 @@ internal fun registerKonanLink( mustRunAfter(project.cleanTask()) androidApiLevel.set(konan.androidApiLevel) this.target.set(target) - libraryBaseName.set(generate.flatMap { it.stubLibraryBaseName }) - stubCFile.set(generate.flatMap { it.stubSourceFile }) + libraryBaseName.set(jni.stubLibraryName) + stubCFile.set(generate.flatMap { it.outputDirectory.file(jni.stubLibraryName.map { name -> "${GeneratorRequest.Jni.C_DIR}/$name.c" }) }) includeDirs.from(binding.includeDirs) // A plain path, not a tracked directory: a .def at the project root would make the whole // project, build/ included, an input of this task. includePaths.add(binding.defFile.map { it.asFile.parentFile.absolutePath }) staticLibrary.set(project.layout.file(project.provider { archive(binding, artifacts, target) })) - if (!target.isAndroid) jniIncludeDirs.from(generate.flatMap { it.jniIncludeDirectory }) + if (!target.isAndroid) jniIncludeDirs.from(output.map { it.dir(GeneratorRequest.Jni.INCLUDE_DIR) }) additionalLinkerArgs.set( - binding.jni.linkerOptions.zip(binding.jni.targetLinkerOptions) { common, perTarget -> + jni.linkerOptions.zip(jni.targetLinkerOptions) { common, perTarget -> defLinkerOpts(binding) + common + perTarget[target].orEmpty() } ) - outputDirectory.set(binding.jni.libraryDirectoryFor(target)) + outputDirectory.set(jni.libraryDirectoryFor(target)) // From the task's own property: a closure over the artifacts would drag their TaskProvider into // the configuration cache. // Linker options alone are enough: `-laaudio` links the stub against a platform library. onlyIf { staticLibrary.isPresent || additionalLinkerArgs.getOrElse(emptyList()).isNotEmpty() || run { logger.lifecycle( - "konan.bindings.$bindingName: no library(), jni.archive, staticLibraries or linkerOpts in the .def, so " + + "konan.bindings.$bindingName: no library(), staticLibraries or linkerOpts in the .def, so " + "only the JNI bindings and the .c stub were generated β€” link them from your own build." ) false @@ -71,12 +72,8 @@ internal fun registerKonanLink( private fun defLinkerOpts(binding: BindingSpec): List = binding.defFile.orNull?.asFile?.takeIf { it.isFile }?.let { DefFile.parse(it).linkerOpts }.orEmpty() -/** - * The archive to link: `jni.archive` if set, else the library's for [target], else the `.def`'s - * `staticLibraries`. - */ +/** The archive to link: the library's for [target], else the `.def`'s `staticLibraries`. */ private fun archive(binding: BindingSpec, artifacts: NativeArtifacts?, target: NativeTarget): File? { - binding.jni.archive.orNull?.asFile?.let { return it.takeIf { f -> f.isFile } } artifacts?.let { return it.archive(target).get().asFile.takeIf { _ -> target in it.targets.getOrElse(emptyList()) } } diff --git a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt deleted file mode 100644 index d2c3ac0..0000000 --- a/src/main/kotlin/io/github/lemcoder/consumer/JniConsumer.kt +++ /dev/null @@ -1,112 +0,0 @@ -package io.github.lemcoder.consumer - -import io.github.lemcoder.jni.GenerateJniBindingsTask -import io.github.lemcoder.jni.JniStubInputs -import io.github.lemcoder.jni.stubBuildFor -import io.github.lemcoder.kotlin.KotlinGradlePlugin -import io.github.lemcoder.model.NativeArtifacts -import io.github.lemcoder.model.NativeTarget -import io.github.lemcoder.model.taskName -import io.github.lemcoder.util.cleanTask -import io.github.lemcoder.wiring.AgpWiring -import io.github.lemcoder.wiring.GradlePluginId -import io.github.lemcoder.wiring.SourceKind -import io.github.lemcoder.wiring.withPlugin -import org.gradle.api.Task -import org.gradle.api.tasks.TaskProvider - -/** - * JVM and Android targets, through the JNI generator: Kotlin `external fun`s in the main sources, and a - * JNI library per target from the binding's [io.github.lemcoder.jni.JniStubBuild]. - */ -internal class JniConsumer(private val context: BindingContext) : BindingConsumer { - private val project = context.project - private val binding = context.binding - private val bindingName = context.bindingName - - private lateinit var generate: TaskProvider - private lateinit var linkAll: TaskProvider - - override fun prepare() { - generate = registerGeneration() - binding.jni.stubDirectoryProperty.set(generate.flatMap { it.stubSourceDirectory }) - binding.jni.stubLibraryNameProperty.set(generate.flatMap { it.stubLibraryBaseName }) - binding.jni.jniLibsRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/jni/jniLibs")) - binding.jni.hostLibRoot.set(context.buildDirectory.dir("konan/bindings/$bindingName/jni/lib")) - linkAll = project.tasks.register(taskName("linkJni", bindingName)) { - group = "konan" - description = "Make the '$bindingName' JNI library exist for every target, however it is built." - } - val sources = generate.flatMap { it.kotlinSourceDirectory } - - // Eager: the variant API must be wired at configuration time. - project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.addKmpJvmSources(project, sources, generate) { !binding.ffm.isEnabled } } - AgpWiring.addSources(project, SourceKind.KOTLIN, sources, generate, linkAll) { !replacedByFfm } - AgpWiring.addJniLibs(project, binding.jni.jniLibsRoot, linkAll) { !replacedByFfm } - // AGP looks for a `baselineProfiles` directory beside every source root, including the generated - // one, by plain path β€” no task dependency. Matched by name: inert without AGP. - project.tasks.matching { it.name.contains("ArtProfile") }.configureEach { dependsOn(generate) } - } - - /** - * `ffm()` replaces JNI wherever JNI would go β€” JVM, multiplatform and Android alike β€” since both would - * define the same facade class. Read once the build script has run. - */ - private val replacedByFfm get() = binding.ffm.isEnabled - - private val isPlainJvmConsumer get() = context.hasPlugin(GradlePluginId.KOTLIN_JVM) && !replacedByFfm - - override fun isUsed(): Boolean = !replacedByFfm && ( - isPlainJvmConsumer || - context.hasPlugin(GradlePluginId.ANDROID_BASE) || context.hasPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) || - (context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project))) - - override fun register() { - if (isPlainJvmConsumer) KotlinGradlePlugin.addJvmSources(project, generate.flatMap { it.kotlinSourceDirectory }) - val artifacts = context.artifacts() - val inputs = JniStubInputs(generate, artifacts, targets(artifacts)) - val builds = stubBuildFor(bindingName, binding.jni).register(project, context.konan, binding, inputs) - linkAll.configure { dependsOn(builds) } - bundleInJar(inputs.targets.filter { !it.isAndroid }) - } - - /** The JVM targets' libraries into the jar of a `kotlin("jvm")` project or a multiplatform `jvm()` target. */ - private fun bundleInJar(jvmTargets: List) { - val jni = binding.jni - val kmpJvm = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) && KotlinGradlePlugin.hasKmpJvmTarget(project) && !binding.ffm.isEnabled - if (!jni.bundleInJar.get() || jvmTargets.isEmpty() || !(isPlainJvmConsumer || kmpJvm)) return - val bundle = project.registerJarBundle( - taskName = taskName("bundleJni", bindingName), - taskDescription = "Put the '$bindingName' JNI libraries in the jar, for the bindings to load.", - libraries = jvmTargets.associateWith { target -> - jni.libraryDirectoryFor(target).zip(jni.stubLibraryName) { directory, name -> directory.file(target.sharedLibraryName(name)) } - }, - destination = context.buildDirectory.dir("konan/bindings/$bindingName/jni/resources").get().asFile, - builtBy = linkAll, - ) - val resources = bundle.map { it.destinationDir } - if (isPlainJvmConsumer) KotlinGradlePlugin.addJvmResources(project, resources) - if (kmpJvm) KotlinGradlePlugin.addKmpJvmResources(project, resources) - } - - /** `jni.targets`, else the library's targets a JVM can load β€” the host and Android β€” else the host. */ - private fun targets(artifacts: NativeArtifacts?): List = binding.jni.targets.getOrElse(emptyList()).ifEmpty { - artifacts?.targets?.getOrElse(emptyList())?.filter { it == NativeTarget.host || it.isAndroid } - ?.ifEmpty { null } - ?: listOf(NativeTarget.host) - } - - private fun registerGeneration(): TaskProvider = - project.tasks.register(taskName("generateJni", bindingName), GenerateJniBindingsTask::class.java) { - group = "konan" - description = "Generate the '$bindingName' JNI bindings and stub from its .def." - mustRunAfter(project.cleanTask()) - defFile.set(binding.defFile) - packageName.set(context.packageName) - includeDirs.from(binding.includeDirs) - hostTarget.set(NativeTarget.host) - compilerOptions.set(binding.compilerOptions) - visibility.set(binding.jni.visibility) - outputDirectory.set(context.buildDirectory.dir("generated/konan/$bindingName/jni")) - } -} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt deleted file mode 100644 index dd3490c..0000000 --- a/src/main/kotlin/io/github/lemcoder/dsl/FfmSpec.kt +++ /dev/null @@ -1,118 +0,0 @@ -package io.github.lemcoder.dsl - -import io.github.lemcoder.ffm.FfmLibraryBuild -import io.github.lemcoder.model.NativeTarget -import org.gradle.api.Action -import org.gradle.api.file.Directory -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider -import java.io.File -import javax.inject.Inject - -/** - * How the FFM bindings are generated, and how the shared library they load comes to exist: linked by the - * plugin from the binding's library (the default), built by the project's own build ([cmake], - * [external]), or built elsewhere ([prebuilt]). On a JVM the generated Java needs a JDK 22+ toolchain. - */ -abstract class FfmSpec @Inject constructor(objects: ObjectFactory) { - init { - panamaPortVersion.convention("v0.1.5") - bundleInJar.convention(true) - } - - /** - * Whether a JVM project's jar carries the library of each FFM target, under `konan/natives/`: the - * bindings then load it from there when `java.library.path` does not have it. On by default. - */ - abstract val bundleInJar: Property - - /** - * The version of PanamaPort (`io.github.vova7878.panama:Core`), the FFM backport an Android module's - * bindings use; the plugin adds it as an `implementation` dependency. It needs `minSdk` 26. - */ - abstract val panamaPortVersion: Property - - /** - * The library the bindings load: `System.loadLibrary(libraryName)`, so `lib.so`. Defaults - * to the binding's library's name, else the binding's. Set it to a prebuilt library's own name so that - * libraries depending on it by that name still find it. - */ - abstract val libraryName: Property - - /** - * Targets to provide the shared library for: the build host on a JVM, 64-bit ABIs in an Android - * module. Defaults to the binding's library's targets among those, else all of those. - */ - abstract val targets: ListProperty - - /** Extra options for the plugin's own link of the library. */ - abstract val linkerOptions: ListProperty - - /** - * Where the shared library ends up: for the build host, to put on `java.library.path` together with - * `--enable-native-access`; in an Android module, the `jniLibs` root the plugin packages. - */ - abstract val libraryDirectory: DirectoryProperty - - fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) - - /** Builds the shared library with the project's own build system, as a list of commands per target. */ - val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) - - fun external(action: Action) { - selectedLibraryBuilds += FfmLibraryBuild.External.Generic - action.execute(external) - } - - /** - * Builds the shared library with the project's CMakeLists: configured per target with the contract as - * cache entries and `CMAKE_LIBRARY_OUTPUT_DIRECTORY` set to where the bindings load it, then built. - */ - val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) - - fun cmake(action: Action) { - selectedLibraryBuilds += FfmLibraryBuild.External.CMake - action.execute(cmake) - } - - /** Builds the shared library with the project's Makefile, once per target. */ - val make: MakeSettings = objects.newInstance(MakeSettings::class.java) - - fun make(action: Action) { - selectedLibraryBuilds += FfmLibraryBuild.External.Make - action.execute(make) - } - - /** Shared libraries built elsewhere, one per target. */ - val prebuilt: FfmPrebuiltSettings = objects.newInstance(FfmPrebuiltSettings::class.java) - - fun prebuilt(action: Action) { - selectedLibraryBuilds += FfmLibraryBuild.Prebuilt - action.execute(prebuilt) - } - - /** Where [target]'s shared library must land: `jniLibs//` on Android, a per-target directory otherwise. */ - fun libraryDirectoryFor(target: NativeTarget): Provider = - (if (target.isAndroid) jniLibsRoot else hostLibRoot).dir(target.libraryDirectoryName) - - internal val jniLibsRoot: DirectoryProperty = objects.directoryProperty() - internal val hostLibRoot: DirectoryProperty = objects.directoryProperty() - - /** The library build blocks this binding's script called; at most one may be. */ - internal val selectedLibraryBuilds = mutableSetOf() - - internal var isEnabled: Boolean = false -} - -/** Shared libraries the FFM bindings load, built elsewhere β€” a vendor's `.so`, say. */ -abstract class FfmPrebuiltSettings { - /** The shared library for each target. */ - abstract val sharedLibraries: MapProperty - - /** The shared library built for [target]; placed as `lib` with the target's suffix. */ - fun sharedLibrary(target: NativeTarget, file: File) = sharedLibraries.put(target, file) -} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt deleted file mode 100644 index 75156c7..0000000 --- a/src/main/kotlin/io/github/lemcoder/dsl/JniSpec.kt +++ /dev/null @@ -1,106 +0,0 @@ -package io.github.lemcoder.dsl - -import io.github.lemcoder.jni.JniStubBuild -import io.github.lemcoder.model.BindingVisibility -import io.github.lemcoder.model.NativeTarget -import org.gradle.api.Action -import org.gradle.api.file.Directory -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.model.ObjectFactory -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.MapProperty -import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider -import javax.inject.Inject - -/** How the JNI bindings are generated and linked. */ -abstract class JniSpec @Inject constructor(objects: ObjectFactory) { - init { - visibility.convention(BindingVisibility.INTERNAL) - bundleInJar.convention(true) - } - - /** - * Whether a JVM project's jar carries the JNI library of each JVM target, under `konan/natives/`: the - * bindings then load it from there when `java.library.path` does not have it. On by default. - */ - abstract val bundleInJar: Property - - /** - * Visibility of the generated bindings. They are an implementation detail β€” the idiomatic API is - * written on top of them β€” so they are `internal` by default. - */ - abstract val visibility: Property - - /** - * Targets to link the JNI library for. Defaults to the library's targets a JVM can load β€” the build - * host and Android β€” or the host when there is no library. - */ - abstract val targets: ListProperty - - /** An archive to link instead of the library's, e.g. one built elsewhere. */ - abstract val archive: RegularFileProperty - - /** Extra options for every target's link command. */ - abstract val linkerOptions: ListProperty - - /** Extra link options for one target, after [linkerOptions]. */ - abstract val targetLinkerOptions: MapProperty> - - /** Where the linked library ends up, whoever linked it: put it on `java.library.path`, or package it. */ - abstract val libraryDirectory: DirectoryProperty - - fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) - - /** Appends [options] to the link command of [target] only. */ - fun linkerOptionsFor(target: NativeTarget, vararg options: String) { - val existing = targetLinkerOptions.getOrElse(emptyMap())[target].orEmpty() - targetLinkerOptions.put(target, existing + options) - } - - /** An external CMake build that compiles the stub; declaring it replaces the plugin's own link. */ - val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) - - fun cmake(action: Action) { - selectedStubBuilds += JniStubBuild.External.CMake - action.execute(cmake) - } - - /** Any other build system that compiles the stub, as a list of commands; replaces the plugin's own link. */ - val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) - - fun external(action: Action) { - selectedStubBuilds += JniStubBuild.External.Generic - action.execute(external) - } - - /** The project's Makefile compiles the stub, once per target; replaces the plugin's own link. */ - val make: MakeSettings = objects.newInstance(MakeSettings::class.java) - - fun make(action: Action) { - selectedStubBuilds += JniStubBuild.External.Make - action.execute(make) - } - - /** The directory holding the generated `.c` stub β€” what an external build compiles. */ - val stubDirectory: Provider get() = stubDirectoryProperty - - /** The library name the generated bindings load: an external build must produce exactly that. */ - val stubLibraryName: Provider get() = stubLibraryNameProperty - - /** Where [target]'s JNI library must land: `jniLibs//` for Android, a per-target directory otherwise. */ - fun libraryDirectoryFor(target: NativeTarget): Provider = - (if (target.isAndroid) jniLibsRoot else hostLibRoot).dir(target.libraryDirectoryName) - - internal val stubDirectoryProperty: DirectoryProperty = objects.directoryProperty() - internal val stubLibraryNameProperty: Property = objects.property(String::class.java) - - // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, and a - // host library sitting in the root fails its merge with "… is not an ABI". - internal val jniLibsRoot: DirectoryProperty = objects.directoryProperty() - internal val hostLibRoot: DirectoryProperty = objects.directoryProperty() - - /** The stub build blocks this binding's script called; at most one may be. */ - internal val selectedStubBuilds = mutableSetOf() -} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt index 7359772..645d8d4 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -1,10 +1,13 @@ package io.github.lemcoder.dsl +import io.github.lemcoder.dsl.bindings.BindingSpec +import io.github.lemcoder.dsl.config.KonanConfig +import io.github.lemcoder.dsl.libraries.NativeLibrarySpec +import javax.inject.Inject import org.gradle.api.Action import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property -import javax.inject.Inject /** * The `konan { }` block: the native libraries a project builds, and the bindings that reach them from diff --git a/src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt deleted file mode 100644 index aecf0f3..0000000 --- a/src/main/kotlin/io/github/lemcoder/dsl/PrebuiltSettings.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.github.lemcoder.dsl - -import io.github.lemcoder.model.NativeTarget -import org.gradle.api.provider.MapProperty -import java.io.File - -/** - * A library's archives built elsewhere β€” a vendor drop, another repository's CI. Each is copied to - * `/lib.a`, so a Windows `name.lib` works as it is. - */ -abstract class PrebuiltSettings { - /** The archive for each target. */ - abstract val archives: MapProperty - - /** The static archive built for [target]. */ - fun archive(target: NativeTarget, file: File) = archives.put(target, file) -} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt new file mode 100644 index 0000000..de0cd42 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt @@ -0,0 +1,64 @@ +package io.github.lemcoder.dsl.bindings + +import io.github.lemcoder.dsl.builds.ExternalBuildBlocks +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider + +/** + * The shared library a binding's JVM or Android bindings load, one per target β€” JNI's stub linked with the + * C library, or for FFM the C library itself: which targets get one, how it is linked or built, where it + * lands, and whether a JVM jar carries it. Linked by the plugin with Kotlin/Native's toolchain unless a + * build block ([cmake], [make], [external]) is called. + */ +abstract class BindingLibrarySpec(objects: ObjectFactory) : ExternalBuildBlocks(objects) { + init { + bundleInJar.convention(true) + } + + /** + * Whether a JVM project's jar carries the library of each JVM target, under `konan/natives/`: the + * bindings then load it from there when `java.library.path` does not have it. On by default. + */ + abstract val bundleInJar: Property + + /** + * Targets to provide the library for. Defaults to the targets of `cmake { target(…) { } }` blocks, else + * the binding's library's targets the bindings can load, else what they can load. + */ + abstract val targets: ListProperty + + /** Extra options for every target's link by the plugin. */ + abstract val linkerOptions: ListProperty + + /** Extra link options for one target, after [linkerOptions]. */ + abstract val targetLinkerOptions: MapProperty> + + /** + * Where the library ends up, whoever built it: for the build host, to put on `java.library.path`; in an + * Android module, the `jniLibs` root the plugin packages. + */ + abstract val libraryDirectory: DirectoryProperty + + fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) + + /** Appends [options] to the link command of [target] only. */ + fun linkerOptionsFor(target: NativeTarget, vararg options: String) { + val existing = targetLinkerOptions.getOrElse(emptyMap())[target].orEmpty() + targetLinkerOptions.put(target, existing + options) + } + + /** Where [target]'s library must land: `jniLibs//` for Android, a per-target directory otherwise. */ + fun libraryDirectoryFor(target: NativeTarget): Provider = + (if (target.isAndroid) jniLibsRoot else hostLibRoot).dir(target.libraryDirectoryName) + + // Only per-ABI libraries may live under jniLibs: AGP reads each subdirectory name as an ABI, and a + // host library sitting in the root fails its merge with "… is not an ABI". + internal val jniLibsRoot: DirectoryProperty = objects.directoryProperty() + internal val hostLibRoot: DirectoryProperty = objects.directoryProperty() +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/BindingSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingSpec.kt similarity index 91% rename from src/main/kotlin/io/github/lemcoder/dsl/BindingSpec.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingSpec.kt index e83e53a..a495f33 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/BindingSpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingSpec.kt @@ -1,5 +1,9 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.bindings +import io.github.lemcoder.dsl.bindings.ffm.FfmSpec +import io.github.lemcoder.dsl.bindings.jni.JniSpec +import io.github.lemcoder.dsl.libraries.NativeLibrarySpec +import javax.inject.Inject import org.gradle.api.Action import org.gradle.api.Named import org.gradle.api.file.ConfigurableFileCollection @@ -7,7 +11,6 @@ import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property -import javax.inject.Inject /** * A C API, described by a cinterop `.def`, and how each kind of consumer reaches it. diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmSpec.kt new file mode 100644 index 0000000..80d82b1 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmSpec.kt @@ -0,0 +1,41 @@ +package io.github.lemcoder.dsl.bindings.ffm + +import io.github.lemcoder.dsl.bindings.BindingLibrarySpec +import io.github.lemcoder.dsl.builds.PrebuiltSettings +import javax.inject.Inject +import org.gradle.api.Action +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property + +/** + * How the FFM bindings are generated, and the shared library they load: linked by the plugin from the + * binding's library (the default), built by the project's own build, or built elsewhere ([prebuilt]). FFM + * needs no stub β€” this is the C library itself. On a JVM the generated Java needs a JDK 22+ toolchain. + * [targets] are the build host on a JVM and 64-bit ABIs in an Android module β€” jextract writes LP64 + * layouts β€” defaulting to the binding's library's targets among those. + */ +abstract class FfmSpec @Inject constructor(objects: ObjectFactory) : BindingLibrarySpec(objects) { + init { + panamaPortVersion.convention("v0.1.5") + } + + /** + * The version of PanamaPort (`io.github.vova7878.panama:Core`), the FFM backport an Android module's + * bindings use; the plugin adds it as an `implementation` dependency. It needs `minSdk` 26. + */ + abstract val panamaPortVersion: Property + + /** + * The library the bindings load: `System.loadLibrary(libraryName)`, so `lib.so`. Defaults + * to the binding's library's name, else the binding's. Set it to a prebuilt library's own name so that + * libraries depending on it by that name still find it. + */ + abstract val libraryName: Property + + /** Shared libraries built elsewhere, one per target. */ + val prebuilt: PrebuiltSettings = objects.newInstance(PrebuiltSettings::class.java) + + fun prebuilt(action: Action) = select(prebuilt, action) + + internal var isEnabled: Boolean = false +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniSpec.kt new file mode 100644 index 0000000..919908a --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniSpec.kt @@ -0,0 +1,37 @@ +package io.github.lemcoder.dsl.bindings.jni + +import io.github.lemcoder.dsl.bindings.BindingLibrarySpec +import io.github.lemcoder.model.BindingVisibility +import javax.inject.Inject +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider + +/** + * How the JNI bindings are generated, and the JNI library they load: the generated `.c` stub linked with + * the binding's library β€” by the plugin, or by the project's own build, which gets `KONAN_JNI_STUB_DIR` + * and `KONAN_JNI_INCLUDE_DIR` besides the common contract. [targets] default to the library's targets a + * JVM can load β€” the build host and Android β€” or the host when there is no library. + */ +abstract class JniSpec @Inject constructor(objects: ObjectFactory) : BindingLibrarySpec(objects) { + init { + visibility.convention(BindingVisibility.INTERNAL) + } + + /** + * Visibility of the generated bindings. They are an implementation detail β€” the idiomatic API is + * written on top of them β€” so they are `internal` by default. + */ + abstract val visibility: Property + + /** The directory holding the generated `.c` stub β€” what an external build compiles. */ + val stubDirectory: Provider get() = stubDirectoryProperty + + /** The library name the generated bindings load: an external build must produce exactly that. */ + val stubLibraryName: Provider get() = stubLibraryNameProperty + + internal val stubDirectoryProperty: DirectoryProperty = objects.directoryProperty() + internal val stubLibraryNameProperty: Property = objects.property(String::class.java) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/BuildSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/BuildSettings.kt new file mode 100644 index 0000000..da11a7f --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/BuildSettings.kt @@ -0,0 +1,25 @@ +package io.github.lemcoder.dsl.builds + +/** + * How a library is built, when the build script says: one block β€” `cmake { }`, `make { }`, `cargo { }`, + * `zig { }`, `external { }` or `prebuilt { }`. A library, a binding's `jni` and its `ffm` each offer the + * blocks that make sense for them; calling none leaves the build to the plugin, calling two is an error. + * + * Every block but `prebuilt { }` runs the project's own build system once per target, told the plugin's + * contract (`KONAN_LIB_NAME`, `KONAN_OUTPUT_DIR`, `KONAN_TARGET`, `KONAN_ABI`, and what the owner adds). + */ +sealed interface BuildSettings + +/** A block run by the project's own build system, once per target: all but `prebuilt { }`. */ +sealed interface ExternalBuildSettings : BuildSettings + +/** The block's name as a build script calls it, for messages. */ +internal val BuildSettings.blockName: String + get() = when (this) { + is CMakeSettings -> "cmake { }" + is MakeSettings -> "make { }" + is CargoSettings -> "cargo { }" + is ZigSettings -> "zig { }" + is StepsSettings -> "external { }" + is PrebuiltSettings -> "prebuilt { }" + } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt similarity index 70% rename from src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt index 80e0777..1a1d861 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt @@ -1,6 +1,5 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action import org.gradle.api.file.DirectoryProperty @@ -11,12 +10,13 @@ import org.gradle.api.provider.Property import javax.inject.Inject /** - * An external CMake build that makes the library a binding loads β€” the JNI stub (`jni { cmake { } }`) or - * the C library FFM loads (`ffm { cmake { } }`) β€” the way AGP's `externalNativeBuild` declares that CMake - * owns a module's native code. The plugin then drives that build instead of linking anything itself: it - * passes the contract as cache entries (`KONAN_JNI_STUB_DIR` and `KONAN_JNI_LIB_NAME`, or - * `KONAN_FFM_LIB_NAME`, among others), sets `CMAKE_LIBRARY_OUTPUT_DIRECTORY` to where the library must - * land, and orders it before whatever loads the library. + * A CMake build that makes a library β€” a library's archive (`libraries { cmake { } }`), a binding's JNI + * library (`jni { cmake { } }`) or the library FFM loads (`ffm { cmake { } }`) β€” the way AGP's + * `externalNativeBuild` declares that CMake owns a module's native code. The plugin drives that build + * instead of compiling or linking anything itself: it passes the contract as cache entries + * (`KONAN_LIB_NAME`, `KONAN_OUTPUT_DIR`, `KONAN_TARGET`, `KONAN_ABI`, and the owner's own), sets + * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` and `CMAKE_ARCHIVE_OUTPUT_DIRECTORY` to where the library must land, + * and orders it before whatever uses the library. * * This is the right shape whenever the native library is not the plugin's to build β€” a vendored CMake * project, say β€” because linking an archive from a foreign toolchain with konan's linker is what @@ -25,10 +25,10 @@ import javax.inject.Inject * Either name a [preset] or let the plugin configure [path] into [buildDirectory] directly; everything * else has a default. * - * Declaring [target]s builds once per target instead of once for the host, which is what Android needs: - * the libraries land in `jniLibs//` and are packaged into the AAR. + * [target] blocks give a target its own settings. For a binding's library they also choose the targets + * when `targets` is not set: the libraries for Android ABIs land in `jniLibs//` and are packaged. */ -abstract class CMakeSettings @Inject constructor(private val objects: ObjectFactory) { +abstract class CMakeSettings @Inject constructor(private val objects: ObjectFactory) : ExternalBuildSettings { init { toolchain.convention(ExternalToolchain.AUTO) @@ -40,12 +40,12 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact */ abstract val toolchain: Property - /** Builds for [target] β€” an Android ABI, say β€” with its own settings, instead of once for the host. */ + /** Builds for [target] β€” an Android ABI, say β€” with its own settings. */ fun target(target: NativeTarget, action: Action) { action.execute(targetSettings.getOrPut(target) { objects.newInstance(CMakeTargetSettings::class.java) }) } - /** The per-target builds declared; empty means a single build for the host. */ + /** The per-target settings declared. */ internal val targetSettings = linkedMapOf() /** The `CMakeLists.txt` to configure. Required unless [preset] is set. */ @@ -61,9 +61,8 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact abstract val buildDirectory: DirectoryProperty /** - * Directory holding the linked library, if the build does not leave it where - * `CMAKE_LIBRARY_OUTPUT_DIRECTORY` says: for JNI, where consumers then look; for FFM, where the plugin - * copies it from. + * Where the build leaves its library, if not where `CMAKE_*_OUTPUT_DIRECTORY` says: the plugin copies + * it from there into place. */ abstract val libraryDirectory: DirectoryProperty diff --git a/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoSettings.kt similarity index 93% rename from src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/CargoSettings.kt index 5402cb3..e5f3fed 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/CargoSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoSettings.kt @@ -1,6 +1,5 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.model.ExternalToolchain import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property @@ -11,7 +10,7 @@ import org.gradle.api.provider.Property * library's archive, so bindings and consumers do not change. Each target's standard library has to be * installed: `rustup target add `. */ -abstract class CargoSettings { +abstract class CargoSettings : ExternalBuildSettings { init { profile.convention(CargoProfile.RELEASE) toolchain.convention(ExternalToolchain.AUTO) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt new file mode 100644 index 0000000..11e1eac --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt @@ -0,0 +1,47 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.dsl.builds.BuildSettings +import io.github.lemcoder.dsl.builds.CMakeSettings +import io.github.lemcoder.dsl.builds.MakeSettings +import io.github.lemcoder.dsl.builds.StepsSettings +import org.gradle.api.Action +import org.gradle.api.model.ObjectFactory + +/** + * The build blocks of everything that makes a library β€” `konan.libraries`, a binding's `jni` and `ffm`: + * [cmake], [make] and [external] here, the rest on the owners that take them. Calling one selects its + * [BuildSettings]; at most one may be called. + */ +abstract class ExternalBuildBlocks(objects: ObjectFactory) { + /** + * The project's CMakeLists, configured once per target with `CMAKE_LIBRARY_OUTPUT_DIRECTORY` and + * `CMAKE_ARCHIVE_OUTPUT_DIRECTORY` set to where the library must land, then built β€” + * `add_library(${KONAN_LIB_NAME} …)` is all it needs β€” with Kotlin/Native's compilers as a toolchain + * file when cross-compiling. + */ + val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) + + fun cmake(action: Action) = select(cmake, action) + + /** The project's Makefile, once per target, with Kotlin/Native's compilers on make's command line when cross-compiling. */ + val make: MakeSettings = objects.newInstance(MakeSettings::class.java) + + fun make(action: Action) = select(make, action) + + /** + * Any other build system, as a list of commands run once per target. The build writes the library into + * `KONAN_OUTPUT_DIR`, or names where it leaves it with `producedLibrary`. + */ + val external: StepsSettings = objects.newInstance(StepsSettings::class.java) + + fun external(action: Action) = select(external, action) + + /** The blocks this owner's script called; at most one may be. */ + internal val selectedBuilds = mutableSetOf() + + /** Selects [settings] and configures them. */ + internal fun select(settings: T, action: Action) { + selectedBuilds += settings + action.execute(settings) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalToolchain.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt similarity index 89% rename from src/main/kotlin/io/github/lemcoder/model/ExternalToolchain.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt index 4f7736b..6ca357e 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ExternalToolchain.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt @@ -1,4 +1,6 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.model.NativeTarget /** Which compilers an external build uses for a target. */ enum class ExternalToolchain { diff --git a/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeSettings.kt similarity index 95% rename from src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/MakeSettings.kt index c7dbbb0..a3cc6d1 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/MakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeSettings.kt @@ -1,6 +1,5 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty @@ -20,7 +19,7 @@ import java.io.File * apart per target (under the contract's output directory, or `KONAN_TARGET`), or a second target reuses * the first one's objects. */ -abstract class MakeSettings { +abstract class MakeSettings : ExternalBuildSettings { init { toolchain.convention(ExternalToolchain.AUTO) jobs.convention(Runtime.getRuntime().availableProcessors()) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/PrebuiltSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/PrebuiltSettings.kt new file mode 100644 index 0000000..67d63f8 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/PrebuiltSettings.kt @@ -0,0 +1,19 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.provider.MapProperty +import java.io.File + +/** + * Libraries built elsewhere β€” a vendor drop, another repository's CI β€” one per target, placed where the + * plugin's own build would leave them, under the name consumers look for: a library's archive as + * `/lib.a`, so a Windows `name.lib` works as it is; the library FFM loads as + * `lib` with the target's suffix. + */ +abstract class PrebuiltSettings : BuildSettings { + /** The library for each target. */ + abstract val libraries: MapProperty + + /** The library built for [target]. */ + fun library(target: NativeTarget, file: File) = libraries.put(target, file) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/StepsSettings.kt similarity index 77% rename from src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/StepsSettings.kt index 5f8c9f0..4a85bf0 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/ExternalBuildSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/StepsSettings.kt @@ -1,7 +1,6 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.builds import io.github.lemcoder.model.Executable -import io.github.lemcoder.model.ExternalToolchain import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action import org.gradle.api.file.ConfigurableFileCollection @@ -16,11 +15,12 @@ import javax.inject.Inject /** * Any build system, as a list of commands run once per target β€” for a library (`libraries { external - * { } }`) or a JNI stub (`jni { external { } }`). Each run gets the plugin's contract as environment - * variables, documented on the block that owns this one. The build writes its library where the contract - * says, or names where it leaves it with [producedLibrary], and the plugin copies it into place. + * { } }`), a JNI stub (`jni { external { } }`) or the library FFM loads (`ffm { external { } }`). Each run + * gets the plugin's contract as environment variables, see [BuildSettings]. The build writes its + * library where the contract says, or names where it leaves it with [producedLibrary], and the plugin + * copies it into place. */ -abstract class ExternalBuildSettings @Inject constructor(private val objects: ObjectFactory) { +abstract class StepsSettings @Inject constructor(private val objects: ObjectFactory) : ExternalBuildSettings { init { toolchain.convention(ExternalToolchain.SYSTEM) } @@ -38,22 +38,22 @@ abstract class ExternalBuildSettings @Inject constructor(private val objects: Ob */ abstract val sources: ConfigurableFileCollection - /** Where the build leaves each target's library, when it does not write it to `KONAN_JNI_OUTPUT_DIR`. */ + /** Where the build leaves each target's library, when it does not write it to `KONAN_OUTPUT_DIR`. */ abstract val producedLibraries: MapProperty /** The library the build leaves at [file] for [target]; the plugin copies and renames it into place. */ fun producedLibrary(target: NativeTarget, file: File) = producedLibraries.put(target, file) /** Appends a command; commands run in declaration order, each only after the previous succeeded. */ - fun step(action: Action) { - steps += objects.newInstance(ExternalStepSpec::class.java).also { action.execute(it) } + fun step(action: Action) { + steps += objects.newInstance(StepSpec::class.java).also { action.execute(it) } } - internal val steps = mutableListOf() + internal val steps = mutableListOf() } -/** One command of an [ExternalBuildSettings] build. */ -abstract class ExternalStepSpec { +/** One command of a [StepsSettings] build. */ +abstract class StepSpec { /** The program to run. Required. */ abstract val executable: Property diff --git a/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigSettings.kt similarity index 93% rename from src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/ZigSettings.kt index 3ce68d6..688fd7d 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/ZigSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigSettings.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.builds import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.ListProperty @@ -8,7 +8,7 @@ import org.gradle.api.provider.Property * `zig build` for a library, once per target with `-Dtarget` set to the Zig triple that matches * Kotlin/Native's sysroot. The `build.zig` must install a static library ([artifactName]). */ -abstract class ZigSettings { +abstract class ZigSettings : ExternalBuildSettings { init { optimize.convention(ZigOptimize.RELEASE_FAST) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt similarity index 98% rename from src/main/kotlin/io/github/lemcoder/dsl/KonanConfig.kt rename to src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt index 263c18a..7a84a29 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/KonanConfig.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt @@ -1,18 +1,19 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.config +import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.kotlin.KonanDistribution import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ConfigSource import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.Executable +import java.io.File +import javax.inject.Inject import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.FileSystemLocationProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Provider import org.gradle.api.provider.ProviderFactory -import java.io.File -import javax.inject.Inject /** * `konan { config { } }`: the paths the plugin needs from the machine β€” the Kotlin/Native distribution and diff --git a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt similarity index 53% rename from src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt rename to src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt index d61fc45..13f8da1 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt @@ -1,8 +1,12 @@ -package io.github.lemcoder.dsl +package io.github.lemcoder.dsl.libraries -import io.github.lemcoder.library.LibraryProvider +import io.github.lemcoder.dsl.builds.CargoSettings +import io.github.lemcoder.dsl.builds.ExternalBuildBlocks +import io.github.lemcoder.dsl.builds.PrebuiltSettings +import io.github.lemcoder.dsl.builds.ZigSettings import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget +import javax.inject.Inject import org.gradle.api.Action import org.gradle.api.Named import org.gradle.api.file.ConfigurableFileCollection @@ -12,14 +16,15 @@ import org.gradle.api.file.RegularFile import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Provider -import javax.inject.Inject /** * A C/C++ library built into a static archive per target, `//lib.a`: by * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([cmake], [zig], [cargo], [make], [external]), or built - * elsewhere ([prebuilt]). + * elsewhere ([prebuilt]). An external build gets `KONAN_SOURCE_DIR` besides the common contract, and leaves + * `lib${KONAN_LIB_NAME}.a` in `KONAN_OUTPUT_DIR`, or names where it leaves its archive with `producedLibrary`. */ -abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : Named { +abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : + ExternalBuildBlocks(objects), Named { override fun getName(): String = name /** Targets to build for. Required, unless [prebuilt] declares them. */ @@ -54,73 +59,24 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o */ val zig: ZigSettings = objects.newInstance(ZigSettings::class.java) - fun zig(action: Action) { - selectedProviders += LibraryProvider.External.Zig - action.execute(zig) - } - - /** - * Builds the library with any other build system, as a list of commands run once per target. Each run - * gets `KONAN_LIBRARY_NAME`, `KONAN_LIBRARY_OUTPUT_DIR`, `KONAN_LIBRARY_SOURCE_DIR` and `KONAN_TARGET`; - * the build writes `lib.a` into the output directory, or names where it leaves its archive with - * `producedLibrary`. - */ - val external: ExternalBuildSettings = objects.newInstance(ExternalBuildSettings::class.java) - - fun external(action: Action) { - selectedProviders += LibraryProvider.External.Generic - action.execute(external) - } - - /** - * Builds the library with the project's Makefile, once per target, with Kotlin/Native's compilers on - * make's command line when cross-compiling; the archive lands in `KONAN_LIBRARY_OUTPUT_DIR` as - * `lib.a`, or where `producedLibrary` says. - */ - val make: MakeSettings = objects.newInstance(MakeSettings::class.java) - - fun make(action: Action) { - selectedProviders += LibraryProvider.External.Make - action.execute(make) - } - - /** - * Builds the library with the project's CMakeLists, once per target: the contract as cache entries, - * `CMAKE_ARCHIVE_OUTPUT_DIRECTORY` set to where the archive must land, and Kotlin/Native's compilers - * as a toolchain file when cross-compiling β€” `add_library(${KONAN_LIBRARY_NAME} STATIC …)` is all the - * CMakeLists needs. - */ - val cmake: CMakeSettings = objects.newInstance(CMakeSettings::class.java) - - fun cmake(action: Action) { - selectedProviders += LibraryProvider.External.CMake - action.execute(cmake) - } + fun zig(action: Action) = select(zig, action) /** Builds the library from a Rust crate (`crate-type = ["staticlib"]`) with `cargo build`, once per target. */ val cargo: CargoSettings = objects.newInstance(CargoSettings::class.java) - fun cargo(action: Action) { - selectedProviders += LibraryProvider.External.Cargo - action.execute(cargo) - } + fun cargo(action: Action) = select(cargo, action) /** Archives built elsewhere, one per target; [targets] default to the targets they are declared for. */ val prebuilt: PrebuiltSettings = objects.newInstance(PrebuiltSettings::class.java) - fun prebuilt(action: Action) { - selectedProviders += LibraryProvider.Prebuilt - action.execute(prebuilt) - } - - /** The provider blocks this library's script called; at most one may be. */ - internal val selectedProviders = mutableSetOf() + fun prebuilt(action: Action) = select(prebuilt, action) init { zig.projectDirectory.convention(sourceDirectory) zig.artifactName.convention(name) cargo.crateName.convention(name) + cargo.manifest.convention(sourceDirectory.file("Cargo.toml")) cmake.path.convention(sourceDirectory.file("CMakeLists.txt")) - targets.convention(prebuilt.archives.keySet().map { it.toList() }) + targets.convention(prebuilt.libraries.keySet().map { it.toList() }) } } diff --git a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt index ebe510c..63bb42b 100644 --- a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt @@ -1,7 +1,7 @@ package io.github.lemcoder.external -import io.github.lemcoder.dsl.CMakeSettings -import io.github.lemcoder.dsl.konanConfig +import io.github.lemcoder.dsl.builds.CMakeSettings +import io.github.lemcoder.dsl.config.konanConfig import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.ToolchainVariable diff --git a/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt b/src/main/kotlin/io/github/lemcoder/external/CargoCommands.kt similarity index 63% rename from src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt rename to src/main/kotlin/io/github/lemcoder/external/CargoCommands.kt index 39bbcd5..e388c32 100644 --- a/src/main/kotlin/io/github/lemcoder/library/CargoBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/external/CargoCommands.kt @@ -1,27 +1,25 @@ -package io.github.lemcoder.library +package io.github.lemcoder.external -import io.github.lemcoder.dsl.CargoProfile -import io.github.lemcoder.dsl.NativeLibrarySpec -import io.github.lemcoder.dsl.konanConfig -import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.dsl.builds.CargoProfile +import io.github.lemcoder.dsl.builds.CargoSettings +import io.github.lemcoder.dsl.config.konanConfig import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.toolchain.rustTriple -import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider /** - * [LibraryProvider.External.Cargo]: `cargo build --lib` for one target, into a target directory of the - * plugin's own; the crate's static library is then copied to where every other provider puts the archive. + * [ExternalBuild.Cargo]: `cargo build --lib` for one target, into [targetDirectory]; the crate's static + * library is then copied to where every other build puts the archive. */ -internal fun addCargoCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) { - val cargo = library.cargo +internal fun ExternalBuildTask.addCargoCommands(cargo: CargoSettings, targetDirectory: Provider, context: ExternalBuildContext) { val target = context.target val triple = target.rustTriple() - val targetDirectory = project.layout.buildDirectory.dir("konan/libraries/${library.name}/cargo") - val manifest = cargo.manifest.orElse(library.sourceDirectory.file("Cargo.toml")) + val manifest = cargo.manifest - val cargoExecutable = project.konanConfig.executable(ConfigField.CARGO, task.path) - task.command { + val cargoExecutable = project.konanConfig.executable(ConfigField.CARGO, path) + command { executable.set(cargoExecutable) arguments.addAll("build", "--lib", "--target", triple) arguments.addAll(manifest.map { listOf("--manifest-path", it.asFile.absolutePath) }) @@ -37,7 +35,7 @@ internal fun addCargoCommands(task: ExternalBuildTask, project: Project, library environment.put("AR_$suffix", toolchain.ar.map { it.absolutePath }) } } - task.producedLibrary.set( + producedLibrary.set( targetDirectory.zip(cargo.profile) { directory, profile -> directory.dir("$triple/${profile.directory}") } .zip(cargo.crateName) { directory, crate -> directory.file("lib${crate.replace('-', '_')}.a") } ) diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuild.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuild.kt new file mode 100644 index 0000000..75738b4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/external/ExternalBuild.kt @@ -0,0 +1,162 @@ +package io.github.lemcoder.external + +import io.github.lemcoder.dsl.builds.CMakeSettings +import io.github.lemcoder.dsl.builds.CargoSettings +import io.github.lemcoder.dsl.builds.ExternalBuildSettings +import io.github.lemcoder.dsl.builds.ExternalToolchain +import io.github.lemcoder.dsl.builds.MakeSettings +import io.github.lemcoder.dsl.builds.StepsSettings +import io.github.lemcoder.dsl.builds.ZigSettings +import io.github.lemcoder.kotlin.KonanToolchain +import io.github.lemcoder.model.ExternalBuildContext +import io.github.lemcoder.model.ExternalBuildVariable +import io.github.lemcoder.model.ExternalVariable +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName +import io.github.lemcoder.util.cleanTask +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider + +/** + * What an external build makes, for each of [targets], and where it must leave it. Owners differ only in + * this β€” a build system's commands are the same for a library, a JNI stub and the library FFM loads. + */ +internal class ExternalBuildOutput( + /** The owning block's DSL path, for messages: `konan.libraries.mymath`, `konan.bindings.mymath.jni`. */ + val owner: String, + /** The library's or binding's name, in task names. */ + val name: String, + /** Between the build system and [name] in task names: `""`, `"Jni"`, `"Ffm"` β€” `cmakeBuildJniMymath…`. */ + val side: String, + val targets: List, + /** The [ExternalVariable.LIB_NAME] the build is told. */ + val libraryName: Provider, + /** The file the build must leave in [outputDirectory]: `lib.a`, or a shared library. */ + val libraryFileName: (NativeTarget) -> Provider, + val outputDirectory: (NativeTarget) -> Provider, + /** Where a build system's own files go: CMake's build trees, Cargo's target directory, Zig's prefixes. */ + val workDirectory: Provider, + /** A task description, given the target and the build system's name. */ + val description: (NativeTarget, String) -> String, + /** Variables only this kind of output has, e.g. [ExternalVariable.JNI_STUB_DIR]. */ + val extraVariables: (NativeTarget) -> Map> = { emptyMap() }, + /** Further wiring of every task, e.g. the generated stub as an input. */ + val configure: ExternalBuildTask.() -> Unit = {}, +) + +/** + * The project's own build system, configured by one [ExternalBuildSettings] block, run once per target as an + * [ExternalBuildTask] told the [ExternalVariable] contract. A build system only adds its commands; a new one + * is one more subclass, a settings class and a DSL block. + * + * Where [toolchain] picks Kotlin/Native's for a target, the build waits for its files and the context + * carries them β€” and, with [toolchainInEnvironment], its [io.github.lemcoder.model.ToolchainVariable]s + * under the standard names. Null means the build system brings its own (Zig). + */ +internal sealed class ExternalBuild(private val taskPrefix: String, private val toolName: String) { + protected abstract val toolchain: ExternalToolchain? + + protected open val toolchainInEnvironment: Boolean get() = true + + protected abstract fun ExternalBuildTask.addCommands(project: Project, output: ExternalBuildOutput, context: ExternalBuildContext) + + fun register(project: Project, output: ExternalBuildOutput): List> { + val clean = project.cleanTask() + val toolchain = toolchain + return output.targets.map { target -> + val konanToolchain = toolchain?.takeIf { it.usesKonan(target) }?.let { KonanToolchain.of(project, target) } + val targetOutput = output.outputDirectory(target) + val contract: Map> = mapOf( + ExternalVariable.LIB_NAME to output.libraryName, + ExternalVariable.OUTPUT_DIR to targetOutput.map { it.asFile.absolutePath }, + ExternalVariable.TARGET to project.provider { target.konanName }, + ExternalVariable.ABI to project.provider { target.libraryDirectoryName }, + ) + output.extraVariables(target) + val environment = if (konanToolchain != null && toolchainInEnvironment) contract + konanToolchain.files.environment else contract + val context = ExternalBuildContext(target, targetOutput, environment, konanToolchain?.files) + val taskDescription = output.description(target, toolName) + val fileName = output.libraryFileName(target) + project.tasks.register(taskName(taskPrefix + output.side, output.name, target), ExternalBuildTask::class.java) { + group = "konan" + description = taskDescription + mustRunAfter(clean) + konanToolchain?.let { dependsOn(it.task) } + context.environment.forEach { (variable, value) -> this.environment.put(variable.variableName, value) } + libraryFileName.set(fileName) + outputDirectory.set(targetOutput) + output.configure(this) + addCommands(project, output, context) + } + } + } + + companion object { + /** The build system [settings] configure. */ + fun of(settings: ExternalBuildSettings): ExternalBuild = when (settings) { + is StepsSettings -> Generic(settings) + is MakeSettings -> Make(settings) + is CMakeSettings -> CMake(settings) + is CargoSettings -> Cargo(settings) + is ZigSettings -> Zig(settings) + } + } + + /** `external { }`: any build system, as the commands the build script lists. */ + data class Generic(val settings: StepsSettings) : ExternalBuild("externalBuild", "its external build") { + override val toolchain get() = settings.toolchain.get() + override fun ExternalBuildTask.addCommands(project: Project, output: ExternalBuildOutput, context: ExternalBuildContext) = + addSteps(settings, context.target, output.owner) + } + + /** `make { }`: the project's Makefile, with Kotlin/Native's compilers when cross-compiling. */ + data class Make(val settings: MakeSettings) : ExternalBuild("makeBuild", "make") { + override val toolchain get() = settings.toolchain.get() + override fun ExternalBuildTask.addCommands(project: Project, output: ExternalBuildOutput, context: ExternalBuildContext) = + addMakeCommands(settings, context, "${output.owner}.make") + } + + /** `cmake { }`: the project's CMakeLists, configured with the contract as cache entries, then built. */ + data class CMake(val settings: CMakeSettings) : ExternalBuild("cmakeBuild", "CMake") { + override val toolchain get() = settings.toolchain.get() + override fun ExternalBuildTask.addCommands(project: Project, output: ExternalBuildOutput, context: ExternalBuildContext) { + addCMakeCommands(project, settings, context, "${output.owner}.cmake", output.workDirectory.map { it.dir("cmake") }) + // A build that ignores CMAKE_*_OUTPUT_DIRECTORY: its library is copied into place. + producedLibrary.set(settings.libraryDirectory.zip(libraryFileName) { directory, file -> directory.file(file) }) + } + } + + /** `cargo { }`: a Rust crate built as a static library, per Rust target triple. */ + data class Cargo(val settings: CargoSettings) : ExternalBuild("cargoBuild", "cargo") { + override val toolchain get() = settings.toolchain.get() + // Under the cc crate's target-specific names instead; see addCargoCommands. + override val toolchainInEnvironment get() = false + override fun ExternalBuildTask.addCommands(project: Project, output: ExternalBuildOutput, context: ExternalBuildContext) = + addCargoCommands(settings, output.workDirectory.map { it.dir("cargo") }, context) + } + + /** `zig { }`: the project's own `build.zig`. */ + data class Zig(val settings: ZigSettings) : ExternalBuild("zigBuild", "zig build") { + // Zig is its own cross toolchain. + override val toolchain: ExternalToolchain? get() = null + override fun ExternalBuildTask.addCommands(project: Project, output: ExternalBuildOutput, context: ExternalBuildContext) = + addZigCommands(settings, output.workDirectory.map { it.dir("zig") }, context) + } +} + +/** The build script's own steps, as they are, for [target]: what a generic `external { }` block runs. */ +internal fun ExternalBuildTask.addSteps(settings: StepsSettings, target: NativeTarget, owner: String) { + check(settings.steps.isNotEmpty()) { "$owner.external declares no step." } + settings.steps.forEach { step -> + command { + executable.set(step.executable) + arguments.addAll(step.arguments) + arguments.addAll(step.targetArguments.getting(target).orElse(emptyList())) + environment.putAll(step.environment) + workingDirectory.set(step.workingDirectory.map { it.asFile.absolutePath }) + } + } + sources.from(settings.sources) + producedLibrary.fileProvider(settings.producedLibraries.getting(target)) +} diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt b/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt deleted file mode 100644 index 0b81bde..0000000 --- a/src/main/kotlin/io/github/lemcoder/external/ExternalBuilds.kt +++ /dev/null @@ -1,74 +0,0 @@ -package io.github.lemcoder.external - -import io.github.lemcoder.dsl.ExternalBuildSettings -import io.github.lemcoder.kotlin.KonanToolchain -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ExternalToolchain -import io.github.lemcoder.model.NativeTarget -import io.github.lemcoder.model.ToolchainVariable -import io.github.lemcoder.model.taskName -import io.github.lemcoder.util.cleanTask -import org.gradle.api.Project -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.TaskProvider - -/** - * An external build for each of [targets]: one [ExternalBuildTask] per target, named - * ``, run with [contextFor]'s contract as environment and expected to leave - * [libraryFileName] in the context's output directory. [configure] adds the build system's commands β€” - * the only part that differs between build systems and between a library and a JNI stub. - * - * Where [toolchain] picks Kotlin/Native's for a target, the build waits for its files and the context - * carries them β€” and, with [toolchainInEnvironment], its [ToolchainVariable]s under the standard names. - * Null means the build system brings its own (Zig). - */ -internal fun Project.registerExternalBuilds( - taskPrefix: String, - owner: String, - description: (NativeTarget) -> String, - targets: List, - contextFor: (NativeTarget) -> ExternalBuildContext, - libraryFileName: (NativeTarget) -> Provider, - toolchain: ExternalToolchain?, - toolchainInEnvironment: Boolean = true, - configure: ExternalBuildTask.(ExternalBuildContext) -> Unit, -): List> { - val clean = cleanTask() - return targets.map { target -> - val konanToolchain = toolchain?.takeIf { it.usesKonan(target) }?.let { KonanToolchain.of(this, target) } - val context = contextFor(target).let { context -> - konanToolchain?.let { - val environment = if (toolchainInEnvironment) context.environment + it.files.environment else context.environment - ExternalBuildContext(context.target, context.outputDirectory, environment, it.files) - } ?: context - } - val taskDescription = description(target) - val fileName = libraryFileName(target) - tasks.register(taskName(taskPrefix, owner, target), ExternalBuildTask::class.java) { - group = "konan" - this.description = taskDescription - mustRunAfter(clean) - konanToolchain?.let { dependsOn(it.task) } - context.environment.forEach { (variable, value) -> environment.put(variable.variableName, value) } - this.libraryFileName.set(fileName) - outputDirectory.set(context.outputDirectory) - configure(context) - } - } -} - -/** The build script's own steps, as they are, for [target]: what a generic `external { }` block runs. */ -internal fun ExternalBuildTask.addSteps(settings: ExternalBuildSettings, target: NativeTarget, owner: String) { - check(settings.steps.isNotEmpty()) { "$owner.external declares no step." } - settings.steps.forEach { step -> - command { - executable.set(step.executable) - arguments.addAll(step.arguments) - arguments.addAll(step.targetArguments.getting(target).orElse(emptyList())) - environment.putAll(step.environment) - workingDirectory.set(step.workingDirectory.map { it.asFile.absolutePath }) - } - } - sources.from(settings.sources) - producedLibrary.fileProvider(settings.producedLibraries.getting(target)) -} diff --git a/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt b/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt index 09a53b4..3e22ed4 100644 --- a/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt @@ -1,7 +1,7 @@ package io.github.lemcoder.external -import io.github.lemcoder.dsl.MakeSettings -import io.github.lemcoder.dsl.konanConfig +import io.github.lemcoder.dsl.builds.MakeSettings +import io.github.lemcoder.dsl.config.konanConfig import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.ToolchainVariable diff --git a/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt b/src/main/kotlin/io/github/lemcoder/external/ZigCommands.kt similarity index 56% rename from src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt rename to src/main/kotlin/io/github/lemcoder/external/ZigCommands.kt index b86b6c4..672c9a0 100644 --- a/src/main/kotlin/io/github/lemcoder/library/ZigBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/external/ZigCommands.kt @@ -1,28 +1,26 @@ -package io.github.lemcoder.library +package io.github.lemcoder.external -import io.github.lemcoder.dsl.NativeLibrarySpec -import io.github.lemcoder.dsl.konanConfig -import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.dsl.builds.ZigSettings +import io.github.lemcoder.dsl.config.konanConfig import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.TargetFamily import io.github.lemcoder.toolchain.zigTriple -import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider /** - * [LibraryProvider.External.Zig]: `zig build` for one target, with `-Dtarget` set to the Zig triple that - * matches Kotlin/Native's sysroot, installed into a prefix of its own; its static library is then copied - * to where every other provider puts the archive. + * [ExternalBuild.Zig]: `zig build` for one target, with `-Dtarget` set to the Zig triple that matches + * Kotlin/Native's sysroot, installed into a prefix under [work]; its static library is then copied to + * where every other build puts the archive. */ -internal fun addZigCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) { - val zig = library.zig +internal fun ExternalBuildTask.addZigCommands(zig: ZigSettings, work: Provider, context: ExternalBuildContext) { val target = context.target // Zig's prefix and cache stay out of the project directory, which is the task's input. - val work = project.layout.buildDirectory.dir("konan/libraries/${library.name}/zig") val prefix = work.map { it.dir(target.konanName) } - val zigExecutable = project.konanConfig.executable(ConfigField.ZIG, task.path) - task.command { + val zigExecutable = project.konanConfig.executable(ConfigField.ZIG, path) + command { executable.set(zigExecutable) arguments.addAll(prefix.map { listOf("build", "--prefix", it.asFile.absolutePath) }) arguments.addAll(work.map { listOf("--cache-dir", it.dir("cache").asFile.absolutePath) }) @@ -31,9 +29,9 @@ internal fun addZigCommands(task: ExternalBuildTask, project: Project, library: arguments.addAll(zig.arguments) workingDirectory.set(zig.projectDirectory.map { it.asFile.absolutePath }) } - task.sources.from(zig.projectDirectory.map { directory -> directory.asFileTree.matching { exclude(".zig-cache/**", "zig-out/**") } }) + sources.from(zig.projectDirectory.map { directory -> directory.asFileTree.matching { exclude(".zig-cache/**", "zig-out/**") } }) // Zig names the archive after the target's convention: libfoo.a, or foo.lib on Windows. - task.producedLibrary.set( + producedLibrary.set( prefix.zip(zig.artifactName) { directory, artifact -> directory.file(if (target.family == TargetFamily.MINGW) "lib/$artifact.lib" else "lib/lib$artifact.a") } diff --git a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt deleted file mode 100644 index e23944f..0000000 --- a/src/main/kotlin/io/github/lemcoder/ffm/FfmLibraryBuild.kt +++ /dev/null @@ -1,189 +0,0 @@ -package io.github.lemcoder.ffm - -import io.github.lemcoder.dsl.BindingSpec -import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.external.ExternalBuildTask -import io.github.lemcoder.external.addCMakeCommands -import io.github.lemcoder.external.addMakeCommands -import io.github.lemcoder.external.addSteps -import io.github.lemcoder.external.registerExternalBuilds -import io.github.lemcoder.library.PlacePrebuiltFilesTask -import io.github.lemcoder.link.LinkSharedLibraryTask -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ExternalFfmVariable -import io.github.lemcoder.model.ExternalToolchain -import io.github.lemcoder.model.NativeArtifacts -import io.github.lemcoder.model.NativeTarget -import io.github.lemcoder.model.selectOne -import io.github.lemcoder.model.taskName -import org.gradle.api.Project -import org.gradle.api.tasks.TaskProvider - -/** - * How the shared library FFM bindings load comes to exist, per target: linked by the plugin from the - * binding's library, built by the project's own build, or built elsewhere. FFM needs no stub β€” this is the - * C library itself. Every build leaves `ffm.libraryDirectoryFor(target)/`. - */ -internal sealed interface FfmLibraryBuild { - /** Registers the work for [inputs]' targets. Called once the build script has run. */ - fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> - - /** The default: the binding's library's archive, linked whole into a shared library with Kotlin/Native's clang. */ - data object KonanLink : FfmLibraryBuild { - override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { - val artifacts = inputs.artifacts - if (artifacts == null) { - // Nothing to link: a platform library (libaaudio), or one the app gets some other way. - project.logger.info( - "konan.bindings.${binding.name}.ffm: no library(), so the bindings load lib${inputs.libraryName} as the system provides it." - ) - return emptyList() - } - val built = artifacts.targets.getOrElse(emptyList()) - val (linked, missing) = inputs.targets.partition { it in built } - if (missing.isNotEmpty()) { - project.logger.warn( - "konan.bindings.${binding.name}.ffm: no library is built for ${missing.joinToString { it.konanName }}, so nothing " + - "is linked there β€” name one with library(…), or use ffm { external { } } or ffm { prebuilt { } }." - ) - } - return linked.map { target -> registerLink(project, konan, binding, artifacts, inputs.libraryName, target) } - } - - private fun registerLink( - project: Project, - konan: KonanExtension, - binding: BindingSpec, - artifacts: NativeArtifacts, - libraryName: String, - target: NativeTarget, - ) = project.tasks.register(taskName("linkFfm", binding.name, target), LinkSharedLibraryTask::class.java) { - group = "konan" - description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." - dependsOn(artifacts.builtBy) - androidApiLevel.set(konan.androidApiLevel) - this.target.set(target) - libraryBaseName.set(libraryName) - staticLibrary.set(artifacts.archive(target)) - additionalLinkerArgs.set(binding.ffm.linkerOptions) - outputDirectory.set(binding.ffm.libraryDirectoryFor(target)) - } - } - - /** `ffm { prebuilt { } }`: shared libraries built elsewhere, placed under the name the bindings load. */ - data object Prebuilt : FfmLibraryBuild { - override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { - val libraries = binding.ffm.prebuilt.sharedLibraries.getOrElse(emptyMap()) - val missing = inputs.targets.filter { it !in libraries } - check(missing.isEmpty()) { - "konan.bindings.${binding.name}.ffm.prebuilt has no shared library for ${missing.joinToString { it.konanName }}." - } - val placements = inputs.targets.associate { target -> - libraries.getValue(target) to binding.ffm.libraryDirectoryFor(target).get().file(target.sharedLibraryName(inputs.libraryName)).asFile - } - return listOf( - project.tasks.register(taskName("prebuiltFfm", binding.name), PlacePrebuiltFilesTask::class.java) { - group = "konan" - description = "Place the prebuilt '${inputs.libraryName}' libraries the '${binding.name}' FFM bindings load." - place(placements) - } - ) - } - } - - /** - * The project's own build makes the shared library, once per target, told the [ExternalFfmVariable] - * contract. A build system says which targets it builds and adds its commands. - */ - sealed class External : FfmLibraryBuild { - protected abstract val taskPrefix: String - - /** The build system, for task descriptions. */ - protected abstract val toolName: String - - protected open fun targets(binding: BindingSpec, inputs: FfmLibraryInputs): List = inputs.targets - - protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) - - /** Whose compilers the build gets. */ - protected abstract fun toolchain(binding: BindingSpec): ExternalToolchain - - final override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: FfmLibraryInputs): List> { - val ffm = binding.ffm - val libraryName = inputs.libraryName - return project.registerExternalBuilds( - taskPrefix = taskPrefix, - owner = binding.name, - description = { target -> "Build the '$libraryName' library for ${target.konanName} with $toolName, for the FFM bindings." }, - targets = targets(binding, inputs), - contextFor = { target -> - val output = ffm.libraryDirectoryFor(target) - ExternalBuildContext( - target, output, - mapOf( - ExternalFfmVariable.LIB_NAME to project.provider { libraryName }, - ExternalFfmVariable.OUTPUT_DIR to output.map { it.asFile.absolutePath }, - ExternalFfmVariable.TARGET to project.provider { target.konanName }, - ExternalFfmVariable.ABI to project.provider { target.libraryDirectoryName }, - ), - ) - }, - libraryFileName = { target -> project.provider { target.sharedLibraryName(libraryName) } }, - toolchain = toolchain(binding), - ) { context -> addCommands(this, project, binding, context) } - } - - /** `ffm { external { } }`: any build system, as the commands the build script lists. */ - data object Generic : External() { - override val taskPrefix = "externalBuildFfm" - override val toolName = "its external build" - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = - task.addSteps(binding.ffm.external, context.target, "konan.bindings.${binding.name}.ffm") - override fun toolchain(binding: BindingSpec) = binding.ffm.external.toolchain.get() - } - - /** `ffm { make { } }`: the project's Makefile, told what to call the library and where to leave it. */ - data object Make : External() { - override val taskPrefix = "makeBuildFfm" - override val toolName = "make" - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = - task.addMakeCommands(binding.ffm.make, context, "konan.bindings.${binding.name}.ffm.make") - override fun toolchain(binding: BindingSpec) = binding.ffm.make.toolchain.get() - } - - /** - * `ffm { cmake { } }`: a CMakeLists, configured with the contract as cache entries, then built β€” for - * its `target(…)` blocks if it has any, else for the FFM targets. - */ - data object CMake : External() { - override val taskPrefix = "cmakeBuildFfm" - override val toolName = "CMake" - override fun targets(binding: BindingSpec, inputs: FfmLibraryInputs) = - binding.ffm.cmake.targetSettings.keys.toList().ifEmpty { inputs.targets } - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) { - val cmake = binding.ffm.cmake - task.addCMakeCommands( - project, cmake, context, "konan.bindings.${binding.name}.ffm.cmake", - project.layout.buildDirectory.dir("konan/bindings/${binding.name}/ffm/cmake"), - ) - // A build that ignores CMAKE_LIBRARY_OUTPUT_DIRECTORY: its library is copied into place. - task.producedLibrary.set(cmake.libraryDirectory.zip(task.libraryFileName) { directory, file -> directory.file(file) }) - } - override fun toolchain(binding: BindingSpec) = binding.ffm.cmake.toolchain.get() - } - } -} - -/** The build [binding]'s `ffm` selected: at most one block, else [FfmLibraryBuild.KonanLink]. */ -internal fun ffmLibraryBuildFor(binding: BindingSpec): FfmLibraryBuild = - selectOne("konan.bindings.${binding.name}.ffm", binding.ffm.selectedLibraryBuilds, FfmLibraryBuild.KonanLink) - -/** What every FFM library build gets from the FFM consumer. */ -internal class FfmLibraryInputs( - /** The archives of the binding's library, if it names one. */ - val artifacts: NativeArtifacts?, - /** `ffm.targets`, resolved for the project: the host on a JVM, 64-bit ABIs on Android. */ - val targets: List, - /** The name the bindings load. */ - val libraryName: String, -) diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index 9a3f61b..d91ab31 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -22,6 +22,7 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault +import java.io.File import javax.inject.Inject /** @@ -66,10 +67,9 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( @TaskAction fun generate() { + val request = request.get() exec.runIndexExporter( - request = request.get().let { request -> - if (request is GeneratorRequest.Jextract && jextract.isPresent) request.copy(jextract = jextract.get().require()) else request - }, + request = if (request is GeneratorRequest.Jextract && jextract.isPresent) request.copy(jextract = jextract.get().require()) else request, outputDirectory = outputDirectory.get().asFile, cinteropArgs = buildList { val def = defFile.get().asFile @@ -85,5 +85,17 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( distribution = distribution(), workDir = temporaryDir, ) + if (request is GeneratorRequest.Jni) writeJniHeader(outputDirectory.get().asFile.resolve(GeneratorRequest.Jni.INCLUDE_DIR)) + } + + /** + * Android's `jni.h` β€” VM-neutral and Apache-licensed β€” beside the stub, for compiling it for a desktop + * JVM without a JDK. Android builds use the NDK sysroot's. + */ + private fun writeJniHeader(directory: File) { + val header = directory.resolve("jni.h").apply { parentFile.mkdirs() } + GenerateFromNativeIndexTask::class.java.getResourceAsStream("/io/github/lemcoder/bindings/jni/jni.h").use { input -> + header.outputStream().use { checkNotNull(input) { "jni.h missing from the plugin jar" }.copyTo(it) } + } } } diff --git a/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt b/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt deleted file mode 100644 index a91ac35..0000000 --- a/src/main/kotlin/io/github/lemcoder/jni/GenerateJniBindingsTask.kt +++ /dev/null @@ -1,123 +0,0 @@ -package io.github.lemcoder.jni - -import io.github.lemcoder.index.GeneratorRequest -import io.github.lemcoder.kotlin.UsesKonanDistribution -import io.github.lemcoder.kotlin.distribution -import io.github.lemcoder.kotlin.runIndexExporter -import io.github.lemcoder.model.BindingVisibility -import io.github.lemcoder.model.ConfigValue -import io.github.lemcoder.model.NativeTarget -import io.github.lemcoder.model.stubLibraryBaseName -import org.gradle.api.DefaultTask -import org.gradle.api.file.ConfigurableFileCollection -import org.gradle.api.file.Directory -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFile -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.InputFiles -import org.gradle.api.tasks.Internal -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import org.gradle.process.ExecOperations -import org.gradle.work.DisableCachingByDefault -import javax.inject.Inject - -/** - * Generates the JNI `.c` stub and the Kotlin bindings from a cinterop `.def`, with the index exporter's - * `jni` generator: cinterop's own indexer reads the headers, and the generator writes both sides of each - * bridge from the C declarations. - * - * The same def feeds the native targets' cinterop, so the two legs cannot drift: only the package is - * overridden here, since the JVM bindings live in their own package. - */ -@DisableCachingByDefault(because = "Output depends on the local Kotlin/Native distribution, which is not a tracked input") -internal abstract class GenerateJniBindingsTask @Inject constructor( - private val exec: ExecOperations, -) : DefaultTask(), UsesKonanDistribution { - @get:Internal abstract override val konanHome: Property - - @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val defFile: RegularFileProperty - - @get:Input abstract val packageName: Property - - /** Extra include roots; the def's own directory is always added. */ - @get:InputFiles @get:PathSensitive(PathSensitivity.RELATIVE) - abstract val includeDirs: ConfigurableFileCollection - - /** The headers are indexed once, for the host; the bridges are the same on every target. */ - @get:Input abstract val hostTarget: Property - @get:Input @get:Optional abstract val compilerOptions: ListProperty - - /** `INTERNAL` keeps the bindings out of the module's published API. */ - @get:Input abstract val visibility: Property - @get:OutputDirectory abstract val outputDirectory: DirectoryProperty - - /** - * Directory holding the generated JNI `.c` stub β€” the handover point when another build system - * compiles it. Derived from [outputDirectory] so the layout stays the plugin's to change. - */ - @get:Internal - val stubSourceDirectory: Provider get() = outputDirectory.dir(C_DIR) - - /** The generated stub itself, named after the binding package. */ - @get:Internal - val stubSourceFile: Provider - get() = outputDirectory.file(packageName.map { "$C_DIR/${stubLibraryBaseName(it)}.c" }) - - /** - * Base name of the JNI library the generated bindings will `System.loadLibrary`, derived from the - * binding package. A build that links the stub itself has to produce exactly this name. - */ - @get:Internal - val stubLibraryBaseName: Provider get() = packageName.map { stubLibraryBaseName(it) } - - /** - * Directory holding `jni.h` β€” Android's, which is VM-neutral and Apache-licensed β€” for compiling the stub - * for a desktop JVM without a JDK. Android builds use the NDK sysroot's. - */ - @get:Internal - val jniIncludeDirectory: Provider get() = outputDirectory.dir(INCLUDE_DIR) - - /** Directory holding the generated Kotlin bindings. */ - @get:Internal - val kotlinSourceDirectory: Provider get() = outputDirectory.dir(KOTLIN_DIR) - - @TaskAction - fun run() { - val def = defFile.get().asFile - val includes = listOf(def.parentFile) + includeDirs.files - exec.runIndexExporter( - request = GeneratorRequest.Jni(stubLibrary = stubLibraryBaseName.get(), visibility = visibility.get()), - outputDirectory = outputDirectory.get().asFile, - cinteropArgs = buildList { - add("-def"); add(def.absolutePath) - add("-pkg"); add(packageName.get()) - add("-target"); add(hostTarget.get().konanName) - includes.filter { it.exists() }.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } - compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } - }, - distribution = distribution(), - workDir = temporaryDir, - ) - val header = outputDirectory.get().asFile.resolve("$INCLUDE_DIR/jni.h").apply { parentFile.mkdirs() } - GenerateJniBindingsTask::class.java.getResourceAsStream("/io/github/lemcoder/jni/jni.h").use { input -> - header.outputStream().use { checkNotNull(input) { "jni.h missing from the plugin jar" }.copyTo(it) } - } - } - - private companion object { - // The jni generator's layout: kotlin//.kt and c/.c. - const val C_DIR = "c" - const val KOTLIN_DIR = "kotlin" - const val INCLUDE_DIR = "include" - } -} diff --git a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt b/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt deleted file mode 100644 index 30c0760..0000000 --- a/src/main/kotlin/io/github/lemcoder/jni/JniStubBuild.kt +++ /dev/null @@ -1,145 +0,0 @@ -package io.github.lemcoder.jni - -import io.github.lemcoder.dsl.BindingSpec -import io.github.lemcoder.dsl.JniSpec -import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.external.ExternalBuildTask -import io.github.lemcoder.external.addCMakeCommands -import io.github.lemcoder.external.addMakeCommands -import io.github.lemcoder.external.addSteps -import io.github.lemcoder.external.registerExternalBuilds -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ExternalStubVariable -import io.github.lemcoder.model.ExternalToolchain -import io.github.lemcoder.model.NativeArtifacts -import io.github.lemcoder.model.NativeTarget -import io.github.lemcoder.model.selectOne -import org.gradle.api.Project -import org.gradle.api.file.Directory -import org.gradle.api.provider.Provider -import org.gradle.api.tasks.TaskProvider - -/** - * How a binding's JNI library β€” the generated stub, linked with the C library β€” comes to exist: linked - * by the plugin, or built by the project's own build system. Every build leaves the library in - * `jni.libraryDirectoryFor(target)`, so consumers never learn which one ran. - */ -internal sealed interface JniStubBuild { - /** - * Registers the work that builds the JNI library and sets `jni.libraryDirectory`'s convention. - * Called once the build script has run; returns the tasks `linkJni` depends on. - */ - fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs): List> - - /** The default: the stub and the library's archive, linked per target with Kotlin/Native's toolchain. */ - data object KonanLink : JniStubBuild { - override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs) = - registerKonanLink(project, konan, binding, inputs) - } - - /** - * The project's own build compiles the stub, once per target, told the [ExternalStubVariable] - * contract. A build system says which targets it builds and adds its commands. - */ - sealed class External : JniStubBuild { - protected abstract val taskPrefix: String - - /** The build system, for task descriptions. */ - protected abstract val toolName: String - - protected abstract fun targets(binding: BindingSpec, inputs: JniStubInputs): List - - protected abstract fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) - - /** Whose compilers the build gets. */ - protected abstract fun toolchain(binding: BindingSpec): ExternalToolchain - - /** `jni.libraryDirectory`'s convention, given the plugin's [default]. */ - protected open fun libraryDirectory(binding: BindingSpec, default: Provider): Provider = default - - final override fun register(project: Project, konan: KonanExtension, binding: BindingSpec, inputs: JniStubInputs): List> { - val bindingName = binding.name - val jni = binding.jni - val targets = targets(binding, inputs) - val tasks = project.registerExternalBuilds( - taskPrefix = taskPrefix, - owner = bindingName, - description = { target -> "Build the '$bindingName' JNI library for ${target.konanName} with $toolName." }, - targets = targets, - contextFor = { target -> - val output = jni.libraryDirectoryFor(target) - ExternalBuildContext( - target, output, - mapOf( - ExternalStubVariable.STUB_DIR to jni.stubDirectory.map { it.asFile.absolutePath }, - ExternalStubVariable.LIB_NAME to jni.stubLibraryName, - ExternalStubVariable.OUTPUT_DIR to output.map { it.asFile.absolutePath }, - ExternalStubVariable.TARGET to project.provider { target.konanName }, - ExternalStubVariable.ABI to project.provider { target.libraryDirectoryName }, - ExternalStubVariable.JNI_INCLUDE_DIR to inputs.generate.flatMap { it.jniIncludeDirectory }.map { it.asFile.absolutePath }, - ), - ) - }, - libraryFileName = { target -> jni.stubLibraryName.map { target.sharedLibraryName(it) } }, - toolchain = toolchain(binding), - ) { context -> - dependsOn(inputs.generate) - generatedSources.from(jni.stubDirectory) - addCommands(this, project, binding, context) - } - val host = NativeTarget.host - jni.libraryDirectory.convention(libraryDirectory(binding, if (host in targets) jni.libraryDirectoryFor(host) else jni.jniLibsRoot)) - return tasks - } - - /** `jni { external { } }`: any build system, as the commands the build script lists. */ - data object Generic : External() { - override val taskPrefix = "externalBuildJni" - override val toolName = "its external build" - override fun targets(binding: BindingSpec, inputs: JniStubInputs) = inputs.targets - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = - task.addSteps(binding.jni.external, context.target, "konan.bindings.${binding.name}.jni") - override fun toolchain(binding: BindingSpec) = binding.jni.external.toolchain.get() - } - - /** `jni { make { } }`: the project's Makefile, told where the stub is and what to call the library. */ - data object Make : External() { - override val taskPrefix = "makeBuildJni" - override val toolName = "make" - override fun targets(binding: BindingSpec, inputs: JniStubInputs) = inputs.targets - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = - task.addMakeCommands(binding.jni.make, context, "konan.bindings.${binding.name}.jni.make") - override fun toolchain(binding: BindingSpec) = binding.jni.make.toolchain.get() - } - - /** `jni { cmake { } }`: a CMakeLists, configured with the contract as cache entries, then built. */ - data object CMake : External() { - override val taskPrefix = "cmakeBuildJni" - override val toolName = "CMake" - override fun targets(binding: BindingSpec, inputs: JniStubInputs) = - binding.jni.cmake.targetSettings.keys.toList().ifEmpty { listOf(NativeTarget.host) } - override fun addCommands(task: ExternalBuildTask, project: Project, binding: BindingSpec, context: ExternalBuildContext) = - task.addCMakeCommands( - project, binding.jni.cmake, context, "konan.bindings.${binding.name}.jni.cmake", - project.layout.buildDirectory.dir("konan/bindings/${binding.name}/jni/cmake"), - ) - override fun libraryDirectory(binding: BindingSpec, default: Provider) = - binding.jni.cmake.libraryDirectory.orElse(default) - override fun toolchain(binding: BindingSpec) = binding.jni.cmake.toolchain.get() - } - } -} - -/** The build [jni] selected: at most one block, else [JniStubBuild.KonanLink]. */ -internal fun stubBuildFor(bindingName: String, jni: JniSpec): JniStubBuild = - selectOne("konan.bindings.$bindingName.jni", jni.selectedStubBuilds, JniStubBuild.KonanLink) - -/** What every stub build gets from the JNI consumer. */ -internal class JniStubInputs( - /** The generator, which the build must run after. */ - val generate: TaskProvider, - /** The archives of the binding's library, if it names one. */ - val artifacts: NativeArtifacts?, - /** The JNI targets: `jni.targets`, else the library's that a JVM can load, else the host. */ - val targets: List, -) diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index a81e80c..140aa45 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -1,8 +1,9 @@ package io.github.lemcoder.kotlin -import io.github.lemcoder.dsl.BindingSpec +import io.github.lemcoder.dsl.bindings.BindingSpec import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget +import java.io.File import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider @@ -12,10 +13,9 @@ import org.gradle.api.tasks.TaskProvider import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget import org.jetbrains.kotlin.gradle.targets.jvm.KotlinJvmTarget -import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion -import java.io.File /** * Everything that touches the Kotlin Gradle plugin's types. Called only from `plugins.withId` callbacks @@ -94,6 +94,11 @@ internal object KotlinGradlePlugin { } } + /** Java sources of a `kotlin("jvm")` project's main source set, compiled by the java plugin it applies. */ + fun addJvmJava(project: Project, sources: Provider) { + project.extensions.getByType(SourceSetContainer::class.java).getByName(SourceSet.MAIN_SOURCE_SET_NAME).java.srcDir(sources) + } + /** Resources of a `kotlin("jvm")` project's main source set. */ fun addJvmResources(project: Project, resources: Provider) { project.extensions.getByType(KotlinJvmProjectExtension::class.java).sourceSets.getByName("main").resources.srcDir(resources) diff --git a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/libraries/CompileNativeLibraryTask.kt similarity index 99% rename from src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt rename to src/main/kotlin/io/github/lemcoder/libraries/CompileNativeLibraryTask.kt index 81e625f..0171bf0 100644 --- a/src/main/kotlin/io/github/lemcoder/library/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/libraries/CompileNativeLibraryTask.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.library +package io.github.lemcoder.libraries import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution @@ -6,6 +6,7 @@ import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture +import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty @@ -23,7 +24,6 @@ import org.gradle.api.tasks.PathSensitivity.RELATIVE import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -import javax.inject.Inject /** * Compile C/C++ source files using the diff --git a/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/libraries/KonanClangBuild.kt similarity index 90% rename from src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt rename to src/main/kotlin/io/github/lemcoder/libraries/KonanClangBuild.kt index 89698e0..3e07cc3 100644 --- a/src/main/kotlin/io/github/lemcoder/library/KonanClangBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/libraries/KonanClangBuild.kt @@ -1,7 +1,7 @@ -package io.github.lemcoder.library +package io.github.lemcoder.libraries import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask import org.gradle.api.Project @@ -18,7 +18,7 @@ import org.gradle.api.tasks.TaskProvider private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC") /** - * [LibraryProvider.KonanClang]: the sources in [NativeLibrarySpec.sourceDirectory], compiled with + * The default library build: the sources in [NativeLibrarySpec.sourceDirectory], compiled with * Kotlin/Native's clang and archived with its llvm-ar. */ internal fun registerKonanClang(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = diff --git a/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt b/src/main/kotlin/io/github/lemcoder/libraries/Libraries.kt similarity index 86% rename from src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt rename to src/main/kotlin/io/github/lemcoder/libraries/Libraries.kt index 1eb5663..dfa69e2 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/Libraries.kt +++ b/src/main/kotlin/io/github/lemcoder/libraries/Libraries.kt @@ -1,8 +1,7 @@ -package io.github.lemcoder.wiring +package io.github.lemcoder.libraries import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.NativeLibrarySpec -import io.github.lemcoder.library.providerFor +import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import io.github.lemcoder.model.NativeArtifacts import org.gradle.api.Action import org.gradle.api.Project @@ -28,7 +27,7 @@ private fun Project.registerLibrary(konan: KonanExtension, library: NativeLibrar description = "Build the '${library.name}' library: a static archive per target." } afterEvaluate { - val work = providerFor(library).register(this, konan, library) + val work = registerLibraryBuild(konan, library) lifecycle.configure { dependsOn(work) } } } diff --git a/src/main/kotlin/io/github/lemcoder/libraries/LibraryBuilds.kt b/src/main/kotlin/io/github/lemcoder/libraries/LibraryBuilds.kt new file mode 100644 index 0000000..151e7af --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/libraries/LibraryBuilds.kt @@ -0,0 +1,43 @@ +package io.github.lemcoder.libraries + +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.builds.ExternalBuildSettings +import io.github.lemcoder.dsl.builds.PrebuiltSettings +import io.github.lemcoder.dsl.builds.blockName +import io.github.lemcoder.dsl.libraries.NativeLibrarySpec +import io.github.lemcoder.external.ExternalBuild +import io.github.lemcoder.external.ExternalBuildOutput +import io.github.lemcoder.model.ExternalVariable +import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.model.selectOne +import org.gradle.api.Project +import org.gradle.api.tasks.TaskProvider + +/** + * Registers the work that builds [library]'s archives, however its script said: compiled with + * Kotlin/Native's clang by default, placed from `prebuilt { }`, or built by the project's own build. Each + * leaves one archive per target at [NativeArtifacts.archivePath] under the library's output directory β€” + * the only thing consumers know. Called once the build script has run. + */ +internal fun Project.registerLibraryBuild(konan: KonanExtension, library: NativeLibrarySpec): List> = + when (val settings = selectOne("konan.libraries.${library.name}", library.selectedBuilds) { it.blockName }) { + null -> listOf(registerKonanClang(this, konan, library)) + is PrebuiltSettings -> listOf(registerPrebuilt(this, library)) + is ExternalBuildSettings -> ExternalBuild.of(settings).register(this, externalBuildOutput(library)) + } + +private fun Project.externalBuildOutput(library: NativeLibrarySpec): ExternalBuildOutput { + val name = library.name + return ExternalBuildOutput( + owner = "konan.libraries.$name", + name = name, + side = "", + targets = library.targets.getOrElse(emptyList()), + libraryName = provider { name }, + libraryFileName = { provider { NativeArtifacts.archiveFileName(name) } }, + outputDirectory = library::archiveDirectory, + workDirectory = layout.buildDirectory.dir("konan/libraries/$name"), + description = { target, tool -> "Build the '$name' library for ${target.konanName} with $tool." }, + extraVariables = { mapOf(ExternalVariable.SOURCE_DIR to library.sourceDirectory.map { it.asFile.absolutePath }) }, + ) +} diff --git a/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/libraries/PrebuiltLibrary.kt similarity index 91% rename from src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt rename to src/main/kotlin/io/github/lemcoder/libraries/PrebuiltLibrary.kt index 68d2ee0..2fb4c77 100644 --- a/src/main/kotlin/io/github/lemcoder/library/PrebuiltLibrary.kt +++ b/src/main/kotlin/io/github/lemcoder/libraries/PrebuiltLibrary.kt @@ -1,8 +1,9 @@ -package io.github.lemcoder.library +package io.github.lemcoder.libraries -import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.taskName +import java.io.File import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.ConfigurableFileCollection @@ -15,15 +16,14 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.work.DisableCachingByDefault -import java.io.File /** - * [LibraryProvider.Prebuilt]: archives built elsewhere, placed in the layout every provider leaves β€” + * `prebuilt { }`: archives built elsewhere, placed in the layout every library build leaves β€” * `/lib.a`, whatever the file was called β€” so consumers cannot tell them apart. */ internal fun registerPrebuilt(project: Project, library: NativeLibrarySpec): TaskProvider<*> { val libraryName = library.name - val archives = library.prebuilt.archives.getOrElse(emptyMap()) + val archives = library.prebuilt.libraries.getOrElse(emptyMap()) val targets = library.targets.getOrElse(emptyList()) val missing = targets.filter { it !in archives } check(missing.isEmpty()) { diff --git a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt b/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt deleted file mode 100644 index db7262c..0000000 --- a/src/main/kotlin/io/github/lemcoder/library/LibraryProvider.kt +++ /dev/null @@ -1,142 +0,0 @@ -package io.github.lemcoder.library - -import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.NativeLibrarySpec -import io.github.lemcoder.external.ExternalBuildTask -import io.github.lemcoder.external.addCMakeCommands -import io.github.lemcoder.external.addMakeCommands -import io.github.lemcoder.external.addSteps -import io.github.lemcoder.external.registerExternalBuilds -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ExternalLibraryVariable -import io.github.lemcoder.model.ExternalToolchain -import io.github.lemcoder.model.NativeArtifacts -import io.github.lemcoder.model.selectOne -import org.gradle.api.Project -import org.gradle.api.tasks.TaskProvider - -/** - * How a library's archives come to exist: compiled by the plugin, built by the project's own build - * system, or brought in prebuilt. Every provider leaves one archive per target at - * [NativeArtifacts.archivePath] under the library's output directory β€” the only thing consumers know. - */ -internal sealed interface LibraryProvider { - /** Registers the work that builds [library]'s archives. Called once the build script has run. */ - fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): List> - - /** The default: the library's sources, compiled with Kotlin/Native's clang. */ - data object KonanClang : LibraryProvider { - override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec) = - listOf(registerKonanClang(project, konan, library)) - } - - /** `prebuilt { }`: archives built elsewhere, one per target, placed in the layout. */ - data object Prebuilt : LibraryProvider { - override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec) = - listOf(registerPrebuilt(project, library)) - } - - /** - * The project's own build makes the archive, once per target, told the [ExternalLibraryVariable] - * contract; its archive is copied to `lib.a`. A build system only adds its commands. - */ - sealed class External : LibraryProvider { - protected abstract val taskPrefix: String - - /** The build system, for task descriptions. */ - protected abstract val toolName: String - - protected abstract fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) - - /** Whose compilers the build gets; null when the build system brings its own. */ - protected abstract fun toolchain(library: NativeLibrarySpec): ExternalToolchain? - - /** Whether those compilers go into the environment as `CC`, `CXX`, `AR`. */ - protected open val toolchainInEnvironment: Boolean = true - - final override fun register(project: Project, konan: KonanExtension, library: NativeLibrarySpec): List> { - val libraryName = library.name - val fileName = project.providers.provider { NativeArtifacts.archiveFileName(libraryName) } - return project.registerExternalBuilds( - taskPrefix = taskPrefix, - owner = libraryName, - description = { target -> "Build the '$libraryName' library for ${target.konanName} with $toolName." }, - targets = library.targets.getOrElse(emptyList()), - contextFor = { target -> - val output = library.archiveDirectory(target) - ExternalBuildContext( - target, output, - mapOf( - ExternalLibraryVariable.LIB_NAME to project.providers.provider { libraryName }, - ExternalLibraryVariable.OUTPUT_DIR to output.map { it.asFile.absolutePath }, - ExternalLibraryVariable.SOURCE_DIR to library.sourceDirectory.map { it.asFile.absolutePath }, - ExternalLibraryVariable.TARGET to project.providers.provider { target.konanName }, - ), - ) - }, - libraryFileName = { fileName }, - toolchain = toolchain(library), - toolchainInEnvironment = toolchainInEnvironment, - ) { context -> addCommands(this, project, library, context) } - } - - /** `external { }`: any build system, as the commands the build script lists. */ - data object Generic : External() { - override val taskPrefix = "externalBuild" - override val toolName = "its external build" - override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = - task.addSteps(library.external, context.target, "konan.libraries.${library.name}") - override fun toolchain(library: NativeLibrarySpec) = library.external.toolchain.get() - } - - /** `cmake { }`: the project's CMakeLists, building a static library per target. */ - data object CMake : External() { - override val taskPrefix = "cmakeBuild" - override val toolName = "CMake" - override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) { - val cmake = library.cmake - task.addCMakeCommands( - project, cmake, context, "konan.libraries.${library.name}.cmake", - project.layout.buildDirectory.dir("konan/libraries/${library.name}/cmake"), - ) - // A build that ignores CMAKE_ARCHIVE_OUTPUT_DIRECTORY: its archive is copied into place. - task.producedLibrary.set(cmake.libraryDirectory.zip(task.libraryFileName) { directory, file -> directory.file(file) }) - } - override fun toolchain(library: NativeLibrarySpec) = library.cmake.toolchain.get() - } - - /** `make { }`: the project's Makefile, with Kotlin/Native's compilers when cross-compiling. */ - data object Make : External() { - override val taskPrefix = "makeBuild" - override val toolName = "make" - override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = - task.addMakeCommands(library.make, context, "konan.libraries.${library.name}.make") - override fun toolchain(library: NativeLibrarySpec) = library.make.toolchain.get() - } - - /** `cargo { }`: a Rust crate built as a static library, per Rust target triple. */ - data object Cargo : External() { - override val taskPrefix = "cargoBuild" - override val toolName = "cargo" - override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = - addCargoCommands(task, project, library, context) - override fun toolchain(library: NativeLibrarySpec): ExternalToolchain = library.cargo.toolchain.get() - // Under the cc crate's target-specific names instead; see addCargoCommands. - override val toolchainInEnvironment = false - } - - /** `zig { }`: the project's own `build.zig`. */ - data object Zig : External() { - override val taskPrefix = "zigBuild" - override val toolName = "zig build" - override fun addCommands(task: ExternalBuildTask, project: Project, library: NativeLibrarySpec, context: ExternalBuildContext) = - addZigCommands(task, project, library, context) - // Zig is its own cross toolchain. - override fun toolchain(library: NativeLibrarySpec): ExternalToolchain? = null - } - } -} - -/** The provider [library] selected: at most one block, else [LibraryProvider.KonanClang]. */ -internal fun providerFor(library: NativeLibrarySpec): LibraryProvider = - selectOne("konan.libraries.${library.name}", library.selectedProviders, LibraryProvider.KonanClang) diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt index 11e8fa7..5c4ed11 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt +++ b/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt @@ -10,58 +10,35 @@ internal sealed interface ExternalBuildVariable { val variableName: String } -/** The contract of a build that makes a library's archive (`libraries { zig { } }`, `external { }`). */ -internal enum class ExternalLibraryVariable(override val variableName: String) : ExternalBuildVariable { - /** The library's name: its archive must become `lib.a`. */ - LIB_NAME("KONAN_LIBRARY_NAME"), - - /** Where this target's archive must land, unless the build names a produced library. */ - OUTPUT_DIR("KONAN_LIBRARY_OUTPUT_DIR"), - - /** The library's `sourceDirectory`. */ - SOURCE_DIR("KONAN_LIBRARY_SOURCE_DIR"), - - /** The Kotlin/Native target, e.g. `linux_x64`. */ - TARGET("KONAN_TARGET"), -} - /** - * The contract of a build that makes a binding's JNI library (`jni { cmake { } }`, `external { }`), read - * the way an AGP project reads `ANDROID_ABI`. + * The contract every external build gets, whatever it makes β€” a library's archive (`libraries { cmake { } }`), + * a binding's JNI library (`jni { cmake { } }`) or the library FFM loads (`ffm { cmake { } }`): environment + * variables, and CMake cache entries. Read the way an AGP project reads `ANDROID_ABI`. */ -internal enum class ExternalStubVariable(override val variableName: String) : ExternalBuildVariable { - /** The directory holding the generated `.c` stub. */ - STUB_DIR("KONAN_JNI_STUB_DIR"), +internal enum class ExternalVariable(override val variableName: String) : ExternalBuildVariable { + /** + * The library's name: an archive must become `lib.a`, a shared library what the bindings pass + * to `System.loadLibrary`. + */ + LIB_NAME("KONAN_LIB_NAME"), - /** The library name the generated bindings pass to `System.loadLibrary`. */ - LIB_NAME("KONAN_JNI_LIB_NAME"), - - /** Where the target's library must land, unless the build names a produced library. */ - OUTPUT_DIR("KONAN_JNI_OUTPUT_DIR"), + /** Where this target's library must land, unless the build names a produced library. */ + OUTPUT_DIR("KONAN_OUTPUT_DIR"), /** The Kotlin/Native target, e.g. `android_arm64`. */ TARGET("KONAN_TARGET"), /** The target's Android ABI, e.g. `arm64-v8a`; the target's name elsewhere. */ - ABI("KONAN_JNI_ABI"), + ABI("KONAN_ABI"), - /** The directory holding `jni.h`, which the plugin ships: no JDK needed. */ - JNI_INCLUDE_DIR("KONAN_JNI_INCLUDE_DIR"), -} - -/** The contract of a build that makes the shared library FFM bindings load (`ffm { external { } }`). */ -internal enum class ExternalFfmVariable(override val variableName: String) : ExternalBuildVariable { - /** The library name the bindings pass to `System.loadLibrary`. */ - LIB_NAME("KONAN_FFM_LIB_NAME"), + /** A library's `sourceDirectory`; libraries only. */ + SOURCE_DIR("KONAN_SOURCE_DIR"), - /** Where the target's library must land, unless the build names a produced library. */ - OUTPUT_DIR("KONAN_FFM_OUTPUT_DIR"), + /** The directory holding the generated `.c` stub; JNI only. */ + JNI_STUB_DIR("KONAN_JNI_STUB_DIR"), - /** The Kotlin/Native target, e.g. `android_arm64`. */ - TARGET("KONAN_TARGET"), - - /** The target's Android ABI, e.g. `arm64-v8a`; the target's name elsewhere. */ - ABI("KONAN_FFM_ABI"), + /** The directory holding `jni.h`, which the plugin ships, so no JDK is needed; JNI only. */ + JNI_INCLUDE_DIR("KONAN_JNI_INCLUDE_DIR"), } /** diff --git a/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt b/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt index 09b41e3..e768e71 100644 --- a/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt +++ b/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt @@ -1,6 +1,6 @@ package io.github.lemcoder.model -import io.github.lemcoder.dsl.NativeLibrarySpec +import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.file.RegularFile diff --git a/src/main/kotlin/io/github/lemcoder/model/Selection.kt b/src/main/kotlin/io/github/lemcoder/model/Selection.kt index 2d33460..d3a8879 100644 --- a/src/main/kotlin/io/github/lemcoder/model/Selection.kt +++ b/src/main/kotlin/io/github/lemcoder/model/Selection.kt @@ -1,10 +1,10 @@ package io.github.lemcoder.model /** - * The one choice [selected] holds, else [default]: a DSL owner such as a library or a binding's `jni` - * picks how it is built by calling one block, and calling two is an error naming [owner]. + * The one choice [selected] holds, or null for the default: a DSL owner such as a library or a binding's + * `jni` picks how it is built by calling one block, and calling two is an error naming [owner]. */ -internal fun selectOne(owner: String, selected: Set, default: T): T { - check(selected.size <= 1) { "$owner selects ${selected.joinToString(" and ")}; it is built one way." } - return selected.singleOrNull() ?: default +internal fun selectOne(owner: String, selected: Set, describe: (T) -> String = { it.toString() }): T? { + check(selected.size <= 1) { "$owner selects ${selected.joinToString(" and ", transform = describe)}; it is built one way." } + return selected.singleOrNull() } diff --git a/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt b/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt index edc4f51..f604bbf 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt @@ -1,6 +1,7 @@ package io.github.lemcoder.wiring -import io.github.lemcoder.dsl.KonanConfig +import io.github.lemcoder.dsl.config.KonanConfig +import io.github.lemcoder.dsl.config.konanConfig import io.github.lemcoder.external.ExternalBuildTask import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.model.ConfigField diff --git a/src/main/resources/io/github/lemcoder/jni/jni.h b/src/main/resources/io/github/lemcoder/bindings/jni/jni.h similarity index 100% rename from src/main/resources/io/github/lemcoder/jni/jni.h rename to src/main/resources/io/github/lemcoder/bindings/jni/jni.h diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 154ecb3..3086801 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -1,20 +1,21 @@ package io.github.lemcoder +import io.github.lemcoder.dsl.config.konanConfig import io.github.lemcoder.kotlin.KonanDistribution import io.github.lemcoder.model.NativeTarget -import org.gradle.testkit.runner.GradleRunner -import org.gradle.testkit.runner.TaskOutcome -import org.junit.jupiter.api.Assumptions.assumeTrue import java.io.File import java.util.jar.Attributes import java.util.jar.JarOutputStream import java.util.jar.Manifest import kotlin.io.path.createTempDirectory import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assumptions.assumeTrue /** * Functional tests of the `konan { libraries; bindings }` DSL with Gradle TestKit. Most only configure @@ -155,7 +156,7 @@ class KonanPluginFunctionalTest { ) // Writes straight into the output directory the contract names. File(projectDir, "build.sh").writeText( - "printf '%s' \"${'$'}KONAN_TARGET\" > \"${'$'}KONAN_LIBRARY_OUTPUT_DIR/lib${'$'}KONAN_LIBRARY_NAME.a\"\n" + "printf '%s' \"${'$'}KONAN_TARGET\" > \"${'$'}KONAN_OUTPUT_DIR/lib${'$'}KONAN_LIB_NAME.a\"\n" ) val first = runner("compileNativeLibraryMymath").build() @@ -179,8 +180,8 @@ class KonanPluginFunctionalTest { libraries { create("mymath") { prebuilt { - archive(NativeTarget.LINUX_X64, file("libs/libmymath-linux.a")) - archive(NativeTarget.MINGW_X64, file("libs/mymath.lib")) + library(NativeTarget.LINUX_X64, file("libs/libmymath-linux.a")) + library(NativeTarget.MINGW_X64, file("libs/mymath.lib")) } } } @@ -202,7 +203,7 @@ class KonanPluginFunctionalTest { libraries { create("mymath") { targets(NativeTarget.LINUX_X64, NativeTarget.MACOS_ARM64) - prebuilt { archive(NativeTarget.LINUX_X64, file("libs/absent.a")) } + prebuilt { library(NativeTarget.LINUX_X64, file("libs/absent.a")) } } } """, @@ -228,8 +229,8 @@ class KonanPluginFunctionalTest { File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") // Nothing but the contract and the compilers make is handed. File(projectDir, "native/Makefile").writeText( - "OUT := $(KONAN_LIBRARY_OUTPUT_DIR)\n" + - "all:\n\t$(CC) -c -fPIC mymath.c -o $(OUT)/mymath.o\n\t$(AR) rcs $(OUT)/lib$(KONAN_LIBRARY_NAME).a $(OUT)/mymath.o\n" + "OUT := $(KONAN_OUTPUT_DIR)\n" + + "all:\n\t$(CC) -c -fPIC mymath.c -o $(OUT)/mymath.o\n\t$(AR) rcs $(OUT)/lib$(KONAN_LIB_NAME).a $(OUT)/mymath.o\n" ) write( """id("io.github.lemcoder.konanplugin")""", @@ -265,7 +266,7 @@ class KonanPluginFunctionalTest { """ cmake_minimum_required(VERSION 3.16) project(mymath C) - add_library(${'$'}{KONAN_LIBRARY_NAME} STATIC mymath.c) + add_library(${'$'}{KONAN_LIB_NAME} STATIC mymath.c) """.trimIndent() ) write( @@ -301,7 +302,7 @@ class KonanPluginFunctionalTest { """ cmake_minimum_required(VERSION 3.16) project(mymath C) - add_library(${'$'}{KONAN_JNI_LIB_NAME} SHARED mymath.c) + add_library(${'$'}{KONAN_LIB_NAME} SHARED mymath.c) """.trimIndent() ) write( @@ -419,6 +420,23 @@ class KonanPluginFunctionalTest { } } + @Test + fun `the JNI links for the host and Android depend on generating the stub they compile`() { + val home = realKonanHome() + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") + val hostAndAndroid = mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.${host.name}, NativeTarget.ANDROID_ARM64)") + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", "config.konanHome.set(file(\"$home\"))\n$hostAndAndroid") + + // Executed, not a dry run: Gradle rejects a link that reads the stub without depending on its + // generator only when both run β€” and here only with more than one link. + val result = runner("linkJniMymath").withPluginClasspathAndKgp().build() + + assertEquals(TaskOutcome.SUCCESS, result.task(":generateJniMymath")?.outcome, result.output) + assertTrue(File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.konanName}/${host.sharedLibraryName("examplestubs")}").isFile, result.output) + assertTrue(File(projectDir, "build/konan/bindings/mymath/jni/jniLibs/arm64-v8a/libexamplestubs.so").isFile, result.output) + } + @Test fun `a JVM project's jar carries the JNI library unless told not to`() { write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) @@ -494,7 +512,7 @@ class KonanPluginFunctionalTest { """, ) // Records the arguments it was run with as the "archive". - File(projectDir, "build.sh").writeText("printf '%s ' \"$@\" > \"${'$'}KONAN_LIBRARY_OUTPUT_DIR/lib${'$'}KONAN_LIBRARY_NAME.a\"\n") + File(projectDir, "build.sh").writeText("printf '%s ' \"$@\" > \"${'$'}KONAN_OUTPUT_DIR/lib${'$'}KONAN_LIB_NAME.a\"\n") runner("compileNativeLibraryMymath").build() @@ -590,7 +608,7 @@ class KonanPluginFunctionalTest { """ cmake_minimum_required(VERSION 3.16) project(contract NONE) - file(WRITE "${'$'}{CMAKE_LIBRARY_OUTPUT_DIRECTORY}/contract.txt" "${'$'}{KONAN_JNI_LIB_NAME}|${'$'}{KONAN_JNI_STUB_DIR}|${'$'}{KONAN_JNI_INCLUDE_DIR}") + file(WRITE "${'$'}{CMAKE_LIBRARY_OUTPUT_DIRECTORY}/contract.txt" "${'$'}{KONAN_LIB_NAME}|${'$'}{KONAN_JNI_STUB_DIR}|${'$'}{KONAN_JNI_INCLUDE_DIR}") """.trimIndent() ) @@ -629,7 +647,7 @@ class KonanPluginFunctionalTest { ) // Leaves its library somewhere of its own, as most build systems do. File(projectDir, "build.sh").writeText( - "mkdir -p out && printf '%s|%s|%s' \"\$KONAN_TARGET\" \"\$KONAN_JNI_LIB_NAME\" \"\$KONAN_JNI_STUB_DIR\" > out/built\n" + "mkdir -p out && printf '%s|%s|%s' \"\$KONAN_TARGET\" \"\$KONAN_LIB_NAME\" \"\$KONAN_JNI_STUB_DIR\" > out/built\n" ) val result = runner("linkJniMymath").withPluginClasspathAndKgp().build() @@ -667,7 +685,7 @@ class KonanPluginFunctionalTest { ) // Writes straight into the output directory the contract names, under the name it gives. File(projectDir, "build.sh").writeText( - "printf '%s' \"${'$'}KONAN_TARGET\" > \"${'$'}KONAN_FFM_OUTPUT_DIR/${host.sharedLibraryName("x").replace("x", "${'$'}KONAN_FFM_LIB_NAME")}\"\n" + "printf '%s' \"${'$'}KONAN_TARGET\" > \"${'$'}KONAN_OUTPUT_DIR/${host.sharedLibraryName("x").replace("x", "${'$'}KONAN_LIB_NAME")}\"\n" ) val result = runner("externalBuildFfmMymath$hostSuffix").build() @@ -686,7 +704,7 @@ class KonanPluginFunctionalTest { """ cmake_minimum_required(VERSION 3.16) project(mymath C) - add_library(${'$'}{KONAN_FFM_LIB_NAME} SHARED mymath.c) + add_library(${'$'}{KONAN_LIB_NAME} SHARED mymath.c) """.trimIndent() ) write( @@ -719,7 +737,7 @@ class KonanPluginFunctionalTest { defFile.set(file("mymath.def")) ffm { libraryName.set("ggml") - prebuilt { sharedLibrary(NativeTarget.${host.name}, file("vendor/libggml-host.so")) } + prebuilt { library(NativeTarget.${host.name}, file("vendor/libggml-host.so")) } } } } From 0f3753e90200dfa577c0e5f23c8864be746dd1d8 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 15 Oct 2026 20:23:24 +0200 Subject: [PATCH 27/57] refactor: libraries' and bindings' implementation beside their DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsl.libraries now also holds a library's registration and builds, and dsl.bindings (with .jni and .ffm) a binding's registration, consumers, shared wiring and links β€” one package per DSL block, its implementation internal. The top-level libraries and bindings packages are gone; the bundled jni.h moves to io/github/lemcoder/dsl/bindings/jni/. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 ++++++++------- doc/interop-architecture.md | 5 +---- src/main/kotlin/io/github/lemcoder/KonanPlugin.kt | 4 ++-- .../{ => dsl}/bindings/BindingConsumer.kt | 7 +++---- .../{ => dsl}/bindings/BindingLibraryWiring.kt | 3 +-- .../lemcoder/{ => dsl}/bindings/Bindings.kt | 5 +---- .../lemcoder/{ => dsl}/bindings/JarBundle.kt | 2 +- .../{ => dsl}/bindings/KotlinNativeConsumer.kt | 2 +- .../{ => dsl}/bindings/LinkSharedLibraryTask.kt | 2 +- .../{ => dsl}/bindings/ffm/FfmConsumer.kt | 8 ++++---- .../{ => dsl}/bindings/ffm/FfmLibraryBuild.kt | 6 +++--- .../{ => dsl}/bindings/jni/JniConsumer.kt | 8 ++++---- .../{ => dsl}/bindings/jni/KonanLinkStubBuild.kt | 4 ++-- .../libraries/CompileNativeLibraryTask.kt | 2 +- .../{ => dsl}/libraries/KonanClangBuild.kt | 3 +-- .../lemcoder/{ => dsl}/libraries/Libraries.kt | 3 +-- .../lemcoder/{ => dsl}/libraries/LibraryBuilds.kt | 3 +-- .../{ => dsl}/libraries/PrebuiltLibrary.kt | 3 +-- .../lemcoder/index/GenerateFromNativeIndexTask.kt | 2 +- .../github/lemcoder/{ => dsl}/bindings/jni/jni.h | 0 20 files changed, 38 insertions(+), 49 deletions(-) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/BindingConsumer.kt (91%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/BindingLibraryWiring.kt (98%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/Bindings.kt (84%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/JarBundle.kt (96%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/KotlinNativeConsumer.kt (93%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/LinkSharedLibraryTask.kt (99%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/ffm/FfmConsumer.kt (97%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/ffm/FfmLibraryBuild.kt (95%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/jni/JniConsumer.kt (96%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/bindings/jni/KonanLinkStubBuild.kt (97%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/libraries/CompileNativeLibraryTask.kt (99%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/libraries/KonanClangBuild.kt (95%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/libraries/Libraries.kt (93%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/libraries/LibraryBuilds.kt (95%) rename src/main/kotlin/io/github/lemcoder/{ => dsl}/libraries/PrebuiltLibrary.kt (96%) rename src/main/resources/io/github/lemcoder/{ => dsl}/bindings/jni/jni.h (100%) diff --git a/CLAUDE.md b/CLAUDE.md index c18d13a..2075d08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,11 +24,12 @@ resolved the moment the plugin is applied, in *every* project that applies it. `dsl.bindings` with `dsl.bindings.jni` and `dsl.bindings.ffm`. Build blocks appear at every level, so they share `dsl.builds`. A new block goes in the package of the block that contains it. Never name a source package `build`: `.gitignore`'s `build/` matches it, and `git add` silently skips it. -- **`libraries` and `bindings` hold everything after the DSL**, nested as `dsl.libraries` and - `dsl.bindings` are. `libraries`: a library's registration and builds. `bindings`: a binding's +- **Implementation lives beside its DSL**: `dsl.libraries` also holds a library's registration and builds + (Kotlin/Native's clang, prebuilt, the dispatch to an external build); `dsl.bindings` a binding's registration, the consumers (`BindingConsumer`, `KotlinNativeConsumer`) and what JNI and FFM share - (`BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`); `bindings.jni` and `bindings.ffm` - each hold one consumer and its own link. `wiring` keeps AGP, plugin ids and the config report. + (`BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`); `dsl.bindings.jni` and `dsl.bindings.ffm` + each hold one consumer and its own link. All of it is `internal`. `wiring` keeps AGP, plugin ids and the + config report. - **One home per concept.** `model/` holds the domain (targets, naming, the artifact layout, the external-build contract, `selectOne`), `kotlin/` the touchpoints with Kotlin, `toolchain/` the others (Xcode, running executables, Zig). Task names come from `taskName(...)`, OS checks from @@ -48,7 +49,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. Add a path by adding a `ConfigField` entry and a property, never a per-block `executable`. - **Tasks that run Kotlin/Native implement `UsesKonanDistribution`**; the plugin sets their `konanHome` in one `withType().configureEach`, and `distribution()` checks it when the task runs. -- **The plugin ships Android's `jni.h`** (`src/main/resources/io/github/lemcoder/bindings/jni/`, Apache 2.0): the +- **The plugin ships Android's `jni.h`** (`src/main/resources/io/github/lemcoder/dsl/bindings/jni/`, Apache 2.0): the JNI generator writes it to its `include/` output, the host link and `KONAN_JNI_INCLUDE_DIR` use it. Its JNI is 1.6 β€” enough for the generated stubs. @@ -63,7 +64,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. per-target tasks, consumer detection and KGP's cinterops are deferred to `afterEvaluate` (KGP accepts cinterops declared there). Both sides follow this split: a library's build registers after evaluation (its block's conventions are set in the DSL constructors); a `BindingConsumer` - (`bindings/`) has `prepare()` for the eager part, then `isUsed()` and `register()` after evaluation. + (`dsl/bindings/`) has `prepare()` for the eager part, then `isUsed()` and `register()` after evaluation. - **One set of build systems for every library the plugin makes.** A library's archive, a binding's JNI library and the library FFM loads are each built by the plugin unless the script calls a block of `ExternalBuildBlocks` (`cmake`, `make`, `external`; libraries add `zig`, `cargo`, `prebuilt`, FFM @@ -73,7 +74,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. one `ExternalBuildTask` per target from an `ExternalBuildOutput` β€” the only thing that differs between owners: targets, output directory, file name, extra variables. A build system only adds commands, so a new one is one `ExternalBuild` subclass and a block, never a new task type. JNI and FFM share - `BindingLibrarySpec` in the DSL and `BindingLibraryWiring` in `bindings/`. + `BindingLibrarySpec` and `BindingLibraryWiring`, both in `dsl.bindings`. - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with *"It is too late to add actions as the callbacks already executed"*, so the jniLibs wiring runs at binding-registration time and tolerates an empty directory. diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index cc88dc6..c53d71a 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -302,11 +302,8 @@ Partly settled by what exists: | `model` | The domain, without tasks or KGP: `NativeTarget` (+ `TargetFamily`, `Architecture`, `HostOs`), `NativeArtifacts` (the archive layout), the external-build contract, `Executable`, `ConfigField`/`ConfigValue` (`konan.config`), `DefFile`, task naming, `selectOne` | | `kotlin` | Every touchpoint with Kotlin, and the only package importing it: `NativeTarget.of(konanTarget)`, `KonanDistribution` (home, pinned version, `run_konan` clang and llvm-ar, NDK), `KotlinGradlePlugin` (KGP), the index exporter client | | `toolchain` | The other toolchains: `Xcode` (compiler-rt), running an `Executable`, Zig triples | - | `dsl` | Everything a build script touches, nested as the DSL is: `KonanExtension` (`konan { }`), `dsl.config` (`config { }`), `dsl.libraries` (`libraries { }`), `dsl.bindings` (`bindings { }`, with `BindingLibrarySpec`, what `jni` and `ffm` share), `dsl.bindings.jni`, `dsl.bindings.ffm` | + | `dsl` | Everything a build script touches, nested as the DSL is: `KonanExtension` (`konan { }`), `dsl.config` (`config { }`), `dsl.libraries` (`libraries { }`, and a library's registration and builds: Kotlin/Native's clang, prebuilt, or an external build), `dsl.bindings` (`bindings { }`, with `BindingLibrarySpec`, what `jni` and `ffm` share), `dsl.bindings.jni`, `dsl.bindings.ffm`. The binding packages also hold the binding's implementation: registration, `BindingConsumer` and the Kotlin/Native one, `BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`, and per JNI or FFM its consumer and own link | | `dsl.builds` | The build blocks every level shares β€” `BuildSettings`: CMake, Make, Cargo, Zig, steps, prebuilt β€” their option types, and `ExternalBuildBlocks` | - | `libraries` | A library's registration and build: Kotlin/Native's clang, prebuilt, or an external build | - | `bindings` | Registration of bindings, `BindingConsumer` and the Kotlin/Native one, what JNI and FFM share (`BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`) | - | `bindings.jni`, `bindings.ffm` | Each one's consumer and the plugin's own link | | `index` | The generate task | | `external` | `ExternalBuild` (CMake, Make, Cargo, Zig, generic), `ExternalBuildTask`, and their commands | | `wiring` | AGP, plugin ids, the `konanConfig` report | diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 34033aa..89ca994 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -1,10 +1,10 @@ package io.github.lemcoder -import io.github.lemcoder.bindings.registerBindings +import io.github.lemcoder.dsl.bindings.registerBindings import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.kotlin.UsesKonanDistribution -import io.github.lemcoder.libraries.registerLibraries +import io.github.lemcoder.dsl.libraries.registerLibraries import io.github.lemcoder.model.ConfigField import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId diff --git a/src/main/kotlin/io/github/lemcoder/bindings/BindingConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt similarity index 91% rename from src/main/kotlin/io/github/lemcoder/bindings/BindingConsumer.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt index bea1754..e6a99b8 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/BindingConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt @@ -1,9 +1,8 @@ -package io.github.lemcoder.bindings +package io.github.lemcoder.dsl.bindings -import io.github.lemcoder.bindings.ffm.FfmConsumer -import io.github.lemcoder.bindings.jni.JniConsumer +import io.github.lemcoder.dsl.bindings.ffm.FfmConsumer +import io.github.lemcoder.dsl.bindings.jni.JniConsumer import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.bindings.BindingSpec import io.github.lemcoder.model.DefFile import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.wiring.GradlePluginId diff --git a/src/main/kotlin/io/github/lemcoder/bindings/BindingLibraryWiring.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt similarity index 98% rename from src/main/kotlin/io/github/lemcoder/bindings/BindingLibraryWiring.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt index 6a01e76..917b59d 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/BindingLibraryWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt @@ -1,6 +1,5 @@ -package io.github.lemcoder.bindings +package io.github.lemcoder.dsl.bindings -import io.github.lemcoder.dsl.bindings.BindingLibrarySpec import io.github.lemcoder.dsl.builds.CMakeSettings import io.github.lemcoder.dsl.builds.ExternalBuildSettings import io.github.lemcoder.dsl.builds.PrebuiltSettings diff --git a/src/main/kotlin/io/github/lemcoder/bindings/Bindings.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/Bindings.kt similarity index 84% rename from src/main/kotlin/io/github/lemcoder/bindings/Bindings.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/Bindings.kt index 394ac3f..02609c4 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/Bindings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/Bindings.kt @@ -1,9 +1,6 @@ -package io.github.lemcoder.bindings +package io.github.lemcoder.dsl.bindings -import io.github.lemcoder.bindings.BindingContext -import io.github.lemcoder.bindings.bindingConsumers import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.bindings.BindingSpec import org.gradle.api.Action import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/bindings/JarBundle.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/JarBundle.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/bindings/JarBundle.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/JarBundle.kt index 91aca18..60c41e0 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/JarBundle.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/JarBundle.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.bindings +package io.github.lemcoder.dsl.bindings import io.github.lemcoder.model.BundledLibraries import io.github.lemcoder.model.NativeTarget diff --git a/src/main/kotlin/io/github/lemcoder/bindings/KotlinNativeConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/KotlinNativeConsumer.kt similarity index 93% rename from src/main/kotlin/io/github/lemcoder/bindings/KotlinNativeConsumer.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/KotlinNativeConsumer.kt index a529aaa..a335fd0 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/KotlinNativeConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/KotlinNativeConsumer.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.bindings +package io.github.lemcoder.dsl.bindings import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.wiring.GradlePluginId diff --git a/src/main/kotlin/io/github/lemcoder/bindings/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt similarity index 99% rename from src/main/kotlin/io/github/lemcoder/bindings/LinkSharedLibraryTask.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt index 0f10178..89b0f5b 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.bindings +package io.github.lemcoder.dsl.bindings import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution diff --git a/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt similarity index 97% rename from src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmConsumer.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt index f94ea28..3cf1d4c 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt @@ -1,8 +1,8 @@ -package io.github.lemcoder.bindings.ffm +package io.github.lemcoder.dsl.bindings.ffm -import io.github.lemcoder.bindings.BindingConsumer -import io.github.lemcoder.bindings.BindingContext -import io.github.lemcoder.bindings.BindingLibraryWiring +import io.github.lemcoder.dsl.bindings.BindingConsumer +import io.github.lemcoder.dsl.bindings.BindingContext +import io.github.lemcoder.dsl.bindings.BindingLibraryWiring import io.github.lemcoder.index.FfmApi import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest diff --git a/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt similarity index 95% rename from src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmLibraryBuild.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt index 4175574..bb33907 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt @@ -1,9 +1,9 @@ -package io.github.lemcoder.bindings.ffm +package io.github.lemcoder.dsl.bindings.ffm -import io.github.lemcoder.bindings.LinkSharedLibraryTask +import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.bindings.BindingSpec -import io.github.lemcoder.libraries.PlacePrebuiltFilesTask +import io.github.lemcoder.dsl.libraries.PlacePrebuiltFilesTask import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName diff --git a/src/main/kotlin/io/github/lemcoder/bindings/jni/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/bindings/jni/JniConsumer.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt index 70f67bc..c864ae4 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/jni/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt @@ -1,8 +1,8 @@ -package io.github.lemcoder.bindings.jni +package io.github.lemcoder.dsl.bindings.jni -import io.github.lemcoder.bindings.BindingConsumer -import io.github.lemcoder.bindings.BindingContext -import io.github.lemcoder.bindings.BindingLibraryWiring +import io.github.lemcoder.dsl.bindings.BindingConsumer +import io.github.lemcoder.dsl.bindings.BindingContext +import io.github.lemcoder.dsl.bindings.BindingLibraryWiring import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.kotlin.KotlinGradlePlugin diff --git a/src/main/kotlin/io/github/lemcoder/bindings/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt similarity index 97% rename from src/main/kotlin/io/github/lemcoder/bindings/jni/KonanLinkStubBuild.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt index 680c693..0f8ff01 100644 --- a/src/main/kotlin/io/github/lemcoder/bindings/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt @@ -1,6 +1,6 @@ -package io.github.lemcoder.bindings.jni +package io.github.lemcoder.dsl.bindings.jni -import io.github.lemcoder.bindings.LinkSharedLibraryTask +import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.bindings.BindingSpec import io.github.lemcoder.index.GenerateFromNativeIndexTask diff --git a/src/main/kotlin/io/github/lemcoder/libraries/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt similarity index 99% rename from src/main/kotlin/io/github/lemcoder/libraries/CompileNativeLibraryTask.kt rename to src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt index 0171bf0..15c0df4 100644 --- a/src/main/kotlin/io/github/lemcoder/libraries/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.libraries +package io.github.lemcoder.dsl.libraries import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution diff --git a/src/main/kotlin/io/github/lemcoder/libraries/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt similarity index 95% rename from src/main/kotlin/io/github/lemcoder/libraries/KonanClangBuild.kt rename to src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt index 3e07cc3..0536873 100644 --- a/src/main/kotlin/io/github/lemcoder/libraries/KonanClangBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt @@ -1,7 +1,6 @@ -package io.github.lemcoder.libraries +package io.github.lemcoder.dsl.libraries import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/libraries/Libraries.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt similarity index 93% rename from src/main/kotlin/io/github/lemcoder/libraries/Libraries.kt rename to src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt index dfa69e2..57ee104 100644 --- a/src/main/kotlin/io/github/lemcoder/libraries/Libraries.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt @@ -1,7 +1,6 @@ -package io.github.lemcoder.libraries +package io.github.lemcoder.dsl.libraries import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import io.github.lemcoder.model.NativeArtifacts import org.gradle.api.Action import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/libraries/LibraryBuilds.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt similarity index 95% rename from src/main/kotlin/io/github/lemcoder/libraries/LibraryBuilds.kt rename to src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt index 151e7af..16e337e 100644 --- a/src/main/kotlin/io/github/lemcoder/libraries/LibraryBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt @@ -1,10 +1,9 @@ -package io.github.lemcoder.libraries +package io.github.lemcoder.dsl.libraries import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.builds.ExternalBuildSettings import io.github.lemcoder.dsl.builds.PrebuiltSettings import io.github.lemcoder.dsl.builds.blockName -import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import io.github.lemcoder.external.ExternalBuild import io.github.lemcoder.external.ExternalBuildOutput import io.github.lemcoder.model.ExternalVariable diff --git a/src/main/kotlin/io/github/lemcoder/libraries/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/libraries/PrebuiltLibrary.kt rename to src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt index 2fb4c77..2f2fa19 100644 --- a/src/main/kotlin/io/github/lemcoder/libraries/PrebuiltLibrary.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt @@ -1,6 +1,5 @@ -package io.github.lemcoder.libraries +package io.github.lemcoder.dsl.libraries -import io.github.lemcoder.dsl.libraries.NativeLibrarySpec import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.taskName import java.io.File diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index d91ab31..67872d5 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -94,7 +94,7 @@ abstract class GenerateFromNativeIndexTask @Inject constructor( */ private fun writeJniHeader(directory: File) { val header = directory.resolve("jni.h").apply { parentFile.mkdirs() } - GenerateFromNativeIndexTask::class.java.getResourceAsStream("/io/github/lemcoder/bindings/jni/jni.h").use { input -> + GenerateFromNativeIndexTask::class.java.getResourceAsStream("/io/github/lemcoder/dsl/bindings/jni/jni.h").use { input -> header.outputStream().use { checkNotNull(input) { "jni.h missing from the plugin jar" }.copyTo(it) } } } diff --git a/src/main/resources/io/github/lemcoder/bindings/jni/jni.h b/src/main/resources/io/github/lemcoder/dsl/bindings/jni/jni.h similarity index 100% rename from src/main/resources/io/github/lemcoder/bindings/jni/jni.h rename to src/main/resources/io/github/lemcoder/dsl/bindings/jni/jni.h From 2934a5280dce90110f2cd08acb7ac9b94394006f Mon Sep 17 00:00:00 2001 From: mikolaj Date: Fri, 16 Oct 2026 22:06:16 +0200 Subject: [PATCH 28/57] refactor: each type in the package of the DSL block it serves The remaining shared packages are split by owner: - dsl.config: ConfigField, ConfigSource, ConfigValue and the konanConfig report - dsl.libraries: NativeArtifacts, the layout a library publishes - dsl.bindings: DefFile; dsl.bindings.jni: stubLibraryBaseName (JniNaming) - dsl.builds: the external builds (ExternalBuild, ExternalBuildTask, each build system's commands), their contract, selectOne, Zig and Rust triples - kotlin: Xcode, used only by the distribution's links external/ and toolchain/ are gone; model keeps the shared domain (NativeTarget, HostOs, Executable, taskName) and wiring AGP and plugin ids. Tests follow their classes. ConfigField, ConfigSource and ConfigValue are public and now live in io.github.lemcoder.dsl.config. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 27 ++++++++++--------- doc/interop-architecture.md | 12 ++++----- .../kotlin/io/github/lemcoder/KonanPlugin.kt | 8 +++--- .../lemcoder/dsl/bindings/BindingConsumer.kt | 5 ++-- .../dsl/bindings/BindingLibraryWiring.kt | 10 +++---- .../{model => dsl/bindings}/DefFile.kt | 2 +- .../dsl/bindings/LinkSharedLibraryTask.kt | 2 +- .../lemcoder/dsl/bindings/ffm/FfmConsumer.kt | 2 +- .../dsl/bindings/ffm/FfmLibraryBuild.kt | 4 +-- .../lemcoder/dsl/bindings/jni/JniConsumer.kt | 5 ++-- .../lemcoder/dsl/bindings/jni/JniNaming.kt | 7 +++++ .../dsl/bindings/jni/KonanLinkStubBuild.kt | 6 ++--- .../{external => dsl/builds}/CMakeCommands.kt | 7 ++--- .../{external => dsl/builds}/CargoCommands.kt | 8 ++---- .../{toolchain => dsl/builds}/Executables.kt | 2 +- .../{external => dsl/builds}/ExternalBuild.kt | 12 +-------- .../dsl/builds/ExternalBuildBlocks.kt | 4 --- .../builds}/ExternalBuildContract.kt | 5 ++-- .../builds}/ExternalBuildTask.kt | 7 +++-- .../{external => dsl/builds}/MakeCommands.kt | 7 ++--- .../{toolchain => dsl/builds}/RustTargets.kt | 2 +- .../{model => dsl/builds}/Selection.kt | 2 +- .../{external => dsl/builds}/ZigCommands.kt | 7 ++--- .../{toolchain => dsl/builds}/ZigTargets.kt | 2 +- .../{model => dsl/config}/ConfigField.kt | 2 +- .../{wiring => dsl/config}/ConfigReport.kt | 8 ++---- .../github/lemcoder/dsl/config/KonanConfig.kt | 3 --- .../dsl/libraries/CompileNativeLibraryTask.kt | 3 +-- .../lemcoder/dsl/libraries/Libraries.kt | 1 - .../lemcoder/dsl/libraries/LibraryBuilds.kt | 9 +++---- .../libraries}/NativeArtifacts.kt | 5 ++-- .../dsl/libraries/NativeLibrarySpec.kt | 1 - .../lemcoder/dsl/libraries/PrebuiltLibrary.kt | 1 - .../index/GenerateFromNativeIndexTask.kt | 6 ++--- .../lemcoder/kotlin/KonanDistribution.kt | 9 +++---- .../github/lemcoder/kotlin/KonanToolchain.kt | 8 +++--- .../lemcoder/kotlin/KotlinGradlePlugin.kt | 2 +- .../lemcoder/{toolchain => kotlin}/Xcode.kt | 2 +- .../io/github/lemcoder/model/Executable.kt | 1 + .../kotlin/io/github/lemcoder/model/Naming.kt | 6 ----- .../{model => dsl/bindings}/DefFileTest.kt | 2 +- .../{model => dsl/config}/ConfigValueTest.kt | 2 +- 42 files changed, 94 insertions(+), 132 deletions(-) rename src/main/kotlin/io/github/lemcoder/{model => dsl/bindings}/DefFile.kt (98%) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniNaming.kt rename src/main/kotlin/io/github/lemcoder/{external => dsl/builds}/CMakeCommands.kt (94%) rename src/main/kotlin/io/github/lemcoder/{external => dsl/builds}/CargoCommands.kt (87%) rename src/main/kotlin/io/github/lemcoder/{toolchain => dsl/builds}/Executables.kt (90%) rename src/main/kotlin/io/github/lemcoder/{external => dsl/builds}/ExternalBuild.kt (93%) rename src/main/kotlin/io/github/lemcoder/{model => dsl/builds}/ExternalBuildContract.kt (97%) rename src/main/kotlin/io/github/lemcoder/{external => dsl/builds}/ExternalBuildTask.kt (98%) rename src/main/kotlin/io/github/lemcoder/{external => dsl/builds}/MakeCommands.kt (84%) rename src/main/kotlin/io/github/lemcoder/{toolchain => dsl/builds}/RustTargets.kt (96%) rename src/main/kotlin/io/github/lemcoder/{model => dsl/builds}/Selection.kt (92%) rename src/main/kotlin/io/github/lemcoder/{external => dsl/builds}/ZigCommands.kt (88%) rename src/main/kotlin/io/github/lemcoder/{toolchain => dsl/builds}/ZigTargets.kt (96%) rename src/main/kotlin/io/github/lemcoder/{model => dsl/config}/ConfigField.kt (99%) rename src/main/kotlin/io/github/lemcoder/{wiring => dsl/config}/ConfigReport.kt (91%) rename src/main/kotlin/io/github/lemcoder/{model => dsl/libraries}/NativeArtifacts.kt (93%) rename src/main/kotlin/io/github/lemcoder/{toolchain => kotlin}/Xcode.kt (97%) rename src/test/kotlin/io/github/lemcoder/{model => dsl/bindings}/DefFileTest.kt (93%) rename src/test/kotlin/io/github/lemcoder/{model => dsl/config}/ConfigValueTest.kt (98%) diff --git a/CLAUDE.md b/CLAUDE.md index 2075d08..31f3f86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,17 +24,20 @@ resolved the moment the plugin is applied, in *every* project that applies it. `dsl.bindings` with `dsl.bindings.jni` and `dsl.bindings.ffm`. Build blocks appear at every level, so they share `dsl.builds`. A new block goes in the package of the block that contains it. Never name a source package `build`: `.gitignore`'s `build/` matches it, and `git add` silently skips it. -- **Implementation lives beside its DSL**: `dsl.libraries` also holds a library's registration and builds - (Kotlin/Native's clang, prebuilt, the dispatch to an external build); `dsl.bindings` a binding's - registration, the consumers (`BindingConsumer`, `KotlinNativeConsumer`) and what JNI and FFM share - (`BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`); `dsl.bindings.jni` and `dsl.bindings.ffm` - each hold one consumer and its own link. All of it is `internal`. `wiring` keeps AGP, plugin ids and the - config report. -- **One home per concept.** `model/` holds the domain (targets, naming, the artifact layout, the - external-build contract, `selectOne`), `kotlin/` the touchpoints with Kotlin, `toolchain/` the - others (Xcode, running executables, Zig). Task names come from `taskName(...)`, OS checks from - `HostOs`, the distribution's layout from `KonanDistribution` β€” add to those, don't re-derive. - `BindingVisibility` lives in `src/exporterProtocol` so the DSL and the generator share one enum. +- **Implementation lives beside its DSL**, all of it `internal`: + - `dsl.config` β€” `ConfigField`/`ConfigValue` and the `konanConfig` report; + - `dsl.libraries` β€” a library's registration and builds, and `NativeArtifacts`, the layout it publishes; + - `dsl.bindings` β€” a binding's registration, `DefFile`, the consumers (`BindingConsumer`, + `KotlinNativeConsumer`) and what JNI and FFM share (`BindingLibraryWiring`, the jar bundle, + `LinkSharedLibraryTask`); `dsl.bindings.jni` and `dsl.bindings.ffm` each hold one consumer and its own link; + - `dsl.builds` β€” the external builds (`ExternalBuild`, `ExternalBuildTask`, each build system's commands), + their contract (`ExternalVariable`), `selectOne`, and the Zig and Rust target triples. +- **What no single block owns stays outside `dsl`**: `model/` the shared domain (`NativeTarget`, `HostOs`, + `Executable`, `taskName`), `kotlin/` the touchpoints with Kotlin (distribution, KGP, Xcode's compiler-rt for + its links, the index exporter client), `index/` the generate task, `wiring/` AGP and plugin ids, `util/` + process and Gradle helpers. Task names come from `taskName(...)`, OS checks from `HostOs`, the + distribution's layout from `KonanDistribution` β€” add to those, don't re-derive. `BindingVisibility` lives + in `src/exporterProtocol` so the DSL and the generator share one enum. - AGP types are only touched inside `plugins.withId(...)` blocks. A plain reference in an eagerly executed method is `NoClassDefFoundError: KotlinMultiplatformAndroidComponentsExtension` in a plugin-only build. The KMP Android plugin does **not** apply `com.android.base`, so both ids need @@ -70,7 +73,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. `ExternalBuildBlocks` (`cmake`, `make`, `external`; libraries add `zig`, `cargo`, `prebuilt`, FFM `prebuilt`). Every block's settings live in `dsl.builds` and implement the sealed `BuildSettings`; calling a block selects its settings object, and owners dispatch on `PrebuiltSettings` or `ExternalBuildSettings`. - `ExternalBuild.of(settings)` (`external/ExternalBuild.kt`) registers + `ExternalBuild.of(settings)` (`dsl/builds/ExternalBuild.kt`) registers one `ExternalBuildTask` per target from an `ExternalBuildOutput` β€” the only thing that differs between owners: targets, output directory, file name, extra variables. A build system only adds commands, so a new one is one `ExternalBuild` subclass and a block, never a new task type. JNI and FFM share diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index c53d71a..fa01236 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -299,14 +299,12 @@ Partly settled by what exists: | Package | Holds | | --- | --- | - | `model` | The domain, without tasks or KGP: `NativeTarget` (+ `TargetFamily`, `Architecture`, `HostOs`), `NativeArtifacts` (the archive layout), the external-build contract, `Executable`, `ConfigField`/`ConfigValue` (`konan.config`), `DefFile`, task naming, `selectOne` | - | `kotlin` | Every touchpoint with Kotlin, and the only package importing it: `NativeTarget.of(konanTarget)`, `KonanDistribution` (home, pinned version, `run_konan` clang and llvm-ar, NDK), `KotlinGradlePlugin` (KGP), the index exporter client | - | `toolchain` | The other toolchains: `Xcode` (compiler-rt), running an `Executable`, Zig triples | - | `dsl` | Everything a build script touches, nested as the DSL is: `KonanExtension` (`konan { }`), `dsl.config` (`config { }`), `dsl.libraries` (`libraries { }`, and a library's registration and builds: Kotlin/Native's clang, prebuilt, or an external build), `dsl.bindings` (`bindings { }`, with `BindingLibrarySpec`, what `jni` and `ffm` share), `dsl.bindings.jni`, `dsl.bindings.ffm`. The binding packages also hold the binding's implementation: registration, `BindingConsumer` and the Kotlin/Native one, `BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`, and per JNI or FFM its consumer and own link | - | `dsl.builds` | The build blocks every level shares β€” `BuildSettings`: CMake, Make, Cargo, Zig, steps, prebuilt β€” their option types, and `ExternalBuildBlocks` | + | `model` | The shared domain, without tasks or KGP: `NativeTarget` (+ `TargetFamily`, `Architecture`, `HostOs`), `Executable`, task naming | + | `kotlin` | Every touchpoint with Kotlin, and the only package importing it: `NativeTarget.of(konanTarget)`, `KonanDistribution` (home, version, `run_konan` clang and llvm-ar, NDK), `Xcode` (compiler-rt for macOS links), `KotlinGradlePlugin` (KGP), the index exporter client | + | `dsl` | Everything a build script touches, nested as the DSL is: `KonanExtension` (`konan { }`), `dsl.config` (`config { }`, with `ConfigField`/`ConfigValue` and the `konanConfig` report), `dsl.libraries` (`libraries { }`, a library's registration and builds β€” Kotlin/Native's clang, prebuilt, or an external build β€” and `NativeArtifacts`, the layout it publishes), `dsl.bindings` (`bindings { }`, with `BindingLibrarySpec`, what `jni` and `ffm` share), `dsl.bindings.jni`, `dsl.bindings.ffm`. The binding packages also hold the binding's implementation: registration, `DefFile`, `BindingConsumer` and the Kotlin/Native one, `BindingLibraryWiring`, the jar bundle, `LinkSharedLibraryTask`, and per JNI or FFM its consumer and own link | + | `dsl.builds` | The build blocks every level shares β€” `BuildSettings`: CMake, Make, Cargo, Zig, steps, prebuilt β€” their option types and `ExternalBuildBlocks`; and their implementation: `ExternalBuild`, `ExternalBuildTask`, each build system's commands, the `ExternalVariable` contract, `selectOne`, Zig and Rust triples | | `index` | The generate task | - | `external` | `ExternalBuild` (CMake, Make, Cargo, Zig, generic), `ExternalBuildTask`, and their commands | - | `wiring` | AGP, plugin ids, the `konanConfig` report | + | `wiring` | AGP, plugin ids | - **Generator output**: each exporter generator owns its layout under the task's output directory β€” `jni`: `kotlin//.kt` and `c/.c`; `jextract`: `java//`; `dump`: `index.txt`. Kotlin/Native klibs stay where KGP puts them. diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 89ca994..7ed1c9d 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -1,14 +1,14 @@ package io.github.lemcoder -import io.github.lemcoder.dsl.bindings.registerBindings import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.bindings.registerBindings +import io.github.lemcoder.dsl.config.ConfigField +import io.github.lemcoder.dsl.config.registerConfigReport +import io.github.lemcoder.dsl.libraries.registerLibraries import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.kotlin.UsesKonanDistribution -import io.github.lemcoder.dsl.libraries.registerLibraries -import io.github.lemcoder.model.ConfigField import io.github.lemcoder.wiring.AgpWiring import io.github.lemcoder.wiring.GradlePluginId -import io.github.lemcoder.wiring.registerConfigReport import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Plugin import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt index e6a99b8..01e6fab 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt @@ -1,10 +1,9 @@ package io.github.lemcoder.dsl.bindings +import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.bindings.ffm.FfmConsumer import io.github.lemcoder.dsl.bindings.jni.JniConsumer -import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.model.DefFile -import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.dsl.libraries.NativeArtifacts import io.github.lemcoder.wiring.GradlePluginId import io.github.lemcoder.wiring.hasPlugin import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt index 917b59d..d01d013 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt @@ -1,17 +1,17 @@ package io.github.lemcoder.dsl.bindings import io.github.lemcoder.dsl.builds.CMakeSettings +import io.github.lemcoder.dsl.builds.ExternalBuild +import io.github.lemcoder.dsl.builds.ExternalBuildOutput import io.github.lemcoder.dsl.builds.ExternalBuildSettings +import io.github.lemcoder.dsl.builds.ExternalBuildTask +import io.github.lemcoder.dsl.builds.ExternalBuildVariable import io.github.lemcoder.dsl.builds.PrebuiltSettings import io.github.lemcoder.dsl.builds.blockName -import io.github.lemcoder.external.ExternalBuild -import io.github.lemcoder.external.ExternalBuildOutput -import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.dsl.builds.selectOne import io.github.lemcoder.kotlin.KotlinGradlePlugin -import io.github.lemcoder.model.ExternalBuildVariable import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.capitalized -import io.github.lemcoder.model.selectOne import io.github.lemcoder.model.taskName import org.gradle.api.Task import org.gradle.api.file.Directory diff --git a/src/main/kotlin/io/github/lemcoder/model/DefFile.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt similarity index 98% rename from src/main/kotlin/io/github/lemcoder/model/DefFile.kt rename to src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt index e813024..54d0d67 100644 --- a/src/main/kotlin/io/github/lemcoder/model/DefFile.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.bindings import java.io.File diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt index 89b0f5b..cb789b5 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt @@ -1,8 +1,8 @@ package io.github.lemcoder.dsl.bindings +import io.github.lemcoder.dsl.config.ConfigValue import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution -import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture import javax.inject.Inject diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt index 3cf1d4c..44d16b1 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt @@ -3,12 +3,12 @@ package io.github.lemcoder.dsl.bindings.ffm import io.github.lemcoder.dsl.bindings.BindingConsumer import io.github.lemcoder.dsl.bindings.BindingContext import io.github.lemcoder.dsl.bindings.BindingLibraryWiring +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.index.FfmApi import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.kotlin.KotlinGradlePlugin import io.github.lemcoder.model.Architecture -import io.github.lemcoder.model.ConfigField import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import io.github.lemcoder.wiring.AgpWiring diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt index bb33907..e374212 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt @@ -1,10 +1,10 @@ package io.github.lemcoder.dsl.bindings.ffm -import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.bindings.BindingSpec +import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask +import io.github.lemcoder.dsl.libraries.NativeArtifacts import io.github.lemcoder.dsl.libraries.PlacePrebuiltFilesTask -import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt index c864ae4..d8a0344 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt @@ -3,13 +3,12 @@ package io.github.lemcoder.dsl.bindings.jni import io.github.lemcoder.dsl.bindings.BindingConsumer import io.github.lemcoder.dsl.bindings.BindingContext import io.github.lemcoder.dsl.bindings.BindingLibraryWiring +import io.github.lemcoder.dsl.builds.ExternalVariable +import io.github.lemcoder.dsl.libraries.NativeArtifacts import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest import io.github.lemcoder.kotlin.KotlinGradlePlugin -import io.github.lemcoder.model.ExternalVariable -import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget -import io.github.lemcoder.model.stubLibraryBaseName import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask import io.github.lemcoder.wiring.AgpWiring diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniNaming.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniNaming.kt new file mode 100644 index 0000000..4e6c5a1 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniNaming.kt @@ -0,0 +1,7 @@ +package io.github.lemcoder.dsl.bindings.jni + +/** + * The JNI library a binding's generated code loads: cinterop's own stub naming, the package's parts + * joined plus `stubs` β€” `io.example.m` β†’ `ioexamplemstubs`. + */ +internal fun stubLibraryBaseName(packageName: String): String = packageName.split('.').joinToString("") + "stubs" diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt index 0f8ff01..3d524bc 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt @@ -1,12 +1,12 @@ package io.github.lemcoder.dsl.bindings.jni -import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.bindings.BindingSpec +import io.github.lemcoder.dsl.bindings.DefFile +import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask +import io.github.lemcoder.dsl.libraries.NativeArtifacts import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest -import io.github.lemcoder.model.DefFile -import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask diff --git a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt similarity index 94% rename from src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt index 63bb42b..fbace57 100644 --- a/src/main/kotlin/io/github/lemcoder/external/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt @@ -1,10 +1,7 @@ -package io.github.lemcoder.external +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.dsl.builds.CMakeSettings +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.config.konanConfig -import io.github.lemcoder.model.ConfigField -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ToolchainVariable import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.provider.Provider diff --git a/src/main/kotlin/io/github/lemcoder/external/CargoCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoCommands.kt similarity index 87% rename from src/main/kotlin/io/github/lemcoder/external/CargoCommands.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/CargoCommands.kt index e388c32..93e02fb 100644 --- a/src/main/kotlin/io/github/lemcoder/external/CargoCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoCommands.kt @@ -1,11 +1,7 @@ -package io.github.lemcoder.external +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.dsl.builds.CargoProfile -import io.github.lemcoder.dsl.builds.CargoSettings +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.config.konanConfig -import io.github.lemcoder.model.ConfigField -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.toolchain.rustTriple import org.gradle.api.file.Directory import org.gradle.api.provider.Provider diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/Executables.kt similarity index 90% rename from src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/Executables.kt index 8e975b8..57414e2 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/Executables.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/Executables.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.toolchain +package io.github.lemcoder.dsl.builds import io.github.lemcoder.model.Executable diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt similarity index 93% rename from src/main/kotlin/io/github/lemcoder/external/ExternalBuild.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt index 75738b4..63b9d9b 100644 --- a/src/main/kotlin/io/github/lemcoder/external/ExternalBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt @@ -1,16 +1,6 @@ -package io.github.lemcoder.external +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.dsl.builds.CMakeSettings -import io.github.lemcoder.dsl.builds.CargoSettings -import io.github.lemcoder.dsl.builds.ExternalBuildSettings -import io.github.lemcoder.dsl.builds.ExternalToolchain -import io.github.lemcoder.dsl.builds.MakeSettings -import io.github.lemcoder.dsl.builds.StepsSettings -import io.github.lemcoder.dsl.builds.ZigSettings import io.github.lemcoder.kotlin.KonanToolchain -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ExternalBuildVariable -import io.github.lemcoder.model.ExternalVariable import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt index 11e1eac..ea5dc39 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt @@ -1,9 +1,5 @@ package io.github.lemcoder.dsl.builds -import io.github.lemcoder.dsl.builds.BuildSettings -import io.github.lemcoder.dsl.builds.CMakeSettings -import io.github.lemcoder.dsl.builds.MakeSettings -import io.github.lemcoder.dsl.builds.StepsSettings import org.gradle.api.Action import org.gradle.api.model.ObjectFactory diff --git a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt similarity index 97% rename from src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt index 5c4ed11..b3440fa 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ExternalBuildContract.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt @@ -1,8 +1,9 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.builds +import io.github.lemcoder.model.NativeTarget +import java.io.File import org.gradle.api.file.Directory import org.gradle.api.provider.Provider -import java.io.File /** A variable of an external build's contract: what the plugin tells the build, by environment. */ internal sealed interface ExternalBuildVariable { diff --git a/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt similarity index 98% rename from src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt index fe1f068..96cd65b 100644 --- a/src/main/kotlin/io/github/lemcoder/external/ExternalBuildTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt @@ -1,9 +1,10 @@ -package io.github.lemcoder.external +package io.github.lemcoder.dsl.builds import io.github.lemcoder.model.Executable -import io.github.lemcoder.toolchain.command import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.execCapture +import java.io.File +import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty @@ -23,8 +24,6 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -import java.io.File -import javax.inject.Inject /** * Runs a build system the plugin does not model, for one target: its [commands] in order, with the diff --git a/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeCommands.kt similarity index 84% rename from src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/MakeCommands.kt index 3e22ed4..a96b550 100644 --- a/src/main/kotlin/io/github/lemcoder/external/MakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeCommands.kt @@ -1,10 +1,7 @@ -package io.github.lemcoder.external +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.dsl.builds.MakeSettings +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.config.konanConfig -import io.github.lemcoder.model.ConfigField -import io.github.lemcoder.model.ExternalBuildContext -import io.github.lemcoder.model.ToolchainVariable /** The Make preset of an external build: one `make` run for the context's target; see [MakeSettings]. */ internal fun ExternalBuildTask.addMakeCommands(make: MakeSettings, context: ExternalBuildContext, owner: String) { diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/RustTargets.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/RustTargets.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/toolchain/RustTargets.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/RustTargets.kt index ba98c8a..3c9f749 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/RustTargets.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/RustTargets.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.toolchain +package io.github.lemcoder.dsl.builds import io.github.lemcoder.model.NativeTarget diff --git a/src/main/kotlin/io/github/lemcoder/model/Selection.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/Selection.kt similarity index 92% rename from src/main/kotlin/io/github/lemcoder/model/Selection.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/Selection.kt index d3a8879..c115c46 100644 --- a/src/main/kotlin/io/github/lemcoder/model/Selection.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/Selection.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.builds /** * The one choice [selected] holds, or null for the default: a DSL owner such as a library or a binding's diff --git a/src/main/kotlin/io/github/lemcoder/external/ZigCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigCommands.kt similarity index 88% rename from src/main/kotlin/io/github/lemcoder/external/ZigCommands.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/ZigCommands.kt index 672c9a0..b9ebd48 100644 --- a/src/main/kotlin/io/github/lemcoder/external/ZigCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigCommands.kt @@ -1,11 +1,8 @@ -package io.github.lemcoder.external +package io.github.lemcoder.dsl.builds -import io.github.lemcoder.dsl.builds.ZigSettings +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.config.konanConfig -import io.github.lemcoder.model.ConfigField -import io.github.lemcoder.model.ExternalBuildContext import io.github.lemcoder.model.TargetFamily -import io.github.lemcoder.toolchain.zigTriple import org.gradle.api.file.Directory import org.gradle.api.provider.Provider diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigTargets.kt similarity index 96% rename from src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt rename to src/main/kotlin/io/github/lemcoder/dsl/builds/ZigTargets.kt index b2b36bc..264b4ab 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/ZigTargets.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigTargets.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.toolchain +package io.github.lemcoder.dsl.builds import io.github.lemcoder.model.NativeTarget diff --git a/src/main/kotlin/io/github/lemcoder/model/ConfigField.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt similarity index 99% rename from src/main/kotlin/io/github/lemcoder/model/ConfigField.kt rename to src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt index 26671de..2f4edf4 100644 --- a/src/main/kotlin/io/github/lemcoder/model/ConfigField.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.config import org.gradle.api.InvalidUserDataException import java.io.File diff --git a/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigReport.kt similarity index 91% rename from src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt rename to src/main/kotlin/io/github/lemcoder/dsl/config/ConfigReport.kt index f604bbf..789e7ee 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/ConfigReport.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigReport.kt @@ -1,11 +1,7 @@ -package io.github.lemcoder.wiring +package io.github.lemcoder.dsl.config -import io.github.lemcoder.dsl.config.KonanConfig -import io.github.lemcoder.dsl.config.konanConfig -import io.github.lemcoder.external.ExternalBuildTask +import io.github.lemcoder.dsl.builds.ExternalBuildTask import io.github.lemcoder.kotlin.UsesKonanDistribution -import io.github.lemcoder.model.ConfigField -import io.github.lemcoder.model.ConfigValue import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.provider.ListProperty diff --git a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt index 7a84a29..e5fa32c 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt @@ -2,9 +2,6 @@ package io.github.lemcoder.dsl.config import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.kotlin.KonanDistribution -import io.github.lemcoder.model.ConfigField -import io.github.lemcoder.model.ConfigSource -import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.Executable import java.io.File import javax.inject.Inject diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt index 15c0df4..39d3435 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt @@ -1,9 +1,8 @@ package io.github.lemcoder.dsl.libraries +import io.github.lemcoder.dsl.config.ConfigValue import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution -import io.github.lemcoder.model.ConfigValue -import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture import javax.inject.Inject diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt index 57ee104..06d23a2 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt @@ -1,7 +1,6 @@ package io.github.lemcoder.dsl.libraries import io.github.lemcoder.dsl.KonanExtension -import io.github.lemcoder.model.NativeArtifacts import org.gradle.api.Action import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt index 16e337e..a66d8b1 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt @@ -1,14 +1,13 @@ package io.github.lemcoder.dsl.libraries import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.builds.ExternalBuild +import io.github.lemcoder.dsl.builds.ExternalBuildOutput import io.github.lemcoder.dsl.builds.ExternalBuildSettings +import io.github.lemcoder.dsl.builds.ExternalVariable import io.github.lemcoder.dsl.builds.PrebuiltSettings import io.github.lemcoder.dsl.builds.blockName -import io.github.lemcoder.external.ExternalBuild -import io.github.lemcoder.external.ExternalBuildOutput -import io.github.lemcoder.model.ExternalVariable -import io.github.lemcoder.model.NativeArtifacts -import io.github.lemcoder.model.selectOne +import io.github.lemcoder.dsl.builds.selectOne import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider diff --git a/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt similarity index 93% rename from src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt rename to src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt index e768e71..ad09fcb 100644 --- a/src/main/kotlin/io/github/lemcoder/model/NativeArtifacts.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt @@ -1,6 +1,7 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.libraries -import io.github.lemcoder.dsl.libraries.NativeLibrarySpec +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.taskName import org.gradle.api.Project import org.gradle.api.file.Directory import org.gradle.api.file.RegularFile diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt index 13f8da1..4381ceb 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt @@ -4,7 +4,6 @@ import io.github.lemcoder.dsl.builds.CargoSettings import io.github.lemcoder.dsl.builds.ExternalBuildBlocks import io.github.lemcoder.dsl.builds.PrebuiltSettings import io.github.lemcoder.dsl.builds.ZigSettings -import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.NativeTarget import javax.inject.Inject import org.gradle.api.Action diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt index 2f2fa19..dad2679 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt @@ -1,6 +1,5 @@ package io.github.lemcoder.dsl.libraries -import io.github.lemcoder.model.NativeArtifacts import io.github.lemcoder.model.taskName import java.io.File import org.gradle.api.DefaultTask diff --git a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt index 67872d5..721b310 100644 --- a/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -1,10 +1,12 @@ package io.github.lemcoder.index +import io.github.lemcoder.dsl.config.ConfigValue import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution import io.github.lemcoder.kotlin.runIndexExporter -import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.NativeTarget +import java.io.File +import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty @@ -22,8 +24,6 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -import java.io.File -import javax.inject.Inject /** * Indexes the headers a `.def` names with cinterop's own indexer and runs the generator [request] asks for over the diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt index a704971..4fa5412 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -1,21 +1,20 @@ package io.github.lemcoder.kotlin +import io.github.lemcoder.dsl.config.ConfigValue import io.github.lemcoder.model.Architecture -import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.TargetFamily -import io.github.lemcoder.toolchain.Xcode import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.execCapture +import java.io.File +import java.util.Properties +import java.util.jar.JarFile import org.gradle.api.InvalidUserDataException import org.gradle.api.Task import org.gradle.api.provider.Property import org.gradle.api.tasks.Internal import org.gradle.process.ExecOperations -import java.io.File -import java.util.Properties -import java.util.jar.JarFile /** * A Kotlin/Native distribution β€” `kotlin-native-prebuilt-*` β€” and the tools in it the plugin runs: clang diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt index 08bd413..6ade4d1 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt @@ -1,12 +1,14 @@ package io.github.lemcoder.kotlin +import io.github.lemcoder.dsl.builds.ToolchainFiles +import io.github.lemcoder.dsl.config.ConfigValue import io.github.lemcoder.model.Architecture -import io.github.lemcoder.model.ConfigValue import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.TargetFamily -import io.github.lemcoder.model.ToolchainFiles import io.github.lemcoder.model.taskName +import java.io.File +import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.Project import org.gradle.api.file.Directory @@ -20,8 +22,6 @@ import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault -import java.io.File -import javax.inject.Inject /** * Kotlin/Native's toolchain for one target, as files an external build can be handed: `cc` and `c++` diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index 140aa45..8e6f9b1 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -1,7 +1,7 @@ package io.github.lemcoder.kotlin import io.github.lemcoder.dsl.bindings.BindingSpec -import io.github.lemcoder.model.NativeArtifacts +import io.github.lemcoder.dsl.libraries.NativeArtifacts import io.github.lemcoder.model.NativeTarget import java.io.File import org.gradle.api.Project diff --git a/src/main/kotlin/io/github/lemcoder/toolchain/Xcode.kt b/src/main/kotlin/io/github/lemcoder/kotlin/Xcode.kt similarity index 97% rename from src/main/kotlin/io/github/lemcoder/toolchain/Xcode.kt rename to src/main/kotlin/io/github/lemcoder/kotlin/Xcode.kt index 82780b2..c9bbaa2 100644 --- a/src/main/kotlin/io/github/lemcoder/toolchain/Xcode.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/Xcode.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.toolchain +package io.github.lemcoder.kotlin import io.github.lemcoder.util.execCapture import org.gradle.process.ExecOperations diff --git a/src/main/kotlin/io/github/lemcoder/model/Executable.kt b/src/main/kotlin/io/github/lemcoder/model/Executable.kt index 6c6207b..17e2389 100644 --- a/src/main/kotlin/io/github/lemcoder/model/Executable.kt +++ b/src/main/kotlin/io/github/lemcoder/model/Executable.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.model +import io.github.lemcoder.dsl.config.ConfigValue import java.io.Serializable /** A program an external build step runs: a file, or a name the build's `PATH` resolves. */ diff --git a/src/main/kotlin/io/github/lemcoder/model/Naming.kt b/src/main/kotlin/io/github/lemcoder/model/Naming.kt index fdf92a1..70352a6 100644 --- a/src/main/kotlin/io/github/lemcoder/model/Naming.kt +++ b/src/main/kotlin/io/github/lemcoder/model/Naming.kt @@ -6,9 +6,3 @@ internal fun String.capitalized(): String = replaceFirstChar { it.uppercase() } /** Every task name the plugin registers: `[]`, e.g. `linkJniMymathAndroid_arm64`. */ internal fun taskName(prefix: String, owner: String, target: NativeTarget? = null): String = prefix + owner.capitalized() + (target?.taskSuffix ?: "") - -/** - * The JNI library a binding's generated code loads: cinterop's own stub naming, the package's parts - * joined plus `stubs` β€” `io.example.m` β†’ `ioexamplemstubs`. - */ -internal fun stubLibraryBaseName(packageName: String): String = packageName.split('.').joinToString("") + "stubs" diff --git a/src/test/kotlin/io/github/lemcoder/model/DefFileTest.kt b/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt similarity index 93% rename from src/test/kotlin/io/github/lemcoder/model/DefFileTest.kt rename to src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt index 28f1f6f..dad96d1 100644 --- a/src/test/kotlin/io/github/lemcoder/model/DefFileTest.kt +++ b/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.bindings import kotlin.test.Test import kotlin.test.assertEquals diff --git a/src/test/kotlin/io/github/lemcoder/model/ConfigValueTest.kt b/src/test/kotlin/io/github/lemcoder/dsl/config/ConfigValueTest.kt similarity index 98% rename from src/test/kotlin/io/github/lemcoder/model/ConfigValueTest.kt rename to src/test/kotlin/io/github/lemcoder/dsl/config/ConfigValueTest.kt index a534c7c..8466973 100644 --- a/src/test/kotlin/io/github/lemcoder/model/ConfigValueTest.kt +++ b/src/test/kotlin/io/github/lemcoder/dsl/config/ConfigValueTest.kt @@ -1,4 +1,4 @@ -package io.github.lemcoder.model +package io.github.lemcoder.dsl.config import org.gradle.api.InvalidUserDataException import java.io.File From 2ce9f0b1addc13420410b9b4c0baf686c3a4cf8f Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 20 Oct 2026 22:37:21 +0200 Subject: [PATCH 29/57] feat: Android links carry libc++ and the unwinder; one Android example over C, Rust and C++ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android gives apps neither libc++ nor an unwinder, and the plugin's Android links left both undefined: a C++ library (operator new, std::string, exceptions) or a Rust staticlib (std's _Unwind_*) linked fine and failed on the device with "cannot locate symbol". Kotlin/Native's NDK has all three β€” libc++_static.a and libc++abi.a in its sysroot, the unwinder in lib/gcc//4.9.x/libgcc_real.a β€” so LinkSharedLibraryTask now ends every Android link with them (KonanDistribution.androidRuntime) and links with -Wl,--no-undefined, so a missing symbol fails the build instead. An archive only contributes what is used: C libraries link as before. examples/android replaces examples/android and examples/android-ffm: one app over three libraries β€” C (clang) and Rust (cargo) through JNI, C++ (clang) through FFM on PanamaPort β€” built from this checkout with includeBuild. It runs on an arm64 emulator and a phone. scripts/run-android.sh follows it. A test links a C++ library using new, std::vector and an exception for android_arm64, which fails without the runtime archives. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 + README.md | 17 +- examples/README.md | 20 +- examples/android-ffm/README.md | 33 --- examples/android-ffm/build.gradle.kts | 45 ---- examples/android-ffm/gradle.properties | 2 - .../gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - examples/android-ffm/gradlew | 252 ------------------ examples/android-ffm/native/mymath.c | 18 -- examples/android-ffm/native/mymath.h | 13 - examples/android-ffm/settings.gradle.kts | 21 -- .../android-ffm/src/main/AndroidManifest.xml | 17 -- .../kotlin/com/example/ffmapp/MainActivity.kt | 38 --- .../src/main/nativeInterop/mymath.def | 3 - examples/android/README.md | 103 +++---- examples/android/build.gradle.kts | 69 +++-- examples/android/gradle.properties | 4 +- .../android/gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 - examples/android/gradlew | 252 ------------------ examples/android/native/c/cmath.c | 4 + examples/android/native/c/cmath.h | 7 + examples/android/native/cpp/cppmath.cpp | 20 ++ examples/android/native/cpp/cppmath.h | 16 ++ examples/android/native/mymath.c | 4 - examples/android/native/mymath.h | 7 - examples/android/native/rust/Cargo.lock | 7 + examples/android/native/rust/Cargo.toml | 14 + examples/android/native/rust/rustmath.h | 10 + examples/android/native/rust/src/lib.rs | 21 ++ examples/android/settings.gradle.kts | 6 +- examples/android/src/main/AndroidManifest.xml | 2 +- .../kotlin/com/example/jniapp/MainActivity.kt | 27 -- .../com/example/nativeapp/MainActivity.kt | 43 +++ .../android/src/main/kotlin/example/Api.kt | 6 - .../android/src/main/nativeInterop/mymath.def | 3 - .../src/nativeInterop/cinterop/cmath.def | 3 + .../src/nativeInterop/cinterop/cppmath.def | 3 + .../src/nativeInterop/cinterop/rustmath.def | 3 + scripts/run-android.sh | 30 +-- .../dsl/bindings/LinkSharedLibraryTask.kt | 4 + .../lemcoder/kotlin/KonanDistribution.kt | 39 ++- .../lemcoder/KonanPluginFunctionalTest.kt | 26 ++ 44 files changed, 339 insertions(+), 893 deletions(-) delete mode 100644 examples/android-ffm/README.md delete mode 100644 examples/android-ffm/build.gradle.kts delete mode 100644 examples/android-ffm/gradle.properties delete mode 100644 examples/android-ffm/gradle/wrapper/gradle-wrapper.jar delete mode 100644 examples/android-ffm/gradle/wrapper/gradle-wrapper.properties delete mode 100755 examples/android-ffm/gradlew delete mode 100644 examples/android-ffm/native/mymath.c delete mode 100644 examples/android-ffm/native/mymath.h delete mode 100644 examples/android-ffm/settings.gradle.kts delete mode 100644 examples/android-ffm/src/main/AndroidManifest.xml delete mode 100644 examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt delete mode 100644 examples/android-ffm/src/main/nativeInterop/mymath.def delete mode 100644 examples/android/gradle/wrapper/gradle-wrapper.jar delete mode 100644 examples/android/gradle/wrapper/gradle-wrapper.properties delete mode 100755 examples/android/gradlew create mode 100644 examples/android/native/c/cmath.c create mode 100644 examples/android/native/c/cmath.h create mode 100644 examples/android/native/cpp/cppmath.cpp create mode 100644 examples/android/native/cpp/cppmath.h delete mode 100644 examples/android/native/mymath.c delete mode 100644 examples/android/native/mymath.h create mode 100644 examples/android/native/rust/Cargo.lock create mode 100644 examples/android/native/rust/Cargo.toml create mode 100644 examples/android/native/rust/rustmath.h create mode 100644 examples/android/native/rust/src/lib.rs delete mode 100644 examples/android/src/main/kotlin/com/example/jniapp/MainActivity.kt create mode 100644 examples/android/src/main/kotlin/com/example/nativeapp/MainActivity.kt delete mode 100644 examples/android/src/main/kotlin/example/Api.kt delete mode 100644 examples/android/src/main/nativeInterop/mymath.def create mode 100644 examples/android/src/nativeInterop/cinterop/cmath.def create mode 100644 examples/android/src/nativeInterop/cinterop/cppmath.def create mode 100644 examples/android/src/nativeInterop/cinterop/rustmath.def diff --git a/CLAUDE.md b/CLAUDE.md index 31f3f86..599611f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,12 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t - **Link flags live in `KonanDistribution.linkArguments`**: `-fuse-ld=lld` for Linux (the GCC toolchain's `ld.bfd` is a Linux binary; a cross link from macOS fails without it), NDK compiler-rt and `--unwindlib=none` for Android, Xcode's compiler-rt for macOS. The wrappers add them only when linking. +- **Android has no libc++ and no unwinder for apps, but Kotlin/Native's NDK does**: `libc++_static.a` and + `libc++abi.a` in its toolchain's `sysroot/usr/lib//`, the unwinder in `lib/gcc//4.9.x/libgcc_real.a` + (not in the sysroot, which is why it looked missing). `KonanDistribution.androidRuntime` lists them; + `LinkSharedLibraryTask` appends them last β€” the NDK's linker resolves archives in order β€” and links + with `-Wl,--no-undefined`. Without that flag a missing symbol linked fine and failed only on the device + ("cannot locate symbol"); never drop it to make a link pass. - **The toolchain needs its own `ranlib`** (`llvm-ar s`): CMake indexes static archives with the host's, and Apple's `ranlib` silently drops ELF objects β€” a cross-built archive came out empty. - **`external { }` defaults to `ExternalToolchain.SYSTEM`**, the presets to `AUTO`: generic steps may not diff --git a/README.md b/README.md index 51ff0d8..8aecfb2 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,8 @@ written against a C header for the crate's `extern "C"` functions (cbindgen writ C library, for every consumer. Each target's standard library must be installed (`rustup target add aarch64-linux-android`). Crates with C code get Kotlin/Native's compilers as `CC_`, `CXX_` and `AR_` β€” what the `cc` crate reads for the target, leaving build scripts on -the host's. On Android, link with `panic = "abort"`: Kotlin/Native's NDK sysroot has no unwinder. +the host's. The plugin's Android links carry the NDK's unwinder, which Rust's std needs; keep +`panic = "abort"` all the same, since unwinding out of an `extern "C"` function is undefined behaviour. | Property | Type | Default | Description | |--------------|---------------------|--------------------------|-------------| @@ -562,7 +563,17 @@ with no stub. The app needs: - 64-bit ABIs only: jextract writes the build host's LP64 layouts, so 32-bit ABIs get no library and a warning. -`examples/android-ffm` runs end to end on an emulator. +`examples/android` runs FFM and JNI side by side on an emulator. + +#### Android's C++ runtime and unwinder +Android gives apps neither libc++ nor an unwinder, so every Android library the plugin links β€” the JNI +library, the library FFM loads β€” ends with the NDK's `libc++_static`, `libc++abi` and its unwinder +(`libgcc_real`), from Kotlin/Native's own NDK. A C library links as before: an archive only contributes +what is used. C++ code (`new`, `std::string`, exceptions) and Rust's std then load on a device. The links +also pass `-Wl,--no-undefined`, so a symbol nothing provides fails the build, not the app when it loads +the library; a library that resolves symbols from another `.so` at load time names it in +`linkerOptions` (`-lfoo`), or turns the check off with `-Wl,-z,undefs`. Your own build (`cmake { }`, +`make { }`, `external { }`) links its own runtime. ## Tasks | Task | Description | @@ -585,7 +596,7 @@ On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks au ## Examples - `examples/native` β€” Kotlin/Native consumer through KGP's cinterop. - `examples/jvm` β€” plain JVM app loading the JNI library bundled in its jar. -- `examples/android` β€” Android app calling C through the generated bindings. +- `examples/android` β€” one Android app over three libraries: C and Rust through JNI, C++ through FFM. `scripts/run-android.sh` publishes the plugin, builds, installs and launches it in one command. - `examples/kmp` β€” Kotlin Multiplatform library for iOS, macOS, Linux and Android: one binding, cinterop on Kotlin/Native and JNI on Android, behind an `expect`/`actual` API, plus an Android app over it. diff --git a/examples/README.md b/examples/README.md index 163ff5d..be33b3a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,24 +1,22 @@ # Examples -One C library (`native/mymath.c`: `my_add`, `my_scale`) consumed from each platform the plugin binds. Each declares it -the same way β€” `konan { val mymath = libraries.create("mymath"); bindings { create("mymath") { library(mymath) } } }` β€” +One C library (`native/mymath.c`: `my_add`, `my_scale`) consumed from each platform the plugin binds, and +an Android app over three. Each declares a library and a binding the same way β€” +`konan { val mymath = libraries.create("mymath"); bindings { create("mymath") { library(mymath) } } }` β€” and gets the binding its Kotlin targets need: | Example | Flow | What it proves | Run | |---|---|---|---| | [`jvm/`](jvm) | a library (.a) + a binding β†’ JNI bindings and library | full JNI path, end to end | `../../gradlew -p jvm run` | -| [`android/`](android) | same, cross-compiled per ABI, real app | APK with `.so` (arm64-v8a + x86_64) + `MainActivity` | `cd android && ./gradlew installDebug` | -| [`android-ffm/`](android-ffm) | same library, FFM instead of JNI: jextract's bindings on PanamaPort, `libmymath.so` per ABI, no stub | FFM on Android, end to end on an emulator (a multiplatform library's Android target works the same, with `withJava()`) | `cd android-ffm && ./gradlew installDebug` | +| [`android/`](android) | three libraries in one app, each built and bound its own way: C (clang) and Rust (cargo) over JNI, C++ (clang) over FFM on PanamaPort | JNI and FFM side by side in one APK, a Rust staticlib and C++'s runtime linked for Android, on an emulator | `../../gradlew -p android installDebug` | | [`native/`](native) | a library (.a) + a binding β†’ KGP's cinterop | the native path | `../../gradlew -p native runDebugExecutableMacosArm64` | | [`kmp/`](kmp) | one binding for a multiplatform library: cinterop on iOS, macOS and Linux, JNI on Android, `expect`/`actual` over both | tests on an iOS simulator, macOS and Linux, and an Android app, calling the same C through one common API | `../../gradlew -p kmp :shared:allTests :androidApp:installDebug` | -`jvm`, `native` and `kmp` build the plugin from source via `includeBuild`. `android` needs AGP 9 (Gradle 9.4, -its own wrapper), so it consumes the plugin from mavenLocal β€” publish it first: -`(cd ../.. && ./gradlew publishToMavenLocal)`. +Every example builds the plugin from source via `includeBuild`, with the repository's wrapper. Each example sets `konan { config { konanHome.set(kotlinNativeDistribution()) } }`: the Kotlin/Native -version the plugin needs, where the Kotlin plugin installs it. `android-ffm` also needs -`konan.config.jextract=/path/to/jextract`, e.g. in `~/.gradle/gradle.properties`. +version the plugin needs, where the Kotlin plugin installs it. `android` also needs +`konan.config.cargo` and `konan.config.jextract`, e.g. in `~/.gradle/gradle.properties`. No example needs a JDK's `jni.h`: the plugin ships one for desktop links, and Android's comes from the NDK sysroot. @@ -60,5 +58,5 @@ Strings and primitive buffers are marshalled by the binding; opaque pointers arr - `kmp` passes its common tests on an iOS simulator and macOS, and β€” cross-linked on macOS β€” in an x86-64 Ubuntu container; links `Shared.framework` for devices with `my_add` in it; and its app shows C's results on an Android emulator. -- `android` produces and verifies the `.so` (ELF + exported `Java_example_example_my_1add`-style symbols). Running it - on Android needs an emulator/device and an AGP module β€” see [`android/README.md`](android/README.md). +- `android` runs on an arm64 emulator: C and Rust through JNI, C++ through FFM, in one app β€” see + [`android/README.md`](android/README.md). diff --git a/examples/android-ffm/README.md b/examples/android-ffm/README.md deleted file mode 100644 index 8fa85ca..0000000 --- a/examples/android-ffm/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Android FFM example - -An Android app that calls `native/mymath.c` through **FFM** bindings instead of JNI. The plugin: - -- cross-compiles `native/*.c` to `libmymath.a` per ABI; -- runs jextract over the same `.def` the JNI and Kotlin/Native bindings use, and moves its output from - `java.lang.foreign` onto [PanamaPort](https://github.com/vova7878/PanamaPort)'s `com.v7878.foreign`, - the FFM API backported to Android 8.0+, which it adds as a dependency; -- links the library itself as `libmymath.so` per ABI into `jniLibs/` β€” FFM needs no stub. - -## Requirements - -- jextract, as `konan.config.jextract=/path/to/jextract` in `~/.gradle/gradle.properties` (or `KONAN_JEXTRACT`). -- `compileSdk` 37 and `minSdk` 26, which PanamaPort requires; Java 21 source, which jextract's output uses. -- 64-bit ABIs: jextract writes the build host's LP64 layouts, so 32-bit ABIs get no library. - -## Run - -```bash -./gradlew installDebug -adb shell am start -n com.example.ffmapp/.MainActivity -adb logcat -s KonanFfm -``` - -``` -FFM via PanamaPort: -my_add(2, 3) = 5 -my_scale(4.0) = 40.0 -my_length("kotlin") = 6 -my_greeting() = hello from C -my_sum([1, 2, 3, 4]) = 10 -my_fill -> [7, 7, 7] -``` diff --git a/examples/android-ffm/build.gradle.kts b/examples/android-ffm/build.gradle.kts deleted file mode 100644 index 982f872..0000000 --- a/examples/android-ffm/build.gradle.kts +++ /dev/null @@ -1,45 +0,0 @@ -// An Android app that calls a C library through FFM bindings: jextract writes them from the same .def, -// the plugin moves them onto PanamaPort (the FFM API backported to Android 8.0+) and links the library -// itself as libmymath.so per ABI β€” no JNI stub. Needs konan.config.jextract β€” e.g. in ~/.gradle/gradle.properties. - -import io.github.lemcoder.model.NativeTarget - -plugins { - id("com.android.application") - id("io.github.lemcoder.konanplugin") -} - -konan { - // Where the Kotlin plugin installs the Kotlin/Native version this plugin needs; or set - // konan.config.konanHome in ~/.gradle/gradle.properties. - config { konanHome.set(kotlinNativeDistribution()) } - // 64-bit ABIs only: jextract writes the host's LP64 layouts. - val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } - bindings { - create("mymath") { - defFile.set(file("src/main/nativeInterop/mymath.def")) - includeDirs.from("native") - library(mymath) - ffm() // in an Android module: PanamaPort bindings in place of JNI - } - } -} - -android { - namespace = "com.example.ffmapp" - compileSdk = 37 // PanamaPort's AAR metadata requires it - - defaultConfig { - applicationId = "com.example.ffmapp" - minSdk = 26 // PanamaPort's floor - targetSdk = 36 - versionCode = 1 - versionName = "1.0" - } - - compileOptions { - // jextract's output uses pattern matching in switch. - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } -} diff --git a/examples/android-ffm/gradle.properties b/examples/android-ffm/gradle.properties deleted file mode 100644 index 1edd5f5..0000000 --- a/examples/android-ffm/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 -android.useAndroidX=true diff --git a/examples/android-ffm/gradle/wrapper/gradle-wrapper.jar b/examples/android-ffm/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index a4b76b9530d66f5e68d973ea569d8e19de379189..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X diff --git a/examples/android-ffm/gradle/wrapper/gradle-wrapper.properties b/examples/android-ffm/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index c61a118..0000000 --- a/examples/android-ffm/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/examples/android-ffm/gradlew b/examples/android-ffm/gradlew deleted file mode 100755 index f5feea6..0000000 --- a/examples/android-ffm/gradlew +++ /dev/null @@ -1,252 +0,0 @@ -#!/bin/sh - -# -# Copyright Β© 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β», -# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»; -# * compound commands having a testable exit status, especially Β«caseΒ»; -# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/examples/android-ffm/native/mymath.c b/examples/android-ffm/native/mymath.c deleted file mode 100644 index b2969f0..0000000 --- a/examples/android-ffm/native/mymath.c +++ /dev/null @@ -1,18 +0,0 @@ -#include -#include "mymath.h" - -int my_add(int a, int b) { return a + b; } -double my_scale(double x) { return x * 10.0; } - -int my_length(const char* text) { return text ? (int)strlen(text) : -1; } -const char* my_greeting(void) { return "hello from C"; } - -int my_sum(const int* values, int count) { - int total = 0; - for (int i = 0; i < count; i++) total += values[i]; - return total; -} - -void my_fill(int* out, int count, int value) { - for (int i = 0; i < count; i++) out[i] = value; -} diff --git a/examples/android-ffm/native/mymath.h b/examples/android-ffm/native/mymath.h deleted file mode 100644 index 6c65efe..0000000 --- a/examples/android-ffm/native/mymath.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef MYMATH_H -#define MYMATH_H - -int my_add(int a, int b); -double my_scale(double x); - -/* Marshalled shapes: strings both ways, a read-only buffer, and one the callee writes into. */ -int my_length(const char* text); -const char* my_greeting(void); -int my_sum(const int* values, int count); -void my_fill(int* out, int count, int value); - -#endif diff --git a/examples/android-ffm/settings.gradle.kts b/examples/android-ffm/settings.gradle.kts deleted file mode 100644 index 3a4e98a..0000000 --- a/examples/android-ffm/settings.gradle.kts +++ /dev/null @@ -1,21 +0,0 @@ -pluginManagement { - repositories { - google() - gradlePluginPortal() - mavenCentral() - } - plugins { - id("com.android.application") version "9.2.1" - } - // The konan plugin, built from this checkout. - includeBuild("../..") -} - -dependencyResolutionManagement { - repositories { - google() - mavenCentral() // PanamaPort - } -} - -rootProject.name = "android-ffm-example" diff --git a/examples/android-ffm/src/main/AndroidManifest.xml b/examples/android-ffm/src/main/AndroidManifest.xml deleted file mode 100644 index 5c3488e..0000000 --- a/examples/android-ffm/src/main/AndroidManifest.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - diff --git a/examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt b/examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt deleted file mode 100644 index 8daecdc..0000000 --- a/examples/android-ffm/src/main/kotlin/com/example/ffmapp/MainActivity.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.example.ffmapp - -import android.app.Activity -import android.os.Bundle -import android.util.Log -import android.view.Gravity -import android.widget.TextView -import com.v7878.foreign.Arena -import com.v7878.foreign.ValueLayout.JAVA_INT -import example.example as mymath - -class MainActivity : Activity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Calls into C through jextract's FFM bindings on PanamaPort (libmymath.so, no JNI stub). - val text = buildString { - appendLine("FFM via PanamaPort:") - appendLine("my_add(2, 3) = ${mymath.my_add(2, 3)}") - appendLine("my_scale(4.0) = ${mymath.my_scale(4.0)}") - Arena.ofConfined().use { arena -> - appendLine("my_length(\"kotlin\") = ${mymath.my_length(arena.allocateFrom("kotlin"))}") - appendLine("my_greeting() = ${mymath.my_greeting().reinterpret(Long.MAX_VALUE).getString(0)}") - appendLine("my_sum([1, 2, 3, 4]) = ${mymath.my_sum(arena.allocateFrom(JAVA_INT, 1, 2, 3, 4), 4)}") - val buffer = arena.allocate(JAVA_INT, 3) - mymath.my_fill(buffer, 3, 7) - appendLine("my_fill -> ${buffer.toArray(JAVA_INT).toList()}") - } - } - Log.i("KonanFfm", text) - - setContentView(TextView(this).apply { - this.text = text - textSize = 18f - gravity = Gravity.CENTER - }) - } -} diff --git a/examples/android-ffm/src/main/nativeInterop/mymath.def b/examples/android-ffm/src/main/nativeInterop/mymath.def deleted file mode 100644 index 4acf40c..0000000 --- a/examples/android-ffm/src/main/nativeInterop/mymath.def +++ /dev/null @@ -1,3 +0,0 @@ -headers = mymath.h -headerFilter = mymath.h -package = example diff --git a/examples/android/README.md b/examples/android/README.md index 5100b1c..65e45a7 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -1,85 +1,46 @@ -# Android example +# Android: three libraries, JNI and FFM in one app -A real Android app (`MainActivity`) that calls a C library through JNI bindings generated by the -plugin. Both plugins are applied to this single module: +One Android app over three native libraries, each built its own way and bound its own way: -- **konan plugin** β†’ cross-compiles `native/*.c` to `libmymath.a` per ABI, generates the JNI `.c` - stubs + runtime-free Kotlin bridges, and links a self-contained `libexamplestubs.so` per ABI. -- **AGP** (9.x, with built-in Kotlin) β†’ compiles the app and packages the `.so` into the APK. - -## Prerequisites - -- The Kotlin/Native distribution the plugin needs (`config { konanHome.set(kotlinNativeDistribution()) }` in the build script). -- Android SDK (`local.properties` β†’ `sdk.dir`); the example targets `compileSdk 36`, `minSdk 21`. -- The plugin published to mavenLocal: - ```bash - (cd ../.. && ./gradlew publishToMavenLocal) - ``` -- This module uses its own Gradle wrapper (9.4.1, required by AGP 9). - -## Build - -```bash -./gradlew assembleDebug -``` - -Or, from anywhere in the repo, build + install + launch in one step (no IDE root switching): - -```bash -../../scripts/run-android.sh # publishes the plugin first; -s skips that, -l tails logcat -``` - -This runs the full chain and produces `build/outputs/apk/debug/android-example-debug.apk` containing: +| Library | Language | Built by | Bound with | Loaded as | +|---|---|---|---|---| +| `cmath` | C | Kotlin/Native's clang | JNI | `libcmathstubs.so` (stub + archive) | +| `rustmath` | Rust | `cargo { }`, a `staticlib` | JNI | `librustmathstubs.so` (stub + archive) | +| `cppmath` | C++ | Kotlin/Native's clang | FFM, jextract on PanamaPort | `libcppmath.so` (the library itself) | ``` -lib/arm64-v8a/libexamplestubs.so -lib/x86_64/libexamplestubs.so # emulator -classes*.dex # MainActivity + generated example.kt bridges +native/c/ cmath.c, cmath.h +native/rust/ Cargo.toml, src/lib.rs, rustmath.h (the C header the bindings are generated from) +native/cpp/ cppmath.cpp, cppmath.h (an extern "C" API over C++, which jextract reads) +src/nativeInterop/cinterop/{cmath,rustmath,cppmath}.def +src/main/kotlin/com/example/nativeapp/MainActivity.kt ``` -## Run on a device / emulator +`build.gradle.kts` declares three `libraries` and three `bindings`; `ffm()` on `cppmath` replaces JNI for +that binding only. The activity calls all three: -```bash -./gradlew installDebug # deploy to a connected device/emulator -# then launch "JNI Example" from the launcher +```kotlin +cmath.c_add(2, 3) // C, JNI +rustmath.rust_count_vowels("Kotlin Native") // Rust, JNI +cpp.cpp_median(arena.allocateFrom(JAVA_INT, 5, 1, 4, 2), 4) // C++, FFM ``` -The screen shows: -``` -JNI from Kotlin/Native cinterop: -my_add(2, 3) = 5 -my_scale(4.0) = 40.0 -``` +The Rust and C++ libraries need runtimes Android does not give apps β€” Rust's std its unwinder, C++ libc++ +and the unwinder. The plugin's Android links carry them from Kotlin/Native's NDK, and fail the build if +anything is left undefined. -## How it's wired (`build.gradle.kts`) +FFM sets what the whole app needs: `compileSdk` 37 and `minSdk` 26 (PanamaPort), Java 21 sources +(jextract), and 64-bit ABIs only β€” here `arm64-v8a`, phones and Apple-silicon emulators. -One config block β€” the plugin does the wiring: +## Run -```kotlin -konan { - val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } // device + emulator - bindings { - create("mymath") { - defFile.set(file("src/main/nativeInterop/mymath.def")) - includeDirs.from("native") - library(mymath) - } - } -} +```bash +rustup target add aarch64-linux-android +../../gradlew -p . installDebug -Pkonan.config.cargo=$HOME/.cargo/bin/cargo -Pkonan.config.jextract=$(which jextract) +# or, from the repository root, build, install and launch: +KONAN_CARGO=$HOME/.cargo/bin/cargo KONAN_JEXTRACT=$(which jextract) scripts/run-android.sh ``` -- the library β†’ `libmymath.a` per ABI, from `native/`, in `build/konan/libraries/mymath//`. -- the binding β†’ Kotlin bindings and a `.so` per ABI the library is built for: an Android app gets JNI. - The Kotlin/Native distribution comes from `konan.config.konanHome`. - -When AGP is present the plugin **automatically** adds the generated Kotlin bridges as a source -directory and the per-ABI `jniLibs/` as native libraries (via the AGP variant API), and orders -`compileNativeLibraryMymath` / `generateJniMymath` / `linkJniMymath` ahead of the build. No `sourceSets` or -`dependsOn` boilerplate needed. - -`MainActivity` calls `example.add/scale` (in `src/main/kotlin/example/Api.kt`), which call the -generated `my_add` / `my_scale`. `System.loadLibrary("examplestubs")` loads the `.so` from the APK β€” no -`java.library.path` on Android. - -The `.so` gets `jni.h` from the NDK sysroot; the bridges are runtime-free (no `kotlinx.cinterop`), so -nothing extra ships in the APK. +The plugin is built from this checkout (`includeBuild("../..")`). Java 21 comes from +`kotlin { jvmToolchain(21) }`, whatever JDK runs Gradle. Needs an Android SDK (`local.properties` or +`ANDROID_HOME`). diff --git a/examples/android/build.gradle.kts b/examples/android/build.gradle.kts index 61b79dc..0c4d6d6 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -1,6 +1,11 @@ -// A real Android app that calls a C library through JNI bindings generated by the plugin. -// Both plugins are applied to this one module: the konan plugin produces the .a, the JNI stub .so and the -// Kotlin bindings; AGP packages the .so and compiles the app. +// One Android app over three native libraries, each built its own way and bound its own way: +// +// cmath C, compiled by Kotlin/Native's clang -> JNI bindings + libcmathstubs.so +// rustmath Rust, built by cargo (staticlib) -> JNI bindings + librustmathstubs.so +// cppmath C++, compiled by Kotlin/Native's clang -> FFM bindings (jextract, on PanamaPort) + libcppmath.so +// +// Needs konan.config.cargo and konan.config.jextract, e.g. in ~/.gradle/gradle.properties, and the Rust +// target: rustup target add aarch64-linux-android. import io.github.lemcoder.model.NativeTarget @@ -13,36 +18,62 @@ konan { // Where the Kotlin plugin installs the Kotlin/Native version this plugin needs; or set // konan.config.konanHome in ~/.gradle/gradle.properties. config { konanHome.set(kotlinNativeDistribution()) } - // native/*.c -> one static archive per ABI: device + emulator. - val mymath = libraries.create("mymath") { targets(NativeTarget.ANDROID_ARM64, NativeTarget.ANDROID_X64) } - // An Android app gets JNI bindings, wired into its variants, and a JNI library per ABI the library - // is built for, packaged as jniLibs. + + // 64-bit only: FFM's jextract writes LP64 layouts. arm64 is phones and Apple-silicon emulators. + val abi = NativeTarget.ANDROID_ARM64 + + libraries { + create("cmath") { + targets(abi) + sourceDirectory.set(file("native/c")) + } + create("rustmath") { + targets(abi) + sourceDirectory.set(file("native/rust")) + cargo { } // native/rust/Cargo.toml, crate-type = ["staticlib"] + } + create("cppmath") { + targets(abi) + sourceDirectory.set(file("native/cpp")) + } + } + bindings { - create("mymath") { - defFile.set(file("src/main/nativeInterop/mymath.def")) - includeDirs.from("native") - library(mymath) + create("cmath") { + includeDirs.from("native/c") + library(libraries.getByName("cmath")) + } + create("rustmath") { + includeDirs.from("native/rust") + library(libraries.getByName("rustmath")) + } + create("cppmath") { + includeDirs.from("native/cpp") + library(libraries.getByName("cppmath")) + ffm() // PanamaPort bindings in place of JNI, for this binding only } } } android { - namespace = "com.example.jniapp" - compileSdk = 36 + namespace = "com.example.nativeapp" + compileSdk = 37 // PanamaPort's AAR metadata requires it defaultConfig { - applicationId = "com.example.jniapp" - minSdk = 21 // matches the NDK __ANDROID_API__=21 the stubs are built against + applicationId = "com.example.nativeapp" + minSdk = 26 // PanamaPort's floor targetSdk = 36 versionCode = 1 versionName = "1.0" + ndk { abiFilters += "arm64-v8a" } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // jextract's output uses pattern matching in switch. + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } } -// That's it β€” the plugin wires the generated Kotlin bindings and per-ABI jniLibs into AGP and orders -// compileNativeLibraryMymath / generateJniMymath / linkJniMymath before the build. +// jextract's Java needs a JDK 21 compiler, whatever JDK runs Gradle. +kotlin { jvmToolchain(21) } diff --git a/examples/android/gradle.properties b/examples/android/gradle.properties index 1edd5f5..82427e3 100644 --- a/examples/android/gradle.properties +++ b/examples/android/gradle.properties @@ -1,2 +1,4 @@ -org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +# The Kotlin/Native compiler runs inside the Gradle daemon; Gradle's default 512 MiB heap and 384 MiB +# metaspace run out on repeated links ("OutOfMemoryError: Metaspace"). +org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8 android.useAndroidX=true diff --git a/examples/android/gradle/wrapper/gradle-wrapper.jar b/examples/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index a4b76b9530d66f5e68d973ea569d8e19de379189..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X diff --git a/examples/android/gradle/wrapper/gradle-wrapper.properties b/examples/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index c61a118..0000000 --- a/examples/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/examples/android/gradlew b/examples/android/gradlew deleted file mode 100755 index f5feea6..0000000 --- a/examples/android/gradlew +++ /dev/null @@ -1,252 +0,0 @@ -#!/bin/sh - -# -# Copyright Β© 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions Β«$varΒ», Β«${var}Β», Β«${var:-default}Β», Β«${var+SET}Β», -# Β«${var#prefix}Β», Β«${var%suffix}Β», and Β«$( cmd )Β»; -# * compound commands having a testable exit status, especially Β«caseΒ»; -# * various built-in commands including Β«commandΒ», Β«setΒ», and Β«ulimitΒ». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/examples/android/native/c/cmath.c b/examples/android/native/c/cmath.c new file mode 100644 index 0000000..e07a8aa --- /dev/null +++ b/examples/android/native/c/cmath.c @@ -0,0 +1,4 @@ +#include "cmath.h" + +int c_add(int a, int b) { return a + b; } +const char *c_greeting(void) { return "hello from C"; } diff --git a/examples/android/native/c/cmath.h b/examples/android/native/c/cmath.h new file mode 100644 index 0000000..3780d82 --- /dev/null +++ b/examples/android/native/c/cmath.h @@ -0,0 +1,7 @@ +#ifndef CMATH_H +#define CMATH_H + +int c_add(int a, int b); +const char *c_greeting(void); + +#endif diff --git a/examples/android/native/cpp/cppmath.cpp b/examples/android/native/cpp/cppmath.cpp new file mode 100644 index 0000000..1287e39 --- /dev/null +++ b/examples/android/native/cpp/cppmath.cpp @@ -0,0 +1,20 @@ +#include "cppmath.h" + +#include +#include +#include + +double cpp_median(const int *values, int count) { + if (values == nullptr || count <= 0) return 0.0; + std::vector sorted(values, values + count); + std::sort(sorted.begin(), sorted.end()); + return count % 2 ? sorted[count / 2] : (sorted[count / 2 - 1] + sorted[count / 2]) / 2.0; +} + +const char *cpp_reverse(const char *text) { + // Valid until the next call on this thread. + thread_local std::string reversed; + reversed = text ? text : ""; + std::reverse(reversed.begin(), reversed.end()); + return reversed.c_str(); +} diff --git a/examples/android/native/cpp/cppmath.h b/examples/android/native/cpp/cppmath.h new file mode 100644 index 0000000..fb366ac --- /dev/null +++ b/examples/android/native/cpp/cppmath.h @@ -0,0 +1,16 @@ +#ifndef CPPMATH_H +#define CPPMATH_H + +/* A C API over C++ (cppmath.cpp): jextract binds C declarations. */ +#ifdef __cplusplus +extern "C" { +#endif + +double cpp_median(const int *values, int count); +const char *cpp_reverse(const char *text); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/examples/android/native/mymath.c b/examples/android/native/mymath.c deleted file mode 100644 index e24c976..0000000 --- a/examples/android/native/mymath.c +++ /dev/null @@ -1,4 +0,0 @@ -#include "mymath.h" - -int my_add(int a, int b) { return a + b; } -double my_scale(double x) { return x * 10.0; } diff --git a/examples/android/native/mymath.h b/examples/android/native/mymath.h deleted file mode 100644 index c951cb1..0000000 --- a/examples/android/native/mymath.h +++ /dev/null @@ -1,7 +0,0 @@ -#ifndef MYMATH_H -#define MYMATH_H - -int my_add(int a, int b); -double my_scale(double x); - -#endif diff --git a/examples/android/native/rust/Cargo.lock b/examples/android/native/rust/Cargo.lock new file mode 100644 index 0000000..96e8d0f --- /dev/null +++ b/examples/android/native/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "rustmath" +version = "0.1.0" diff --git a/examples/android/native/rust/Cargo.toml b/examples/android/native/rust/Cargo.toml new file mode 100644 index 0000000..71e1d02 --- /dev/null +++ b/examples/android/native/rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rustmath" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib"] + +# Unwinding out of an extern "C" function is undefined behaviour; abort instead. +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" diff --git a/examples/android/native/rust/rustmath.h b/examples/android/native/rust/rustmath.h new file mode 100644 index 0000000..b67353a --- /dev/null +++ b/examples/android/native/rust/rustmath.h @@ -0,0 +1,10 @@ +#ifndef RUSTMATH_H +#define RUSTMATH_H + +#include + +/* Implemented in Rust (src/lib.rs); this header is what cinterop and the JNI generator read. */ +int32_t rust_fib(int32_t n); +uint32_t rust_count_vowels(const char *text); + +#endif diff --git a/examples/android/native/rust/src/lib.rs b/examples/android/native/rust/src/lib.rs new file mode 100644 index 0000000..6d08f26 --- /dev/null +++ b/examples/android/native/rust/src/lib.rs @@ -0,0 +1,21 @@ +use std::ffi::{c_char, CStr}; + +/// The n-th Fibonacci number, iteratively. +#[no_mangle] +pub extern "C" fn rust_fib(n: i32) -> i32 { + let (mut a, mut b) = (0i32, 1i32); + for _ in 0..n.max(0) { + (a, b) = (b, a.wrapping_add(b)); + } + a +} + +/// Vowels in a NUL-terminated UTF-8 string; 0 for null. Uses std's string handling and an allocation. +#[no_mangle] +pub extern "C" fn rust_count_vowels(text: *const c_char) -> u32 { + if text.is_null() { + return 0; + } + let text = unsafe { CStr::from_ptr(text) }.to_string_lossy().to_lowercase(); + text.chars().filter(|c| "aeiou".contains(*c)).count() as u32 +} diff --git a/examples/android/settings.gradle.kts b/examples/android/settings.gradle.kts index 498e665..3a7d2c3 100644 --- a/examples/android/settings.gradle.kts +++ b/examples/android/settings.gradle.kts @@ -3,18 +3,18 @@ pluginManagement { google() gradlePluginPortal() mavenCentral() - mavenLocal() // the konan plugin, published via `../../gradlew publishToMavenLocal` } plugins { id("com.android.application") version "9.2.1" - id("io.github.lemcoder.konanplugin") version "1.2.0-alpha" } + // The konan plugin, built from this checkout. + includeBuild("../..") } dependencyResolutionManagement { repositories { google() - mavenCentral() + mavenCentral() // PanamaPort } } diff --git a/examples/android/src/main/AndroidManifest.xml b/examples/android/src/main/AndroidManifest.xml index bb45246..7290324 100644 --- a/examples/android/src/main/AndroidManifest.xml +++ b/examples/android/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ + appendLine(" cpp_median([5, 1, 4, 2]) = ${cpp.cpp_median(arena.allocateFrom(JAVA_INT, 5, 1, 4, 2), 4)}") + val reversed = cpp.cpp_reverse(arena.allocateFrom("kotlin")).reinterpret(Long.MAX_VALUE).getString(0) + appendLine(" cpp_reverse(\"kotlin\") = $reversed") + } + } + Log.i("KonanExample", text) + + setContentView(TextView(this).apply { + this.text = text + textSize = 16f + gravity = Gravity.CENTER + }) + } +} diff --git a/examples/android/src/main/kotlin/example/Api.kt b/examples/android/src/main/kotlin/example/Api.kt deleted file mode 100644 index aa7d6cb..0000000 --- a/examples/android/src/main/kotlin/example/Api.kt +++ /dev/null @@ -1,6 +0,0 @@ -package example - -// Idiomatic API over the generated JNI bindings (build/generated/jvmInterop/kotlin/example/example.kt), -// which are named after the C functions. -fun add(a: Int, b: Int): Int = my_add(a, b) -fun scale(x: Double): Double = my_scale(x) diff --git a/examples/android/src/main/nativeInterop/mymath.def b/examples/android/src/main/nativeInterop/mymath.def deleted file mode 100644 index 4acf40c..0000000 --- a/examples/android/src/main/nativeInterop/mymath.def +++ /dev/null @@ -1,3 +0,0 @@ -headers = mymath.h -headerFilter = mymath.h -package = example diff --git a/examples/android/src/nativeInterop/cinterop/cmath.def b/examples/android/src/nativeInterop/cinterop/cmath.def new file mode 100644 index 0000000..6351ebd --- /dev/null +++ b/examples/android/src/nativeInterop/cinterop/cmath.def @@ -0,0 +1,3 @@ +headers = cmath.h +headerFilter = cmath.h +package = cmath diff --git a/examples/android/src/nativeInterop/cinterop/cppmath.def b/examples/android/src/nativeInterop/cinterop/cppmath.def new file mode 100644 index 0000000..46bd955 --- /dev/null +++ b/examples/android/src/nativeInterop/cinterop/cppmath.def @@ -0,0 +1,3 @@ +headers = cppmath.h +headerFilter = cppmath.h +package = cppmath diff --git a/examples/android/src/nativeInterop/cinterop/rustmath.def b/examples/android/src/nativeInterop/cinterop/rustmath.def new file mode 100644 index 0000000..c1229d8 --- /dev/null +++ b/examples/android/src/nativeInterop/cinterop/rustmath.def @@ -0,0 +1,3 @@ +headers = rustmath.h +headerFilter = rustmath.h +package = rustmath diff --git a/scripts/run-android.sh b/scripts/run-android.sh index 53fc36a..3e19ae5 100755 --- a/scripts/run-android.sh +++ b/scripts/run-android.sh @@ -2,14 +2,12 @@ # # Build and run the Android example without switching IDE root folders. # -# The Android example (examples/android) is a standalone Gradle build that consumes the -# plugin from mavenLocal, so running it takes two builds: publish the plugin from the repo -# root, then assemble/install the app with the example's own wrapper. +# The Android example (examples/android) is a standalone Gradle build that builds the plugin +# from this checkout (includeBuild), run with the repository's wrapper. It needs +# konan.config.cargo and konan.config.jextract, e.g. in ~/.gradle/gradle.properties. # # Usage: scripts/run-android.sh [options] # -# -s, --skip-publish don't republish the plugin to mavenLocal (faster when only the -# example's app/native code changed) # -c, --clean clean the example build before assembling # -n, --no-launch install only, don't start the activity # -l, --logcat tail the app's logcat after launching (Ctrl-C to stop) @@ -21,10 +19,9 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" EXAMPLE_DIR="$REPO_ROOT/examples/android" -APP_ID="com.example.jniapp" +APP_ID="com.example.nativeapp" ACTIVITY="$APP_ID/.MainActivity" -SKIP_PUBLISH=0 CLEAN=0 LAUNCH=1 LOGCAT=0 @@ -36,7 +33,6 @@ usage() { awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "${BASH_ while [[ $# -gt 0 ]]; do case "$1" in - -s|--skip-publish) SKIP_PUBLISH=1 ;; -c|--clean) CLEAN=1 ;; -n|--no-launch) LAUNCH=0 ;; -l|--logcat) LOGCAT=1 ;; @@ -115,27 +111,15 @@ if [[ -z "$DEVICE" ]]; then fi [[ -n "$DEVICE" ]] && export ANDROID_SERIAL="$DEVICE" -# --- 1) publish the plugin to mavenLocal ------------------------------------ -# The example resolves io.github.lemcoder.konanplugin from mavenLocal, so any change to the -# plugin itself has to be republished before the app build will see it. -if [[ $SKIP_PUBLISH -eq 0 ]]; then - step "Publishing plugin to mavenLocal (repo root)" - "$REPO_ROOT/gradlew" -p "$REPO_ROOT" publishToMavenLocal -else - step "Skipping plugin publish (--skip-publish)" -fi - -# --- 2) build + install the app --------------------------------------------- -# The example has its own wrapper (Gradle 9.4.1, required by AGP 9) β€” always use it, not the -# root wrapper. +# --- 1) build + install the app --------------------------------------------- GRADLE_TASKS=() [[ $CLEAN -eq 1 ]] && GRADLE_TASKS+=(clean) GRADLE_TASKS+=(installDebug) step "Building + installing android-example (${GRADLE_TASKS[*]})" -"$EXAMPLE_DIR/gradlew" -p "$EXAMPLE_DIR" "${GRADLE_TASKS[@]}" +"$REPO_ROOT/gradlew" -p "$EXAMPLE_DIR" "${GRADLE_TASKS[@]}" -# --- 3) launch -------------------------------------------------------------- +# --- 2) launch -------------------------------------------------------------- if [[ $LAUNCH -eq 0 ]]; then step "Installed on ${DEVICE:-device}. Launch skipped (--no-launch)." exit 0 diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt index cb789b5..e63a448 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt @@ -79,6 +79,8 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( // On Android jni.h comes from the NDK sysroot. if (!nativeTarget.isAndroid) jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } addAll(distribution.linkArguments(nativeTarget, exec)) + // A symbol nothing provides fails here, not as "cannot locate symbol" when the app loads the library. + if (nativeTarget.isAndroid) add("-Wl,--no-undefined") // After run_konan's own --sysroot, so this one wins. if (nativeTarget.isAndroid) androidApiLevel.orNull?.let { add("--sysroot=${distribution.androidSysroot(nativeTarget, it, exec)}") } if (stub != null) { @@ -94,6 +96,8 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( // dir here would pull its static libc in ahead of the sysroot's shared one, which fails // on x86_64 with "relocation R_X86_64_PC32 ... recompile with -fPIC". addAll(additionalLinkerArgs.getOrElse(emptyList())) + // Last, after everything that may need them: the NDK's linker resolves archives in order. + if (nativeTarget.isAndroid) distribution.androidRuntime(nativeTarget).forEach { add(it.absolutePath) } } val result = exec.execCapture { commandLine(distribution.clang(nativeTarget, arguments)) } diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt index 4fa5412..a3dd164 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -57,8 +57,8 @@ internal class KonanDistribution( * [target] β€” the one place these are known, for the plugin's own links and the toolchain it hands * to external builds: * - Linux: `lld`. The GCC toolchain's own `ld.bfd` is a Linux binary, so a cross link fails without it. - * - Android: the NDK's compiler-rt, which Kotlin/Native's LLVM lacks, and no unwinder, which the NDK - * sysroot lacks. + * - Android: the NDK's compiler-rt, which Kotlin/Native's LLVM lacks, and no unwinder from clang's + * default: the plugin's own links end with [androidRuntime] instead. * - macOS: Xcode's compiler-rt, which Apple-framework code needs and Kotlin/Native's LLVM lacks. */ fun linkArguments(target: NativeTarget, exec: ExecOperations): List = when (target.family) { @@ -124,13 +124,40 @@ internal class KonanDistribution( fun llvmAr(arguments: List): List = listOf(runKonan.absolutePath, "llvm", "llvm-ar") + arguments /** The Android NDK clang resource directory, which holds compiler-rt, from the distribution's dependencies. */ - fun ndkResourceDirectory(): File? { - val dependencies = home.parentFile.resolve("dependencies") - val ndk = dependencies.listFiles { f -> f.isDirectory && f.name.contains("android_ndk") }?.firstOrNull() ?: return null - return ndk.resolve("lib64/clang").listFiles { f -> f.isDirectory }?.maxByOrNull { it.name } + fun ndkResourceDirectory(): File? = + ndkToolchain()?.resolve("lib64/clang")?.listFiles { f -> f.isDirectory }?.maxByOrNull { it.name } + + /** + * The archives the plugin's own Android links end with, so a library that needs them loads on a device, + * which provides neither: libc++ (`libc++_static`, `libc++abi`) for C++ code, and libgcc's unwinder + * (`libgcc_real`, the NDK's `_Unwind_*`) for C++ exceptions and Rust's std. An archive only contributes + * what is referenced, so a C library links as before. Full paths, not `-L`: the sysroot's library + * directory also holds a static libc, which would win over the shared one. + */ + fun androidRuntime(target: NativeTarget): List { + val toolchain = ndkToolchain() ?: return emptyList() + val triple = NDK_TRIPLES[target] ?: return emptyList() + return listOf( + toolchain.resolve("sysroot/usr/lib/$triple/libc++_static.a"), + toolchain.resolve("sysroot/usr/lib/$triple/libc++abi.a"), + toolchain.resolve("lib/gcc/$triple/4.9.x/libgcc_real.a"), + ).filter { it.isFile } } + /** Kotlin/Native's NDK toolchain among its dependencies: the one with clang's resource directory. */ + private fun ndkToolchain(): File? = home.parentFile.resolve("dependencies") + .listFiles { f -> f.isDirectory && f.name.contains("android_ndk") && f.resolve("lib64/clang").isDirectory } + ?.firstOrNull() + companion object { + /** The NDK's directory names for each Android target's libraries. */ + private val NDK_TRIPLES = mapOf( + NativeTarget.ANDROID_ARM64 to "aarch64-linux-android", + NativeTarget.ANDROID_X64 to "x86_64-linux-android", + NativeTarget.ANDROID_ARM32 to "arm-linux-androideabi", + NativeTarget.ANDROID_X86 to "i686-linux-android", + ) + /** * Where the Kotlin plugin installs Kotlin/Native [version] for this host: * `kotlin-native-prebuilt--` under `$KONAN_DATA_DIR`, else `~/.konan`. Computed, not diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 3086801..a2d9a1d 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -420,6 +420,32 @@ class KonanPluginFunctionalTest { } } + @Test + fun `an Android link of C++ carries libc++ and the unwinder, and leaves nothing undefined`() { + val home = realKonanHome() + File(projectDir, "native").mkdirs() + // operator new, std::vector and a throw: libc++, libc++abi and _Unwind_* β€” none of which Android provides. + File(projectDir, "native/mymath.cpp").writeText( + """ + #include + #include + #include + extern "C" int my_add(int a, int b) { + std::vector values{a, b}; + try { if (values.empty()) throw std::runtime_error("empty"); } catch (const std::exception &) { return 0; } + return std::accumulate(values.begin(), values.end(), 0); + } + """.trimIndent() + ) + val android = mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.ANDROID_ARM64)") + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", "config.konanHome.set(file(\"$home\"))\n$android") + + // The link uses --no-undefined: without the runtime it fails here, not when the app loads the library. + val result = runner("linkJniMymathAndroid_arm64").withPluginClasspathAndKgp().build() + + assertTrue(File(projectDir, "build/konan/bindings/mymath/jni/jniLibs/arm64-v8a/libexamplestubs.so").isFile, result.output) + } + @Test fun `the JNI links for the host and Android depend on generating the stub they compile`() { val home = realKonanHome() From 32d6849888c49675ec9d207d5f040fc2be5e10e2 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 12:11:33 +0100 Subject: [PATCH 30/57] fix: version 2.0.0-alpha01, and refuse to publish a version the Portal has feature/konan-dsl still said 1.2.0-alpha05, already on the Plugin Portal as the previous plugin, so publishToMavenLocal overwrote that version in ~/.m2 with the new DSL: every project pinning 1.2.0-alpha05 with mavenLocal() first stopped configuring, while CI kept building. The DSL changed without a compatibility shim, so the branch is now 2.0.0-alpha01, which the Portal does not have. checkVersionUnpublished runs before publishToMavenLocal and publishPlugins and fails when the version's marker POM is already on the Portal; it skips offline or when the Portal cannot be reached. Fixes #8 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +++- README.md | 2 +- build.gradle.kts | 36 +++++++++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 599611f..cd15a52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,7 +273,9 @@ the plugin to a project with neither KGP nor AGP. Keep at least one fixture like plugin-under-test classpath itself (`withPluginClasspathAndKgp()` in `TestKitSupport.kt`); never add KGP to the shared metadata, or the no-KGP fixtures stop proving anything. -Portal versions are immutable β€” bump and publish, never reuse an alpha. Consumers with `mavenLocal()` +Portal versions are immutable β€” bump and publish, never reuse an alpha. `checkVersionUnpublished` runs before +`publishToMavenLocal` and `publishPlugins` and fails when the version is already on the Portal: a local +copy of a published version shadowed the Portal's for koinference and broke it overnight (#8). Consumers with `mavenLocal()` ahead of the portal will silently use a local build instead, so a version that "works locally" proves nothing about what CI resolves. Verify a publish by configuring a throwaway project with only `gradlePluginPortal()`. diff --git a/README.md b/README.md index 8aecfb2..bf26223 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and 1. Apply the plugin in your `build.gradle.kts`: ```kotlin plugins { - id("io.github.lemcoder.konanplugin") version "1.2.0-alpha" + id("io.github.lemcoder.konanplugin") version "2.0.0-alpha01" } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 5c24330..ad7ef53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.HttpURLConnection +import java.net.URI + plugins { `kotlin-dsl` id("com.gradle.plugin-publish") version "2.2.1" @@ -5,7 +8,8 @@ plugins { id("native-index") } -version = "1.2.0-alpha05" +// Every published version is immutable on the Portal: bump before publishing, and never reuse one. +version = "2.0.0-alpha01" group = "io.github.lemcoder" repositories { @@ -72,3 +76,33 @@ tasks.test { inputs.property("testGradleVersion", gradleVersion) jvmArgumentProviders.add(CommandLineArgumentProvider { listOf("-Dkgp.classpath=${kgp.asPath}", "-Dtest.gradle.version=${gradleVersion.get()}") }) } + +// A version already on the Plugin Portal must never be published again, not even to mavenLocal: a local +// copy shadows the Portal's for every project that lists mavenLocal() first, so those projects break +// locally and build in CI. Skipped offline, or when the Portal cannot be reached. +val checkVersionUnpublished = tasks.register("checkVersionUnpublished") { + group = "publishing" + description = "Fails when this version is already on the Gradle Plugin Portal." + val pluginVersion = version.toString() + val offline = gradle.startParameter.isOffline + val marker = "https://plugins.gradle.org/m2/io/github/lemcoder/konanplugin/io.github.lemcoder.konanplugin.gradle.plugin/" + + "$pluginVersion/io.github.lemcoder.konanplugin.gradle.plugin-$pluginVersion.pom" + inputs.property("version", pluginVersion) + doLast { + if (offline) return@doLast logger.warn("Offline: not checking whether $pluginVersion is on the Plugin Portal.") + val status = runCatching { + HttpURLConnection.setFollowRedirects(true) + (URI(marker).toURL().openConnection() as HttpURLConnection).run { + requestMethod = "HEAD" + connectTimeout = 10_000 + readTimeout = 10_000 + responseCode.also { disconnect() } + } + }.getOrElse { return@doLast logger.warn("Could not reach the Plugin Portal ($it); not checking $pluginVersion.") } + check(status != 200) { + "Version $pluginVersion is already on the Gradle Plugin Portal. Bump `version` in build.gradle.kts: " + + "publishing it again, even to mavenLocal, shadows the Portal's copy for every project that lists mavenLocal() first." + } + } +} +tasks.matching { it.name == "publishToMavenLocal" || it.name == "publishPlugins" }.configureEach { dependsOn(checkVersionUnpublished) } From b7a61bf65d5203596f6a36684ecf59029fc29e86 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 12:12:55 +0100 Subject: [PATCH 31/57] fix: a CMake build directory overrides the preset's binaryDir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a preset, configure ran `cmake --preset

` with no -B, so it configured into the preset's binaryDir, while the build step ran `cmake --build `: the build failed, or configure wrote its cache entries into a directory another build owned (koinference's library and JNI builds shared one through a preset). -B overrides a preset's binaryDir, so a set buildDirectory β€” the target block's, else the block's β€” is now passed with the preset too, and configure and build agree. A target block's unset buildDirectory now falls back to the block's; it used to shadow it, because the property exists either way. Fixes #4 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../lemcoder/dsl/builds/CMakeCommands.kt | 10 +++-- .../lemcoder/dsl/builds/CMakeSettings.kt | 7 ++-- .../lemcoder/KonanPluginFunctionalTest.kt | 40 +++++++++++++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bf26223..befabca 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,7 @@ target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arriv |------------------|------------------|--------------------|-------------| | `path` | `File` | β€” | The `CMakeLists.txt`. Required unless `preset` is set. | | `preset` | `String` | β€” | A configure preset from `CMakePresets.json`. | -| `buildDirectory` | `Directory` | `/build/`, else under `build/` | Where CMake builds. | +| `buildDirectory` | `Directory` | the preset's `binaryDir` (`/build/`), else under `build/` | Where CMake builds. Set, it is passed as `-B`, overriding a preset's `binaryDir` (CMake 3.21+). | | `buildTargets` | `List` | the default target | `--target`s to build. | | `arguments` | `List` | β€” | Extra configure arguments. | | `libraryDirectory` | `Directory` | β€” | Where the build leaves the library, if it ignores `CMAKE_LIBRARY_OUTPUT_DIRECTORY`: the plugin copies it into place. | diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt index fbace57..1b357d9 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt @@ -32,9 +32,11 @@ internal fun ExternalBuildTask.addCMakeCommands( val cmakeExecutable = project.konanConfig.executable(ConfigField.CMAKE, path) check(cmake.path.isPresent || preset.isPresent) { "$owner needs a path or a preset." } val sourceDirectory = cmake.path.map { it.asFile.parentFile } - // With a preset the binary directory is the preset's to choose, and `-B` cannot override it; the - // conventional layout is /build/, which stays overridable. - val buildDirectory: Provider = (perTarget?.buildDirectory ?: cmake.buildDirectory).orElse( + // The target's, else the block's: a target block that sets no directory still has the property. + val explicitBuildDirectory: Provider = perTarget?.buildDirectory?.orElse(cmake.buildDirectory) ?: cmake.buildDirectory + // Without one, a preset's binary directory is the preset's to choose β€” conventionally + // /build/ β€” and without a preset the plugin's own. + val buildDirectory: Provider = explicitBuildDirectory.orElse( project.layout.dir( preset.map { presetName -> sourceDirectory.get().resolve("build/$presetName") } .orElse(defaultBuildRoot.map { it.dir(context.target.konanName).asFile }) @@ -48,6 +50,8 @@ internal fun ExternalBuildTask.addCMakeCommands( sourceDirectory.zip(buildDirectory) { source, build -> listOf("-S", source.absolutePath, "-B", build.asFile.absolutePath) } ) ) + // `-B` overrides a preset's binaryDir, so configure and build agree on the directory that was set. + if (preset.isPresent) arguments.addAll(explicitBuildDirectory.map { listOf("-B", it.asFile.absolutePath) }.orElse(emptyList())) // Kotlin/Native's compilers by toolchain file β€” the arguments after it can still name another, and // a preset names its own. if (!preset.isPresent) { diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt index 1a1d861..25da595 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt @@ -55,8 +55,9 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact abstract val preset: Property /** - * Where CMake builds. Defaults to `/build/` with a preset β€” the layout - * `binaryDir` conventionally uses β€” and to a directory under the Gradle build dir without one. + * Where CMake builds. Set, it is passed as `-B`, overriding a preset's `binaryDir`, so configure and + * build use the same directory. Unset, a preset's `binaryDir` decides β€” conventionally + * `/build/` β€” and without a preset a directory under the Gradle build dir. */ abstract val buildDirectory: DirectoryProperty @@ -79,7 +80,7 @@ abstract class CMakeTargetSettings { /** Configure preset for this target. */ abstract val preset: Property - /** Build directory for this target; defaults the same way as the enclosing block's. */ + /** Build directory for this target, else the enclosing block's; passed as `-B`, even with a preset. */ abstract val buildDirectory: DirectoryProperty /** Extra configure arguments for this target only. */ diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index a2d9a1d..b5745a0 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -254,6 +254,46 @@ class KonanPluginFunctionalTest { assertTrue(elf > 0 && archive[elf + 18] == 0x3e.toByte(), "expected an x86-64 ELF member:\n${result.output}") } + @Test + fun `a CMake preset configures into the build directory the target block sets`() { + val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } + assumeTrue(cmake != null, "needs cmake") + File(projectDir, "native").mkdirs() + File(projectDir, "native/CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.21) + project(mymath NONE) + file(WRITE "${'$'}{CMAKE_ARCHIVE_OUTPUT_DIRECTORY}/lib${'$'}{KONAN_LIB_NAME}.a" "archive") + """.trimIndent() + ) + File(projectDir, "native/CMakePresets.json").writeText( + """{ "version": 3, "configurePresets": [ { "name": "host", "binaryDir": "${'$'}{sourceDir}/build/host" } ] }""" + ) + write( + """id("io.github.lemcoder.konanplugin")""", + """ + libraries { + create("mymath") { + targets(NativeTarget.${host.name}) + cmake { + target(NativeTarget.${host.name}) { + preset.set("host") + buildDirectory.set(layout.buildDirectory.dir("cmake/mine")) + } + } + } + } + """, + ) + + val result = runner("compileNativeLibraryMymath").build() + + // Configure and build agree on the directory that was set; the preset's own is never created. + assertTrue(File(projectDir, "build/cmake/mine/CMakeCache.txt").isFile, result.output) + assertFalse(File(projectDir, "native/build/host").exists(), result.output) + assertTrue(File(projectDir, "build/konan/libraries/mymath/${host.konanName}/libmymath.a").isFile, result.output) + } + @Test fun `CMake builds a library's archive for the host and, cross-compiled, for linux_x64`() { val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } From 2ecfd299d4cbf94b9906296a069d006e9d3ea27a Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 12:14:14 +0100 Subject: [PATCH 32/57] fix: a JVM jar builds only the JVM targets' native libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bundleJni (and bundleFfm) put only the JVM targets' libraries in the jar but depended on link, which builds every target's: jvmTest built the Android JNI libraries too β€” a full NDK build per ABI, and a failure on a machine without one. BindingLibraryWiring now records each target's build β€” the plugin's link, an external build's per-target task, or the prebuilt placement β€” and the bundle depends on the JVM targets' only. Fixes #5 Co-Authored-By: Claude Opus 5.5 --- .../dsl/bindings/BindingLibraryWiring.kt | 17 +++++++++----- .../dsl/bindings/ffm/FfmLibraryBuild.kt | 22 +++++++++--------- .../dsl/bindings/jni/KonanLinkStubBuild.kt | 4 ++-- .../lemcoder/KonanPluginFunctionalTest.kt | 23 +++++++++++++++++++ 4 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt index d01d013..d3b7917 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt @@ -46,6 +46,9 @@ internal class BindingLibraryWiring( spec.hostLibRoot.set(root.map { it.dir("lib") }) } + /** Each target's build, once [registerBuild] has run. */ + private var buildsByTarget: Map> = emptyMap() + /** `targets`, else those of `cmake { target(…) { } }` blocks; empty when the build script named none. */ fun declaredTargets(): List = spec.targets.getOrElse(emptyList()).ifEmpty { (spec.selectedBuilds.singleOrNull() as? CMakeSettings)?.targetSettings?.keys?.toList().orEmpty() @@ -58,15 +61,15 @@ internal class BindingLibraryWiring( fun registerBuild( targets: List, libraryName: Provider, - own: () -> List>, - prebuilt: () -> List> = { error("$owner has no prebuilt { } block") }, + own: () -> Map>, + prebuilt: () -> Map> = { error("$owner has no prebuilt { } block") }, extraVariables: (NativeTarget) -> Map> = { emptyMap() }, configure: ExternalBuildTask.() -> Unit = {}, ) { val builds = when (val settings = selectOne(owner, spec.selectedBuilds) { it.blockName }) { null -> own() is PrebuiltSettings -> prebuilt() - is ExternalBuildSettings -> ExternalBuild.of(settings).register( + is ExternalBuildSettings -> targets.zip(ExternalBuild.of(settings).register( project, ExternalBuildOutput( owner = owner, @@ -81,9 +84,10 @@ internal class BindingLibraryWiring( extraVariables = extraVariables, configure = configure, ), - ) + )).toMap() } - linkAll.configure { dependsOn(builds) } + buildsByTarget = builds + linkAll.configure { dependsOn(builds.values.distinct()) } val host = NativeTarget.host spec.libraryDirectory.convention(if (host in targets) spec.libraryDirectoryFor(host) else spec.jniLibsRoot) } @@ -101,7 +105,8 @@ internal class BindingLibraryWiring( spec.libraryDirectoryFor(target).zip(libraryName) { directory, name -> directory.file(target.sharedLibraryName(name)) } }, destination = root.get().dir("resources").asFile, - builtBy = linkAll, + // Only these targets' builds, not linkAll: a JVM jar must not build every Android library. + builtBy = jvmTargets.mapNotNull { buildsByTarget[it] }.distinct(), ) val resources = bundle.map { it.destinationDir } if (plainJvm) KotlinGradlePlugin.addJvmResources(project, resources) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt index e374212..16da134 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt @@ -22,11 +22,11 @@ internal fun registerFfmLink( artifacts: NativeArtifacts?, libraryName: String, targets: List, -): List> { +): Map> { if (artifacts == null) { // Nothing to link: a platform library (libaaudio), or one the app gets some other way. project.logger.info("konan.bindings.${binding.name}.ffm: no library(), so the bindings load lib$libraryName as the system provides it.") - return emptyList() + return emptyMap() } val built = artifacts.targets.getOrElse(emptyList()) val (linked, missing) = targets.partition { it in built } @@ -37,7 +37,7 @@ internal fun registerFfmLink( ) } val ffm = binding.ffm - return linked.map { target -> + return linked.associateWith { target -> project.tasks.register(taskName("linkFfm", binding.name, target), LinkSharedLibraryTask::class.java) { group = "konan" description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." @@ -53,7 +53,7 @@ internal fun registerFfmLink( } /** `ffm { prebuilt { } }`: shared libraries built elsewhere, placed under the name the bindings load. */ -internal fun registerFfmPrebuilt(project: Project, binding: BindingSpec, libraryName: String, targets: List): List> { +internal fun registerFfmPrebuilt(project: Project, binding: BindingSpec, libraryName: String, targets: List): Map> { val ffm = binding.ffm val libraries = ffm.prebuilt.libraries.getOrElse(emptyMap()) val missing = targets.filter { it !in libraries } @@ -63,11 +63,11 @@ internal fun registerFfmPrebuilt(project: Project, binding: BindingSpec, library val placements = targets.associate { target -> libraries.getValue(target) to ffm.libraryDirectoryFor(target).get().file(target.sharedLibraryName(libraryName)).asFile } - return listOf( - project.tasks.register(taskName("prebuiltFfm", binding.name), PlacePrebuiltFilesTask::class.java) { - group = "konan" - description = "Place the prebuilt '$libraryName' libraries the '${binding.name}' FFM bindings load." - place(placements) - } - ) + val place = project.tasks.register(taskName("prebuiltFfm", binding.name), PlacePrebuiltFilesTask::class.java) { + group = "konan" + description = "Place the prebuilt '$libraryName' libraries the '${binding.name}' FFM bindings load." + place(placements) + } + // One task places every target's library. + return targets.associateWith { place } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt index 3d524bc..30fb5cd 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt @@ -25,11 +25,11 @@ internal fun registerKonanLink( generate: TaskProvider, artifacts: NativeArtifacts?, targets: List, -): List> { +): Map> { val bindingName = binding.name val jni = binding.jni val output = generate.flatMap { it.outputDirectory } - return targets.map { target -> + return targets.associateWith { target -> project.tasks.register(taskName("linkJni", bindingName, target), LinkSharedLibraryTask::class.java) { group = "konan" description = "Link the '$bindingName' JNI library for ${target.konanName}." diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index b5745a0..93668d5 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -254,6 +254,29 @@ class KonanPluginFunctionalTest { assertTrue(elf > 0 && archive[elf + 18] == 0x3e.toByte(), "expected an x86-64 ELF member:\n${result.output}") } + @Test + fun `the jar builds only the JVM targets' JNI libraries, not Android's`() { + val hostAndAndroid = mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.${host.name}, NativeTarget.ANDROID_ARM64)") + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", hostAndAndroid) + + // The plugin's own link, per target. + val own = runner("jar", "--dry-run").withPluginClasspathAndKgp().build().output + assertTrue(":linkJniMymath$hostSuffix SKIPPED" in own && ":bundleJniMymath SKIPPED" in own, own) + assertFalse(":linkJniMymathAndroid_arm64 SKIPPED" in own, own) + + // The project's own build, per target block. + File(projectDir, "build.gradle.kts").appendText( + """ + konan.bindings.named("mymath") { + jni { cmake { path.set(file("CMakeLists.txt")); target(NativeTarget.${host.name}) { }; target(NativeTarget.ANDROID_ARM64) { } } } + } + """.trimIndent() + ) + val cmake = runner("jar", "--dry-run").withPluginClasspathAndKgp().build().output + assertTrue(":cmakeBuildJniMymath$hostSuffix SKIPPED" in cmake, cmake) + assertFalse(":cmakeBuildJniMymathAndroid_arm64 SKIPPED" in cmake, cmake) + } + @Test fun `a CMake preset configures into the build directory the target block sets`() { val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } From fe523e3a0da5cf1417c7db6e7c894e239c662f22 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 12:16:28 +0100 Subject: [PATCH 33/57] feat: merge the archives a library's build leaves into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library must embed one archive per target, but a CMake project built with CPM or add_subdirectory leaves one per add_library β€” whisper.cpp leaves libwhisper.a, libggml.a, libggml-base.a, libggml-cpu.a and more β€” and every consumer had to merge them by hand, per platform. A library's external build now writes into a staging directory, which is what KONAN_OUTPUT_DIR and CMake's CMAKE_ARCHIVE_OUTPUT_DIRECTORY name, emptied before each run. ExternalBuildTask then copies the one archive it finds, or merges several into lib.a with Kotlin/Native's llvm-ar (quick-append of each archive's members, then an index; darwin format for Apple targets), the build's own lib.a first. Consumers β€” cinterop's -staticLibrary, the JNI and FFM links β€” are unchanged. A test builds two archives, one needing the other, and links a JNI library from the merged one. Fixes #7 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++ README.md | 2 +- .../lemcoder/dsl/builds/ExternalBuild.kt | 17 +++++- .../lemcoder/dsl/builds/ExternalBuildTask.kt | 57 +++++++++++++++++-- .../lemcoder/dsl/libraries/LibraryBuilds.kt | 1 + .../dsl/libraries/NativeLibrarySpec.kt | 3 +- .../lemcoder/KonanPluginFunctionalTest.kt | 47 +++++++++++++++ 7 files changed, 124 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd15a52..c25da20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,11 @@ resolved the moment the plugin is applied, in *every* project that applies it. - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with *"It is too late to add actions as the callbacks already executed"*, so the jniLibs wiring runs at binding-registration time and tolerates an empty directory. +- **A library has one archive per target, however many its build leaves.** A library's external build writes + into a staging directory (`KONAN_OUTPUT_DIR`, CMake's output directories), emptied before each run; + `ExternalBuildTask` copies the one archive, or merges several with Kotlin/Native's llvm-ar (`qcL`, then `s`; + `darwin` format for Apple) into `lib.a`. Consumers keep embedding a single archive. Staging is what + keeps a re-run from merging the previous merge in again (#7). - **An external build's configure and build steps are one task.** As two tasks, changing a configure argument re-ran configure, left the build `UP-TO-DATE`, and silently kept the previous library β€” a linker flag appeared to do nothing. `ExternalBuildTask` runs every step, and without declared diff --git a/README.md b/README.md index befabca..a1c61b6 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ environment variables and, for CMake, as cache entries: | variable | meaning | |---|---| | `KONAN_LIB_NAME` | the library's name: a library's archive becomes `lib.a`; for `jni` and `ffm`, what the bindings `System.loadLibrary` | -| `KONAN_OUTPUT_DIR` | where this target's library must land: `jniLibs//` for a binding's Android library, which the plugin packages | +| `KONAN_OUTPUT_DIR` | where this target's library must land: `jniLibs//` for a binding's Android library, which the plugin packages. For a library, where its archives go: every `.a` the build leaves there becomes the one `lib.a` β€” copied if there is one, merged if there are several (a CMake project with dependencies leaves one per `add_library`) | | `KONAN_TARGET` | the Kotlin/Native target, e.g. `android_arm64` | | `KONAN_ABI` | its Android ABI, e.g. `arm64-v8a`; the target's name elsewhere | | `KONAN_SOURCE_DIR` | a library's `sourceDirectory` β€” libraries only | diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt index 63b9d9b..255527d 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt @@ -28,6 +28,12 @@ internal class ExternalBuildOutput( val outputDirectory: (NativeTarget) -> Provider, /** Where a build system's own files go: CMake's build trees, Cargo's target directory, Zig's prefixes. */ val workDirectory: Provider, + /** + * For a static library: where the build leaves its archives for a target β€” `KONAN_OUTPUT_DIR` and + * CMake's output directories β€” which [ExternalBuildTask] copies or merges into [libraryFileName]. + * Null for a shared library, built straight into [outputDirectory]. + */ + val archiveStaging: ((NativeTarget) -> Provider)? = null, /** A task description, given the target and the build system's name. */ val description: (NativeTarget, String) -> String, /** Variables only this kind of output has, e.g. [ExternalVariable.JNI_STUB_DIR]. */ @@ -58,14 +64,17 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val return output.targets.map { target -> val konanToolchain = toolchain?.takeIf { it.usesKonan(target) }?.let { KonanToolchain.of(project, target) } val targetOutput = output.outputDirectory(target) + val staging = output.archiveStaging?.invoke(target) + // Where the build itself writes: the staging directory for archives, else the final one. + val buildOutput = staging ?: targetOutput val contract: Map> = mapOf( ExternalVariable.LIB_NAME to output.libraryName, - ExternalVariable.OUTPUT_DIR to targetOutput.map { it.asFile.absolutePath }, + ExternalVariable.OUTPUT_DIR to buildOutput.map { it.asFile.absolutePath }, ExternalVariable.TARGET to project.provider { target.konanName }, ExternalVariable.ABI to project.provider { target.libraryDirectoryName }, ) + output.extraVariables(target) val environment = if (konanToolchain != null && toolchainInEnvironment) contract + konanToolchain.files.environment else contract - val context = ExternalBuildContext(target, targetOutput, environment, konanToolchain?.files) + val context = ExternalBuildContext(target, buildOutput, environment, konanToolchain?.files) val taskDescription = output.description(target, toolName) val fileName = output.libraryFileName(target) project.tasks.register(taskName(taskPrefix + output.side, output.name, target), ExternalBuildTask::class.java) { @@ -76,6 +85,10 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val context.environment.forEach { (variable, value) -> this.environment.put(variable.variableName, value) } libraryFileName.set(fileName) outputDirectory.set(targetOutput) + staging?.let { + archiveStagingDirectory.set(it) + archiveTarget.set(target) + } output.configure(this) addCommands(project, output, context) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt index 96cd65b..9807565 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt @@ -1,6 +1,10 @@ package io.github.lemcoder.dsl.builds +import io.github.lemcoder.dsl.config.ConfigValue +import io.github.lemcoder.kotlin.UsesKonanDistribution +import io.github.lemcoder.kotlin.distribution import io.github.lemcoder.model.Executable +import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.ExecCaptureResult import io.github.lemcoder.util.execCapture import java.io.File @@ -32,12 +36,20 @@ import org.gradle.work.DisableCachingByDefault * * Up-to-date only when [sources] are declared: without them the plugin cannot know what the build * reads, so it always runs and the build's own incrementality decides. + * + * A library's build writes into [archiveStagingDirectory] instead, emptied before every run, and its + * archives become the one [libraryFileName]: copied when there is one, merged with Kotlin/Native's + * llvm-ar for [archiveTarget] when there are several β€” a CMake project with dependencies leaves one per + * `add_library`, and consumers embed a single archive. */ @DisableCachingByDefault(because = "Runs a build system whose inputs the plugin does not model") internal abstract class ExternalBuildTask @Inject constructor( private val exec: ExecOperations, private val objects: ObjectFactory, -) : DefaultTask() { +) : DefaultTask(), UsesKonanDistribution { + /** Only for merging several archives, the one step that needs Kotlin/Native. */ + @get:Internal abstract override val konanHome: Property + @get:Nested val commands: MutableList = mutableListOf() /** Every command's environment: the plugin's contract. A command's own entries win. */ @@ -57,6 +69,12 @@ internal abstract class ExternalBuildTask @Inject constructor( @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + /** Where a library's build leaves its archives; unset for a shared library, which lands in place. */ + @get:Internal abstract val archiveStagingDirectory: DirectoryProperty + + /** The target the archives are for: the format they are merged in. */ + @get:Input @get:Optional abstract val archiveTarget: Property + init { outputs.upToDateWhen { !(it as ExternalBuildTask).sources.isEmpty } } @@ -68,6 +86,8 @@ internal abstract class ExternalBuildTask @Inject constructor( @TaskAction fun run() { outputDirectory.get().asFile.mkdirs() + // Fresh every run: a stale archive, or the previous merge, must not be merged in again. + val staging = archiveStagingDirectory.orNull?.asFile?.apply { deleteRecursively(); mkdirs() } // Read before exec's block: inside it, `environment` is the ExecSpec's own. val contract = environment.get() commands.forEachIndexed { index, command -> @@ -84,11 +104,40 @@ internal abstract class ExternalBuildTask @Inject constructor( "Command ${index + 1} of $path failed: ${line.joinToString(" ")}\n${result.output}" } } - producedLibrary.orNull?.asFile?.let { produced -> - check(produced.isFile) { "$path: the build left no library at $produced" } - produced.copyTo(outputDirectory.get().asFile.resolve(libraryFileName.get()), overwrite = true) + val library = outputDirectory.get().asFile.resolve(libraryFileName.get()) + val produced = producedLibrary.orNull?.asFile + when { + produced != null -> { + check(produced.isFile) { "$path: the build left no library at $produced" } + produced.copyTo(library, overwrite = true) + } + staging != null -> collectArchives(staging, library) } } + + /** The archives the build left in [staging] as one [library]: the build's own `lib.a` first. */ + private fun collectArchives(staging: File, library: File) { + val archives = staging.walk().filter { it.isFile && it.extension in ARCHIVE_EXTENSIONS } + .sortedWith(compareBy { it.name != library.name }.thenBy { it.path }).toList() + check(archives.isNotEmpty()) { "$path: the build left no archive in $staging (KONAN_OUTPUT_DIR)" } + library.delete() + if (archives.size == 1) { + archives.single().copyTo(library, overwrite = true) + return + } + logger.info("$path: merging ${archives.size} archives into ${library.name}: ${archives.joinToString { it.name }}") + val distribution = distribution() + val format = if (archiveTarget.get().family.isApple) "darwin" else "gnu" + // q appends, and L appends an archive's members rather than the archive itself; s writes the index. + for (arguments in listOf(listOf("qcL", "--format=$format", library.absolutePath) + archives.map { it.absolutePath }, listOf("s", library.absolutePath))) { + val result = exec.execCapture { commandLine(distribution.llvmAr(arguments)) } + check(result is ExecCaptureResult.Success) { "$path: merging the archives failed:\n${result.output}" } + } + } + + private companion object { + val ARCHIVE_EXTENSIONS = setOf("a", "lib") + } } /** One command of an external build. */ diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt index a66d8b1..048eca9 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt @@ -35,6 +35,7 @@ private fun Project.externalBuildOutput(library: NativeLibrarySpec): ExternalBui libraryFileName = { provider { NativeArtifacts.archiveFileName(name) } }, outputDirectory = library::archiveDirectory, workDirectory = layout.buildDirectory.dir("konan/libraries/$name"), + archiveStaging = { target -> layout.buildDirectory.dir("konan/libraries/$name/out/${target.konanName}") }, description = { target, tool -> "Build the '$name' library for ${target.konanName} with $tool." }, extraVariables = { mapOf(ExternalVariable.SOURCE_DIR to library.sourceDirectory.map { it.asFile.absolutePath }) }, ) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt index 4381ceb..819a032 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt @@ -20,7 +20,8 @@ import org.gradle.api.provider.Provider * A C/C++ library built into a static archive per target, `//lib.a`: by * Kotlin/Native's clang from [sourceDirectory], by the project's own build ([cmake], [zig], [cargo], [make], [external]), or built * elsewhere ([prebuilt]). An external build gets `KONAN_SOURCE_DIR` besides the common contract, and leaves - * `lib${KONAN_LIB_NAME}.a` in `KONAN_OUTPUT_DIR`, or names where it leaves its archive with `producedLibrary`. + * its archives in `KONAN_OUTPUT_DIR` β€” several are merged into `lib.a` β€” or names where it leaves its + * archive with `producedLibrary`. */ abstract class NativeLibrarySpec @Inject constructor(private val name: String, objects: ObjectFactory) : ExternalBuildBlocks(objects), Named { diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 93668d5..38c8157 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -169,6 +169,53 @@ class KonanPluginFunctionalTest { assertFalse(":konanClangMymath" in first.output, first.output) } + @Test + fun `a library build that leaves several archives is merged into one`() { + val tools = listOf("cc", "ar").map { tool -> listOf("/usr/bin", "/opt/homebrew/bin").map { File(it, tool) }.firstOrNull { it.canExecute() } } + assumeTrue(tools.all { it != null } && File("/bin/sh").canExecute(), "needs cc, ar and /bin/sh") + val (cc, ar) = tools.map { it!!.absolutePath } + val home = realKonanHome() + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + """ + config.konanHome.set(file("$home")) + libraries { + create("mymath") { + targets(NativeTarget.${host.name}) + external { + step { + executable.set(Executable.File(file("/bin/sh"))) + arguments.add("build.sh") + workingDirectory.set(layout.projectDirectory) + } + } + } + } + bindings { create("mymath") { defFile.set(file("mymath.def")); library(libraries.getByName("mymath")) } } + """, + ) + // Two archives, as a CMake project with a dependency leaves them: my_add in libmymath.a needs + // extra_value from libextra.a. + File(projectDir, "a.c").writeText("int extra_value(void);\nint my_add(int a, int b) { return a + b + extra_value(); }\n") + File(projectDir, "b.c").writeText("int extra_value(void) { return 0; }\n") + File(projectDir, "build.sh").writeText( + """ + set -e + mkdir -p obj + $cc -c -fPIC a.c -o obj/a.o && $ar rcs "${'$'}KONAN_OUTPUT_DIR/lib${'$'}KONAN_LIB_NAME.a" obj/a.o + $cc -c -fPIC b.c -o obj/b.o && $ar rcs "${'$'}KONAN_OUTPUT_DIR/libextra.a" obj/b.o + """.trimIndent() + ) + + // Linking the JNI library needs both: the merged archive must carry extra_value. + val result = runner("linkJniMymath").withPluginClasspathAndKgp().build() + + val archive = File(projectDir, "build/konan/libraries/mymath/${host.konanName}/libmymath.a") + val members = ProcessBuilder(ar, "t", archive.absolutePath).start().inputStream.bufferedReader().readText() + assertTrue("a.o" in members && "b.o" in members, "expected both objects in $archive:\n$members\n${result.output}") + assertTrue(File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.konanName}/${host.sharedLibraryName("examplestubs")}").isFile, result.output) + } + @Test fun `prebuilt archives are placed where every provider puts them`() { File(projectDir, "libs").mkdirs() From e0b7140af47c7aba1f845e5d7ca4eb67dfa500a9 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 12:19:08 +0100 Subject: [PATCH 34/57] feat: konan.config.androidNdk for Android external builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Android cross-build through cmake { }, make { }, cargo { } or external { } with no toolchain of its own compiled with Kotlin/Native's bundled Android toolchain β€” NDK r20, clang 8 β€” and nothing said so. Modern C++ needs a current NDK, and archives must match the libc++ they link against. konan.config.androidNdk, read like every other machine path (build script, Gradle property, ANDROID_NDK_HOME), gives Android targets on the AUTO toolchain that NDK instead: its per-API clang drivers, llvm-ar and llvm-ranlib as CC, CXX, AR and RANLIB; for CMake its android.toolchain.cmake with ANDROID_ABI and ANDROID_PLATFORM from androidApiLevel; and ANDROID_NDK_HOME in the environment for presets that read it. Its files are checked when the build reads them. Without one, such a build warns that it uses Kotlin/Native's toolchain; toolchain = KONAN keeps it without the warning. The plugin's own links keep Kotlin/Native's toolchain. Fixes #6 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 7 +- README.md | 13 ++- .../dsl/builds/AndroidNdkToolchain.kt | 58 +++++++++++ .../lemcoder/dsl/builds/CMakeCommands.kt | 5 +- .../lemcoder/dsl/builds/ExternalBuild.kt | 28 +++++- .../dsl/builds/ExternalBuildContract.kt | 9 +- .../lemcoder/dsl/builds/ExternalToolchain.kt | 5 +- .../github/lemcoder/dsl/config/ConfigField.kt | 1 + .../github/lemcoder/dsl/config/KonanConfig.kt | 8 ++ .../lemcoder/KonanPluginFunctionalTest.kt | 99 ++++++++++++++++++- 10 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt diff --git a/CLAUDE.md b/CLAUDE.md index c25da20..a24b9db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. ## Machine paths: `konan { config { } }` - **The single source of every path the plugin needs from the machine** β€” `konanHome`, `jextract`, - `cmake`, `make`, `cargo`, `zig`. Nothing is searched for: no `PATH` scan, no install-directory or + `cmake`, `make`, `cargo`, `zig`, `androidNdk`. Nothing is searched for: no `PATH` scan, no install-directory or `~/.konan` lookup, no JDK. `KonanConfig.readConfigField(field, usedBy)` reads the DSL, then the Gradle property `konan.config.`, then the environment variable, and records `usedBy` for `konanConfig`. Add a path by adding a `ConfigField` entry and a property, never a per-block `executable`. @@ -142,6 +142,11 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t `LinkSharedLibraryTask` appends them last β€” the NDK's linker resolves archives in order β€” and links with `-Wl,--no-undefined`. Without that flag a missing symbol linked fine and failed only on the device ("cannot locate symbol"); never drop it to make a link pass. +- **An Android external build on `AUTO` uses `konan.config.androidNdk` when it is set** (`AndroidNdkToolchain`: + the NDK's per-API clang drivers, its CMake toolchain file with `ANDROID_ABI`/`ANDROID_PLATFORM`), else + Kotlin/Native's NDK r20 / clang 8 with a warning β€” never silently (#6). The NDK's clang prefix for 32-bit + ARM is `armv7a-linux-androideabi`, its library directory `arm-linux-androideabi`. The plugin's own links stay + on Kotlin/Native's toolchain. - **The toolchain needs its own `ranlib`** (`llvm-ar s`): CMake indexes static archives with the host's, and Apple's `ranlib` silently drops ELF objects β€” a cross-built archive came out empty. - **`external { }` defaults to `ExternalToolchain.SYSTEM`**, the presets to `AUTO`: generic steps may not diff --git a/README.md b/README.md index a1c61b6..e299db3 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ from the build script, else the Gradle property `konan.config.` (the buil | `make` | `konan.config.make` | `KONAN_MAKE` | `make { }` | | `cargo` | `konan.config.cargo` | `KONAN_CARGO` | `cargo { }` | | `zig` | `konan.config.zig` | `KONAN_ZIG` | `zig { }` | +| `androidNdk` | `konan.config.androidNdk` | `ANDROID_NDK_HOME` | optional: Android targets of external builds on `AUTO` (below) | ```kotlin konan { @@ -268,7 +269,7 @@ Linux, its NDK sysroot on Android): | `toolchain` | Meaning | |---------------|---------| -| `AUTO` | Kotlin/Native's when cross-compiling, the system's for the build host. The default of `make { }` and `cmake { }`. | +| `AUTO` | Kotlin/Native's when cross-compiling, the system's for the build host β€” and for Android, the NDK in `konan.config.androidNdk` when one is set. The default of `make { }`, `cmake { }` and `cargo { }`. | | `KONAN` | Always Kotlin/Native's. | | `SYSTEM` | Never: the build finds its own. The default of `external { }`, whose steps are the build script's own. | @@ -278,6 +279,16 @@ as `-DCMAKE_TOOLCHAIN_FILE` unless it uses a preset β€” a toolchain file in `arg say. They are written per target by `konanToolchain` under `build/konan/toolchains//`: `cc` and `c++` wrap the clang `run_konan` would run, with the target's link flags when linking. +**Android: your NDK, or Kotlin/Native's.** Kotlin/Native's Android toolchain is NDK r20 with clang 8 β€” enough +for C, old for modern C++ (llama.cpp, whisper.cpp), and its libc++ must match whatever the archives are +linked against. Set `konan.config.androidNdk` (or `ANDROID_NDK_HOME`) and every Android target on `AUTO` +builds with that NDK instead: its `-clang` drivers, `llvm-ar` and `llvm-ranlib` as `CC`, `CXX`, +`AR` and `RANLIB`; `cmake { }` gets its `android.toolchain.cmake` with `ANDROID_ABI` and `ANDROID_PLATFORM` +(`androidApiLevel`); and `ANDROID_NDK_HOME` is in the environment, for presets that read it. Without one, +such a build warns that it uses Kotlin/Native's; `toolchain.set(KONAN)` keeps it, without the warning. The +plugin's own links (the JNI library, the library FFM loads) keep Kotlin/Native's toolchain either way: +a binding over a library built with a newer NDK links it in your build too (`jni { cmake { } }`). + #### Archives built elsewhere β€” `prebuilt { }` ```kotlin create("mymath") { diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt new file mode 100644 index 0000000..cf1c206 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt @@ -0,0 +1,58 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.dsl.config.ConfigValue +import io.github.lemcoder.model.Architecture +import io.github.lemcoder.model.HostOs +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.provider.Provider +import java.io.File + +/** + * An installed Android NDK's compilers for one Android target, which external builds get instead of + * Kotlin/Native's when `konan.config.androidNdk` is set: the LLVM toolchain's per-API clang drivers, + * `llvm-ar` and `llvm-ranlib`, and `build/cmake/android.toolchain.cmake` with `ANDROID_ABI` and + * `ANDROID_PLATFORM`. Every file is checked when the build reads it, so a wrong NDK fails the build, not + * configuration. + */ +internal object AndroidNdkToolchain { + fun files(ndk: Provider, target: NativeTarget, apiLevel: Provider): ToolchainFiles { + val home = ndk.map { it.require() } + val windows = HostOs.current == HostOs.WINDOWS + val bin = home.map { it.resolve("toolchains/llvm/prebuilt/${hostTag()}/bin") } + fun tool(name: Provider): Provider = bin.zip(name) { directory, file -> directory.resolve(file) } + .zip(home) { file, ndkHome -> file.also { check(it.isFile) { "konan.config.androidNdk $ndkHome has no ${it.relativeTo(ndkHome)}" } } } + val compiler = apiLevel.map { "${compilerTriple(target)}$it" } + val script = if (windows) ".cmd" else "" + val exe = if (windows) ".exe" else "" + return ToolchainFiles( + cc = tool(compiler.map { "$it-clang$script" }), + cxx = tool(compiler.map { "$it-clang++$script" }), + ar = tool(apiLevel.map { "llvm-ar$exe" }), + ranlib = tool(apiLevel.map { "llvm-ranlib$exe" }), + cmakeToolchainFile = home.map { ndkHome -> + ndkHome.resolve("build/cmake/android.toolchain.cmake") + .also { check(it.isFile) { "konan.config.androidNdk $ndkHome has no build/cmake/android.toolchain.cmake" } } + }, + cmakeDefinitions = mapOf( + "ANDROID_ABI" to apiLevel.map { checkNotNull(target.androidAbi) }, + "ANDROID_PLATFORM" to apiLevel.map { "android-$it" }, + ), + androidNdk = home, + ) + } + + /** The NDK's clang driver prefix: armv7a for 32-bit ARM, whose libraries are arm-linux-androideabi. */ + private fun compilerTriple(target: NativeTarget): String = when (target.architecture) { + Architecture.ARM64 -> "aarch64-linux-android" + Architecture.ARM32 -> "armv7a-linux-androideabi" + Architecture.X64 -> "x86_64-linux-android" + Architecture.X86 -> "i686-linux-android" + } + + /** The NDK's host directory, named darwin-x86_64 even where its tools are universal binaries. */ + private fun hostTag(): String = when (HostOs.current) { + HostOs.MACOS -> "darwin-x86_64" + HostOs.LINUX -> "linux-x86_64" + HostOs.WINDOWS -> "windows-x86_64" + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt index 1b357d9..8476dfd 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt @@ -55,7 +55,10 @@ internal fun ExternalBuildTask.addCMakeCommands( // Kotlin/Native's compilers by toolchain file β€” the arguments after it can still name another, and // a preset names its own. if (!preset.isPresent) { - context.toolchain?.let { toolchain -> arguments.add(toolchain.cmakeToolchainFile.map { "-DCMAKE_TOOLCHAIN_FILE=${it.absolutePath}" }) } + context.toolchain?.let { toolchain -> + arguments.add(toolchain.cmakeToolchainFile.map { "-DCMAKE_TOOLCHAIN_FILE=${it.absolutePath}" }) + toolchain.cmakeDefinitions.forEach { (name, value) -> arguments.add(value.map { "-D$name=$it" }) } + } } context.environment.filterKeys { it !is ToolchainVariable } .forEach { (variable, value) -> arguments.add(value.map { "-D${variable.variableName}=$it" }) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt index 255527d..84dd309 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt @@ -1,5 +1,7 @@ package io.github.lemcoder.dsl.builds +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.kotlin.KonanToolchain import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName @@ -61,8 +63,23 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val fun register(project: Project, output: ExternalBuildOutput): List> { val clean = project.cleanTask() val toolchain = toolchain + val konan = project.extensions.getByType(KonanExtension::class.java) return output.targets.map { target -> - val konanToolchain = toolchain?.takeIf { it.usesKonan(target) }?.let { KonanToolchain.of(project, target) } + val name = taskName(taskPrefix + output.side, output.name, target) + val taskPath = if (project.path == ":") ":$name" else "${project.path}:$name" + // An Android target on AUTO gets the configured NDK; without one, Kotlin/Native's β€” and is told so. + val ndk = konan.config.resolve(ConfigField.ANDROID_NDK, taskPath) + .takeIf { target.isAndroid && toolchain == ExternalToolchain.AUTO && it.get().raw != null } + val konanToolchain = toolchain?.takeIf { ndk == null && it.usesKonan(target) }?.let { KonanToolchain.of(project, target) } + if (konanToolchain != null && target.isAndroid && toolchain == ExternalToolchain.AUTO) { + project.logger.warn( + "$taskPath builds ${target.konanName} with Kotlin/Native's own Android toolchain (NDK r20, clang 8). " + + "Set konan.config.androidNdk (or ANDROID_NDK_HOME) to build with your NDK, or the block's " + + "toolchain to KONAN to keep this one without the warning." + ) + } + val toolchainFiles = ndk?.let { AndroidNdkToolchain.files(konan.config.readConfigField(ConfigField.ANDROID_NDK, taskPath), target, konan.androidApiLevel.orElse(DEFAULT_ANDROID_API)) } + ?: konanToolchain?.files val targetOutput = output.outputDirectory(target) val staging = output.archiveStaging?.invoke(target) // Where the build itself writes: the staging directory for archives, else the final one. @@ -73,11 +90,11 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val ExternalVariable.TARGET to project.provider { target.konanName }, ExternalVariable.ABI to project.provider { target.libraryDirectoryName }, ) + output.extraVariables(target) - val environment = if (konanToolchain != null && toolchainInEnvironment) contract + konanToolchain.files.environment else contract - val context = ExternalBuildContext(target, buildOutput, environment, konanToolchain?.files) + val environment = if (toolchainFiles != null && toolchainInEnvironment) contract + toolchainFiles.environment else contract + val context = ExternalBuildContext(target, buildOutput, environment, toolchainFiles) val taskDescription = output.description(target, toolName) val fileName = output.libraryFileName(target) - project.tasks.register(taskName(taskPrefix + output.side, output.name, target), ExternalBuildTask::class.java) { + project.tasks.register(name, ExternalBuildTask::class.java) { group = "konan" description = taskDescription mustRunAfter(clean) @@ -96,6 +113,9 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val } companion object { + /** The NDK's lowest API level, when the module gives none (no `minSdk`). */ + private const val DEFAULT_ANDROID_API = 21 + /** The build system [settings] configure. */ fun of(settings: ExternalBuildSettings): ExternalBuild = when (settings) { is StepsSettings -> Generic(settings) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt index b3440fa..b0c2c7b 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt @@ -61,6 +61,9 @@ internal enum class ToolchainVariable(override val variableName: String) : Exter /** A CMake toolchain file naming all three, with the target's system and processor. */ CMAKE_TOOLCHAIN_FILE("KONAN_CMAKE_TOOLCHAIN_FILE"), + + /** The Android NDK the compilers come from, when it is `konan.config.androidNdk`: what presets read. */ + ANDROID_NDK_HOME("ANDROID_NDK_HOME"), } /** What an external build is told for one [target]: where to leave its output, and its contract. */ @@ -81,6 +84,10 @@ internal class ToolchainFiles( val ar: Provider, val ranlib: Provider, val cmakeToolchainFile: Provider, + /** Cache entries the toolchain file needs, e.g. the NDK's `ANDROID_ABI`; passed with it. */ + val cmakeDefinitions: Map> = emptyMap(), + /** The Android NDK, when the compilers are its. */ + val androidNdk: Provider? = null, ) { /** The [ToolchainVariable]s, under the standard names. */ val environment: Map> = mapOf( @@ -89,5 +96,5 @@ internal class ToolchainFiles( ToolchainVariable.AR to ar.map { it.absolutePath }, ToolchainVariable.RANLIB to ranlib.map { it.absolutePath }, ToolchainVariable.CMAKE_TOOLCHAIN_FILE to cmakeToolchainFile.map { it.absolutePath }, - ) + ) + listOfNotNull(androidNdk?.let { ndk -> ToolchainVariable.ANDROID_NDK_HOME to ndk.map { it.absolutePath } }) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt index 6ca357e..179f5ec 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt @@ -4,7 +4,10 @@ import io.github.lemcoder.model.NativeTarget /** Which compilers an external build uses for a target. */ enum class ExternalToolchain { - /** Kotlin/Native's when cross-compiling, the system's for the build host. */ + /** + * Kotlin/Native's when cross-compiling, the system's for the build host β€” and for Android, the NDK in + * `konan.config.androidNdk` when one is set. + */ AUTO, /** diff --git a/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt index 2f4edf4..fc42ad2 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt @@ -16,6 +16,7 @@ enum class ConfigField(val dslName: String, val environmentVariable: String, val MAKE("make", "KONAN_MAKE", Kind.EXECUTABLE, "/path/to/make"), CARGO("cargo", "KONAN_CARGO", Kind.EXECUTABLE, "/path/to/cargo"), ZIG("zig", "KONAN_ZIG", Kind.EXECUTABLE, "/path/to/zig"), + ANDROID_NDK("androidNdk", "ANDROID_NDK_HOME", Kind.DIRECTORY, "/path/to/android-sdk/ndk/"), ; val gradleProperty: String get() = "konan.config.$dslName" diff --git a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt index e5fa32c..0ddcef8 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt @@ -50,6 +50,13 @@ abstract class KonanConfig @Inject constructor(private val providers: ProviderFa /** The zig program, for `zig { }`. */ abstract val zig: RegularFileProperty + /** + * An Android NDK, for the Android targets of external builds (`cmake { }`, `make { }`, `cargo { }`, + * `external { }`) whose toolchain is `AUTO`: its compilers instead of Kotlin/Native's, whose NDK is r20 + * with clang 8. Optional; unset, those builds use Kotlin/Native's and say so. + */ + abstract val androidNdk: DirectoryProperty + /** * Where the Kotlin plugin installs Kotlin/Native for this host: `konanHome.set(kotlinNativeDistribution())`. * The project's Kotlin version when it applies the Kotlin plugin, else the plugin's default. Computed, @@ -95,6 +102,7 @@ abstract class KonanConfig @Inject constructor(private val providers: ProviderFa ConfigField.MAKE -> make ConfigField.CARGO -> cargo ConfigField.ZIG -> zig + ConfigField.ANDROID_NDK -> androidNdk } /** Not whether it is installed: that is known when the task runs, not when the configuration cache stores. */ diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 38c8157..29d4c89 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -1,7 +1,7 @@ package io.github.lemcoder -import io.github.lemcoder.dsl.config.konanConfig import io.github.lemcoder.kotlin.KonanDistribution +import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget import java.io.File import java.util.jar.Attributes @@ -76,10 +76,12 @@ class KonanPluginFunctionalTest { val output = runner("konanConfig", "-Pkonan.config.jextract=/from/property/jextract", "-Pkonan.config.zig=/from/property/zig") .withEnvironment(System.getenv() + mapOf("KONAN_ZIG" to "/from/env/zig", "KONAN_CARGO" to "/from/env/cargo")) .build().output + // The report aligns its columns to the longest field name; what is compared is the content. + .replace(Regex("[ \\t]+"), " ") - assertContains(output, "jextract /from/dsl/jextract (build script) β€” not needed here") - assertContains(output, "zig /from/property/zig (Gradle property konan.config.zig) β€” not needed here") - assertContains(output, "cargo /from/env/cargo (environment variable KONAN_CARGO) β€” not needed here") + assertContains(output, "jextract /from/dsl/jextract (build script) β€” not needed here") + assertContains(output, "zig /from/property/zig (Gradle property konan.config.zig) β€” not needed here") + assertContains(output, "cargo /from/env/cargo (environment variable KONAN_CARGO) β€” not needed here") assertContains(output, "needed by :cmakeBuildMymathLinux_x64") } @@ -216,6 +218,95 @@ class KonanPluginFunctionalTest { assertTrue(File(projectDir, "build/konan/bindings/mymath/jni/lib/${host.konanName}/${host.sharedLibraryName("examplestubs")}").isFile, result.output) } + /** An NDK with the files the plugin hands over, empty: what matters is that a build is given them. */ + private fun fakeNdk(): File { + val ndk = File(projectDir, "ndk") + val tag = when (HostOs.current) { HostOs.MACOS -> "darwin-x86_64"; HostOs.LINUX -> "linux-x86_64"; HostOs.WINDOWS -> "windows-x86_64" } + val bin = ndk.resolve("toolchains/llvm/prebuilt/$tag/bin").apply { mkdirs() } + listOf("aarch64-linux-android26-clang", "aarch64-linux-android26-clang++", "llvm-ar", "llvm-ranlib") + .forEach { bin.resolve(it).apply { writeText("#!/bin/sh\n"); setExecutable(true) } } + ndk.resolve("build/cmake").mkdirs() + ndk.resolve("build/cmake/android.toolchain.cmake").writeText("# fake\n") + return ndk + } + + @Test + fun `an Android external build gets the NDK konan config names`() { + assumeTrue(File("/bin/sh").canExecute(), "needs /bin/sh") + val ndk = fakeNdk() + write( + """id("io.github.lemcoder.konanplugin")""", + """ + config.androidNdk.set(file("${ndk.absolutePath}")) + androidApiLevel.set(26) + libraries { + create("mymath") { + targets(NativeTarget.ANDROID_ARM64) + external { + toolchain.set(io.github.lemcoder.dsl.builds.ExternalToolchain.AUTO) + step { + executable.set(Executable.File(file("/bin/sh"))) + arguments.add("build.sh") + workingDirectory.set(layout.projectDirectory) + } + } + } + } + """, + ) + File(projectDir, "build.sh").writeText("printf '%s|%s|%s' \"\$CC\" \"\$AR\" \"\$ANDROID_NDK_HOME\" > \"\$KONAN_OUTPUT_DIR/lib\$KONAN_LIB_NAME.a\"\n") + + val result = runner("compileNativeLibraryMymath").build() + + val (cc, ar, home) = File(projectDir, "build/konan/libraries/mymath/android_arm64/libmymath.a").readText().split('|') + assertTrue(cc.endsWith("/bin/aarch64-linux-android26-clang") && cc.startsWith(ndk.absolutePath), "CC: $cc\n${result.output}") + assertTrue(ar.endsWith("/bin/llvm-ar"), "AR: $ar") + assertEquals(ndk.absolutePath, home) + assertFalse("Kotlin/Native's own Android toolchain" in result.output, result.output) + } + + @Test + fun `CMake gets the NDK's toolchain file, ABI and platform`() { + val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } + assumeTrue(cmake != null, "needs cmake") + val ndk = fakeNdk() + File(projectDir, "native").mkdirs() + File(projectDir, "native/CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.16) + project(mymath NONE) + file(WRITE "${'$'}{CMAKE_ARCHIVE_OUTPUT_DIRECTORY}/lib${'$'}{KONAN_LIB_NAME}.a" "${'$'}{CMAKE_TOOLCHAIN_FILE}|${'$'}{ANDROID_ABI}|${'$'}{ANDROID_PLATFORM}") + """.trimIndent() + ) + write( + """id("io.github.lemcoder.konanplugin")""", + """ + config.androidNdk.set(file("${ndk.absolutePath}")) + androidApiLevel.set(26) + libraries { create("mymath") { targets(NativeTarget.ANDROID_ARM64); cmake { } } } + """, + ) + + val result = runner("compileNativeLibraryMymath").build() + + val (toolchain, abi, platform) = File(projectDir, "build/konan/libraries/mymath/android_arm64/libmymath.a").readText().split('|') + assertEquals(ndk.resolve("build/cmake/android.toolchain.cmake").absolutePath, toolchain, result.output) + assertEquals("arm64-v8a", abi) + assertEquals("android-26", platform) + } + + @Test + fun `an Android external build without an NDK says it uses Kotlin-Native's toolchain`() { + write( + """id("io.github.lemcoder.konanplugin")""", + """libraries { create("mymath") { targets(NativeTarget.ANDROID_ARM64); cmake { } } }""", + ) + + val result = runner("compileNativeLibraryMymath", "--dry-run").build() + + assertTrue("with Kotlin/Native's own Android toolchain (NDK r20, clang 8)" in result.output, result.output) + } + @Test fun `prebuilt archives are placed where every provider puts them`() { File(projectDir, "libs").mkdirs() From 77ac3cffa791b12ce4628c52e66cf50f4309eb66 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 13:21:11 +0100 Subject: [PATCH 35/57] feat: Android libraries are built with the Android NDK, which they require MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin built Android libraries with Kotlin/Native's toolchain: its clang 21, but NDK r20's sysroot, libc++ and LLD 8, with sysroots only up to API 29. An archive built with a current NDK β€” what llama.cpp and whisper.cpp need β€” then linked against an older libc++ than it was compiled for. konan.config.androidNdk (or ANDROID_NDK_HOME) is now required for Android targets and used by every Android build: the default library build compiles and archives with the NDK's -clang and llvm-ar; the JNI and FFM links compile the stub with its C driver and link with its C++ driver, -static-libstdc++ (libc++, libc++abi and libunwind inside each library, no libc++_shared) and -Wl,--no-undefined; external builds on AUTO get its compilers, failing rather than falling back. Only an external build with toolchain = KONAN still uses Kotlin/Native's. androidApiLevel reaches every API level the NDK has. The tasks read the NDK as a config value checked when they run, and track its path and revision as an input. Kotlin/Native's Android runtime archives (androidRuntime) and sysroot override (androidSysroot) are gone. A test compiles C++ needing a newer libc++ than r20's and C calling an API 30 function, and checks the library was linked by the NDK's LLD without libc++_shared; a build without an NDK fails saying how to set one. scripts/run-android.sh uses the SDK's newest NDK when ANDROID_NDK_HOME is unset. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 25 +++-- README.md | 44 ++++---- examples/README.md | 3 +- examples/android/README.md | 12 +- examples/kmp/README.md | 5 +- scripts/run-android.sh | 11 +- .../io/github/lemcoder/android/AndroidNdk.kt | 79 +++++++++++++ .../dsl/bindings/LinkSharedLibraryTask.kt | 104 ++++++++++++------ .../dsl/bindings/ffm/FfmLibraryBuild.kt | 2 + .../dsl/bindings/jni/KonanLinkStubBuild.kt | 2 + .../dsl/builds/AndroidNdkToolchain.kt | 51 ++------- .../lemcoder/dsl/builds/ExternalBuild.kt | 25 ++--- .../github/lemcoder/dsl/config/KonanConfig.kt | 14 ++- .../dsl/libraries/CompileNativeLibraryTask.kt | 34 ++++-- .../lemcoder/dsl/libraries/KonanClangBuild.kt | 9 +- .../lemcoder/kotlin/KonanDistribution.kt | 48 +------- .../lemcoder/KonanPluginFunctionalTest.kt | 63 ++++++++--- 17 files changed, 324 insertions(+), 207 deletions(-) create mode 100644 src/main/kotlin/io/github/lemcoder/android/AndroidNdk.kt diff --git a/CLAUDE.md b/CLAUDE.md index a24b9db..4cfb624 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ resolved the moment the plugin is applied, in *every* project that applies it. their contract (`ExternalVariable`), `selectOne`, and the Zig and Rust target triples. - **What no single block owns stays outside `dsl`**: `model/` the shared domain (`NativeTarget`, `HostOs`, `Executable`, `taskName`), `kotlin/` the touchpoints with Kotlin (distribution, KGP, Xcode's compiler-rt for - its links, the index exporter client), `index/` the generate task, `wiring/` AGP and plugin ids, `util/` + its links, the index exporter client), `android/` the Android NDK every Android build uses, `index/` the generate task, `wiring/` AGP and plugin ids, `util/` process and Gradle helpers. Task names come from `taskName(...)`, OS checks from `HostOs`, the distribution's layout from `KonanDistribution` β€” add to those, don't re-derive. `BindingVisibility` lives in `src/exporterProtocol` so the DSL and the generator share one enum. @@ -136,17 +136,18 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t - **Link flags live in `KonanDistribution.linkArguments`**: `-fuse-ld=lld` for Linux (the GCC toolchain's `ld.bfd` is a Linux binary; a cross link from macOS fails without it), NDK compiler-rt and `--unwindlib=none` for Android, Xcode's compiler-rt for macOS. The wrappers add them only when linking. -- **Android has no libc++ and no unwinder for apps, but Kotlin/Native's NDK does**: `libc++_static.a` and - `libc++abi.a` in its toolchain's `sysroot/usr/lib//`, the unwinder in `lib/gcc//4.9.x/libgcc_real.a` - (not in the sysroot, which is why it looked missing). `KonanDistribution.androidRuntime` lists them; - `LinkSharedLibraryTask` appends them last β€” the NDK's linker resolves archives in order β€” and links - with `-Wl,--no-undefined`. Without that flag a missing symbol linked fine and failed only on the device - ("cannot locate symbol"); never drop it to make a link pass. -- **An Android external build on `AUTO` uses `konan.config.androidNdk` when it is set** (`AndroidNdkToolchain`: - the NDK's per-API clang drivers, its CMake toolchain file with `ANDROID_ABI`/`ANDROID_PLATFORM`), else - Kotlin/Native's NDK r20 / clang 8 with a warning β€” never silently (#6). The NDK's clang prefix for 32-bit - ARM is `armv7a-linux-androideabi`, its library directory `arm-linux-androideabi`. The plugin's own links stay - on Kotlin/Native's toolchain. +- **Android requires an NDK (`konan.config.androidNdk`), and everything Android uses it** β€” `android/AndroidNdk`: + the default library build (`CompileNativeLibraryTask`), the JNI and FFM links (`LinkSharedLibraryTask`), and + external builds on `AUTO` (`AndroidNdkToolchain`). Kotlin/Native's Android toolchain is clang 21 driving NDK + r20's sysroot, libc++ and LLD 8, with sysroots only up to API 29; mixing it with archives built by a current NDK + is what broke koinference. Only an external build with `toolchain = KONAN` still uses it. The tasks read the NDK + as a `ConfigValue` checked when they run, and track its path and `Pkg.Revision` as an input. +- **An Android link compiles the stub with the NDK's C driver, then links with its C++ driver**: + `-static-libstdc++` puts libc++, libc++abi and libunwind in the library (Android gives apps none), and + `-Wl,--no-undefined` makes a missing symbol fail the link β€” without it, it linked fine and failed only on + the device ("cannot locate symbol"); never drop it to make a link pass. The stub is C: `clang++` would + compile a `.c` as C++, where `(*env)->…` does not compile. The NDK's clang prefix for 32-bit ARM is + `armv7a-linux-androideabi`, its library directory `arm-linux-androideabi`. - **The toolchain needs its own `ranlib`** (`llvm-ar s`): CMake indexes static archives with the host's, and Apple's `ranlib` silently drops ELF objects β€” a cross-built archive came out empty. - **`external { }` defaults to `ExternalToolchain.SYSTEM`**, the presets to `AUTO`: generic steps may not diff --git a/README.md b/README.md index e299db3..b9a562a 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ yourself β€” in Kotlin, or over your own build of the library for the web. | Property | Type | Default | Description | |-------------------|-------|---------|-------------| -| `androidApiLevel` | `Int` | the module's lowest `minSdk`, else Kotlin/Native's, 21 | The API level whose sysroot the plugin's Android links use, so platform libraries such as `libaaudio` or `libcamera2ndk` link. Kotlin/Native's NDK stops at 29; a higher level uses the highest it has. | +| `androidApiLevel` | `Int` | the module's lowest `minSdk`, else 21 | The API level Android libraries are compiled and linked for, with the NDK's matching clang driver, so platform libraries such as `libaaudio` or `libcamera2ndk` link. A level outside the NDK's range uses its nearest. | | `config { }` | | | The paths the plugin needs from the machine; below. | ### `config { }` @@ -116,7 +116,7 @@ from the build script, else the Gradle property `konan.config.` (the buil | `make` | `konan.config.make` | `KONAN_MAKE` | `make { }` | | `cargo` | `konan.config.cargo` | `KONAN_CARGO` | `cargo { }` | | `zig` | `konan.config.zig` | `KONAN_ZIG` | `zig { }` | -| `androidNdk` | `konan.config.androidNdk` | `ANDROID_NDK_HOME` | optional: Android targets of external builds on `AUTO` (below) | +| `androidNdk` | `konan.config.androidNdk` | `ANDROID_NDK_HOME` | every Android target: the plugin's own compiles and links, and external builds on `AUTO` (below) | ```kotlin konan { @@ -269,7 +269,7 @@ Linux, its NDK sysroot on Android): | `toolchain` | Meaning | |---------------|---------| -| `AUTO` | Kotlin/Native's when cross-compiling, the system's for the build host β€” and for Android, the NDK in `konan.config.androidNdk` when one is set. The default of `make { }`, `cmake { }` and `cargo { }`. | +| `AUTO` | Kotlin/Native's when cross-compiling, the system's for the build host β€” and for Android, the NDK in `konan.config.androidNdk`, which it then requires. The default of `make { }`, `cmake { }` and `cargo { }`. | | `KONAN` | Always Kotlin/Native's. | | `SYSTEM` | Never: the build finds its own. The default of `external { }`, whose steps are the build script's own. | @@ -279,15 +279,12 @@ as `-DCMAKE_TOOLCHAIN_FILE` unless it uses a preset β€” a toolchain file in `arg say. They are written per target by `konanToolchain` under `build/konan/toolchains//`: `cc` and `c++` wrap the clang `run_konan` would run, with the target's link flags when linking. -**Android: your NDK, or Kotlin/Native's.** Kotlin/Native's Android toolchain is NDK r20 with clang 8 β€” enough -for C, old for modern C++ (llama.cpp, whisper.cpp), and its libc++ must match whatever the archives are -linked against. Set `konan.config.androidNdk` (or `ANDROID_NDK_HOME`) and every Android target on `AUTO` -builds with that NDK instead: its `-clang` drivers, `llvm-ar` and `llvm-ranlib` as `CC`, `CXX`, -`AR` and `RANLIB`; `cmake { }` gets its `android.toolchain.cmake` with `ANDROID_ABI` and `ANDROID_PLATFORM` -(`androidApiLevel`); and `ANDROID_NDK_HOME` is in the environment, for presets that read it. Without one, -such a build warns that it uses Kotlin/Native's; `toolchain.set(KONAN)` keeps it, without the warning. The -plugin's own links (the JNI library, the library FFM loads) keep Kotlin/Native's toolchain either way: -a binding over a library built with a newer NDK links it in your build too (`jni { cmake { } }`). +**Android builds with your NDK.** An external build's Android targets on `AUTO` get the NDK in +`konan.config.androidNdk` (or `ANDROID_NDK_HOME`): its `-clang` drivers, `llvm-ar` and +`llvm-ranlib` as `CC`, `CXX`, `AR` and `RANLIB`; for `cmake { }` its `android.toolchain.cmake` with +`ANDROID_ABI` and `ANDROID_PLATFORM` (`androidApiLevel`); and `ANDROID_NDK_HOME` in the environment, for +presets that read it. Kotlin/Native's own Android toolchain β€” NDK r20's sysroot, libc++ and linker β€” is used +only by a build that asks for it with `toolchain.set(KONAN)`. #### Archives built elsewhere β€” `prebuilt { }` ```kotlin @@ -576,15 +573,20 @@ with no stub. The app needs: `examples/android` runs FFM and JNI side by side on an emulator. -#### Android's C++ runtime and unwinder -Android gives apps neither libc++ nor an unwinder, so every Android library the plugin links β€” the JNI -library, the library FFM loads β€” ends with the NDK's `libc++_static`, `libc++abi` and its unwinder -(`libgcc_real`), from Kotlin/Native's own NDK. A C library links as before: an archive only contributes -what is used. C++ code (`new`, `std::string`, exceptions) and Rust's std then load on a device. The links -also pass `-Wl,--no-undefined`, so a symbol nothing provides fails the build, not the app when it loads -the library; a library that resolves symbols from another `.so` at load time names it in -`linkerOptions` (`-lfoo`), or turns the check off with `-Wl,-z,undefs`. Your own build (`cmake { }`, -`make { }`, `external { }`) links its own runtime. +#### Android: the NDK, and its C++ runtime +Every Android library the plugin builds β€” the default library build, the JNI library, the library FFM +loads β€” is compiled and linked with the Android NDK in `konan.config.androidNdk` (r23 or newer), which +Android targets require: `konan.config.androidNdk=/ndk/` in +`~/.gradle/gradle.properties`, or `ANDROID_NDK_HOME`. One NDK for everything, so an archive your own build +made with it links against the same libc++, and `androidApiLevel` reaches every API level the NDK has. + +Android gives apps neither libc++ nor an unwinder, so the links use `-static-libstdc++`: the NDK's libc++, +libc++abi and libunwind go inside each library, and nothing needs `libc++_shared.so`. A C library links as +before. C++ code (`new`, `std::string`, exceptions) and Rust's std then load on a device. The links also +pass `-Wl,--no-undefined`, so a symbol nothing provides fails the build, not the app when it loads the +library; a library that resolves symbols from another `.so` at load time names it in `linkerOptions` +(`-lfoo`), or turns the check off with `-Wl,-z,undefs`. Archives Kotlin/Native's toolchain built still link: +the NDK's libc++ is the newer one. ## Tasks | Task | Description | diff --git a/examples/README.md b/examples/README.md index be33b3a..267d9a1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,7 +16,8 @@ Every example builds the plugin from source via `includeBuild`, with the reposit Each example sets `konan { config { konanHome.set(kotlinNativeDistribution()) } }`: the Kotlin/Native version the plugin needs, where the Kotlin plugin installs it. `android` also needs -`konan.config.cargo` and `konan.config.jextract`, e.g. in `~/.gradle/gradle.properties`. +`konan.config.cargo` and `konan.config.jextract`, e.g. in `~/.gradle/gradle.properties`. `android` and +`kmp` build for Android, which needs an Android NDK: `ANDROID_NDK_HOME`, or `konan.config.androidNdk`. No example needs a JDK's `jni.h`: the plugin ships one for desktop links, and Android's comes from the NDK sysroot. diff --git a/examples/android/README.md b/examples/android/README.md index 65e45a7..8c574ca 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -25,9 +25,10 @@ rustmath.rust_count_vowels("Kotlin Native") // Rust, JNI cpp.cpp_median(arena.allocateFrom(JAVA_INT, 5, 1, 4, 2), 4) // C++, FFM ``` -The Rust and C++ libraries need runtimes Android does not give apps β€” Rust's std its unwinder, C++ libc++ -and the unwinder. The plugin's Android links carry them from Kotlin/Native's NDK, and fail the build if -anything is left undefined. +Everything Android is built with your NDK (`konan.config.androidNdk` or `ANDROID_NDK_HOME`): the C and +C++ compiles, cargo's C compilers, and the links. The Rust and C++ libraries need runtimes Android does not +give apps β€” Rust's std its unwinder, C++ libc++ and the unwinder β€” which the links put inside each library +(`-static-libstdc++`), failing the build if anything is left undefined. FFM sets what the whole app needs: `compileSdk` 37 and `minSdk` 26 (PanamaPort), Java 21 sources (jextract), and 64-bit ABIs only β€” here `arm64-v8a`, phones and Apple-silicon emulators. @@ -36,8 +37,9 @@ FFM sets what the whole app needs: `compileSdk` 37 and `minSdk` 26 (PanamaPort), ```bash rustup target add aarch64-linux-android -../../gradlew -p . installDebug -Pkonan.config.cargo=$HOME/.cargo/bin/cargo -Pkonan.config.jextract=$(which jextract) -# or, from the repository root, build, install and launch: +ANDROID_NDK_HOME=/ndk/ \ + ../../gradlew -p . installDebug -Pkonan.config.cargo=$HOME/.cargo/bin/cargo -Pkonan.config.jextract=$(which jextract) +# or, from the repository root, build, install and launch (it uses the SDK's newest NDK unless ANDROID_NDK_HOME is set): KONAN_CARGO=$HOME/.cargo/bin/cargo KONAN_JEXTRACT=$(which jextract) scripts/run-android.sh ``` diff --git a/examples/kmp/README.md b/examples/kmp/README.md index d79d3ce..b74a6e5 100644 --- a/examples/kmp/README.md +++ b/examples/kmp/README.md @@ -74,8 +74,9 @@ docker run --rm --platform linux/amd64 -v "$PWD/shared/build/bin/linuxX64":/kn u ../../gradlew -p . :androidApp:installDebug ``` -Needs Xcode for the iOS and macOS targets, and an Android SDK (`local.properties` or `ANDROID_HOME`). Kotlin/Native's -distribution brings the NDK sysroot the Android library is built against. +Needs Xcode for the iOS and macOS targets, an Android SDK (`local.properties` or `ANDROID_HOME`), and an +Android NDK for the Android library: `ANDROID_NDK_HOME=/ndk/`, or +`konan.config.androidNdk` in `~/.gradle/gradle.properties`. From Swift, the common API is in the framework: diff --git a/scripts/run-android.sh b/scripts/run-android.sh index 3e19ae5..64397b3 100755 --- a/scripts/run-android.sh +++ b/scripts/run-android.sh @@ -4,7 +4,8 @@ # # The Android example (examples/android) is a standalone Gradle build that builds the plugin # from this checkout (includeBuild), run with the repository's wrapper. It needs -# konan.config.cargo and konan.config.jextract, e.g. in ~/.gradle/gradle.properties. +# konan.config.cargo and konan.config.jextract, e.g. in ~/.gradle/gradle.properties, and an Android +# NDK: ANDROID_NDK_HOME, else the SDK's newest. # # Usage: scripts/run-android.sh [options] # @@ -71,6 +72,14 @@ ADB="$SDK/platform-tools/adb" [[ -x "$ADB" ]] || ADB="$(command -v adb || true)" [[ -n "$ADB" && -x "$ADB" ]] || die "adb not found under $SDK/platform-tools." +# --- Android NDK ------------------------------------------------------------ +# The plugin requires one for Android (konan.config.androidNdk, else ANDROID_NDK_HOME) and never looks for +# it; this script does, for convenience: the SDK's newest, unless one is already named. +if [[ -z "${ANDROID_NDK_HOME:-}" && -d "$SDK/ndk" ]]; then + ndk="$(ls -1 "$SDK/ndk" | sort -V | tail -1)" + [[ -n "$ndk" ]] && export ANDROID_NDK_HOME="$SDK/ndk/$ndk" && echo "Using ANDROID_NDK_HOME=$ANDROID_NDK_HOME" +fi + # --- device selection ------------------------------------------------------- connected_devices() { "$ADB" devices | awk 'NR>1 && $2=="device" {print $1}' diff --git a/src/main/kotlin/io/github/lemcoder/android/AndroidNdk.kt b/src/main/kotlin/io/github/lemcoder/android/AndroidNdk.kt new file mode 100644 index 0000000..9c5843d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/android/AndroidNdk.kt @@ -0,0 +1,79 @@ +package io.github.lemcoder.android + +import io.github.lemcoder.dsl.config.ConfigValue +import io.github.lemcoder.model.Architecture +import io.github.lemcoder.model.HostOs +import io.github.lemcoder.model.NativeTarget +import org.gradle.api.InvalidUserDataException +import java.io.File +import java.util.Properties + +/** + * An installed Android NDK, `konan.config.androidNdk`: what the plugin compiles and links every Android + * library with β€” the default library build, the JNI library, the library FFM loads β€” and what external + * builds get. Its LLVM toolchain's per-API clang drivers carry the NDK's own sysroot and C++ runtime, so + * nothing of Kotlin/Native's (NDK r20) reaches an Android library. + */ +internal class AndroidNdk private constructor(val home: File) { + private val bin = home.resolve("toolchains/llvm/prebuilt/${hostTag()}/bin") + private val windows = HostOs.current == HostOs.WINDOWS + + /** `Pkg.Revision` from `source.properties`, e.g. `29.0.14206865`: what a task's up-to-date check tracks. */ + val revision: String by lazy { + val properties = home.resolve("source.properties") + if (!properties.isFile) "unknown" else Properties().apply { properties.inputStream().use(::load) }.getProperty("Pkg.Revision", "unknown") + } + + /** + * The clang driver for [target] at [apiLevel] β€” the C driver, or with [cxx] the C++ one, which links + * libc++. An API level outside the NDK's range is clamped to it: below its lowest the NDK cannot build, + * above its highest the newest it has is used. + */ + fun clang(target: NativeTarget, apiLevel: Int, cxx: Boolean = false): File { + val triple = compilerTriple(target) + val levels = bin.listFiles().orEmpty().mapNotNull { Regex("^${Regex.escape(triple)}(\\d+)-clang$").find(it.nameWithoutExtension)?.groupValues?.get(1)?.toInt() } + if (levels.isEmpty()) fail("has no clang for ${target.konanName} in ${bin.relativeTo(home)}") + val level = apiLevel.coerceIn(levels.min(), levels.max()) + return tool("$triple$level-clang${if (cxx) "++" else ""}${if (windows) ".cmd" else ""}") + } + + val llvmAr: File get() = tool("llvm-ar${if (windows) ".exe" else ""}") + val llvmRanlib: File get() = tool("llvm-ranlib${if (windows) ".exe" else ""}") + + val cmakeToolchainFile: File + get() = home.resolve("build/cmake/android.toolchain.cmake").also { if (!it.isFile) fail("has no build/cmake/android.toolchain.cmake") } + + private fun tool(name: String): File = bin.resolve(name).also { if (!it.isFile) fail("has no ${it.relativeTo(home)}") } + + private fun fail(problem: String): Nothing = + throw InvalidUserDataException("konan.config.androidNdk $home $problem; is it an NDK (r23 or newer)?") + + companion object { + /** The NDK [value] names, checked to exist: when it is unset, the error says which task needs it and how to set it. */ + fun of(value: ConfigValue): AndroidNdk = AndroidNdk(value.require()) + + /** The NDK's path and revision, or null when unset: an Android task's input. */ + fun identity(value: ConfigValue): String? = value.raw?.let { raw -> "$raw@${AndroidNdk(File(raw)).revision}" } + + /** The NDK's clang driver prefix: armv7a for 32-bit ARM, whose libraries are arm-linux-androideabi. */ + fun compilerTriple(target: NativeTarget): String { + require(target.isAndroid) { "${target.konanName} is not an Android target" } + return when (target.architecture) { + Architecture.ARM64 -> "aarch64-linux-android" + Architecture.ARM32 -> "armv7a-linux-androideabi" + Architecture.X64 -> "x86_64-linux-android" + Architecture.X86 -> "i686-linux-android" + } + } + + /** The NDK's host directory, named darwin-x86_64 even where its tools are universal binaries. */ + private fun hostTag(): String = when (HostOs.current) { + HostOs.MACOS -> "darwin-x86_64" + HostOs.LINUX -> "linux-x86_64" + HostOs.WINDOWS -> "windows-x86_64" + } + + /** The API level when the module gives none (no `minSdk`): the lowest current NDKs build for. */ + const val DEFAULT_API_LEVEL = 21 + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt index e63a448..2dde309 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt @@ -1,10 +1,13 @@ package io.github.lemcoder.dsl.bindings +import io.github.lemcoder.android.AndroidNdk import io.github.lemcoder.dsl.config.ConfigValue import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.util.execCapture +import org.gradle.api.provider.Provider +import java.io.File import javax.inject.Inject import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection @@ -25,9 +28,10 @@ import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault /** - * Links a target's static archive into a shared library with Kotlin/Native's clang: with [stubCFile], the - * generated JNI stub compiled against it (JNI) β€” or against [additionalLinkerArgs] alone, for a platform - * library; without, the whole archive on its own, every symbol exported (FFM). + * Links a target's static archive into a shared library β€” with Kotlin/Native's clang, and for Android with + * the NDK's ([androidNdk], required then): with [stubCFile], the generated JNI stub compiled against it + * (JNI) β€” or against [additionalLinkerArgs] alone, for a platform library; without, the whole archive on its + * own, every symbol exported (FFM). */ @DisableCachingByDefault(because = "Native link output depends on the local toolchain; cheap to rebuild") internal abstract class LinkSharedLibraryTask @Inject constructor( @@ -55,54 +59,84 @@ internal abstract class LinkSharedLibraryTask @Inject constructor( @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.NONE) abstract val staticLibrary: RegularFileProperty @get:Input @get:Optional abstract val additionalLinkerArgs: ListProperty - /** For Android targets, the API level whose sysroot to link against instead of Kotlin/Native's. */ + /** For Android targets, the API level the NDK compiles and links for. */ @get:Input @get:Optional abstract val androidApiLevel: Property + + /** The Android NDK that links Android targets; set when this is one. */ + @get:Internal abstract val androidNdk: Property + + /** Which NDK β€” path and revision β€” so another one relinks. */ + @get:Input @get:Optional + val androidNdkIdentity: Provider get() = androidNdk.map { AndroidNdk.identity(it) } @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction fun run() { - val distribution = distribution() - check(distribution.runKonan.isFile) { "run_konan not found: ${distribution.runKonan}" } val nativeTarget = target.get() - distribution.ensureDependencies(nativeTarget, exec, temporaryDir.resolve("dependencies")) - val outDir = outputDirectory.get().asFile outDir.deleteRecursively(); outDir.mkdirs() val outLib = outDir.resolve(nativeTarget.sharedLibraryName(libraryBaseName.get())) - val archive = staticLibrary.orNull?.asFile?.absolutePath - val stub = stubCFile.orNull?.asFile + val command = if (nativeTarget.isAndroid) ndkLink(nativeTarget, outLib) else konanLink(nativeTarget, outLib) + val result = exec.execCapture { commandLine(command) } + logger.lifecycle(result.output) + result.assertNormalExitValue() + logger.lifecycle("Shared library: $outLib") + } + /** Every target but Android's: Kotlin/Native's clang compiles the stub and links in one call. */ + private fun konanLink(nativeTarget: NativeTarget, outLib: File): List { + val distribution = distribution() + check(distribution.runKonan.isFile) { "run_konan not found: ${distribution.runKonan}" } + distribution.ensureDependencies(nativeTarget, exec, temporaryDir.resolve("dependencies")) val arguments = buildList { add("-shared") - includeDirs.files.filter { it.exists() }.forEach { add("-I${it.absolutePath}") } - includePaths.getOrElse(emptyList()).forEach { add("-I$it") } - // On Android jni.h comes from the NDK sysroot. - if (!nativeTarget.isAndroid) jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } + addAll(includeArguments()) + jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } addAll(distribution.linkArguments(nativeTarget, exec)) - // A symbol nothing provides fails here, not as "cannot locate symbol" when the app loads the library. - if (nativeTarget.isAndroid) add("-Wl,--no-undefined") - // After run_konan's own --sysroot, so this one wins. - if (nativeTarget.isAndroid) androidApiLevel.orNull?.let { add("--sysroot=${distribution.androidSysroot(nativeTarget, it, exec)}") } - if (stub != null) { - add(stub.absolutePath) - archive?.let(::add) - } else { - val whole = checkNotNull(archive) { "Nothing to link: neither an archive nor a JNI stub" } - if (nativeTarget.family.isApple) add("-Wl,-force_load,$whole") - else { add("-Wl,--whole-archive"); add(whole); add("-Wl,--no-whole-archive") } - } + addAll(inputs(nativeTarget, stubCFile.orNull?.asFile?.absolutePath)) add("-o"); add(outLib.absolutePath) - // A C++ runtime, extra -L, frameworks: all caller-supplied. Adding konan's own NDK lib - // dir here would pull its static libc in ahead of the sysroot's shared one, which fails - // on x86_64 with "relocation R_X86_64_PC32 ... recompile with -fPIC". + // A C++ runtime, extra -L, frameworks: all caller-supplied. addAll(additionalLinkerArgs.getOrElse(emptyList())) - // Last, after everything that may need them: the NDK's linker resolves archives in order. - if (nativeTarget.isAndroid) distribution.androidRuntime(nativeTarget).forEach { add(it.absolutePath) } } + return distribution.clang(nativeTarget, arguments) + } - val result = exec.execCapture { commandLine(distribution.clang(nativeTarget, arguments)) } - logger.lifecycle(result.output) - result.assertNormalExitValue() - logger.lifecycle("Shared library: $outLib") + /** + * Android: the NDK compiles the stub as C, then its C++ driver links β€” with `-static-libstdc++`, so + * libc++, libc++abi and libunwind are inside the library, which Android gives apps none of β€” and + * `--no-undefined`, so a symbol nothing provides fails here, not as "cannot locate symbol" on the device. + * The NDK's sysroot has `jni.h`. + */ + private fun ndkLink(nativeTarget: NativeTarget, outLib: File): List { + val ndk = AndroidNdk.of(androidNdk.get()) + val apiLevel = androidApiLevel.getOrElse(AndroidNdk.DEFAULT_API_LEVEL) + val stub = stubCFile.orNull?.asFile?.let { source -> + val objectFile = temporaryDir.resolve("${source.nameWithoutExtension}.o") + val compile = listOf(ndk.clang(nativeTarget, apiLevel).absolutePath, "-c", "-fPIC") + includeArguments() + + listOf(source.absolutePath, "-o", objectFile.absolutePath) + val result = exec.execCapture { commandLine(compile) } + logger.lifecycle(result.output) + result.assertNormalExitValue() + objectFile.absolutePath + } + return buildList { + add(ndk.clang(nativeTarget, apiLevel, cxx = true).absolutePath) + add("-shared"); add("-static-libstdc++"); add("-Wl,--no-undefined") + addAll(inputs(nativeTarget, stub)) + add("-o"); add(outLib.absolutePath) + addAll(additionalLinkerArgs.getOrElse(emptyList())) + } + } + + private fun includeArguments(): List = + includeDirs.files.filter { it.exists() }.map { "-I${it.absolutePath}" } + includePaths.getOrElse(emptyList()).map { "-I$it" } + + /** The stub (source or object) with the archive, or the whole archive on its own. */ + private fun inputs(nativeTarget: NativeTarget, stub: String?): List { + val archive = staticLibrary.orNull?.asFile?.absolutePath + if (stub != null) return listOfNotNull(stub, archive) + val whole = checkNotNull(archive) { "Nothing to link: neither an archive nor a JNI stub" } + return if (nativeTarget.family.isApple) listOf("-Wl,-force_load,$whole") + else listOf("-Wl,--whole-archive", whole, "-Wl,--no-whole-archive") } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt index 16da134..6bcc0cc 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt @@ -3,6 +3,7 @@ package io.github.lemcoder.dsl.bindings.ffm import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.bindings.BindingSpec import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.libraries.NativeArtifacts import io.github.lemcoder.dsl.libraries.PlacePrebuiltFilesTask import io.github.lemcoder.model.NativeTarget @@ -43,6 +44,7 @@ internal fun registerFfmLink( description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." dependsOn(artifacts.builtBy) androidApiLevel.set(konan.androidApiLevel) + if (target.isAndroid) androidNdk.set(konan.config.readConfigField(ConfigField.ANDROID_NDK, path)) this.target.set(target) libraryBaseName.set(libraryName) staticLibrary.set(artifacts.archive(target)) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt index 30fb5cd..bad0182 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt @@ -4,6 +4,7 @@ import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.bindings.BindingSpec import io.github.lemcoder.dsl.bindings.DefFile import io.github.lemcoder.dsl.bindings.LinkSharedLibraryTask +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.libraries.NativeArtifacts import io.github.lemcoder.index.GenerateFromNativeIndexTask import io.github.lemcoder.index.GeneratorRequest @@ -37,6 +38,7 @@ internal fun registerKonanLink( artifacts?.let { dependsOn(it.builtBy) } mustRunAfter(project.cleanTask()) androidApiLevel.set(konan.androidApiLevel) + if (target.isAndroid) androidNdk.set(konan.config.readConfigField(ConfigField.ANDROID_NDK, path)) this.target.set(target) libraryBaseName.set(jni.stubLibraryName) stubCFile.set(generate.flatMap { it.outputDirectory.file(jni.stubLibraryName.map { name -> "${GeneratorRequest.Jni.C_DIR}/$name.c" }) }) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt index cf1c206..9152a94 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt @@ -1,58 +1,29 @@ package io.github.lemcoder.dsl.builds +import io.github.lemcoder.android.AndroidNdk import io.github.lemcoder.dsl.config.ConfigValue -import io.github.lemcoder.model.Architecture -import io.github.lemcoder.model.HostOs import io.github.lemcoder.model.NativeTarget import org.gradle.api.provider.Provider -import java.io.File /** - * An installed Android NDK's compilers for one Android target, which external builds get instead of - * Kotlin/Native's when `konan.config.androidNdk` is set: the LLVM toolchain's per-API clang drivers, - * `llvm-ar` and `llvm-ranlib`, and `build/cmake/android.toolchain.cmake` with `ANDROID_ABI` and - * `ANDROID_PLATFORM`. Every file is checked when the build reads it, so a wrong NDK fails the build, not - * configuration. + * The Android NDK's compilers for one Android target, as an external build gets them: `CC`, `CXX`, `AR`, + * `RANLIB`, `ANDROID_NDK_HOME`, and for CMake `android.toolchain.cmake` with `ANDROID_ABI` and + * `ANDROID_PLATFORM`. Resolved when the build reads them, so an unset or wrong NDK fails the build. */ internal object AndroidNdkToolchain { fun files(ndk: Provider, target: NativeTarget, apiLevel: Provider): ToolchainFiles { - val home = ndk.map { it.require() } - val windows = HostOs.current == HostOs.WINDOWS - val bin = home.map { it.resolve("toolchains/llvm/prebuilt/${hostTag()}/bin") } - fun tool(name: Provider): Provider = bin.zip(name) { directory, file -> directory.resolve(file) } - .zip(home) { file, ndkHome -> file.also { check(it.isFile) { "konan.config.androidNdk $ndkHome has no ${it.relativeTo(ndkHome)}" } } } - val compiler = apiLevel.map { "${compilerTriple(target)}$it" } - val script = if (windows) ".cmd" else "" - val exe = if (windows) ".exe" else "" + val resolved = ndk.map { AndroidNdk.of(it) } return ToolchainFiles( - cc = tool(compiler.map { "$it-clang$script" }), - cxx = tool(compiler.map { "$it-clang++$script" }), - ar = tool(apiLevel.map { "llvm-ar$exe" }), - ranlib = tool(apiLevel.map { "llvm-ranlib$exe" }), - cmakeToolchainFile = home.map { ndkHome -> - ndkHome.resolve("build/cmake/android.toolchain.cmake") - .also { check(it.isFile) { "konan.config.androidNdk $ndkHome has no build/cmake/android.toolchain.cmake" } } - }, + cc = resolved.zip(apiLevel) { it, api -> it.clang(target, api) }, + cxx = resolved.zip(apiLevel) { it, api -> it.clang(target, api, cxx = true) }, + ar = resolved.map { it.llvmAr }, + ranlib = resolved.map { it.llvmRanlib }, + cmakeToolchainFile = resolved.map { it.cmakeToolchainFile }, cmakeDefinitions = mapOf( "ANDROID_ABI" to apiLevel.map { checkNotNull(target.androidAbi) }, "ANDROID_PLATFORM" to apiLevel.map { "android-$it" }, ), - androidNdk = home, + androidNdk = resolved.map { it.home }, ) } - - /** The NDK's clang driver prefix: armv7a for 32-bit ARM, whose libraries are arm-linux-androideabi. */ - private fun compilerTriple(target: NativeTarget): String = when (target.architecture) { - Architecture.ARM64 -> "aarch64-linux-android" - Architecture.ARM32 -> "armv7a-linux-androideabi" - Architecture.X64 -> "x86_64-linux-android" - Architecture.X86 -> "i686-linux-android" - } - - /** The NDK's host directory, named darwin-x86_64 even where its tools are universal binaries. */ - private fun hostTag(): String = when (HostOs.current) { - HostOs.MACOS -> "darwin-x86_64" - HostOs.LINUX -> "linux-x86_64" - HostOs.WINDOWS -> "windows-x86_64" - } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt index 84dd309..ae96a3f 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.dsl.builds +import io.github.lemcoder.android.AndroidNdk import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.kotlin.KonanToolchain @@ -67,19 +68,16 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val return output.targets.map { target -> val name = taskName(taskPrefix + output.side, output.name, target) val taskPath = if (project.path == ":") ":$name" else "${project.path}:$name" - // An Android target on AUTO gets the configured NDK; without one, Kotlin/Native's β€” and is told so. - val ndk = konan.config.resolve(ConfigField.ANDROID_NDK, taskPath) - .takeIf { target.isAndroid && toolchain == ExternalToolchain.AUTO && it.get().raw != null } - val konanToolchain = toolchain?.takeIf { ndk == null && it.usesKonan(target) }?.let { KonanToolchain.of(project, target) } - if (konanToolchain != null && target.isAndroid && toolchain == ExternalToolchain.AUTO) { - project.logger.warn( - "$taskPath builds ${target.konanName} with Kotlin/Native's own Android toolchain (NDK r20, clang 8). " + - "Set konan.config.androidNdk (or ANDROID_NDK_HOME) to build with your NDK, or the block's " + - "toolchain to KONAN to keep this one without the warning." + // An Android target on AUTO builds with the NDK in konan.config, which it then requires; only an + // explicit KONAN keeps Kotlin/Native's own Android toolchain (NDK r20). + val ndk = target.isAndroid && toolchain == ExternalToolchain.AUTO + val konanToolchain = toolchain?.takeIf { !ndk && it.usesKonan(target) }?.let { KonanToolchain.of(project, target) } + val toolchainFiles = if (ndk) { + AndroidNdkToolchain.files( + konan.config.readConfigField(ConfigField.ANDROID_NDK, taskPath), target, + konan.androidApiLevel.orElse(AndroidNdk.DEFAULT_API_LEVEL), ) - } - val toolchainFiles = ndk?.let { AndroidNdkToolchain.files(konan.config.readConfigField(ConfigField.ANDROID_NDK, taskPath), target, konan.androidApiLevel.orElse(DEFAULT_ANDROID_API)) } - ?: konanToolchain?.files + } else konanToolchain?.files val targetOutput = output.outputDirectory(target) val staging = output.archiveStaging?.invoke(target) // Where the build itself writes: the staging directory for archives, else the final one. @@ -113,9 +111,6 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val } companion object { - /** The NDK's lowest API level, when the module gives none (no `minSdk`). */ - private const val DEFAULT_ANDROID_API = 21 - /** The build system [settings] configure. */ fun of(settings: ExternalBuildSettings): ExternalBuild = when (settings) { is StepsSettings -> Generic(settings) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt index 0ddcef8..7158a44 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt @@ -51,9 +51,10 @@ abstract class KonanConfig @Inject constructor(private val providers: ProviderFa abstract val zig: RegularFileProperty /** - * An Android NDK, for the Android targets of external builds (`cmake { }`, `make { }`, `cargo { }`, - * `external { }`) whose toolchain is `AUTO`: its compilers instead of Kotlin/Native's, whose NDK is r20 - * with clang 8. Optional; unset, those builds use Kotlin/Native's and say so. + * An Android NDK (r23 or newer), required for Android targets: the plugin compiles and links every + * Android library with it β€” the default library build, the JNI library, the library FFM loads β€” and + * external builds on the `AUTO` toolchain get its compilers. Kotlin/Native's own Android toolchain (NDK + * r20) is used only by external builds that ask for it with `KONAN`. */ abstract val androidNdk: DirectoryProperty @@ -80,7 +81,12 @@ abstract class KonanConfig @Inject constructor(private val providers: ProviderFa /** [field] as [readConfigField] reads it, without counting [usedBy] as needing it. */ internal fun resolve(field: ConfigField, usedBy: String): Provider { - val hint = if (field == ConfigField.KONAN_HOME) konanHomeHint() else null + val hint = when (field) { + ConfigField.KONAN_HOME -> konanHomeHint() + ConfigField.ANDROID_NDK -> + "Android Studio installs NDKs under /ndk/ (SDK Manager β†’ SDK Tools β†’ NDK (Side by side))." + else -> null + } fun value(source: ConfigSource) = { raw: String -> ConfigValue(field, raw, source, usedBy, hint) } return property(field).map { it.asFile.absolutePath }.map(value(ConfigSource.DSL)) .orElse(providers.gradleProperty(field.gradleProperty).map(value(ConfigSource.GRADLE_PROPERTY))) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt index 39d3435..5bca8c8 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.dsl.libraries +import io.github.lemcoder.android.AndroidNdk import io.github.lemcoder.dsl.config.ConfigValue import io.github.lemcoder.kotlin.UsesKonanDistribution import io.github.lemcoder.kotlin.distribution @@ -25,9 +26,9 @@ import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault /** - * Compile C/C++ source files using the - * [`run_konan`](https://github.com/JetBrains/kotlin/blob/v1.9.0/kotlin-native/HACKING.md#running-clang-the-same-way-kotlinnative-compiler-does) - * utility. + * Compiles C/C++ sources and archives them, per target: with Kotlin/Native's clang and llvm-ar through + * [`run_konan`](https://github.com/JetBrains/kotlin/blob/v1.9.0/kotlin-native/HACKING.md#running-clang-the-same-way-kotlinnative-compiler-does), + * and for Android targets with the NDK's β€” [androidNdk], required then. */ @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native toolchain, which is not a tracked input") internal abstract class CompileNativeLibraryTask @Inject constructor( @@ -51,9 +52,19 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( @get:PathSensitive(RELATIVE) abstract val includeDirs: ConfigurableFileCollection - /** The Kotlin/Native distribution whose clang compiles. */ + /** The Kotlin/Native distribution whose clang compiles every target but Android's. */ @get:Internal abstract override val konanHome: Property + /** The Android NDK whose clang compiles Android targets; set when there is one. */ + @get:Internal abstract val androidNdk: Property + + /** Which NDK β€” path and revision β€” so another one recompiles. */ + @get:Input @get:Optional + val androidNdkIdentity: Provider get() = androidNdk.map { AndroidNdk.identity(it) } + + /** The API level Android targets compile for. */ + @get:Input @get:Optional abstract val androidApiLevel: Property + /** Targets to compile for */ @get:Input abstract val targets: ListProperty @@ -74,9 +85,16 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( @TaskAction fun compileAndLink() { val workingDir = workingDir.asFile.getOrElse(temporaryDir) - val distribution = distribution() + // Each only when a target needs it: an Android-only library needs no Kotlin/Native, a host one no NDK. + val distribution by lazy { distribution() } + val ndk by lazy { AndroidNdk.of(androidNdk.get()) } + val apiLevel = androidApiLevel.getOrElse(AndroidNdk.DEFAULT_API_LEVEL) targets.get().forEach { target -> - distribution.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) + val clang: (List) -> List = + if (target.isAndroid) { args -> listOf(ndk.clang(target, apiLevel).absolutePath) + args } else { args -> distribution.clang(target, args) } + val llvmAr: (List) -> List = + if (target.isAndroid) { args -> listOf(ndk.llvmAr.absolutePath) + args } else distribution::llvmAr + if (!target.isAndroid) distribution.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) // prepare output dirs val sourcesDir = workingDir.resolve("sources") val headersDir = workingDir.resolve("headers") @@ -118,7 +136,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( // compile files val compileResult = exec.execCapture { - commandLine(distribution.clang(target, listOf("@args"))) + commandLine(clang(listOf("@args"))) workingDir(compileDir) } @@ -155,7 +173,7 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( // link files val linkResult = exec.execCapture { - commandLine(distribution.llvmAr(listOf("@args"))) + commandLine(llvmAr(listOf("@args"))) workingDir(linkDir) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt index 0536873..f099ebe 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt @@ -1,5 +1,6 @@ package io.github.lemcoder.dsl.libraries +import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.KonanExtension import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask @@ -18,12 +19,12 @@ private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC" /** * The default library build: the sources in [NativeLibrarySpec.sourceDirectory], compiled with - * Kotlin/Native's clang and archived with its llvm-ar. + * Kotlin/Native's clang and archived with its llvm-ar β€” for Android targets, the NDK's. */ internal fun registerKonanClang(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = project.tasks.register(taskName("konanClang", library.name), CompileNativeLibraryTask::class.java) { group = "konan" - description = "Compile the '${library.name}' C/C++ sources with Kotlin/Native's clang." + description = "Compile the '${library.name}' C/C++ sources with Kotlin/Native's clang, and the Android NDK's for Android." // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives // under build/tmp β€” so `gradle clean assembleDebug` can delete it mid-compile. mustRunAfter(project.cleanTask()) @@ -33,6 +34,10 @@ internal fun registerKonanClang(project: Project, konan: KonanExtension, library sourceFiles.from(library.sourceDirectory.map { it.asFileTree }) includeDirs.from(library.sourceDirectory, library.includeDirs) libName.set(library.name) + if (library.targets.getOrElse(emptyList()).any { it.isAndroid }) { + androidNdk.set(konan.config.readConfigField(ConfigField.ANDROID_NDK, path)) + androidApiLevel.set(konan.androidApiLevel) + } arguments.addAll(DEFAULT_COMPILER_OPTIONS) arguments.addAll(library.compilerOptions) diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt index a3dd164..5ddb9f9 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -57,8 +57,9 @@ internal class KonanDistribution( * [target] β€” the one place these are known, for the plugin's own links and the toolchain it hands * to external builds: * - Linux: `lld`. The GCC toolchain's own `ld.bfd` is a Linux binary, so a cross link fails without it. - * - Android: the NDK's compiler-rt, which Kotlin/Native's LLVM lacks, and no unwinder from clang's - * default: the plugin's own links end with [androidRuntime] instead. + * - Android: the NDK's compiler-rt, which Kotlin/Native's LLVM lacks, and no unwinder. Only external + * builds that ask for Kotlin/Native's toolchain (`KONAN`) link Android this way; the plugin's own + * Android builds use the Android NDK in `konan.config`. * - macOS: Xcode's compiler-rt, which Apple-framework code needs and Kotlin/Native's LLVM lacks. */ fun linkArguments(target: NativeTarget, exec: ExecOperations): List = when (target.family) { @@ -80,25 +81,6 @@ internal class KonanDistribution( return line.removeSuffix(" --version").split(' ').filter { it.isNotEmpty() } } - /** - * [target]'s sysroot at Android API [apiLevel], or the highest below it the NDK has: the one `run_konan` - * links against, at another level. Kotlin/Native's level, 21, predates platform libraries such as - * `libaaudio`; its NDK stops at 29. - */ - fun androidSysroot(target: NativeTarget, apiLevel: Int, exec: ExecOperations): File { - val default = clangCommand(target, ClangDriver.C, exec).firstOrNull { it.startsWith("--sysroot=") } - ?.let { File(it.removePrefix("--sysroot=")) } - ?: error("run_konan passes no --sysroot for ${target.konanName}") - // /android-/arch- - val ndk = default.parentFile.parentFile - val available = ndk.list().orEmpty() - .mapNotNull { it.removePrefix("android-").takeIf { _ -> it.startsWith("android-") }?.toIntOrNull() } - .filter { ndk.resolve("android-$it").resolve(default.name).isDirectory } - val level = available.filter { it <= apiLevel }.maxOrNull() - ?: error("Kotlin/Native's NDK has no API level up to $apiLevel for ${target.konanName}; it has ${available.sorted().joinToString()}") - return ndk.resolve("android-$level").resolve(default.name) - } - /** * Downloads what [target] needs β€” its sysroot and toolchain under `~/.konan/dependencies` β€” when * missing. `run_konan` uses them but never fetches them; the compiler does when it links, so a trivial @@ -127,36 +109,12 @@ internal class KonanDistribution( fun ndkResourceDirectory(): File? = ndkToolchain()?.resolve("lib64/clang")?.listFiles { f -> f.isDirectory }?.maxByOrNull { it.name } - /** - * The archives the plugin's own Android links end with, so a library that needs them loads on a device, - * which provides neither: libc++ (`libc++_static`, `libc++abi`) for C++ code, and libgcc's unwinder - * (`libgcc_real`, the NDK's `_Unwind_*`) for C++ exceptions and Rust's std. An archive only contributes - * what is referenced, so a C library links as before. Full paths, not `-L`: the sysroot's library - * directory also holds a static libc, which would win over the shared one. - */ - fun androidRuntime(target: NativeTarget): List { - val toolchain = ndkToolchain() ?: return emptyList() - val triple = NDK_TRIPLES[target] ?: return emptyList() - return listOf( - toolchain.resolve("sysroot/usr/lib/$triple/libc++_static.a"), - toolchain.resolve("sysroot/usr/lib/$triple/libc++abi.a"), - toolchain.resolve("lib/gcc/$triple/4.9.x/libgcc_real.a"), - ).filter { it.isFile } - } - /** Kotlin/Native's NDK toolchain among its dependencies: the one with clang's resource directory. */ private fun ndkToolchain(): File? = home.parentFile.resolve("dependencies") .listFiles { f -> f.isDirectory && f.name.contains("android_ndk") && f.resolve("lib64/clang").isDirectory } ?.firstOrNull() companion object { - /** The NDK's directory names for each Android target's libraries. */ - private val NDK_TRIPLES = mapOf( - NativeTarget.ANDROID_ARM64 to "aarch64-linux-android", - NativeTarget.ANDROID_X64 to "x86_64-linux-android", - NativeTarget.ANDROID_ARM32 to "arm-linux-androideabi", - NativeTarget.ANDROID_X86 to "i686-linux-android", - ) /** * Where the Kotlin plugin installs Kotlin/Native [version] for this host: diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 29d4c89..73e6ccd 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -296,15 +296,18 @@ class KonanPluginFunctionalTest { } @Test - fun `an Android external build without an NDK says it uses Kotlin-Native's toolchain`() { + fun `an Android build without an NDK fails saying how to set one`() { write( """id("io.github.lemcoder.konanplugin")""", - """libraries { create("mymath") { targets(NativeTarget.ANDROID_ARM64); cmake { } } }""", + """libraries { create("mymath") { targets(NativeTarget.ANDROID_ARM64) } }""", ) + File(projectDir, "native").mkdirs() + File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") - val result = runner("compileNativeLibraryMymath", "--dry-run").build() + val result = runner("compileNativeLibraryMymath").withEnvironment(System.getenv() - "ANDROID_NDK_HOME").buildAndFail() - assertTrue("with Kotlin/Native's own Android toolchain (NDK r20, clang 8)" in result.output, result.output) + assertContains(result.output, "konan.config.androidNdk is not set β€” :konanClangMymath needs it.") + assertContains(result.output, "/ndk/") } @Test @@ -353,6 +356,15 @@ class KonanPluginFunctionalTest { } /** The installed Kotlin/Native distribution, for tests that really cross-compile with it. */ + /** An installed NDK, for tests that really build for Android: ANDROID_NDK_HOME, else the SDK's newest. */ + private fun realNdk(): String { + val sdk = listOfNotNull(System.getenv("ANDROID_HOME"), System.getenv("ANDROID_SDK_ROOT"), "${System.getProperty("user.home")}/Library/Android/sdk", "${System.getProperty("user.home")}/Android/Sdk") + val ndk = System.getenv("ANDROID_NDK_HOME")?.let(::File)?.takeIf { it.isDirectory } + ?: sdk.map { File(it, "ndk") }.firstOrNull { it.isDirectory }?.listFiles()?.filter { it.resolve("source.properties").isFile }?.maxByOrNull { it.name } + assumeTrue(ndk != null, "needs an Android NDK (ANDROID_NDK_HOME, or the SDK's ndk/)") + return ndk!!.absolutePath.replace("\\", "/") + } + private fun realKonanHome(): String { assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") return KonanDistribution.installed()!!.home.absolutePath.replace("\\", "/") @@ -622,29 +634,47 @@ class KonanPluginFunctionalTest { } @Test - fun `an Android link of C++ carries libc++ and the unwinder, and leaves nothing undefined`() { + fun `Android libraries compile and link with the NDK, its C++ runtime inside`() { val home = realKonanHome() + val ndk = realNdk() File(projectDir, "native").mkdirs() - // operator new, std::vector and a throw: libc++, libc++abi and _Unwind_* β€” none of which Android provides. + // Floating-point std::to_chars is C++17 libc++ that Kotlin/Native's NDK r20 does not have; operator new + // and a throw need libc++abi and the unwinder, which Android gives apps none of. File(projectDir, "native/mymath.cpp").writeText( """ - #include + #include #include - #include + #include extern "C" int my_add(int a, int b) { - std::vector values{a, b}; - try { if (values.empty()) throw std::runtime_error("empty"); } catch (const std::exception &) { return 0; } - return std::accumulate(values.begin(), values.end(), 0); + char buffer[32]; + auto end = std::to_chars(buffer, buffer + sizeof buffer, double(a + b)).ptr; + std::string text(buffer, end); + try { if (text.empty()) throw std::runtime_error("empty"); } catch (const std::exception &) { return 0; } + return std::stoi(text); } """.trimIndent() ) - val android = mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.ANDROID_ARM64)") - write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", "config.konanHome.set(file(\"$home\"))\n$android") + // AThermal_acquireManager is API 30: past the 29 Kotlin/Native's NDK stops at. + File(projectDir, "native/thermal.c").writeText( + "#include \nint my_thermal(void) { AThermalManager *m = AThermal_acquireManager(); if (m) AThermal_releaseManager(m); return m != 0; }\n" + ) + val android = mymath + .replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.ANDROID_ARM64)") + .replace("library(mymath)", "library(mymath); jni { linkerOptions.add(\"-landroid\") }") + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + "config.konanHome.set(file(\"$home\"))\nconfig.androidNdk.set(file(\"$ndk\"))\nandroidApiLevel.set(31)\n$android", + ) - // The link uses --no-undefined: without the runtime it fails here, not when the app loads the library. + // --no-undefined: a missing runtime would fail here, not when the app loads the library. val result = runner("linkJniMymathAndroid_arm64").withPluginClasspathAndKgp().build() - assertTrue(File(projectDir, "build/konan/bindings/mymath/jni/jniLibs/arm64-v8a/libexamplestubs.so").isFile, result.output) + val library = File(projectDir, "build/konan/bindings/mymath/jni/jniLibs/arm64-v8a/libexamplestubs.so") + assertTrue(library.isFile, result.output) + val readelf = File(ndk).walk().first { it.name == "llvm-readelf" && it.parentFile.name == "bin" }.absolutePath + fun readelf(vararg args: String) = ProcessBuilder(listOf(readelf) + args + library.absolutePath).start().inputStream.bufferedReader().readText() + assertContains(readelf("-p", ".comment"), "Linker: LLD", message = "linked by the NDK's LLD") + assertFalse("libc++_shared" in readelf("-d"), "the C++ runtime is static") } @Test @@ -652,8 +682,9 @@ class KonanPluginFunctionalTest { val home = realKonanHome() File(projectDir, "native").mkdirs() File(projectDir, "native/mymath.c").writeText("int my_add(int a, int b) { return a + b; }\n") + val ndk = realNdk() val hostAndAndroid = mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.${host.name}, NativeTarget.ANDROID_ARM64)") - write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", "config.konanHome.set(file(\"$home\"))\n$hostAndAndroid") + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", "config.konanHome.set(file(\"$home\"))\nconfig.androidNdk.set(file(\"$ndk\"))\n$hostAndAndroid") // Executed, not a dry run: Gradle rejects a link that reads the stub without depending on its // generator only when both run β€” and here only with more than one link. From cca6bd36838f8f72881029052ff417cdfd777536 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 13:36:55 +0100 Subject: [PATCH 36/57] feat: androidNdk defaults to the NDK the module's Android Gradle plugin uses Android targets require an NDK, but an Android module already names one: android { ndkVersion }, else AGP's default. konan.config.androidNdk now falls back to it, after the build script, the Gradle property and ANDROID_NDK_HOME, so an Android module needs nothing set. It is the project's own configuration, read through AndroidComponents.sdkComponents .ndkDirectory, not a search. AgpWiring sets it, for both the application/library plugins and the multiplatform Android library plugin. AGP throws when that NDK is not installed; the value then reads as unset, so the plugin's own error says what to set. konanConfig reports the source as the Android Gradle plugin's NDK. scripts/run-android.sh no longer exports the SDK's newest NDK, which as an environment variable would have overridden ndkVersion. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 ++++ README.md | 11 +++++++---- examples/README.md | 2 +- examples/android/README.md | 8 ++++---- examples/kmp/README.md | 6 +++--- scripts/run-android.sh | 12 ++---------- .../kotlin/io/github/lemcoder/KonanPlugin.kt | 1 + .../github/lemcoder/dsl/config/ConfigField.kt | 4 ++++ .../github/lemcoder/dsl/config/KonanConfig.kt | 17 ++++++++++++++--- .../io/github/lemcoder/wiring/AgpWiring.kt | 15 +++++++++++++++ 10 files changed, 55 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4cfb624..9d2a0aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,10 @@ unmangled method name, so a mangled internal name compiles, links, and dies on t - **Link flags live in `KonanDistribution.linkArguments`**: `-fuse-ld=lld` for Linux (the GCC toolchain's `ld.bfd` is a Linux binary; a cross link from macOS fails without it), NDK compiler-rt and `--unwindlib=none` for Android, Xcode's compiler-rt for macOS. The wrappers add them only when linking. +- **`androidNdk` falls back, last, to the module's AGP NDK** (`AndroidComponents.sdkComponents.ndkDirectory`, + set into `KonanConfig.androidGradlePluginNdk` by `AgpWiring.ndkDirectory`, source `ANDROID_GRADLE_PLUGIN`). + Not a search: it is the project's own `android.ndkVersion`. AGP throws when that NDK is not installed, so the + provider catches it and reads as unset, leaving the plugin's own "not set" error. - **Android requires an NDK (`konan.config.androidNdk`), and everything Android uses it** β€” `android/AndroidNdk`: the default library build (`CompileNativeLibraryTask`), the JNI and FFM links (`LinkSharedLibraryTask`), and external builds on `AUTO` (`AndroidNdkToolchain`). Kotlin/Native's Android toolchain is clang 21 driving NDK diff --git a/README.md b/README.md index b9a562a..4cd6ae8 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ from the build script, else the Gradle property `konan.config.` (the buil | `make` | `konan.config.make` | `KONAN_MAKE` | `make { }` | | `cargo` | `konan.config.cargo` | `KONAN_CARGO` | `cargo { }` | | `zig` | `konan.config.zig` | `KONAN_ZIG` | `zig { }` | -| `androidNdk` | `konan.config.androidNdk` | `ANDROID_NDK_HOME` | every Android target: the plugin's own compiles and links, and external builds on `AUTO` (below) | +| `androidNdk` | `konan.config.androidNdk` | `ANDROID_NDK_HOME` | every Android target: the plugin's own compiles and links, and external builds on `AUTO` (below). In an Android module, last of all the NDK the Android Gradle plugin uses | ```kotlin konan { @@ -576,9 +576,12 @@ with no stub. The app needs: #### Android: the NDK, and its C++ runtime Every Android library the plugin builds β€” the default library build, the JNI library, the library FFM loads β€” is compiled and linked with the Android NDK in `konan.config.androidNdk` (r23 or newer), which -Android targets require: `konan.config.androidNdk=/ndk/` in -`~/.gradle/gradle.properties`, or `ANDROID_NDK_HOME`. One NDK for everything, so an archive your own build -made with it links against the same libc++, and `androidApiLevel` reaches every API level the NDK has. +Android targets require. In an Android module it is the NDK the Android Gradle plugin uses β€” the one +`android { ndkVersion = "…" }` names, else AGP's default, once it is installed β€” so nothing needs setting; +`konan.config.androidNdk=/ndk/` or `ANDROID_NDK_HOME` choose another, and are what a +module without AGP sets. `konanConfig` shows which one a build uses. One NDK for everything, so an archive +your own build made with it links against the same libc++, and `androidApiLevel` reaches every API level +the NDK has. Android gives apps neither libc++ nor an unwinder, so the links use `-static-libstdc++`: the NDK's libc++, libc++abi and libunwind go inside each library, and nothing needs `libc++_shared.so`. A C library links as diff --git a/examples/README.md b/examples/README.md index 267d9a1..54f21d0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,7 +17,7 @@ Every example builds the plugin from source via `includeBuild`, with the reposit Each example sets `konan { config { konanHome.set(kotlinNativeDistribution()) } }`: the Kotlin/Native version the plugin needs, where the Kotlin plugin installs it. `android` also needs `konan.config.cargo` and `konan.config.jextract`, e.g. in `~/.gradle/gradle.properties`. `android` and -`kmp` build for Android, which needs an Android NDK: `ANDROID_NDK_HOME`, or `konan.config.androidNdk`. +`kmp` build for Android with the NDK the Android Gradle plugin uses, which must be installed. No example needs a JDK's `jni.h`: the plugin ships one for desktop links, and Android's comes from the NDK sysroot. diff --git a/examples/android/README.md b/examples/android/README.md index 8c574ca..9e74a30 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -25,7 +25,8 @@ rustmath.rust_count_vowels("Kotlin Native") // Rust, JNI cpp.cpp_median(arena.allocateFrom(JAVA_INT, 5, 1, 4, 2), 4) // C++, FFM ``` -Everything Android is built with your NDK (`konan.config.androidNdk` or `ANDROID_NDK_HOME`): the C and +Everything Android is built with the NDK the Android Gradle plugin uses (`android.ndkVersion`, else its +default; `ANDROID_NDK_HOME` or `konan.config.androidNdk` choose another): the C and C++ compiles, cargo's C compilers, and the links. The Rust and C++ libraries need runtimes Android does not give apps β€” Rust's std its unwinder, C++ libc++ and the unwinder β€” which the links put inside each library (`-static-libstdc++`), failing the build if anything is left undefined. @@ -37,9 +38,8 @@ FFM sets what the whole app needs: `compileSdk` 37 and `minSdk` 26 (PanamaPort), ```bash rustup target add aarch64-linux-android -ANDROID_NDK_HOME=/ndk/ \ - ../../gradlew -p . installDebug -Pkonan.config.cargo=$HOME/.cargo/bin/cargo -Pkonan.config.jextract=$(which jextract) -# or, from the repository root, build, install and launch (it uses the SDK's newest NDK unless ANDROID_NDK_HOME is set): +../../gradlew -p . installDebug -Pkonan.config.cargo=$HOME/.cargo/bin/cargo -Pkonan.config.jextract=$(which jextract) +# or, from the repository root, build, install and launch: KONAN_CARGO=$HOME/.cargo/bin/cargo KONAN_JEXTRACT=$(which jextract) scripts/run-android.sh ``` diff --git a/examples/kmp/README.md b/examples/kmp/README.md index b74a6e5..be72826 100644 --- a/examples/kmp/README.md +++ b/examples/kmp/README.md @@ -74,9 +74,9 @@ docker run --rm --platform linux/amd64 -v "$PWD/shared/build/bin/linuxX64":/kn u ../../gradlew -p . :androidApp:installDebug ``` -Needs Xcode for the iOS and macOS targets, an Android SDK (`local.properties` or `ANDROID_HOME`), and an -Android NDK for the Android library: `ANDROID_NDK_HOME=/ndk/`, or -`konan.config.androidNdk` in `~/.gradle/gradle.properties`. +Needs Xcode for the iOS and macOS targets, an Android SDK (`local.properties` or `ANDROID_HOME`), and the +NDK the Android Gradle plugin uses installed (SDK Manager β†’ NDK (Side by side)); `ANDROID_NDK_HOME` or +`konan.config.androidNdk` choose another. From Swift, the common API is in the framework: diff --git a/scripts/run-android.sh b/scripts/run-android.sh index 64397b3..7acac59 100755 --- a/scripts/run-android.sh +++ b/scripts/run-android.sh @@ -4,8 +4,8 @@ # # The Android example (examples/android) is a standalone Gradle build that builds the plugin # from this checkout (includeBuild), run with the repository's wrapper. It needs -# konan.config.cargo and konan.config.jextract, e.g. in ~/.gradle/gradle.properties, and an Android -# NDK: ANDROID_NDK_HOME, else the SDK's newest. +# konan.config.cargo and konan.config.jextract, e.g. in ~/.gradle/gradle.properties. The Android NDK is +# the one the Android Gradle plugin uses, unless ANDROID_NDK_HOME or konan.config.androidNdk names another. # # Usage: scripts/run-android.sh [options] # @@ -72,14 +72,6 @@ ADB="$SDK/platform-tools/adb" [[ -x "$ADB" ]] || ADB="$(command -v adb || true)" [[ -n "$ADB" && -x "$ADB" ]] || die "adb not found under $SDK/platform-tools." -# --- Android NDK ------------------------------------------------------------ -# The plugin requires one for Android (konan.config.androidNdk, else ANDROID_NDK_HOME) and never looks for -# it; this script does, for convenience: the SDK's newest, unless one is already named. -if [[ -z "${ANDROID_NDK_HOME:-}" && -d "$SDK/ndk" ]]; then - ndk="$(ls -1 "$SDK/ndk" | sort -V | tail -1)" - [[ -n "$ndk" ]] && export ANDROID_NDK_HOME="$SDK/ndk/$ndk" && echo "Using ANDROID_NDK_HOME=$ANDROID_NDK_HOME" -fi - # --- device selection ------------------------------------------------------- connected_devices() { "$ADB" devices | awk 'NR>1 && $2=="device" {print $1}' diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 7ed1c9d..c5c54f6 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -27,6 +27,7 @@ abstract class KonanPlugin : Plugin { project.withPlugin(kotlin) { konan.config.kotlinPluginVersion = KotlinGradlePlugin.version(project) } } konan.androidApiLevel.convention(AgpWiring.minSdk(project)) + AgpWiring.ndkDirectory(project, konan.config.androidGradlePluginNdk) project.registerLibraries(konan) project.registerBindings(konan) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt index fc42ad2..0a6003d 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt @@ -29,6 +29,9 @@ enum class ConfigSource(val description: String) { DSL("build script"), GRADLE_PROPERTY("Gradle property"), ENVIRONMENT("environment variable"), + + /** `androidNdk` only: the NDK the module's Android Gradle plugin uses (`android.ndkVersion`, else its default). */ + ANDROID_GRADLE_PLUGIN("the Android Gradle plugin's NDK"), } /** @@ -68,6 +71,7 @@ data class ConfigValue( ConfigSource.DSL -> "$raw (${source.description})" ConfigSource.GRADLE_PROPERTY -> "$raw (${source.description} ${field.gradleProperty})" ConfigSource.ENVIRONMENT -> "$raw (${source.description} ${field.environmentVariable})" + ConfigSource.ANDROID_GRADLE_PLUGIN -> "$raw (${source.description}, android.ndkVersion)" } private fun fail(problem: String): Nothing { diff --git a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt index 7158a44..24e760e 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt @@ -9,6 +9,8 @@ import org.gradle.api.Project import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.FileSystemLocationProperty import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.provider.ProviderFactory @@ -31,7 +33,7 @@ import org.gradle.api.provider.ProviderFactory * Values are absolute paths. All are optional until a task that needs one runs; it then fails saying * which value it needs and how to set it. `gradlew konanConfig` prints them all. */ -abstract class KonanConfig @Inject constructor(private val providers: ProviderFactory) { +abstract class KonanConfig @Inject constructor(private val providers: ProviderFactory, objects: ObjectFactory) { /** The Kotlin/Native distribution: `kotlin-native-prebuilt--`. See [kotlinNativeDistribution]. */ abstract val konanHome: DirectoryProperty @@ -54,10 +56,15 @@ abstract class KonanConfig @Inject constructor(private val providers: ProviderFa * An Android NDK (r23 or newer), required for Android targets: the plugin compiles and links every * Android library with it β€” the default library build, the JNI library, the library FFM loads β€” and * external builds on the `AUTO` toolchain get its compilers. Kotlin/Native's own Android toolchain (NDK - * r20) is used only by external builds that ask for it with `KONAN`. + * r20) is used only by external builds that ask for it with `KONAN`. In an Android module it defaults to + * the NDK the Android Gradle plugin uses (`android.ndkVersion`), after the build script, the Gradle + * property and `ANDROID_NDK_HOME`. */ abstract val androidNdk: DirectoryProperty + /** The module's Android Gradle plugin NDK, when it has one: `androidNdk`'s last fallback. Set by `AgpWiring`. */ + internal val androidGradlePluginNdk: Property = objects.property(String::class.java) + /** * Where the Kotlin plugin installs Kotlin/Native for this host: `konanHome.set(kotlinNativeDistribution())`. * The project's Kotlin version when it applies the Kotlin plugin, else the plugin's default. Computed, @@ -84,13 +91,17 @@ abstract class KonanConfig @Inject constructor(private val providers: ProviderFa val hint = when (field) { ConfigField.KONAN_HOME -> konanHomeHint() ConfigField.ANDROID_NDK -> - "Android Studio installs NDKs under /ndk/ (SDK Manager β†’ SDK Tools β†’ NDK (Side by side))." + "Android Studio installs NDKs under /ndk/ (SDK Manager β†’ SDK Tools β†’ NDK (Side by side)); " + + "in an Android module the plugin also takes the one android.ndkVersion names, once it is installed." else -> null } fun value(source: ConfigSource) = { raw: String -> ConfigValue(field, raw, source, usedBy, hint) } return property(field).map { it.asFile.absolutePath }.map(value(ConfigSource.DSL)) .orElse(providers.gradleProperty(field.gradleProperty).map(value(ConfigSource.GRADLE_PROPERTY))) .orElse(providers.environmentVariable(field.environmentVariable).map(value(ConfigSource.ENVIRONMENT))) + .let { configured -> + if (field == ConfigField.ANDROID_NDK) configured.orElse(androidGradlePluginNdk.map(value(ConfigSource.ANDROID_GRADLE_PLUGIN))) else configured + } .orElse(ConfigValue(field, null, null, usedBy, hint)) } diff --git a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt index 13fe27b..7d49771 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt @@ -1,10 +1,12 @@ package io.github.lemcoder.wiring +import com.android.build.api.variant.AndroidComponents import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.file.Directory +import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider @@ -72,6 +74,19 @@ internal object AgpWiring { * The lowest `minSdk` among the module's variants, once AGP has computed them; absent outside Android. * The API level Android links target, so they see every platform library the app may call. */ + /** + * The NDK the module's Android Gradle plugin uses β€” `android.ndkVersion`, else AGP's default β€” into + * [ndk]. Unset when there is none: no AGP, or its NDK is not installed, which AGP reports by throwing. + */ + fun ndkDirectory(project: Project, ndk: Property) { + fun from(components: AndroidComponents?) { + val directory = components?.sdkComponents?.ndkDirectory ?: return + ndk.set(project.provider { runCatching { directory.orNull?.asFile?.absolutePath }.getOrNull() }) + } + project.withPlugin(GradlePluginId.ANDROID_BASE) { from(project.extensions.findByType(AndroidComponentsExtension::class.java)) } + project.withPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) { from(project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java)) } + } + fun minSdk(project: Project): Provider { val levels = mutableSetOf() project.withPlugin(GradlePluginId.ANDROID_BASE) { From e928798205d71b2ede310fa445f05fce5d24f26d Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 13:39:22 +0100 Subject: [PATCH 37/57] docs: README features, Android NDK setup, and 2.0 migration notes The features list predated the build presets (CMake, Make, Cargo), archive merging and Android's NDK; installation gains a step for Android's NDK, which the Android Gradle plugin's usually provides; the migration notes cover the NDK requirement and merged archives; run-android.sh no longer publishes the plugin. Co-Authored-By: Claude Opus 5.5 --- README.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4cd6ae8..e4d9992 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,28 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and > This plugin is based on awesome work by [aSemy](https://gist.github.com/aSemy) πŸš€ ## Features -- Builds C/C++ libraries into a static archive per Kotlin/Native target β€” with Kotlin/Native's own - clang, or with the project's own `build.zig`. +- Builds C/C++ libraries into a static archive per target β€” with Kotlin/Native's clang (the Android NDK's + for Android), or with the project's own build: CMake, Make, Cargo for a Rust crate, `zig build`, or any + build system as a list of commands; or takes archives built elsewhere. A build that leaves several + archives (a CMake project with dependencies) is merged into one. - Binds one cinterop `.def` for every kind of Kotlin consumer the project has: - **Kotlin/Native** targets through KGP's own cinterop, the library's archive embedded in the klib; - **JVM** targets and **Android** through generated JNI bindings and a stub library per target, with - strings and primitive buffers marshalled; + strings, primitive buffers and structs marshalled; - the **JVM 22+** through jextract's FFM bindings, on request β€” and **Android** too, over [PanamaPort](https://github.com/vova7878/PanamaPort)'s backport of the FFM API. + + One project can mix them: several libraries, each bound through JNI or FFM, in plain JVM, Android and + Kotlin Multiplatform modules. JavaScript and WebAssembly targets get no bindings. +- Wires generated sources, `jniLibs`, jar resources and task order into Kotlin and Android builds; can + hand the JNI library, or the library FFM loads, to the project's own build instead. +- Builds every Android library with the Android NDK β€” the one the Android Gradle plugin uses, or one you + name β€” with its C++ runtime linked in, so C++ and Rust libraries load on a device. - Has its own target type, `NativeTarget`, one entry per Kotlin/Native target; a KGP target converts with `NativeTarget.of(kotlinNativeTarget.konanTarget)`. The plugin's API does not depend on the Kotlin compiler's. -- Wires generated sources, `jniLibs` and task order into Kotlin and Android builds; can hand the JNI - stub to the project's own build β€” CMake, or any build system as a list of commands β€” instead. -- Takes every machine path from one place, [`config { }`](#config--) β€” nothing is searched for β€” and - finds the compiler-rt builtins each target needs inside the distribution (NDK on Android, Xcode on - macOS). Ships `jni.h`, so no JDK is needed to link JNI. +- Takes every machine path from one place, [`config { }`](#config--) β€” nothing is searched for. Ships + `jni.h`, so no JDK is needed to link JNI. ## Installation 1. Apply the plugin in your `build.gradle.kts`: @@ -38,6 +44,11 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and or `konan { config { konanHome.set(kotlinNativeDistribution()) } }`, which names where the Kotlin plugin installs your project's Kotlin/Native version β€” see [Kotlin versions](#kotlin-versions). +3. For Android targets, install an NDK (r23 or newer). In an Android module the plugin uses the one the + Android Gradle plugin does β€” `android { ndkVersion = "…" }`, else AGP's default β€” so installing it + (SDK Manager β†’ SDK Tools β†’ NDK (Side by side)) is enough; elsewhere, or to choose another, set + `konan.config.androidNdk` or `ANDROID_NDK_HOME`. See [Android: the NDK](#android-the-ndk-and-its-c-runtime). + ### Kotlin versions JNI and FFM bindings are generated from cinterop's own C model, by code that runs inside the Kotlin/Native distribution `konanHome` names and calls its compiler. The plugin carries that code for @@ -613,7 +624,7 @@ On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks au - `examples/native` β€” Kotlin/Native consumer through KGP's cinterop. - `examples/jvm` β€” plain JVM app loading the JNI library bundled in its jar. - `examples/android` β€” one Android app over three libraries: C and Rust through JNI, C++ through FFM. - `scripts/run-android.sh` publishes the plugin, builds, installs and launches it in one command. + `scripts/run-android.sh` builds, installs and launches it in one command. - `examples/kmp` β€” Kotlin Multiplatform library for iOS, macOS, Linux and Android: one binding, cinterop on Kotlin/Native and JNI on Android, behind an `expect`/`actual` API, plus an Android app over it. @@ -654,6 +665,10 @@ The DSL is new and there is no compatibility shim. `library(target, file)`: `archive(…)` and `sharedLibrary(…)` are gone. - JNI bindings are named after the C functions (`my_add`), not `kniBridge0`; tasks are `generateJni…` and `linkJni…`. +- Android needs an Android NDK (r23 or newer), which builds every Android library: in an Android module the + one the Android Gradle plugin uses, else `konan.config.androidNdk` or `ANDROID_NDK_HOME`. Kotlin/Native's + own Android toolchain is no longer used, and `androidApiLevel` is no longer capped at 29. +- A library's build may leave several archives in `KONAN_OUTPUT_DIR`; they are merged into `lib.a`. ## License This project is licensed under the Apache 2.0 License. For more details, see the `LICENSE` file. From f40b755fca4db43e35d05d1654c547be46805695 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 14:57:32 +0100 Subject: [PATCH 38/57] fix: a .def without a package binds into one named after the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A binding whose .def had no `package =` line, and that set no packageName, failed with Gradle's generic "property 'request' doesn't have a configured value", naming neither the binding nor the fix. cinterop accepts such a .def and names the package after the file, so a .def that worked with plain cinterop failed here. The package now falls back, as cinterop's does, to the .def's file name, made a valid package name (koinference-llama.def β†’ koinference_llama). Fixes #11 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../github/lemcoder/dsl/bindings/BindingConsumer.kt | 9 +++++++-- .../io/github/lemcoder/dsl/bindings/DefFile.kt | 7 +++++++ .../io/github/lemcoder/KonanPluginFunctionalTest.kt | 12 ++++++++++++ .../io/github/lemcoder/dsl/bindings/DefFileTest.kt | 7 +++++++ 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e4d9992..5aeff89 100644 --- a/README.md +++ b/README.md @@ -356,7 +356,7 @@ A C API described by a cinterop `.def`. |-------------------|------------------|-----------------------------------------|-------------| | `defFile` | `File` | `src/nativeInterop/cinterop/.def` | cinterop's own default location. | | `library` | `NativeLibrarySpec` | β€” | One of `libraries` β€” `library(mymath)`: its archive is linked for every consumer and built first, so the `.def` needs no `staticLibraries`. | -| `packageName` | `String` | the `.def`'s `package` | Package of the generated bindings. | +| `packageName` | `String` | the `.def`'s `package`, else the `.def`'s file name, as cinterop does | Package of the generated bindings. | | `includeDirs` | `FileCollection` | β€” | Include roots beyond the `.def`'s directory. | | `compilerOptions` | `List` | β€” | Extra clang options for reading the headers. | | `jni { }` | | | How the JNI bindings are generated and linked; below. | diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt index 01e6fab..6738245 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt @@ -40,9 +40,14 @@ internal class BindingContext(val project: Project, val konan: KonanExtension, v /** The archives of the library the binding names, once the build script has been evaluated. */ fun artifacts(): NativeArtifacts? = binding.library.orNull?.let { NativeArtifacts.of(project, it) } - /** The package the bindings go in: the binding's, else the `.def`'s own. */ + /** + * The package the bindings go in: the binding's, else the `.def`'s `package`, else β€” as cinterop does β€” + * one named after the `.def` file. + */ val packageName: Provider = binding.packageName.orElse( - project.provider { binding.defFile.orNull?.asFile?.takeIf { it.isFile }?.let { DefFile.parse(it).packageName } } + project.provider { + binding.defFile.orNull?.asFile?.let { def -> def.takeIf { it.isFile }?.let { DefFile.parse(it).packageName } ?: DefFile.defaultPackage(def) } + } ) fun hasPlugin(id: GradlePluginId): Boolean = project.hasPlugin(id) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt index 54d0d67..216294d 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt @@ -30,6 +30,13 @@ internal data class DefFile( fun parse(file: File): DefFile = parse(file.readText()) + /** + * cinterop's package for a `.def` with no `package`: the file's name, as a package name β€” + * `koinference-llama.def` β†’ `koinference_llama`. + */ + fun defaultPackage(file: File): String = + file.nameWithoutExtension.replace(Regex("[^A-Za-z0-9_]"), "_").let { if (it.firstOrNull()?.isDigit() != false) "_$it" else it } + fun parse(text: String): DefFile { val properties = SEPARATOR.split(text, limit = 2)[0].lineSequence() .map { it.trim() } diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 73e6ccd..cef666d 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -677,6 +677,18 @@ class KonanPluginFunctionalTest { assertFalse("libc++_shared" in readelf("-d"), "the C++ runtime is static") } + @Test + fun `a def without a package binds into a package named after the def`() { + val home = realKonanHome() + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", "config.konanHome.set(file(\"$home\"))\n$mymath") + // As plain cinterop accepts it: no package line. + File(projectDir, "mymath.def").writeText("headers = mymath.h\n") + + val result = runner("generateJniMymath").withPluginClasspathAndKgp().build() + + assertTrue(File(projectDir, "build/generated/konan/mymath/jni/kotlin/mymath/mymath.kt").isFile, result.output) + } + @Test fun `the JNI links for the host and Android depend on generating the stub they compile`() { val home = realKonanHome() diff --git a/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt b/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt index dad96d1..3c901b3 100644 --- a/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt +++ b/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt @@ -4,6 +4,13 @@ import kotlin.test.Test import kotlin.test.assertEquals class DefFileTest { + @Test + fun `a def without a package is named after its file, as a package name`() { + assertEquals("koinference", DefFile.defaultPackage(java.io.File("src/koinference.def"))) + assertEquals("koinference_llama", DefFile.defaultPackage(java.io.File("koinference-llama.def"))) + assertEquals("_3d", DefFile.defaultPackage(java.io.File("3d.def"))) + } + @Test fun `linker options of a platform library are read before the separator`() { val def = DefFile.parse( From a172fc97dbf14b868c2b9c5d1a4ae589a6efab62 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 14:59:06 +0100 Subject: [PATCH 39/57] fix: a job count for CMake, and one external build at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every CMake build ran `cmake --build --parallel` with no job count, which CMake leaves to the generator: with Unix Makefiles, CMake's default on macOS and Linux, that is an unbounded `make -j`, one compiler per ready source. Gradle then ran several such builds at once (#9), and five llama.cpp builds exhausted 32 GB and froze the machine. cmake { } gains `jobs`, defaulting to the processor count as make { }'s does, passed as --parallel . And every external build β€” CMake, Make, Cargo, Zig, generic steps β€” now uses one shared build service with maxParallelUsages 1, so builds that each use every core never stack; the Gradle property konan.externalBuilds.maxParallel raises it. Tests: the build step's arguments carry the count; two builds that each record when they ran do not overlap by default, and do with the limit raised to 2. Fixes #10 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++ README.md | 7 +++ .../lemcoder/dsl/builds/CMakeCommands.kt | 1 + .../lemcoder/dsl/builds/CMakeSettings.kt | 7 +++ .../lemcoder/dsl/builds/ExternalBuild.kt | 2 + .../lemcoder/dsl/builds/ExternalBuildLimit.kt | 22 +++++++ .../lemcoder/KonanPluginFunctionalTest.kt | 62 +++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildLimit.kt diff --git a/CLAUDE.md b/CLAUDE.md index 9d2a0aa..61d8497 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,11 @@ resolved the moment the plugin is applied, in *every* project that applies it. `ExternalBuildTask` copies the one archive, or merges several with Kotlin/Native's llvm-ar (`qcL`, then `s`; `darwin` format for Apple) into `lib.a`. Consumers keep embedding a single archive. Staging is what keeps a re-run from merging the previous merge in again (#7). +- **External builds run one at a time** (`ExternalBuildLimit`, a shared build service with `maxParallelUsages` + 1, `konan.externalBuilds.maxParallel` to raise it), and CMake's build gets `--parallel `. Each build + already uses every core; five llama.cpp builds at once, with CMake's count-less `--parallel` turning into + an unbounded `make -j`, exhausted 32 GB and froze the machine (#10). Same-project tasks only run in + parallel with the configuration cache on, which is how the test shows the limit holds. - **An external build's configure and build steps are one task.** As two tasks, changing a configure argument re-ran configure, left the build `UP-TO-DATE`, and silently kept the previous library β€” a linker flag appeared to do nothing. `ExternalBuildTask` runs every step, and without declared diff --git a/README.md b/README.md index 5aeff89..c984eed 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,12 @@ target reuses the first one's objects. `jni { make { } }` and `ffm { make { } }` take the same block, for the libraries those load. +#### One external build at a time +Every external build β€” `cmake { }`, `make { }`, `cargo { }`, `zig { }`, `external { }` β€” already uses every +core, so the plugin runs them one at a time across the whole build, however many Gradle could run in +parallel: two `make -j` side by side only compete for memory. Set the Gradle property +`konan.externalBuilds.maxParallel` to allow more. + #### Kotlin/Native's compilers for any build β€” `toolchain` Cross-compiling with your own build needs a compiler and sysroot per target. The plugin hands over Kotlin/Native's β€” the ones its own builds use, so the archives match Kotlin/Native's ABI (glibc 2.19 on @@ -438,6 +444,7 @@ target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arriv | `buildDirectory` | `Directory` | the preset's `binaryDir` (`/build/`), else under `build/` | Where CMake builds. Set, it is passed as `-B`, overriding a preset's `binaryDir` (CMake 3.21+). | | `buildTargets` | `List` | the default target | `--target`s to build. | | `arguments` | `List` | β€” | Extra configure arguments. | +| `jobs` | `Int` | processors | `cmake --build --parallel `; without a count, Unix Makefiles runs every compile at once. | | `libraryDirectory` | `Directory` | β€” | Where the build leaves the library, if it ignores `CMAKE_LIBRARY_OUTPUT_DIRECTORY`: the plugin copies it into place. | | `target(t) { }` | | β€” | A per-target build with its own `preset`, `buildDirectory`, `arguments`. | diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt index 8476dfd..4527279 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt @@ -75,5 +75,6 @@ internal fun ExternalBuildTask.addCMakeCommands( arguments.addAll(buildDirectory.map { listOf("--build", it.asFile.absolutePath) }) arguments.addAll(cmake.buildTargets.map { targets -> targets.flatMap { listOf("--target", it) } }) arguments.add("--parallel") + arguments.add(cmake.jobs.map { it.toString() }) } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt index 25da595..6caf8ac 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt @@ -32,6 +32,7 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact init { toolchain.convention(ExternalToolchain.AUTO) + jobs.convention(Runtime.getRuntime().availableProcessors()) } /** @@ -73,6 +74,12 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact /** Extra arguments for the configure step, e.g. your own `-D` switches. */ abstract val arguments: ListProperty + /** + * `cmake --build --parallel `. Defaults to the number of processors: without a count, CMake leaves + * it to the generator, and Unix Makefiles then runs every compile at once. + */ + abstract val jobs: Property + } /** One target's build: everything not set here comes from the enclosing [CMakeSettings]. */ diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt index ae96a3f..d873c71 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt @@ -63,6 +63,7 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val fun register(project: Project, output: ExternalBuildOutput): List> { val clean = project.cleanTask() + val limit = ExternalBuildLimit.register(project) val toolchain = toolchain val konan = project.extensions.getByType(KonanExtension::class.java) return output.targets.map { target -> @@ -96,6 +97,7 @@ internal sealed class ExternalBuild(private val taskPrefix: String, private val group = "konan" description = taskDescription mustRunAfter(clean) + usesService(limit) konanToolchain?.let { dependsOn(it.task) } context.environment.forEach { (variable, value) -> this.environment.put(variable.variableName, value) } libraryFileName.set(fileName) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildLimit.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildLimit.kt new file mode 100644 index 0000000..3654855 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildLimit.kt @@ -0,0 +1,22 @@ +package io.github.lemcoder.dsl.builds + +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters + +/** + * How many external builds run at once, across the whole build: one by default. Each already uses every + * core (`make -j`, `cmake --build --parallel `, cargo, zig), so two at once only stack β€” + * five llama.cpp builds side by side exhausted 32 GB. `konan.externalBuilds.maxParallel` raises it. + */ +internal abstract class ExternalBuildLimit : BuildService { + companion object { + const val PROPERTY = "konan.externalBuilds.maxParallel" + + fun register(project: Project): Provider = + project.gradle.sharedServices.registerIfAbsent("konanExternalBuilds", ExternalBuildLimit::class.java) { + maxParallelUsages.set(project.providers.gradleProperty(PROPERTY).map { it.toInt() }.orElse(1)) + } + } +} diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index cef666d..11bfac5 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -310,6 +310,68 @@ class KonanPluginFunctionalTest { assertContains(result.output, "/ndk/") } + @Test + fun `CMake builds with a job count`() { + write( + """id("io.github.lemcoder.konanplugin")""", + """libraries { create("mymath") { targets(NativeTarget.${host.name}); cmake { jobs.set(3) } } }""", + ) + File(projectDir, "build.gradle.kts").appendText( + """ + afterEvaluate { + tasks.getByName("cmakeBuildMymath$hostSuffix").inputs.properties + .filterKeys { it.endsWith("arguments") }.values.forEach { println("ARGS=" + it) } + } + """.trimIndent() + ) + + val output = runner("help").build().output + + // Without a count, Unix Makefiles runs every compile at once. + assertTrue(output.lines().any { it.startsWith("ARGS=[--build") && it.contains("--parallel, 3") }, output) + } + + @Test + fun `external builds run one at a time unless told otherwise`() { + assumeTrue(File("/bin/sh").canExecute() && listOf("/usr/bin/python3", "/opt/homebrew/bin/python3").any { File(it).canExecute() }, "needs /bin/sh and python3") + write( + """id("io.github.lemcoder.konanplugin")""", + """ + libraries { + create("mymath") { + targets(NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) + external { + step { + executable.set(Executable.File(file("/bin/sh"))) + arguments.add("build.sh") + workingDirectory.set(layout.projectDirectory) + } + } + } + } + """, + ) + // Each build records when it ran, for a second. + File(projectDir, "build.sh").writeText( + "now() { python3 -c 'import time; print(time.time())'; }\n" + + "start=${'$'}(now); sleep 1; printf '%s %s' \"${'$'}start\" \"${'$'}(now)\" > \"${'$'}KONAN_OUTPUT_DIR/lib${'$'}KONAN_LIB_NAME.a\"\n" + ) + fun intervals(vararg arguments: String): List> { + runner("compileNativeLibraryMymath", "--configuration-cache", "--parallel", "--max-workers=4", *arguments).build() + return listOf("linux_x64", "mingw_x64").map { target -> + val (start, end) = File(projectDir, "build/konan/libraries/mymath/$target/libmymath.a").readText().split(' ').map(String::toDouble) + start..end + } + } + fun overlap(a: ClosedFloatingPointRange, b: ClosedFloatingPointRange) = a.start < b.endInclusive && b.start < a.endInclusive + + val (one, other) = intervals() + assertFalse(overlap(one, other), "one at a time by default: $one, $other") + // Raised, they do run together β€” so the default above is the limit, not chance. + val (first, second) = intervals("-Pkonan.externalBuilds.maxParallel=2") + assertTrue(overlap(first, second), "two at once when allowed: $first, $second") + } + @Test fun `prebuilt archives are placed where every provider puts them`() { File(projectDir, "libs").mkdirs() From 37f1474d6709ff713b06925782e9b45a8709cb30 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 26 Oct 2026 15:06:18 +0100 Subject: [PATCH 40/57] fix: a consumer of one target builds only that target's library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cinterop, the JNI link and the FFM library for one target all depended on compileNativeLibrary, which builds every target: a macosArm64Test built the iOS, macOS x64 and Linux archives too, all at once, and a machine that could not build one target could run no native test. With #10's unbounded make -j, five llama.cpp builds side by side froze a 32 GB machine. Each library now records its build per target (NativeLibrarySpec .buildsByTarget), and NativeArtifacts.builtBy(target) gives a consumer that target's task: the external build's per-target task, the prebuilt placement, or β€” the default build being split for this β€” one konanClang per target, whose archive directory is now a declared output. It is read lazily, when the graph is built, so libraries and bindings may be declared in either order. compileNativeLibrary stays the task that builds them all. Fixes #9 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 + README.md | 6 +- doc/interop-architecture.md | 2 +- .../dsl/bindings/ffm/FfmLibraryBuild.kt | 2 +- .../dsl/bindings/jni/KonanLinkStubBuild.kt | 2 +- .../dsl/libraries/CompileNativeLibraryTask.kt | 178 ++++++------------ .../lemcoder/dsl/libraries/KonanClangBuild.kt | 40 ++-- .../lemcoder/dsl/libraries/Libraries.kt | 5 +- .../lemcoder/dsl/libraries/LibraryBuilds.kt | 12 +- .../lemcoder/dsl/libraries/NativeArtifacts.kt | 24 ++- .../dsl/libraries/NativeLibrarySpec.kt | 4 + .../lemcoder/kotlin/KotlinGradlePlugin.kt | 2 +- .../lemcoder/KonanPluginFunctionalTest.kt | 35 +++- 13 files changed, 150 insertions(+), 167 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 61d8497..31a1f2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,6 +81,11 @@ resolved the moment the plugin is applied, in *every* project that applies it. - **AGP variant callbacks must be registered eagerly.** From `afterEvaluate` AGP refuses with *"It is too late to add actions as the callbacks already executed"*, so the jniLibs wiring runs at binding-registration time and tolerates an empty directory. +- **A consumer of one target depends on that target's build only** (`NativeArtifacts.builtBy(target)`, from + `NativeLibrarySpec.buildsByTarget`): cinterop, the JNI link and the FFM link. Depending on the aggregate + `compileNativeLibrary` made `macosArm64Test` build every target's llama.cpp at once (#9). The default + build is therefore one `konanClang` per target. `builtBy(target)` is a provider read when the + task graph is built, so the library and binding may be declared in either order. - **A library has one archive per target, however many its build leaves.** A library's external build writes into a staging directory (`KONAN_OUTPUT_DIR`, CMake's output directories), emptied before each run; `ExternalBuildTask` copies the one archive, or merges several with Kotlin/Native's llvm-ar (`qcL`, then `s`; diff --git a/README.md b/README.md index c984eed..5b212f2 100644 --- a/README.md +++ b/README.md @@ -612,8 +612,8 @@ the NDK's libc++ is the newer one. ## Tasks | Task | Description | |-------------------------------|-------------| -| `compileNativeLibrary` | Builds a library to `lib.a` per target, with whichever provider it uses. | -| `konanClang`, `cmakeBuild`, `zigBuild`, `cargoBuild`, `makeBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, make, the library's own steps, or placing prebuilt archives. | +| `compileNativeLibrary` | Builds a library to `lib.a` for every target. A consumer of one target β€” its cinterop, JNI link or FFM link β€” depends only on that target's build. | +| `konanClang`, `cmakeBuild`, `zigBuild`, `cargoBuild`, `makeBuild`, `externalBuild`, `prebuilt` | The provider doing it: Kotlin/Native's clang, `zig build`, make, the library's own steps, or placing prebuilt archives. | | `konanToolchain` | Writes Kotlin/Native's compilers for a target, for external builds that use them. | | `generateJni` | Generates a binding's JNI `.c` stub and Kotlin bindings. | | `linkJni` | Makes the binding's JNI library exist for every target, however it is built. | @@ -641,7 +641,7 @@ The DSL is new and there is no compatibility shim. - `konanConfig { targets; libName; sourceDir; … }` becomes a library: `konan { libraries { create("") { targets(…); sourceDirectory.set(…) } } }`. Its archives move from `build/native//` to `build/konan/libraries///`, and `runKonanClang` becomes - `compileNativeLibrary` (which runs `konanClang`). + `compileNativeLibrary` (which runs `konanClang` per target). - `jvmInterops { create(…) { … } }` β€” on a compilation or on the project β€” becomes `konan { bindings { create(…) { … } } }`, with the JNI-only settings in `jni { }`: `jniHome` is gone (the plugin ships `jni.h`), `library` β†’ a library with `prebuilt { library(target, file) }`, named with diff --git a/doc/interop-architecture.md b/doc/interop-architecture.md index fa01236..669706e 100644 --- a/doc/interop-architecture.md +++ b/doc/interop-architecture.md @@ -107,7 +107,7 @@ flowchart TD | 1 | **DSL** | Declares libraries, bindings, toolchains and packaging; validates; no work of its own | Build script β†’ model objects | `konan { libraries; bindings }` (section A) | | 2 | **Target model** | The plugin's own target type (OS, architecture, ABI) with mappings to every ecosystem's name | β€” | `model.NativeTarget`: one entry per Kotlin/Native target (checked by a test against the pinned version), with family, architecture, Android ABI, library naming and Zig triple; `NativeTarget.of(konanTarget)` is the only conversion | | 3 | **Toolchain** | Compile, archive and link C/C++ for one target | Sources + flags β†’ `.o`, `.a`, `.so`/`.dylib`/`.dll` | Kotlin/Native's (clang + downloaded sysroots, `KonanDistribution`), also handed to external builds per target as `CC`/`CXX`/`AR` and a CMake toolchain file (`KonanToolchain`, chosen by `ExternalToolchain`); Zig brings its own; the system's for host builds | -| 4 | **Library build** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | Kotlin/Native's clang by default (`konanClang`); **Prebuilt** (`prebuilt { library(target, file) }`, placed by `prebuilt`); or an **`ExternalBuild`** β€” one `ExternalBuildTask` per target with the `ExternalVariable` contract: **CMake** (`cmake { }`), **Make** (`make { }`), **Cargo** (`cargo { }`, a Rust `staticlib` per Rust triple), **Zig** (`zig { }`) and **Generic** (`external { step { } }`); all behind `compileNativeLibrary` | +| 4 | **Library build** | Makes a library's per-target artifacts exist | Declaration β†’ native artifacts | Kotlin/Native's clang by default (`konanClang`, the NDK's for Android); **Prebuilt** (`prebuilt { library(target, file) }`, placed by `prebuilt`); or an **`ExternalBuild`** β€” one `ExternalBuildTask` per target with the `ExternalVariable` contract: **CMake** (`cmake { }`), **Make** (`make { }`), **Cargo** (`cargo { }`, a Rust `staticlib` per Rust triple), **Zig** (`zig { }`) and **Generic** (`external { step { } }`); all behind `compileNativeLibrary` | | 5 | **Binding spec** | What to bind | `.def` + cinterop command line β†’ cinterop's `NativeIndex` | cinterop itself, in the index exporter (section B) | | 6 | **Binding generator** | Turns the spec into bindings for one kind of consumer | Index β†’ generated bindings; or a binding β†’ another tool's pipeline | Exporter generators **jni** (Kotlin + C stub), **jextract** (FFM Java), **dump** (text); the **KGP cinterop adapter** (`KotlinGradlePlugin.declareCinterops`) | | 7 | **Glue builder** | Compiles and links native glue a generator emits, such as the JNI stub | Glue sources + native artifacts + toolchain β†’ shared library | The JNI library: the plugin's link (`LinkSharedLibraryTask`) by default, or the same `ExternalBuild`s as libraries β€” **CMake**, **Make**, **Generic** β€” with `KONAN_JNI_STUB_DIR` and `KONAN_JNI_INCLUDE_DIR` added to the contract. FFM has no glue, but the shared library it loads is built the same way: the archive linked whole by default, **Prebuilt** (`ffm { prebuilt { library(…) } }`), or an `ExternalBuild`. JNI and FFM share `BindingLibrarySpec` and `BindingLibraryWiring` | diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt index 6bcc0cc..abd3a48 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt @@ -42,7 +42,7 @@ internal fun registerFfmLink( project.tasks.register(taskName("linkFfm", binding.name, target), LinkSharedLibraryTask::class.java) { group = "konan" description = "Link the '$libraryName' library for ${target.konanName}, for the FFM bindings." - dependsOn(artifacts.builtBy) + dependsOn(artifacts.builtBy(target)) androidApiLevel.set(konan.androidApiLevel) if (target.isAndroid) androidNdk.set(konan.config.readConfigField(ConfigField.ANDROID_NDK, path)) this.target.set(target) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt index bad0182..5e4f0e9 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt @@ -35,7 +35,7 @@ internal fun registerKonanLink( group = "konan" description = "Link the '$bindingName' JNI library for ${target.konanName}." dependsOn(generate) - artifacts?.let { dependsOn(it.builtBy) } + artifacts?.let { dependsOn(it.builtBy(target)) } mustRunAfter(project.cleanTask()) androidApiLevel.set(konan.androidApiLevel) if (target.isAndroid) androidNdk.set(konan.config.readConfigField(ConfigField.ANDROID_NDK, path)) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt index 5bca8c8..b405be5 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt @@ -11,7 +11,6 @@ import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.FileSystemOperations -import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.provider.Provider @@ -19,6 +18,7 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity.RELATIVE import org.gradle.api.tasks.TaskAction @@ -26,22 +26,17 @@ import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault /** - * Compiles C/C++ sources and archives them, per target: with Kotlin/Native's clang and llvm-ar through + * Compiles C/C++ sources and archives them for one [target]: with Kotlin/Native's clang and llvm-ar through * [`run_konan`](https://github.com/JetBrains/kotlin/blob/v1.9.0/kotlin-native/HACKING.md#running-clang-the-same-way-kotlinnative-compiler-does), - * and for Android targets with the NDK's β€” [androidNdk], required then. + * and for an Android target with the NDK's β€” [androidNdk], required then. One task per target, so a + * consumer of one target builds only its archive. */ @DisableCachingByDefault(because = "Output depends on the local Kotlin/Native toolchain, which is not a tracked input") internal abstract class CompileNativeLibraryTask @Inject constructor( private val exec: ExecOperations, private val fs: FileSystemOperations, - private val objects: ObjectFactory, ) : DefaultTask(), UsesKonanDistribution { - /** Destination of compiled `.o` object files */ - @get:InputFiles - @get:PathSensitive(RELATIVE) - abstract val outputDir: DirectoryProperty - /** C and C++ source files to compile to object files */ @get:InputFiles @get:PathSensitive(RELATIVE) @@ -55,139 +50,74 @@ internal abstract class CompileNativeLibraryTask @Inject constructor( /** The Kotlin/Native distribution whose clang compiles every target but Android's. */ @get:Internal abstract override val konanHome: Property - /** The Android NDK whose clang compiles Android targets; set when there is one. */ + /** The Android NDK whose clang compiles an Android target; set when this is one. */ @get:Internal abstract val androidNdk: Property /** Which NDK β€” path and revision β€” so another one recompiles. */ @get:Input @get:Optional val androidNdkIdentity: Provider get() = androidNdk.map { AndroidNdk.identity(it) } - /** The API level Android targets compile for. */ + /** The API level an Android target compiles for. */ @get:Input @get:Optional abstract val androidApiLevel: Property - /** Targets to compile for */ - @get:Input - abstract val targets: ListProperty + @get:Input abstract val target: Property @get:Input @get:Optional abstract val arguments: ListProperty - @get:Internal - abstract val workingDir: DirectoryProperty - @get:Input abstract val libName: Property - private val libFileName: Provider - get() = libName.map { NativeArtifacts.archiveFileName(it) } + /** Where the archive goes: `lib.a`, and nothing else. */ + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty @TaskAction - fun compileAndLink() { - val workingDir = workingDir.asFile.getOrElse(temporaryDir) - // Each only when a target needs it: an Android-only library needs no Kotlin/Native, a host one no NDK. - val distribution by lazy { distribution() } - val ndk by lazy { AndroidNdk.of(androidNdk.get()) } - val apiLevel = androidApiLevel.getOrElse(AndroidNdk.DEFAULT_API_LEVEL) - targets.get().forEach { target -> - val clang: (List) -> List = - if (target.isAndroid) { args -> listOf(ndk.clang(target, apiLevel).absolutePath) + args } else { args -> distribution.clang(target, args) } - val llvmAr: (List) -> List = - if (target.isAndroid) { args -> listOf(ndk.llvmAr.absolutePath) + args } else distribution::llvmAr - if (!target.isAndroid) distribution.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) - // prepare output dirs - val sourcesDir = workingDir.resolve("sources") - val headersDir = workingDir.resolve("headers") - val compileDir = workingDir.resolve("compile") - val linkDir = workingDir.resolve("link") - - fs.sync { - from(sourceFiles) - into(sourcesDir) - } - - fs.sync { - from(includeDirs) - into(headersDir) - } - - fs.delete { delete(compileDir) } - compileDir.mkdirs() - - fs.delete { delete(linkDir) } - linkDir.mkdirs() - - // --- - // Compiling - - // prepare args file - val sourceFilePaths = sourcesDir.walk() - .filter { it.extension in listOf("cpp", "c") } - .joinToString("\n") { it.invariantSeparatorsPath } - - compileDir.resolve("args") - .writeText(/*language=text*/ - """ - |--include-directory ${headersDir.invariantSeparatorsPath} - |${arguments.getOrElse(emptyList()).joinToString("\n")} - |-c $sourceFilePaths - """.trimMargin() - ) - - // compile files - val compileResult = exec.execCapture { - commandLine(clang(listOf("@args"))) - workingDir(compileDir) - } - - // verify output - logger.lifecycle(compileResult.output) - compileResult.assertNormalExitValue() - - // move compiled files to output directory - val outDir = outputDir.get().dir(target.konanName) - fs.delete { delete(outDir) } - fs.sync { - from(compileDir) { - include("**/*.o") - } - into(outDir) - } - - // --- - // Linking - - val objFilesPaths = outDir - .asFileTree - .matching { include("**/*.o") } - .joinToString("\n") { it.invariantSeparatorsPath } - - linkDir.resolve("args") - .writeText( - /*language=text*/ """ - |-rv - |${libFileName.get()} - |$objFilesPaths - """.trimMargin() - ) - - // link files - val linkResult = exec.execCapture { - commandLine(llvmAr(listOf("@args"))) - workingDir(linkDir) - } - - logger.lifecycle(linkResult.output) - linkResult.assertNormalExitValue() - - fs.delete { delete(outDir) } - fs.sync { - from(linkDir) { - include("**/*.a") - } - into(outDir) - } + fun compileAndArchive() { + val target = target.get() + val clang: (List) -> List + val llvmAr: (List) -> List + if (target.isAndroid) { + val ndk = AndroidNdk.of(androidNdk.get()) + val clangPath = ndk.clang(target, androidApiLevel.getOrElse(AndroidNdk.DEFAULT_API_LEVEL)).absolutePath + clang = { args -> listOf(clangPath) + args } + llvmAr = { args -> listOf(ndk.llvmAr.absolutePath) + args } + } else { + val distribution = distribution() + distribution.ensureDependencies(target, exec, temporaryDir.resolve("dependencies")) + clang = { args -> distribution.clang(target, args) } + llvmAr = distribution::llvmAr + } + + val work = temporaryDir + val sourcesDir = work.resolve("sources") + val headersDir = work.resolve("headers") + val compileDir = work.resolve("compile").apply { deleteRecursively(); mkdirs() } + val archiveDir = work.resolve("archive").apply { deleteRecursively(); mkdirs() } + fs.sync { from(sourceFiles); into(sourcesDir) } + fs.sync { from(includeDirs); into(headersDir) } + + val sourcePaths = sourcesDir.walk().filter { it.extension in listOf("cpp", "c") }.joinToString("\n") { it.invariantSeparatorsPath } + compileDir.resolve("args").writeText( + "--include-directory ${headersDir.invariantSeparatorsPath}\n" + + arguments.getOrElse(emptyList()).joinToString("\n") + "\n-c $sourcePaths\n" + ) + run(clang(listOf("@args")), compileDir) + + val objectPaths = compileDir.walk().filter { it.extension == "o" }.joinToString("\n") { it.invariantSeparatorsPath } + val archiveName = NativeArtifacts.archiveFileName(libName.get()) + archiveDir.resolve("args").writeText("-rv\n$archiveName\n$objectPaths\n") + run(llvmAr(listOf("@args")), archiveDir) + + fs.sync { from(archiveDir.resolve(archiveName)); into(outputDirectory) } + } + private fun run(command: List, directory: java.io.File) { + val result = exec.execCapture { + commandLine(command) + workingDir(directory) } + logger.lifecycle(result.output) + result.assertNormalExitValue() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt index f099ebe..47d8c80 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt @@ -2,6 +2,7 @@ package io.github.lemcoder.dsl.libraries import io.github.lemcoder.dsl.config.ConfigField import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.model.NativeTarget import io.github.lemcoder.model.taskName import io.github.lemcoder.util.cleanTask import org.gradle.api.Project @@ -21,25 +22,26 @@ private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC" * The default library build: the sources in [NativeLibrarySpec.sourceDirectory], compiled with * Kotlin/Native's clang and archived with its llvm-ar β€” for Android targets, the NDK's. */ -internal fun registerKonanClang(project: Project, konan: KonanExtension, library: NativeLibrarySpec): TaskProvider<*> = - project.tasks.register(taskName("konanClang", library.name), CompileNativeLibraryTask::class.java) { - group = "konan" - description = "Compile the '${library.name}' C/C++ sources with Kotlin/Native's clang, and the Android NDK's for Android." - // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives - // under build/tmp β€” so `gradle clean assembleDebug` can delete it mid-compile. - mustRunAfter(project.cleanTask()) +internal fun registerKonanClang(project: Project, konan: KonanExtension, library: NativeLibrarySpec): Map> = + library.targets.getOrElse(emptyList()).associateWith { target -> + project.tasks.register(taskName("konanClang", library.name, target), CompileNativeLibraryTask::class.java) { + group = "konan" + description = "Compile the '${library.name}' C/C++ sources for ${target.konanName}, with " + + (if (target.isAndroid) "the Android NDK's clang." else "Kotlin/Native's clang.") + // `clean` and this task are otherwise unordered, and the scratch dir this one writes to lives + // under build/tmp β€” so `gradle clean assembleDebug` can delete it mid-compile. + mustRunAfter(project.cleanTask()) - targets.set(library.targets) - outputDir.set(library.outputDirectory) - sourceFiles.from(library.sourceDirectory.map { it.asFileTree }) - includeDirs.from(library.sourceDirectory, library.includeDirs) - libName.set(library.name) - if (library.targets.getOrElse(emptyList()).any { it.isAndroid }) { - androidNdk.set(konan.config.readConfigField(ConfigField.ANDROID_NDK, path)) - androidApiLevel.set(konan.androidApiLevel) + this.target.set(target) + outputDirectory.set(library.archiveDirectory(target)) + sourceFiles.from(library.sourceDirectory.map { it.asFileTree }) + includeDirs.from(library.sourceDirectory, library.includeDirs) + libName.set(library.name) + if (target.isAndroid) { + androidNdk.set(konan.config.readConfigField(ConfigField.ANDROID_NDK, path)) + androidApiLevel.set(konan.androidApiLevel) + } + arguments.addAll(DEFAULT_COMPILER_OPTIONS) + arguments.addAll(library.compilerOptions) } - arguments.addAll(DEFAULT_COMPILER_OPTIONS) - arguments.addAll(library.compilerOptions) - - onlyIf { targets.get().isNotEmpty() } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt index 06d23a2..95f5429 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt @@ -25,7 +25,8 @@ private fun Project.registerLibrary(konan: KonanExtension, library: NativeLibrar description = "Build the '${library.name}' library: a static archive per target." } afterEvaluate { - val work = registerLibraryBuild(konan, library) - lifecycle.configure { dependsOn(work) } + val builds = registerLibraryBuild(konan, library) + library.buildsByTarget.putAll(builds) + lifecycle.configure { dependsOn(builds.values.distinct()) } } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt index 048eca9..f06cefa 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt @@ -8,20 +8,22 @@ import io.github.lemcoder.dsl.builds.ExternalVariable import io.github.lemcoder.dsl.builds.PrebuiltSettings import io.github.lemcoder.dsl.builds.blockName import io.github.lemcoder.dsl.builds.selectOne +import io.github.lemcoder.model.NativeTarget import org.gradle.api.Project import org.gradle.api.tasks.TaskProvider /** - * Registers the work that builds [library]'s archives, however its script said: compiled with + * Registers the work that builds [library]'s archives, per target, however its script said: compiled with * Kotlin/Native's clang by default, placed from `prebuilt { }`, or built by the project's own build. Each * leaves one archive per target at [NativeArtifacts.archivePath] under the library's output directory β€” * the only thing consumers know. Called once the build script has run. */ -internal fun Project.registerLibraryBuild(konan: KonanExtension, library: NativeLibrarySpec): List> = +internal fun Project.registerLibraryBuild(konan: KonanExtension, library: NativeLibrarySpec): Map> = when (val settings = selectOne("konan.libraries.${library.name}", library.selectedBuilds) { it.blockName }) { - null -> listOf(registerKonanClang(this, konan, library)) - is PrebuiltSettings -> listOf(registerPrebuilt(this, library)) - is ExternalBuildSettings -> ExternalBuild.of(settings).register(this, externalBuildOutput(library)) + null -> registerKonanClang(this, konan, library) + // One task places every target's archive. + is PrebuiltSettings -> registerPrebuilt(this, library).let { place -> library.targets.getOrElse(emptyList()).associateWith { place } } + is ExternalBuildSettings -> externalBuildOutput(library).let { output -> output.targets.zip(ExternalBuild.of(settings).register(this, output)).toMap() } } private fun Project.externalBuildOutput(library: NativeLibrarySpec): ExternalBuildOutput { diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt index ad09fcb..2efeb3b 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt @@ -9,18 +9,28 @@ import org.gradle.api.provider.Provider import org.gradle.api.tasks.TaskProvider /** - * What a library provider guarantees and every consumer relies on: one static archive per target, at - * [archive], once [builtBy] has run. Consumers β€” cinterop, the JNI and FFM links β€” see only this, never - * which provider made the archives or how. + * What a library's build guarantees and every consumer relies on: one static archive per target, at + * [archive], once [builtBy] that target has run. Consumers β€” cinterop, the JNI and FFM links β€” see only + * this, never which build made the archives or how. */ internal class NativeArtifacts( /** The library's name; the archives are `lib.a`. */ val name: String, private val outputDirectory: Provider, val targets: Provider>, - /** Depend on this, not on a provider's task: it stands for "the archives exist". */ - val builtBy: TaskProvider<*>, + /** `compileNativeLibrary`: every target's archive. */ + val builtByAll: TaskProvider<*>, + private val buildsByTarget: Map>, + private val project: Project, ) { + /** + * What makes [target]'s archive: depend on this for one target, not on [builtByAll], or building one + * target builds them all (#9). Read lazily, when the task graph is built: by then every library's + * build is registered, whichever order the script declared them in. + */ + fun builtBy(target: NativeTarget): Provider>> = + project.provider { listOf(buildsByTarget[target] ?: builtByAll) } + fun archive(target: NativeTarget): Provider = outputDirectory.map { it.file(archivePath(name, target)) } fun directory(target: NativeTarget): Provider = outputDirectory.map { it.dir(target.konanName) } @@ -38,7 +48,9 @@ internal class NativeArtifacts( name = library.name, outputDirectory = library.outputDirectory, targets = library.targets, - builtBy = project.tasks.named(taskName(library.name)), + builtByAll = project.tasks.named(taskName(library.name)), + buildsByTarget = library.buildsByTarget, + project = project, ) } } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt index 819a032..21b26a0 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt @@ -5,6 +5,7 @@ import io.github.lemcoder.dsl.builds.ExternalBuildBlocks import io.github.lemcoder.dsl.builds.PrebuiltSettings import io.github.lemcoder.dsl.builds.ZigSettings import io.github.lemcoder.model.NativeTarget +import org.gradle.api.tasks.TaskProvider import javax.inject.Inject import org.gradle.api.Action import org.gradle.api.Named @@ -47,6 +48,9 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o /** All four Android ABIs. */ fun androidTargets() = targets.set(NativeTarget.android) + /** Each target's build, once the build script has run: what a consumer of one target depends on. */ + internal val buildsByTarget = mutableMapOf>() + /** The archive built for [target]. */ fun archive(target: NativeTarget): Provider = outputDirectory.file(NativeArtifacts.archivePath(name, target)) diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index 8e6f9b1..aa4444e 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -137,7 +137,7 @@ internal object KotlinGradlePlugin { "-staticLibrary", NativeArtifacts.archiveFileName(artifacts.name), "-libraryPath", artifacts.directory(nativeTarget).get().asFile.absolutePath, ) - project.tasks.named(interopProcessingTaskName).configure { dependsOn(artifacts.builtBy) } + project.tasks.named(interopProcessingTaskName).configure { dependsOn(artifacts.builtBy(nativeTarget)) } } } } diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 11bfac5..84149b7 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -306,7 +306,7 @@ class KonanPluginFunctionalTest { val result = runner("compileNativeLibraryMymath").withEnvironment(System.getenv() - "ANDROID_NDK_HOME").buildAndFail() - assertContains(result.output, "konan.config.androidNdk is not set β€” :konanClangMymath needs it.") + assertContains(result.output, "konan.config.androidNdk is not set β€” :konanClangMymathAndroid_arm64 needs it.") assertContains(result.output, "/ndk/") } @@ -690,7 +690,8 @@ class KonanPluginFunctionalTest { val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() - listOf(":compileNativeLibraryMymath", ":generateJniMymath", ":linkJniMymath$hostSuffix").forEach { + // The host's own compile, not compileNativeLibraryMymath: a link builds only its target's archive. + listOf(":konanClangMymath$hostSuffix", ":generateJniMymath", ":linkJniMymath$hostSuffix").forEach { assertTrue("$it SKIPPED" in result.output, "expected $it in the graph:\n${result.output}") } } @@ -1095,6 +1096,32 @@ class KonanPluginFunctionalTest { assertTrue(":linkFfmMymath$hostSuffix SKIPPED" in result.output, "the host's library:\n${result.output}") } + @Test + fun `building one target builds the library for that target only`() { + assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") + val other = if (host == NativeTarget.LINUX_X64) NativeTarget.MINGW_X64 else NativeTarget.LINUX_X64 + val twoTargets = mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.${host.name}, NativeTarget.${other.name})") + + // The JNI link for the host. + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", twoTargets) + val jni = runner("linkJniMymath$hostSuffix", "--dry-run").withPluginClasspathAndKgp().build().output + assertTrue(":konanClangMymath$hostSuffix SKIPPED" in jni, jni) + assertFalse("konanClangMymath${other.taskSuffix}" in jni, "the JNI link for one target built another:\n$jni") + assertFalse(":compileNativeLibraryMymath SKIPPED" in jni, jni) + + // KGP's cinterop for the host, in a multiplatform project with both targets. + val dsl = { target: NativeTarget -> target.konanName.split('_').mapIndexed { i, p -> if (i == 0) p else p.replaceFirstChar { it.uppercase() } }.joinToString("") } + write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", twoTargets) + File(projectDir, "build.gradle.kts").appendText( + "\nkonan.config.konanHome.set(file(\"${KonanDistribution.installed()!!.home.absolutePath.replace("\\", "/")}\"))\n" + + "kotlin { ${dsl(host)}(); ${dsl(other)}() }\nrepositories { mavenCentral() }\n" + ) + val cinterop = "cinteropMymath" + dsl(host).replaceFirstChar { it.uppercase() } + val kgp = runner(cinterop, "--dry-run").withPluginClasspathAndKgp().build().output + assertTrue(":konanClangMymath$hostSuffix SKIPPED" in kgp && ":$cinterop SKIPPED" in kgp, kgp) + assertFalse("konanClangMymath${other.taskSuffix}" in kgp, "cinterop for one target built another:\n$kgp") + } + @Test fun `a multiplatform project binds through KGP's cinterop, after compiling the library`() { assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") @@ -1123,10 +1150,10 @@ class KonanPluginFunctionalTest { val result = runner("compileKotlin$targetSuffix", "--stacktrace").withPluginClasspathAndKgp().build() - assertEquals(TaskOutcome.SUCCESS, result.task(":compileNativeLibraryMymath")?.outcome) + assertEquals(TaskOutcome.SUCCESS, result.task(":konanClangMymath$hostSuffix")?.outcome) assertEquals(TaskOutcome.SUCCESS, result.task(":cinteropMymath$targetSuffix")?.outcome) assertEquals(TaskOutcome.SUCCESS, result.task(":compileKotlin$targetSuffix")?.outcome) val order = result.tasks.map { it.path } - assertTrue(order.indexOf(":compileNativeLibraryMymath") < order.indexOf(":cinteropMymath$targetSuffix"), "$order") + assertTrue(order.indexOf(":konanClangMymath$hostSuffix") < order.indexOf(":cinteropMymath$targetSuffix"), "$order") } } From 452d157c7d08fe7d0143da02e57384645a78bff9 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 27 Oct 2026 12:06:06 +0100 Subject: [PATCH 41/57] chore: version 2.0.0-alpha02 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5b212f2..62ac1c1 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and 1. Apply the plugin in your `build.gradle.kts`: ```kotlin plugins { - id("io.github.lemcoder.konanplugin") version "2.0.0-alpha01" + id("io.github.lemcoder.konanplugin") version "2.0.0-alpha02" } ``` diff --git a/build.gradle.kts b/build.gradle.kts index ad7ef53..1e0d18b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } // Every published version is immutable on the Portal: bump before publishing, and never reuse one. -version = "2.0.0-alpha01" +version = "2.0.0-alpha02" group = "io.github.lemcoder" repositories { From 86f5df513333c84bf5f9f78c8ae8acca160b49d9 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 27 Oct 2026 12:09:48 +0100 Subject: [PATCH 42/57] fix: Android packaging builds only the Android targets' libraries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgpWiring hung the jniLibs merge and preBuild off link, which builds every target's library: assembling an Android variant also built the host's JNI library, which no APK or AAR holds β€” a minute of llama.cpp, and a failure on a machine that cannot build the host. BindingLibraryWiring.androidBuilds gives packaging the Android targets' builds only. preBuild and the multiplatform Android compile now depend on generation alone: for FFM that is generateFfmAndroid, rather than linkFfm, which also generated the JDK bindings and built the host. Fixes #12 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +++ .../dsl/bindings/BindingLibraryWiring.kt | 7 +++++++ .../lemcoder/dsl/bindings/ffm/FfmConsumer.kt | 17 ++++++++++------- .../lemcoder/dsl/bindings/jni/JniConsumer.kt | 4 ++-- .../lemcoder/kotlin/KotlinGradlePlugin.kt | 2 +- .../io/github/lemcoder/wiring/AgpWiring.kt | 16 +++++++++------- 6 files changed, 32 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31a1f2d..1d7e864 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,9 @@ resolved the moment the plugin is applied, in *every* project that applies it. `compileNativeLibrary` made `macosArm64Test` build every target's llama.cpp at once (#9). The default build is therefore one `konanClang` per target. `builtBy(target)` is a provider read when the task graph is built, so the library and binding may be declared in either order. + Packaging follows suit: a jar bundles the JVM targets' builds (#5), an APK or AAR the Android targets' + (`BindingLibraryWiring.androidBuilds`, #12), and AGP's compile edges hang off generation alone β€” never + `link`, which builds the host's library too. - **A library has one archive per target, however many its build leaves.** A library's external build writes into a staging directory (`KONAN_OUTPUT_DIR`, CMake's output directories), emptied before each run; `ExternalBuildTask` copies the one archive, or merges several with Kotlin/Native's llvm-ar (`qcL`, then `s`; diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt index d3b7917..94ca575 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt @@ -49,6 +49,13 @@ internal class BindingLibraryWiring( /** Each target's build, once [registerBuild] has run. */ private var buildsByTarget: Map> = emptyMap() + /** + * The Android targets' builds, read when the task graph is built: what an APK or AAR packages. Not + * [linkAll], which also builds the host's library, that no Android artifact holds (#12). + */ + val androidBuilds: Provider>> = + project.provider { buildsByTarget.filterKeys { it.isAndroid }.values.distinct() } + /** `targets`, else those of `cmake { target(…) { } }` blocks; empty when the build script named none. */ fun declaredTargets(): List = spec.targets.getOrElse(emptyList()).ifEmpty { (spec.selectedBuilds.singleOrNull() as? CMakeSettings)?.targetSettings?.keys?.toList().orEmpty() diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt index 44d16b1..e8ba0e4 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt @@ -36,6 +36,9 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume private lateinit var library: BindingLibraryWiring + /** PanamaPort's generation, once [register] has run for an Android consumer. */ + private var generateAndroid: TaskProvider? = null + private val isMultiplatform get() = context.hasPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) /** `kotlin("jvm")`, or a multiplatform `jvm()` target: the JDK's FFM. */ @@ -58,13 +61,13 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume val ffm = binding.ffm ffm.libraryName.convention(binding.library.map { it.name }.orElse(bindingName)) library = BindingLibraryWiring(context, ffm, "ffm") - val linkAll = library.linkAll - // Eager, as the variant API needs; whether FFM is on is only known in the callbacks. linkFfm - // depends on generation, so it orders both the compile and the packaging. + // Eager, as the variant API needs; whether FFM is on is only known in the callbacks. Compiling needs + // PanamaPort's sources, registered after evaluation; packaging the Android libraries (#12). val enabled = { ffm.isEnabled } - AgpWiring.addSources(project, SourceKind.JAVA, generatedRoot(FfmApi.PANAMA_PORT).map { it.dir("java") }, linkAll, linkAll, enabled) - AgpWiring.addJniLibs(project, ffm.jniLibsRoot, linkAll, enabled) - project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.dependOnForKmpAndroid(project, linkAll, enabled) } + val generate = project.provider { listOfNotNull(generateAndroid) } + AgpWiring.addSources(project, SourceKind.JAVA, generatedRoot(FfmApi.PANAMA_PORT).map { it.dir("java") }, generate, enabled) + AgpWiring.addJniLibs(project, ffm.jniLibsRoot, library.androidBuilds, enabled) + project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.dependOnForKmpAndroid(project, generate, enabled) } } override fun isUsed(): Boolean = binding.ffm.isEnabled @@ -83,7 +86,7 @@ internal class FfmConsumer(private val context: BindingContext) : BindingConsume } // The JDK's bindings also when nothing is detected, for a build to wire itself. val generateJvm = if (jvm || !android) registerGeneration(FfmApi.JAVA, libraryName) else null - if (android) registerGeneration(FfmApi.PANAMA_PORT, libraryName) + if (android) generateAndroid = registerGeneration(FfmApi.PANAMA_PORT, libraryName) val loadable = buildList { if (jvm || !android) add(NativeTarget.host) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt index d8a0344..703602c 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt @@ -40,8 +40,8 @@ internal class JniConsumer(private val context: BindingContext) : BindingConsume // Eager: the variant API must be wired at configuration time. project.withPlugin(GradlePluginId.KOTLIN_MULTIPLATFORM) { KotlinGradlePlugin.addKmpJvmSources(project, sources, generate) { !binding.ffm.isEnabled } } - AgpWiring.addSources(project, SourceKind.KOTLIN, sources, generate, library.linkAll) { !replacedByFfm } - AgpWiring.addJniLibs(project, jni.jniLibsRoot, library.linkAll) { !replacedByFfm } + AgpWiring.addSources(project, SourceKind.KOTLIN, sources, generate) { !replacedByFfm } + AgpWiring.addJniLibs(project, jni.jniLibsRoot, library.androidBuilds) { !replacedByFfm } // AGP looks for a `baselineProfiles` directory beside every source root, including the generated // one, by plain path β€” no task dependency. Matched by name: inert without AGP. project.tasks.matching { it.name.contains("ArtProfile") }.configureEach { dependsOn(generate) } diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index aa4444e..82f49a9 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -61,7 +61,7 @@ internal object KotlinGradlePlugin { * Makes every Android target's compilations wait for [task] when [enabled]: their Kotlin reads the * Java the task generates, which reaches them through AGP's variant API with no producer attached. */ - fun dependOnForKmpAndroid(project: Project, task: TaskProvider<*>, enabled: () -> Boolean) { + fun dependOnForKmpAndroid(project: Project, task: Any, enabled: () -> Boolean) { val lazyTask = project.provider { if (enabled()) listOf(task) else emptyList() } project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets .matching { it.platformType == KotlinPlatformType.androidJvm } diff --git a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt index 7d49771..a0e0950 100644 --- a/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt @@ -4,7 +4,6 @@ import com.android.build.api.variant.AndroidComponents import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.KotlinMultiplatformAndroidComponentsExtension import org.gradle.api.Project -import org.gradle.api.Task import org.gradle.api.file.Directory import org.gradle.api.provider.Property import org.gradle.api.provider.Provider @@ -25,18 +24,20 @@ internal object AgpWiring { * An Android module outside Kotlin Multiplatform has no Kotlin compilation to attach to, so generated * sources go in through the variant API. A multiplatform module gets Kotlin ones through its Android * target instead. + * + * [generate] is anything `dependsOn` takes. Compiling needs only the sources; the libraries are + * packaging's, through [addJniLibs]. */ fun addSources( project: Project, kind: SourceKind, sources: Provider, - generate: TaskProvider<*>, - link: TaskProvider, + generate: Any, enabled: () -> Boolean, ) { val relative = sources.get().asFile.relativeTo(project.projectDir).path val ordering = { - project.tasks.matching { it.name == "preBuild" }.configureEach { if (enabled()) dependsOn(generate, link) } + project.tasks.matching { it.name == "preBuild" }.configureEach { if (enabled()) dependsOn(generate) } project.tasks .matching { (it.name.startsWith("compile") && (it.name.contains("Kotlin") || it.name.contains("Java"))) || it.name == "compileJava" } .configureEach { if (enabled()) dependsOn(generate) } @@ -101,9 +102,10 @@ internal object AgpWiring { /** * Packages the per-ABI libraries under [jniLibsRoot] into the APK or AAR. Nothing consumes the * directory as a task output, so packaging needs the ordering spelled out. Added once per directory: - * a second registration of the same one fails the merge with "Duplicate resources". + * a second registration of the same one fails the merge with "Duplicate resources". [builds] are the + * Android targets' builds only, as anything `dependsOn` takes. */ - fun addJniLibs(project: Project, jniLibsRoot: Provider, link: TaskProvider, enabled: () -> Boolean) { + fun addJniLibs(project: Project, jniLibsRoot: Provider, builds: Any, enabled: () -> Boolean) { val relative = jniLibsRoot.get().asFile.relativeTo(project.projectDir).path project.withPlugin(GradlePluginId.ANDROID_KMP_LIBRARY) { project.extensions.findByType(KotlinMultiplatformAndroidComponentsExtension::class.java)?.onVariants { variant -> @@ -116,6 +118,6 @@ internal object AgpWiring { } } project.tasks.matching { it.name.contains("JniLibFolders") || it.name.contains("MergeJniLib") } - .configureEach { if (enabled()) dependsOn(link) } + .configureEach { if (enabled()) dependsOn(builds) } } } From 9d4eea475c1e4f95e48246e515285762221ecff8 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 27 Oct 2026 12:13:17 +0100 Subject: [PATCH 43/57] feat: cmake { sources } makes a CMake build up to date cmake { } had no sources, unlike make { } and external { }, so every cmakeBuild task ran on every invocation: a configure and a no-op build per target, and nothing after it could be up to date. sources feeds ExternalBuildTask.sources, as for make { }. It has no default: a CMakeLists that reads outside its own directory (add_subdirectory(../llama.cpp)) would stay up to date after an edit there. Fixes #13 Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../lemcoder/dsl/builds/CMakeCommands.kt | 5 +-- .../lemcoder/dsl/builds/CMakeSettings.kt | 7 +++++ .../lemcoder/KonanPluginFunctionalTest.kt | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 62ac1c1..e82bc4a 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,7 @@ target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arriv | `buildTargets` | `List` | the default target | `--target`s to build. | | `arguments` | `List` | β€” | Extra configure arguments. | | `jobs` | `Int` | processors | `cmake --build --parallel `; without a count, Unix Makefiles runs every compile at once. | +| `sources` | `FileCollection` | β€” | What the build reads, for up-to-date checks: `sources.from(fileTree("native") { exclude("build/**") })`. Omitted, the task always runs and CMake's own incrementality decides. Declare everything the CMakeLists reads, including directories outside it, or an edit there is missed. | | `libraryDirectory` | `Directory` | β€” | Where the build leaves the library, if it ignores `CMAKE_LIBRARY_OUTPUT_DIRECTORY`: the plugin copies it into place. | | `target(t) { }` | | β€” | A per-target build with its own `preset`, `buildDirectory`, `arguments`. | diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt index 4527279..fd3189e 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt @@ -13,8 +13,8 @@ import org.gradle.api.provider.Provider * output directory, so the CMakeLists needs no knowledge of the layout. Used for a library's archive, the * JNI stub and the library FFM loads. * - * Both commands are one task, which always runs: CMake tracks its own sources, and a changed configure - * argument then always reaches the build. + * Both commands are one task, so a changed configure argument always reaches the build. Without declared + * `sources` it always runs, and CMake tracks its own. * * @param owner the DSL path, for messages, e.g. `konan.bindings.mymath.jni.cmake`. * @param defaultBuildRoot where builds go without a preset or a build directory: `/`. @@ -77,4 +77,5 @@ internal fun ExternalBuildTask.addCMakeCommands( arguments.add("--parallel") arguments.add(cmake.jobs.map { it.toString() }) } + sources.from(cmake.sources) } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt index 6caf8ac..a21da30 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt @@ -2,6 +2,7 @@ package io.github.lemcoder.dsl.builds import io.github.lemcoder.model.NativeTarget import org.gradle.api.Action +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.model.ObjectFactory @@ -80,6 +81,12 @@ abstract class CMakeSettings @Inject constructor(private val objects: ObjectFact */ abstract val jobs: Property + /** + * What the build reads. With none, it runs every time β€” a configure and a no-op build β€” and CMake's own + * incrementality decides. No default: a CMakeLists reading outside its own directory + * (`add_subdirectory(../llama.cpp)`) would then stay up to date after an edit there. + */ + abstract val sources: ConfigurableFileCollection } /** One target's build: everything not set here comes from the enclosing [CMakeSettings]. */ diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 84149b7..637f986 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -529,6 +529,37 @@ class KonanPluginFunctionalTest { assertTrue(File(projectDir, "build/konan/libraries/mymath/${host.konanName}/libmymath.a").isFile, result.output) } + @Test + fun `a CMake build with sources is up to date until one changes, and without them always runs`() { + val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } + assumeTrue(cmake != null, "needs cmake") + File(projectDir, "native").mkdirs() + File(projectDir, "native/input.txt").writeText("one") + File(projectDir, "native/CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.16) + project(mymath NONE) + file(READ "${'$'}{CMAKE_CURRENT_SOURCE_DIR}/input.txt" INPUT) + file(WRITE "${'$'}{CMAKE_ARCHIVE_OUTPUT_DIRECTORY}/lib${'$'}{KONAN_LIB_NAME}.a" "${'$'}{INPUT}") + """.trimIndent() + ) + val library = """libraries { create("mymath") { targets(NativeTarget.${host.name}); cmake { SOURCES } } }""" + val task = ":cmakeBuildMymath$hostSuffix" + val archive = File(projectDir, "build/konan/libraries/mymath/${host.konanName}/libmymath.a") + + write("""id("io.github.lemcoder.konanplugin")""", library.replace("SOURCES", """sources.from("native")""")) + assertEquals(TaskOutcome.SUCCESS, runner("compileNativeLibraryMymath").build().task(task)?.outcome) + assertEquals(TaskOutcome.UP_TO_DATE, runner("compileNativeLibraryMymath").build().task(task)?.outcome) + File(projectDir, "native/input.txt").writeText("two") + assertEquals(TaskOutcome.SUCCESS, runner("compileNativeLibraryMymath").build().task(task)?.outcome) + assertEquals("two", archive.readText()) + + // Without sources the plugin cannot know what the build reads. + write("""id("io.github.lemcoder.konanplugin")""", library.replace("SOURCES", "")) + runner("compileNativeLibraryMymath").build() + assertEquals(TaskOutcome.SUCCESS, runner("compileNativeLibraryMymath").build().task(task)?.outcome) + } + @Test fun `CMake builds a library's archive for the host and, cross-compiled, for linux_x64`() { val cmake = listOf("/opt/homebrew/bin/cmake", "/usr/local/bin/cmake", "/usr/bin/cmake").map(::File).firstOrNull { it.canExecute() } From a413c233b9bc15bbf15aa00eba00aea2836b6e88 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 29 Oct 2026 11:36:30 +0100 Subject: [PATCH 44/57] ci: test on Linux x64, and on Linux arm64 with an unofficial Kotlin/Native The first workflow: the test suite on ubuntu-24.04 with JetBrains' Kotlin/Native 2.4.20, and on ubuntu-24.04-arm with voize-gmbh's unofficial 2.4.21 build for Linux arm64 hosts (pinned by checksum), for which JetBrains ships none. The arm64 job may fail without failing the workflow until it is known to pass. Tests skip rather than fail when a tool is missing, so the job summary lists every skipped and failed test. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..011fd0f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,130 @@ +name: CI + +on: + push: + branches: [main, feature/**] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: test (${{ matrix.name }}) + runs-on: ${{ matrix.os }} + # Linux arm64 has no official Kotlin/Native distribution: it runs on an unofficial build and may fail + # without failing the workflow, until it is known to pass. + continue-on-error: ${{ matrix.experimental }} + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + os: ubuntu-24.04 + experimental: false + konan-version: "2.4.20" + - name: linux-arm64 + os: ubuntu-24.04-arm + experimental: true + konan-version: "2.4.21" + env: + KONAN_VERSION: ${{ matrix.konan-version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Install build tools + run: | + sudo apt-get update -q + sudo apt-get install -y -q cmake make + command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + ~/.cargo/bin/cargo --version + + - name: Cache Kotlin/Native + uses: actions/cache@v4 + with: + path: ~/.konan + key: konan-${{ matrix.name }}-${{ matrix.konan-version }} + + # The tests find the distribution at ~/.konan/kotlin-native-prebuilt--, where the Kotlin + # Gradle plugin installs it; without it they skip. + - name: Install Kotlin/Native (JetBrains) + if: matrix.name == 'linux-x64' + run: | + dist=kotlin-native-prebuilt-linux-x86_64-$KONAN_VERSION + if [ ! -d ~/.konan/$dist ]; then + mkdir -p ~/.konan + curl -sSfL "https://download.jetbrains.com/kotlin/native/builds/releases/$KONAN_VERSION/linux-x86_64/$dist.tar.gz" | tar -xz -C ~/.konan + fi + + # voize-gmbh's unofficial Linux arm64 build, pinned by checksum. JetBrains hosts no LLVM or libffi for + # this host, so they go into the dependencies directory by hand, marked as extracted. + - name: Install Kotlin/Native (voize-gmbh, unofficial) + if: matrix.name == 'linux-arm64' + run: | + release=https://github.com/voize-gmbh/kotlin/releases/download/v2.4.21-linux-arm64 + dist=kotlin-native-prebuilt-linux-aarch64-$KONAN_VERSION + deps=~/.konan/dependencies + mkdir -p "$deps" downloads + if [ ! -d ~/.konan/$dist ]; then + cd downloads + cat > SHA256SUMS <<'EOF' + e7dfc692c1e0b1d0ad9ada67031a67b0544974ba77220445c21efc7949281ea3 kotlin-native-prebuilt-linux-aarch64-2.4.21.tar.gz + ad212a7541b98ed2a4ebb3b510f4cdebf2919b8b0cc9dd5a321558a5f4282b13 libffi-3.4.6-1-linux-aarch64.tar.gz + 35e9a2566bf7cb44de427669d010117e98148ca5ea1a4ffef291218581d376a9 llvm-21-aarch64-linux-essentials-1.tar.gz + EOF + sed -i 's/^ *//' SHA256SUMS + for f in $(awk '{print $2}' SHA256SUMS); do curl -sSfLO "$release/$f"; done + sha256sum -c SHA256SUMS + tar -xzf llvm-21-aarch64-linux-essentials-1.tar.gz -C "$deps" + tar -xzf libffi-3.4.6-1-linux-aarch64.tar.gz -C "$deps" + for n in llvm-21-aarch64-linux-essentials-1 libffi-3.4.6-1-linux-aarch64; do + grep -qx "$n" "$deps/.extracted" 2>/dev/null || echo "$n" >> "$deps/.extracted" + done + tar -xzf $dist.tar.gz -C ~/.konan + cd .. && rm -rf downloads + fi + + - name: Check Kotlin/Native + run: | + home=$(ls -d ~/.konan/kotlin-native-prebuilt-linux-*-$KONAN_VERSION) + "$home/bin/kotlinc-native" -version + + # The default Kotlin/Native version is what the tests and kotlin-native-utils use; the arm64 build + # exists for 2.4.21 only. + - name: Test + run: ./gradlew test -PkotlinNativeVersion=$KONAN_VERSION --continue + + - name: Test summary + if: always() + run: | + python3 - <<'EOF' >> "$GITHUB_STEP_SUMMARY" + import glob, xml.etree.ElementTree as ET + rows, totals = [], [0, 0, 0] + for f in sorted(glob.glob("build/test-results/test/*.xml")): + suite = ET.parse(f).getroot() + for case in suite.iter("testcase"): + outcome = next((c.tag for c in case if c.tag in ("failure", "error", "skipped")), "passed") + totals[0 if outcome == "passed" else 2 if outcome == "skipped" else 1] += 1 + if outcome != "passed": + detail = next(c for c in case if c.tag == outcome).get("message", "") or "" + rows.append(f"| {outcome} | {case.get('name')} | {detail.splitlines()[0][:150] if detail else ''} |") + print(f"### ${{ matrix.name }}: {totals[0]} passed, {totals[1]} failed, {totals[2]} skipped\n") + if rows: + print("| Outcome | Test | Message |\n|---|---|---|") + print("\n".join(rows)) + EOF + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-reports-${{ matrix.name }} + path: build/reports/tests/test From d03ccba893c33ad2df188a4ef7b059a87f1ce3bf Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 29 Oct 2026 11:46:54 +0100 Subject: [PATCH 45/57] fix: a target named twice registers its tasks once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit targets(NativeTarget.host, …, NativeTarget.LINUX_ARM64) names linux_arm64 twice on a Linux arm64 host, and the plugin registered its tasks twice: "Cannot add task 'konanClangMymathLinux_arm64' as a task with that name already exists". A build script naming its host explicitly breaks the same way on any host. Libraries and bindings register from distinctTargets, the declared targets without repeats. Tests that need Kotlin to know the build host now skip where it does not: Kotlin/Native has no Linux arm64 host, and KGP and kotlin-native-utils from Maven throw "Unknown host target" there, though the plugin runs on an unofficial distribution. Co-Authored-By: Claude Opus 5.5 --- .../lemcoder/dsl/bindings/BindingLibrarySpec.kt | 6 ++++++ .../lemcoder/dsl/bindings/BindingLibraryWiring.kt | 2 +- .../lemcoder/dsl/libraries/KonanClangBuild.kt | 2 +- .../github/lemcoder/dsl/libraries/LibraryBuilds.kt | 4 ++-- .../lemcoder/dsl/libraries/NativeArtifacts.kt | 2 +- .../lemcoder/dsl/libraries/NativeLibrarySpec.kt | 6 ++++++ .../lemcoder/dsl/libraries/PrebuiltLibrary.kt | 2 +- .../io/github/lemcoder/KonanPluginFunctionalTest.kt | 13 +++++++++++++ .../kotlin/io/github/lemcoder/TestKitSupport.kt | 9 +++++++++ .../io/github/lemcoder/model/NativeTargetTest.kt | 3 +++ 10 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt index de0cd42..3ca0c83 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt @@ -45,6 +45,12 @@ abstract class BindingLibrarySpec(objects: ObjectFactory) : ExternalBuildBlocks( */ abstract val libraryDirectory: DirectoryProperty + /** + * [targets] without repeats, which is what tasks are registered from: `targets(NativeTarget.host, + * NativeTarget.LINUX_ARM64)` names one target twice on a Linux arm64 host. + */ + internal val distinctTargets: Provider> get() = targets.map { it.distinct() } + fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) /** Appends [options] to the link command of [target] only. */ diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt index 94ca575..e683a37 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt @@ -57,7 +57,7 @@ internal class BindingLibraryWiring( project.provider { buildsByTarget.filterKeys { it.isAndroid }.values.distinct() } /** `targets`, else those of `cmake { target(…) { } }` blocks; empty when the build script named none. */ - fun declaredTargets(): List = spec.targets.getOrElse(emptyList()).ifEmpty { + fun declaredTargets(): List = spec.distinctTargets.getOrElse(emptyList()).ifEmpty { (spec.selectedBuilds.singleOrNull() as? CMakeSettings)?.targetSettings?.keys?.toList().orEmpty() } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt index 47d8c80..20363f5 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt @@ -23,7 +23,7 @@ private val DEFAULT_COMPILER_OPTIONS = listOf("-fno-sanitize=undefined", "-fPIC" * Kotlin/Native's clang and archived with its llvm-ar β€” for Android targets, the NDK's. */ internal fun registerKonanClang(project: Project, konan: KonanExtension, library: NativeLibrarySpec): Map> = - library.targets.getOrElse(emptyList()).associateWith { target -> + library.distinctTargets.getOrElse(emptyList()).associateWith { target -> project.tasks.register(taskName("konanClang", library.name, target), CompileNativeLibraryTask::class.java) { group = "konan" description = "Compile the '${library.name}' C/C++ sources for ${target.konanName}, with " + diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt index f06cefa..4262ef4 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt @@ -22,7 +22,7 @@ internal fun Project.registerLibraryBuild(konan: KonanExtension, library: Native when (val settings = selectOne("konan.libraries.${library.name}", library.selectedBuilds) { it.blockName }) { null -> registerKonanClang(this, konan, library) // One task places every target's archive. - is PrebuiltSettings -> registerPrebuilt(this, library).let { place -> library.targets.getOrElse(emptyList()).associateWith { place } } + is PrebuiltSettings -> registerPrebuilt(this, library).let { place -> library.distinctTargets.getOrElse(emptyList()).associateWith { place } } is ExternalBuildSettings -> externalBuildOutput(library).let { output -> output.targets.zip(ExternalBuild.of(settings).register(this, output)).toMap() } } @@ -32,7 +32,7 @@ private fun Project.externalBuildOutput(library: NativeLibrarySpec): ExternalBui owner = "konan.libraries.$name", name = name, side = "", - targets = library.targets.getOrElse(emptyList()), + targets = library.distinctTargets.getOrElse(emptyList()), libraryName = provider { name }, libraryFileName = { provider { NativeArtifacts.archiveFileName(name) } }, outputDirectory = library::archiveDirectory, diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt index 2efeb3b..5637740 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt @@ -47,7 +47,7 @@ internal class NativeArtifacts( fun of(project: Project, library: NativeLibrarySpec) = NativeArtifacts( name = library.name, outputDirectory = library.outputDirectory, - targets = library.targets, + targets = library.distinctTargets, builtByAll = project.tasks.named(taskName(library.name)), buildsByTarget = library.buildsByTarget, project = project, diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt index 21b26a0..cc7ec01 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt @@ -43,6 +43,12 @@ abstract class NativeLibrarySpec @Inject constructor(private val name: String, o /** Root of the per-target archives. Defaults to `build/konan/libraries/`. */ abstract val outputDirectory: DirectoryProperty + /** + * [targets] without repeats, which is what tasks are registered from: `targets(NativeTarget.host, + * NativeTarget.LINUX_ARM64)` names one target twice on a Linux arm64 host. + */ + internal val distinctTargets: Provider> get() = targets.map { it.distinct() } + fun targets(vararg targets: NativeTarget) = this.targets.set(targets.toList()) /** All four Android ABIs. */ diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt index dad2679..a6280a3 100644 --- a/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt @@ -22,7 +22,7 @@ import org.gradle.work.DisableCachingByDefault internal fun registerPrebuilt(project: Project, library: NativeLibrarySpec): TaskProvider<*> { val libraryName = library.name val archives = library.prebuilt.libraries.getOrElse(emptyMap()) - val targets = library.targets.getOrElse(emptyList()) + val targets = library.distinctTargets.getOrElse(emptyList()) val missing = targets.filter { it !in archives } check(missing.isEmpty()) { "konan.libraries.$libraryName.prebuilt has no archive for ${missing.joinToString { it.konanName }}." diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 637f986..00a9bb6 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -910,6 +910,18 @@ class KonanPluginFunctionalTest { assertFalse(":generateJniMymath" in result.output, "JNI is replaced on jvm():\n${result.output}") } + @Test + fun `a target named twice is built once`() { + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + mymath.replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.${host.name}, NativeTarget.ANDROID_ARM64, NativeTarget.${host.name})"), + ) + + val result = runner("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() + + assertTrue(":konanClangMymath$hostSuffix SKIPPED" in result.output && ":linkJniMymath$hostSuffix SKIPPED" in result.output, result.output) + } + @Test fun `JNI targets default to the library's that a JVM can load`() { write( @@ -1156,6 +1168,7 @@ class KonanPluginFunctionalTest { @Test fun `a multiplatform project binds through KGP's cinterop, after compiling the library`() { assumeTrue(KonanDistribution.installed() != null, "Kotlin/Native ${KonanDistribution.defaultVersion} is not installed") + assumeTrue(kotlinKnowsHost, "KGP builds no Kotlin/Native target on this host") // KMP's DSL names targets in camelCase: macos_arm64 -> macosArm64. val dslName = host.konanName.split('_').mapIndexed { i, part -> if (i == 0) part else part.replaceFirstChar { it.uppercase() } }.joinToString("") val targetSuffix = dslName.replaceFirstChar { it.uppercase() } diff --git a/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt index c96da17..89a3d12 100644 --- a/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt +++ b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt @@ -26,4 +26,13 @@ internal fun testRunner(): GradleRunner = GradleRunner.create().apply { System.getProperty("test.gradle.version")?.takeIf { it.isNotEmpty() }?.let(::withGradleVersion) } +/** + * Whether Kotlin knows the build host. Kotlin/Native has no Linux arm64 host: CI runs there on an + * unofficial distribution, which the plugin can use, but KGP and `kotlin-native-utils` from Maven throw + * "Unknown host target" there and build no Kotlin/Native target. + */ +internal val kotlinKnowsHost: Boolean by lazy { + runCatching { org.jetbrains.kotlin.konan.target.HostManager.host }.isSuccess +} + private object TestKitSupport diff --git a/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt b/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt index b7c55c7..f51d1e9 100644 --- a/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt +++ b/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt @@ -1,11 +1,13 @@ package io.github.lemcoder.model import io.github.lemcoder.kotlin.of +import io.github.lemcoder.kotlinKnowsHost import org.jetbrains.kotlin.konan.target.Family import org.jetbrains.kotlin.konan.target.HostManager import org.jetbrains.kotlin.konan.target.KonanTarget import kotlin.test.Test import kotlin.test.assertEquals +import org.junit.jupiter.api.Assumptions.assumeTrue /** * The plugin's targets against Kotlin/Native's, for the version the plugin is built against. A Kotlin @@ -36,6 +38,7 @@ class NativeTargetTest { @Test fun `the host is Kotlin-Native's host`() { + assumeTrue(kotlinKnowsHost, "Kotlin/Native does not support this host") assertEquals(HostManager.host.name, NativeTarget.host.konanName) } } From f54bf864954c82366517219630ac8d29c41aec9e Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 29 Oct 2026 11:46:54 +0100 Subject: [PATCH 46/57] ci: install JetBrains' Kotlin/Native from its GitHub release download.jetbrains.com returns 404 for the 2.4.20 prebuilt distribution. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011fd0f..885f1a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,7 +62,7 @@ jobs: dist=kotlin-native-prebuilt-linux-x86_64-$KONAN_VERSION if [ ! -d ~/.konan/$dist ]; then mkdir -p ~/.konan - curl -sSfL "https://download.jetbrains.com/kotlin/native/builds/releases/$KONAN_VERSION/linux-x86_64/$dist.tar.gz" | tar -xz -C ~/.konan + curl -sSfL "https://github.com/JetBrains/kotlin/releases/download/v$KONAN_VERSION/$dist.tar.gz" | tar -xz -C ~/.konan fi # voize-gmbh's unofficial Linux arm64 build, pinned by checksum. JetBrains hosts no LLVM or libffi for From 78467c1c60a8bd3a8646961e01af38fc2fe5b96a Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 29 Oct 2026 11:55:36 +0100 Subject: [PATCH 47/57] fix: Kotlin/Native's LLVM is downloaded on a machine that has never had it ensureDependencies found missing dependencies in the clang command run_konan echoes, but read it through clangCommand, which requires clang to run. On a fresh machine LLVM itself is missing, so run_konan failed with "Cannot run program .../llvm-21-x86_64-linux-essentials-116/bin/clang" before anything was downloaded. run_konan echoes the command before running it, so the echo is now read whatever the outcome. The Android NDK test's API 30 source is C++: NDK 27's android/thermal.h names its enums and structs without their tags, which C rejects. Co-Authored-By: Claude Opus 5.5 --- .../io/github/lemcoder/kotlin/KonanDistribution.kt | 10 +++++++++- .../io/github/lemcoder/KonanPluginFunctionalTest.kt | 7 ++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt index 5ddb9f9..e42046d 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -76,6 +76,11 @@ internal class KonanDistribution( fun clangCommand(target: NativeTarget, driver: ClangDriver, exec: ExecOperations): List { val result = exec.execCapture { commandLine(clang(target, listOf("--version"), driver)) } check(result is ExecCaptureResult.Success) { "run_konan could not run ${driver.toolName} for ${target.konanName}:\n${result.output}" } + return echoedCommand(result, target, driver) + } + + /** The command `run_konan` echoed, which it does before running it β€” so also when clang is missing. */ + private fun echoedCommand(result: ExecCaptureResult, target: NativeTarget, driver: ClangDriver): List { val line = result.output.lineSequence().firstOrNull { it.endsWith(" --version") && it.contains(" -target ") } ?: error("run_konan did not echo its ${driver.toolName} command for ${target.konanName}:\n${result.output}") return line.removeSuffix(" --version").split(' ').filter { it.isNotEmpty() } @@ -89,7 +94,10 @@ internal class KonanDistribution( */ fun ensureDependencies(target: NativeTarget, exec: ExecOperations, scratch: File) { val dependencies = home.parentFile.resolve("dependencies").absolutePath - val missing = clangCommand(target, ClangDriver.C, exec) + // Read from the echo, not a successful run: on a machine Kotlin/Native has never built on, LLVM's own + // directory is among the missing, and clang fails to start. + val echo = exec.execCapture { commandLine(clang(target, listOf("--version"), ClangDriver.C)) } + val missing = echoedCommand(echo, target, ClangDriver.C) .mapNotNull { argument -> argument.indexOf(dependencies).takeIf { it >= 0 }?.let { File(argument.substring(it)) } } .filterNot { it.exists() } if (missing.isEmpty()) return diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 00a9bb6..7594325 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -748,9 +748,10 @@ class KonanPluginFunctionalTest { } """.trimIndent() ) - // AThermal_acquireManager is API 30: past the 29 Kotlin/Native's NDK stops at. - File(projectDir, "native/thermal.c").writeText( - "#include \nint my_thermal(void) { AThermalManager *m = AThermal_acquireManager(); if (m) AThermal_releaseManager(m); return m != 0; }\n" + // AThermal_acquireManager is API 30: past the 29 Kotlin/Native's NDK stops at. C++, since NDK 27's + // thermal.h names its enums and structs without their tags, which C rejects. + File(projectDir, "native/thermal.cpp").writeText( + "#include \nextern \"C\" int my_thermal(void) { AThermalManager *m = AThermal_acquireManager(); if (m) AThermal_releaseManager(m); return m != 0; }\n" ) val android = mymath .replace("targets(NativeTarget.${host.name})", "targets(NativeTarget.ANDROID_ARM64)") From 0352635cdc7c88d5ceada64e56c11918bf91b34f Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 2 Nov 2026 01:33:48 +0100 Subject: [PATCH 48/57] chore: version 2.0.0-alpha03 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e82bc4a..1c31972 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and 1. Apply the plugin in your `build.gradle.kts`: ```kotlin plugins { - id("io.github.lemcoder.konanplugin") version "2.0.0-alpha02" + id("io.github.lemcoder.konanplugin") version "2.0.0-alpha03" } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 1e0d18b..0b60b6c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } // Every published version is immutable on the Portal: bump before publishing, and never reuse one. -version = "2.0.0-alpha02" +version = "2.0.0-alpha03" group = "io.github.lemcoder" repositories { From 56dc4b481f4ae007a3f47809d088c0d1ecad9e4c Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 2 Nov 2026 01:26:02 +0100 Subject: [PATCH 49/57] fix: JNI strings cross as real UTF-8 GetStringUTFChars and NewStringUTF speak modified UTF-8: a character outside the BMP reached C as two encoded surrogates on HotSpot, and NewStringUTF given a 4-byte sequence is undefined, which CheckJNI aborts. A const char* now crosses as a NUL-terminated UTF-8 ByteArray. The public binding keeps its String? signature and wraps a private external named $kni; '$' cannot occur in a C identifier, so it clashes with no binding. Fixes #14 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++ README.md | 2 +- .../lemcoder/index/exporter/JniGenerator.kt | 59 +++++++++++++++---- .../lemcoder/KonanPluginFunctionalTest.kt | 41 +++++++++++++ .../index/GenerateFromNativeIndexTest.kt | 13 +++- src/test/resources/nativeindex/sample.h | 1 + 6 files changed, 107 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1d7e864..66476a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,6 +137,11 @@ cinterop's `NativeIndex`: the Kotlin `external fun` named after the C function, function that marshals strings, primitive buffers and structs by value. cinterop's own JVM flavor is not used β€” it emits address-only bridges, every pointer a raw `Long`. +**Strings never use JNI's string functions** (`GetStringUTFChars`, `NewStringUTF`): they speak modified +UTF-8, so πŸ‘ reached C as two surrogates on HotSpot, and `NewStringUTF` given a 4-byte sequence is undefined β€” +CheckJNI aborts (#14). A `const char*` crosses as a `ByteArray` of UTF-8 behind a Kotlin wrapper, around a +private `external` named `$kni`: `$` cannot occur in a C identifier, so it clashes with no binding. + Bindings are `internal` by default and each carries **`@JvmName`**. JNI resolves the C symbol from the unmangled method name, so a mangled internal name compiles, links, and dies on the first call. diff --git a/README.md b/README.md index 1c31972..2fb1b18 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,7 @@ plugin writes both sides of each JNI bridge from the C declarations: | C type | Kotlin type | Crosses as | |----------------------------------------------|----------------------------------|----------------------------------------------------| | integers, `char`, `bool`, floating point, enums | `Int`, `Long`, `Byte`, `Boolean`, `Double`, … | the JNI primitive of the same size | -| `const char*` (argument or result) | `String?` | `GetStringUTFChars` / `NewStringUTF` | +| `const char*` (argument or result) | `String?` | NUL-terminated UTF-8 bytes, through a generated wrapper: JNI's string functions speak modified UTF-8, which splits characters outside the BMP | | `int*`, `uint8_t*`, `float*`, … buffer | `IntArray?`, `ByteArray?`, `FloatArray?`, … | pinned for the call; written back unless `const` | | struct by value (argument or result) | `ByteArray` | the struct's bytes | | pointer to pointer (`handle_t**` out-parameter) | `LongArray?` | addresses, copied into a C pointer array and back unless `const` | diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt index 1f4aa56..996dde4 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -12,7 +12,10 @@ import java.io.File * * How values cross: * - integers, `char`, `bool`, floating point and enums: the Kotlin primitive of the same size; - * - `const char*`: `String?` in both directions, through `GetStringUTFChars` / `NewStringUTF`; + * - `const char*`: `String?` in both directions, crossing as a `ByteArray` of real UTF-8 (#14): JNI's + * string functions speak modified UTF-8, which splits every character outside the BMP into two + * surrogates and is undefined for `NewStringUTF` given a 4-byte sequence. A function that takes or + * returns one gets a Kotlin wrapper doing the conversion around a private `$kni` external; * - other pointers to integers or floating point (`int*`, `uint8_t*`, `float*`, …): the matching * primitive array, pinned for the call and written back afterwards unless the pointee is `const`; * - pointers to pointers (`handle_t**`, the usual out-parameter): a `LongArray?` of addresses, copied @@ -60,7 +63,16 @@ object JniGenerator : BindingGenerator { private class Unsupported(reason: String) : Exception(reason) /** One C function, decided: how each parameter and the result cross. */ - private class Binding(val function: FunctionDecl, val params: List>, val result: Crossing) + private class Binding(val function: FunctionDecl, val params: List>, val result: Crossing) { + /** Strings cross as bytes, so the `external` takes and returns those behind a Kotlin wrapper. */ + val crossesStrings get() = result == Crossing.CString || params.any { it.second == Crossing.CString } + + /** + * The JVM name of the `external`: the C function's, or with a wrapper `$kni` β€” `$` cannot occur + * in a C identifier, so it names no other binding. + */ + val nativeName get() = if (crossesStrings) "${function.name}\$kni" else function.name + } /** How one value crosses the JNI boundary. */ /** The JVM primitives a C value can cross as, with how Kotlin and JNI spell them. */ @@ -85,15 +97,15 @@ object JniGenerator : BindingGenerator { } } - private sealed class Crossing(val kotlinType: String, val jniType: String) { + private sealed class Crossing(val kotlinType: String, val jniType: String, val externalType: String = kotlinType) { /** Passed as is; the C compiler converts between the JNI and C types of the same size. */ class Primitive(val primitive: JvmPrimitive) : Crossing(primitive.kotlinName, primitive.jniName) /** A pointer as an address; [cType] is how C spells the pointer, for the cast. */ class Address(val cType: String) : Crossing(JvmPrimitive.LONG.kotlinName, JvmPrimitive.LONG.jniName) - /** `const char*` as a `String?`. */ - object CString : Crossing("String?", "jstring") + /** `const char*` as a `String?`, through its NUL-terminated UTF-8 bytes. */ + object CString : Crossing("String?", "jbyteArray", "${JvmPrimitive.BYTE.kotlinName}Array?") /** * A pointer to [element] values as a pinned primitive array; [cType] spells the pointer. A [readOnly] @@ -217,14 +229,34 @@ object JniGenerator : BindingGenerator { append(NativeLoaderSource.kotlin()) if (constants.isNotEmpty()) appendLine() constants.forEach { (name, value) -> appendLine("${visibility.name.lowercase()} const val ${kotlinIdentifier(name)}$value") } + if (bound.any { b -> b.params.any { it.second == Crossing.CString } }) { + appendLine() + appendLine("/** A string as the NUL-terminated UTF-8 C reads; JNI's own string functions speak modified UTF-8. */") + appendLine("private fun kniUtf8(s: String): ByteArray = (s + '\\u0000').encodeToByteArray()") + } bound.forEach { b -> val params = b.params.joinToString { (name, crossing) -> "${kotlinIdentifier(name)}: ${crossing.kotlinType}" } + val visibilityName = visibility.name.lowercase() appendLine() appendLine("/** C: `${cSignature(b.function)}` */") // JNI resolves the C symbol from the JVM method name; without @JvmName an internal function // could be mangled. appendLine("""@JvmName("${b.function.name}")""") - appendLine("${visibility.name.lowercase()} external fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType}") + if (!b.crossesStrings) { + appendLine("$visibilityName external fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType}") + return@forEach + } + val native = "`${b.nativeName}`" + val arguments = b.params.joinToString { (name, crossing) -> + if (crossing == Crossing.CString) "${kotlinIdentifier(name)}?.let(::kniUtf8)" else kotlinIdentifier(name) + } + val decode = if (b.result == Crossing.CString) "?.decodeToString()" else "" + appendLine("$visibilityName fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType} = $native($arguments)$decode") + appendLine() + val externalParams = b.params.joinToString { (name, crossing) -> "${kotlinIdentifier(name)}: ${crossing.externalType}" } + // Escaped in the string literal, where `$kni` would be a template. + appendLine("""@JvmName("${b.nativeName.replace("$", "\\$")}")""") + appendLine("private external fun $native($externalParams): ${b.result.externalType}") } if (skipped.isNotEmpty()) { appendLine() @@ -262,7 +294,7 @@ object JniGenerator : BindingGenerator { val params = b.params.mapIndexed { i, (_, c) -> "${c.jniType} p$i" } val returnType = b.result.jniType val failure = if (b.result is Crossing.Void) "return;" else "return 0;" - appendLine("JNIEXPORT $returnType JNICALL ${jniClassPrefix}_${jniMangle(name)}(${(listOf("JNIEnv* env", "jclass cls") + params).joinToString(", ")}) {") + appendLine("JNIEXPORT $returnType JNICALL ${jniClassPrefix}_${jniMangle(b.nativeName)}(${(listOf("JNIEnv* env", "jclass cls") + params).joinToString(", ")}) {") // Checks first, so a failing one leaves nothing acquired. b.params.forEachIndexed { i, (_, c) -> @@ -286,8 +318,8 @@ object JniGenerator : BindingGenerator { is Crossing.Address -> "(${c.cType})(intptr_t)p$i" is Crossing.DirectBuffer -> "(${c.cType})d$i" Crossing.CString -> { - appendLine(" const char* s$i = p$i ? (*env)->GetStringUTFChars(env, p$i, 0) : 0;") - "s$i" + appendLine(" jbyte* s$i = p$i ? (*env)->GetByteArrayElements(env, p$i, 0) : 0;") + "(const char*)s$i" } is Crossing.PrimitiveArray -> { appendLine(" ${c.element.jniName}* a$i = p$i ? (*env)->Get${c.element.kotlinName}ArrayElements(env, p$i, 0) : 0;") @@ -314,7 +346,12 @@ object JniGenerator : BindingGenerator { is Crossing.Address -> appendLine(" jlong result = (jlong)(intptr_t)$call;") Crossing.CString -> { appendLine(" const char* r = $call;") - appendLine(" jstring result = r ? (*env)->NewStringUTF(env, r) : 0;") + appendLine(" jbyteArray result = 0;") + appendLine(" if (r) {") + appendLine(" jsize n = (jsize)strlen(r);") + appendLine(" result = (*env)->NewByteArray(env, n);") + appendLine(" if (result) (*env)->SetByteArrayRegion(env, result, 0, n, (const jbyte*)r);") + appendLine(" }") } is Crossing.StructBytes -> { appendLine(" ${r.cType} r = $call;") @@ -327,7 +364,7 @@ object JniGenerator : BindingGenerator { // copied back. b.params.indices.reversed().forEach { i -> when (val c = b.params[i].second) { - Crossing.CString -> appendLine(" if (p$i) (*env)->ReleaseStringUTFChars(env, p$i, s$i);") + Crossing.CString -> appendLine(" if (p$i) (*env)->ReleaseByteArrayElements(env, p$i, s$i, JNI_ABORT);") is Crossing.PrimitiveArray -> { val mode = if (c.readOnly) "JNI_ABORT" else "0" appendLine(" if (p$i) (*env)->Release${c.element.kotlinName}ArrayElements(env, p$i, a$i, $mode);") diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 7594325..43044dd 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -727,6 +727,47 @@ class KonanPluginFunctionalTest { } } + @Test + fun `strings cross JNI as real UTF-8, characters outside the BMP included`() { + val home = realKonanHome() + File(projectDir, "native").mkdirs() + File(projectDir, "native/text.c").writeText( + """ + #include + static char hex[256]; + const char* text_hex(const char* s) { + char* o = hex; + for (; *s; s++) o += sprintf(o, "%02x", (unsigned char)*s); + *o = 0; + return hex; + } + const char* text_echo(const char* s) { return s; } + """.trimIndent() + ) + File(projectDir, "src/main/kotlin").mkdirs() + // U+1F44D and U+20000 are surrogate pairs in Kotlin; modified UTF-8 splits each into two 3-byte sequences. + File(projectDir, "src/main/kotlin/Main.kt").writeText( + """ + import example.text_echo + import example.text_hex + fun main() { + val s = "aπŸ‘δΈ­π €€" + println("hex=" + text_hex(s)) + println("echo=" + (text_echo(s) == s)) + } + """.trimIndent() + ) + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")${"\n"}application""", "config.konanHome.set(file(\"$home\"))\n$mymath") + File(projectDir, "mymath.h").writeText("const char* text_hex(const char* s);\nconst char* text_echo(const char* s);\n") + File(projectDir, "build.gradle.kts").appendText("repositories { mavenCentral() }\napplication { mainClass.set(\"MainKt\") }\n") + + val output = runner("run", "-q").withPluginClasspathAndKgp().build().output + + // a, then F0 9F 91 8D, E4 B8 AD and F0 A0 80 80: what C reads is UTF-8, not modified UTF-8. + assertContains(output, "hex=61f09f918de4b8adf0a08080") + assertContains(output, "echo=true") + } + @Test fun `Android libraries compile and link with the NDK, its C++ runtime inside`() { val home = realKonanHome() diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index 194f427..924a1df 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -12,6 +12,7 @@ import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue /** @@ -53,7 +54,10 @@ class GenerateFromNativeIndexTest { val kotlin = jni().resolve("kotlin/sample/sample.kt").readText() assertContains(kotlin, "private val nativeLibrary: Unit = loadNativeLibrary(\"samplestubs\")") assertContains(kotlin, "@JvmName(\"sample_sum\")\ninternal external fun sample_sum(values: IntArray?, count: Long): Int") - assertContains(kotlin, "internal external fun sample_name(): String?") + // Strings cross as real UTF-8 bytes (#14): a wrapper around a private external. + assertContains(kotlin, "internal fun sample_name(): String? = `sample_name\$kni`()?.decodeToString()") + assertContains(kotlin, "@JvmName(\"sample_name\\\$kni\")\nprivate external fun `sample_name\$kni`(): ByteArray?") + assertContains(kotlin, "internal fun sample_echo(text: String?): String? = `sample_echo\$kni`(text?.let(::kniUtf8))?.decodeToString()") assertContains(kotlin, "internal external fun sample_buffer(handle: Long, c: Int, s: Int): ByteArray") assertContains(kotlin, "internal external fun sample_bits(f: ByteArray): ByteArray") assertContains(kotlin, "internal external fun sample_sort(base: java.nio.Buffer?, n: Long, cmp: Long): Unit") @@ -91,7 +95,12 @@ class GenerateFromNativeIndexTest { assertContains(c, "Java_sample_sample_sample_1sum(JNIEnv* env, jclass cls, jintArray p0, jlong p1)") // const int*: pinned for the call, not copied back. assertContains(c, "if (p0) (*env)->ReleaseIntArrayElements(env, p0, a0, JNI_ABORT);") - assertContains(c, "jstring result = r ? (*env)->NewStringUTF(env, r) : 0;") + // No JNI string function: they speak modified UTF-8 (#14). + assertFalse("StringUTF" in c, c) + assertContains(c, "Java_sample_sample_sample_1echo_00024kni(JNIEnv* env, jclass cls, jbyteArray p0)") + assertContains(c, "jbyte* s0 = p0 ? (*env)->GetByteArrayElements(env, p0, 0) : 0;") + assertContains(c, "if (p0) (*env)->ReleaseByteArrayElements(env, p0, s0, JNI_ABORT);") + assertContains(c, "if (result) (*env)->SetByteArrayRegion(env, result, 0, n, (const jbyte*)r);") assertContains(c, "if (!p0 || (*env)->GetArrayLength(env, p0) < (jsize)sizeof(struct flags)) {") assertContains(c, "void* d0 = p0 ? (*env)->GetDirectBufferAddress(env, p0) : 0;") assertContains(c, "sample_sort((void *)d0, p1, (compare_fn)(intptr_t)p2);") diff --git a/src/test/resources/nativeindex/sample.h b/src/test/resources/nativeindex/sample.h index 5bccba9..5d37096 100644 --- a/src/test/resources/nativeindex/sample.h +++ b/src/test/resources/nativeindex/sample.h @@ -48,6 +48,7 @@ extern const int sample_global; int sample_sum(const int *values, size_t count); const char *sample_name(void); +const char *sample_echo(const char *text); void sample_sort(void *base, size_t n, compare_fn cmp); double sample_scale(node_t *node, double factor); struct buffer sample_buffer(struct opaque *handle, enum color c, size_class s); From fbc473ac7a24a20ed5bb077a792ec6b06cba92ef Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 2 Nov 2026 01:35:52 +0100 Subject: [PATCH 50/57] chore: version 2.0.0-alpha04-SNAPSHOT Co-Authored-By: Claude Opus 5.5 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0b60b6c..e713914 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } // Every published version is immutable on the Portal: bump before publishing, and never reuse one. -version = "2.0.0-alpha03" +version = "2.0.0-alpha04-SNAPSHOT" group = "io.github.lemcoder" repositories { From fe711e6a4b636d9e3322dff7bdfbc1053234f645 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 2 Nov 2026 22:31:07 +0100 Subject: [PATCH 51/57] fix: generated KMP JNI sources carry their producer addKmpJvmSources added the generated directory through a provider of its own, which carries no producer, and patched only the compile tasks with dependsOn. Any other reader of the source set raced generation: a published Android or jvm() sources jar failed Gradle's implicit-dependency check. The directory is now a file collection built by the generation task, so every reader depends on it. Co-Authored-By: Claude Opus 5.5 --- .../io/github/lemcoder/kotlin/KotlinGradlePlugin.kt | 11 +++++------ .../io/github/lemcoder/KonanPluginFunctionalTest.kt | 10 ++++++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt index 82f49a9..60aa719 100644 --- a/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -36,16 +36,15 @@ internal object KotlinGradlePlugin { /** …and of the main compilation of every JVM and Android target of a multiplatform project. */ fun addKmpJvmSources(project: Project, sources: Provider, generate: TaskProvider<*>, enabled: () -> Boolean) { // Decided when the sources are read, not now: targets are usually declared before konan { }. A - // provider of our own carries no producer, so the compile's dependency is spelled out too. - val lazySources = project.provider { if (enabled()) listOf(sources.get()) else emptyList() } + // provider of our own carries no producer, so the directory is built by the generation explicitly: + // every reader of the source set β€” the compile, a sources jar, lint β€” then depends on it. val lazyGenerate = project.provider { if (enabled()) listOf(generate) else emptyList() } + val lazySources = project.files(project.provider { if (enabled()) listOf(sources.get()) else emptyList() }) + .builtBy(lazyGenerate) project.extensions.getByType(KotlinMultiplatformExtension::class.java).targets .matching { it.platformType in JVM_PLATFORMS } .configureEach { - compilations.matching { it.name == "main" }.configureEach { - defaultSourceSet.kotlin.srcDir(lazySources) - compileTaskProvider.configure { dependsOn(lazyGenerate) } - } + compilations.matching { it.name == "main" }.configureEach { defaultSourceSet.kotlin.srcDir(lazySources) } } } diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 43044dd..ddc577c 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -936,6 +936,16 @@ class KonanPluginFunctionalTest { assertTrue(":generateJniMymath SKIPPED" in result.output, result.output) } + @Test + fun `a multiplatform jvm() target's sources jar is built after its JNI bindings are generated`() { + write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath) + File(projectDir, "build.gradle.kts").appendText("kotlin { jvm() }\nrepositories { mavenCentral() }\n") + + val result = runner("jvmSourcesJar", "--dry-run").withPluginClasspathAndKgp().build() + + assertTrue(":generateJniMymath SKIPPED" in result.output, result.output) + } + @Test fun `ffm() serves a multiplatform project's jvm() targets in place of JNI`() { write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath.replace("library(mymath)", "library(mymath)\n ffm()")) From b70101ac36bbca8617f0571d15f32389d6e8c6ca Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 2 Nov 2026 22:55:49 +0100 Subject: [PATCH 52/57] fix: tasks needing Kotlin/Native run after KGP's download konanHome.set(kotlinNativeDistribution()) names the directory the Kotlin plugin installs in downloadKotlinNativeDistribution (KGP 2.3 and newer), but nothing ordered the plugin's tasks after it: on a machine without that distribution, generateJni could run first and fail. Every UsesKonanDistribution task now depends on that task when it exists and konanHome is that directory. The dependency is a project.provider: given konanHome.map, Gradle asks the mapped property for its producer, finds none, and never evaluates the mapping. Fixes #16 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +++- README.md | 2 ++ .../kotlin/io/github/lemcoder/KonanPlugin.kt | 14 +++++++++++ .../lemcoder/KonanPluginFunctionalTest.kt | 23 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 66476a5..c0d0f75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,10 @@ resolved the moment the plugin is applied, in *every* project that applies it. property `konan.config.`, then the environment variable, and records `usedBy` for `konanConfig`. Add a path by adding a `ConfigField` entry and a property, never a per-block `executable`. - **Tasks that run Kotlin/Native implement `UsesKonanDistribution`**; the plugin sets their `konanHome` - in one `withType().configureEach`, and `distribution()` checks it when the task runs. + in one `withType().configureEach`, and `distribution()` checks it when the task runs. The same block makes + them depend on KGP's `downloadKotlinNativeDistribution` (KGP 2.3+) when `konanHome` is the directory + `kotlinNativeDistribution()` names (#16). That dependency is a `project.provider { }`: given + `konanHome.map { }`, Gradle asks the mapped property for its producer, finds none, and never runs the mapping. - **The plugin ships Android's `jni.h`** (`src/main/resources/io/github/lemcoder/dsl/bindings/jni/`, Apache 2.0): the JNI generator writes it to its `include/` output, the host link and `KONAN_JNI_INCLUDE_DIR` use it. Its JNI is 1.6 β€” enough for the generated stubs. diff --git a/README.md b/README.md index 2fb1b18..f426ea9 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ plugin downloads for a native target: konan { config { konanHome.set(kotlinNativeDistribution()) } } // ~/.konan/kotlin-native-prebuilt-- ``` +When the Kotlin plugin downloads it in a task of its own (`downloadKotlinNativeDistribution`, Kotlin 2.3 and +newer), every plugin task that needs `konanHome` runs after it, so a fresh machine needs no extra step. A project with no native target never makes the Kotlin plugin download it: build any Kotlin Multiplatform project with a native target on that version once, or unpack `kotlin-native-prebuilt--` from [Maven Central](https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-native-prebuilt/). diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index c5c54f6..4ebc809 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -12,6 +12,7 @@ import io.github.lemcoder.wiring.GradlePluginId import io.github.lemcoder.wiring.withPlugin import org.gradle.api.Plugin import org.gradle.api.Project +import java.io.File abstract class KonanPlugin : Plugin { override fun apply(project: Project) { @@ -20,6 +21,15 @@ abstract class KonanPlugin : Plugin { // that never runs one needs none. project.tasks.withType(UsesKonanDistribution::class.java).configureEach { konanHome.convention(konan.config.readConfigField(ConfigField.KONAN_HOME, path)) + // The Kotlin plugin installs the distribution kotlinNativeDistribution() names in a task of its + // own in recent versions (2.3.0 has it, 2.2.10 not); earlier ones download while configuring (#16). + // project.provider, not konanHome.map: Gradle asks a mapped property for its producer, finds none, + // and never evaluates the mapping. + val home = konanHome + dependsOn(project.provider { + val installs = home.get().raw?.let(::File)?.absoluteFile == konan.config.kotlinNativeDistribution().absoluteFile + if (installs && DOWNLOAD_KOTLIN_NATIVE in project.tasks.names) listOf(project.tasks.named(DOWNLOAD_KOTLIN_NATIVE)) else emptyList() + }) } project.registerConfigReport(konan.config) // kotlinNativeDistribution() names the project's own Kotlin/Native version once the Kotlin plugin is known. @@ -31,4 +41,8 @@ abstract class KonanPlugin : Plugin { project.registerLibraries(konan) project.registerBindings(konan) } + + private companion object { + const val DOWNLOAD_KOTLIN_NATIVE = "downloadKotlinNativeDistribution" + } } diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index ddc577c..1a8fff4 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -946,6 +946,29 @@ class KonanPluginFunctionalTest { assertTrue(":generateJniMymath SKIPPED" in result.output, result.output) } + @Test + fun `a konanHome the Kotlin plugin installs is downloaded before the tasks that need it`() { + assumeTrue(kotlinKnowsHost, "Kotlin/Native does not support this host") + val download = ":downloadKotlinNativeDistribution SKIPPED" + fun dryRun(konanHome: String): String { + write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", "$konanHome\n$mymath") + File(projectDir, "build.gradle.kts").appendText( + "kotlin { jvm(); linuxX64() }\nrepositories { mavenCentral() }\n" + + "gradle.taskGraph.whenReady { println(\"registers download: \${\"downloadKotlinNativeDistribution\" in tasks.names}\") }\n" + ) + return runner("generateJniMymath", "--dry-run").withPluginClasspathAndKgp().build().output + } + + val installed = dryRun("config.konanHome.set(config.kotlinNativeDistribution())") + // Before 2.3 the Kotlin plugin downloads while configuring, and registers no task to depend on. + assumeTrue("registers download: true" in installed, "this Kotlin plugin has no downloadKotlinNativeDistribution") + assertTrue(installed.indexOf(download) in 0 until installed.indexOf(":generateJniMymath SKIPPED"), installed) + + // A distribution of the build's own is not the Kotlin plugin's to install. + val own = dryRun("") + assertTrue(":generateJniMymath SKIPPED" in own && download !in own, own) + } + @Test fun `ffm() serves a multiplatform project's jvm() targets in place of JNI`() { write("""kotlin("multiplatform")${"\n"}id("io.github.lemcoder.konanplugin")""", mymath.replace("library(mymath)", "library(mymath)\n ffm()")) From 04f9170f8e6f05b2d98e9877b4ca744cee3da04d Mon Sep 17 00:00:00 2001 From: mikolaj Date: Mon, 2 Nov 2026 23:53:19 +0100 Subject: [PATCH 53/57] chore: version 2.0.0-alpha04 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f426ea9..119c7ef 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The **Konan Plugin** compiles C/C++ libraries with Kotlin/Native's toolchain and 1. Apply the plugin in your `build.gradle.kts`: ```kotlin plugins { - id("io.github.lemcoder.konanplugin") version "2.0.0-alpha03" + id("io.github.lemcoder.konanplugin") version "2.0.0-alpha04" } ``` diff --git a/build.gradle.kts b/build.gradle.kts index e713914..08324f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } // Every published version is immutable on the Portal: bump before publishing, and never reuse one. -version = "2.0.0-alpha04-SNAPSHOT" +version = "2.0.0-alpha04" group = "io.github.lemcoder" repositories { From 9bbc36194403c7b75bdf2f25a4ba5d9725549166 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 3 Nov 2026 01:13:32 +0100 Subject: [PATCH 54/57] docs: changelog for 2.0.0-alpha03 and alpha04 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5118a5b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +Versions are published to the [Gradle Plugin Portal](https://plugins.gradle.org/plugin/io.github.lemcoder.konanplugin) +and never republished. 2.0 replaces 1.x's DSL; the README's migration notes cover the move. + +## 2.0.0-alpha04 + +### Fixed +- **JNI strings cross as real UTF-8** ([#14](https://github.com/lemcoder/KonanPlugin/issues/14)). `const char*` + went through `GetStringUTFChars`/`NewStringUTF`, which speak modified UTF-8: a character outside the BMP + (πŸ‘, π €€) reached C as two encoded surrogates on HotSpot, and a 4-byte sequence returned from C is undefined + behavior that CheckJNI aborts on. Strings now cross as NUL-terminated UTF-8 bytes. The bindings keep their + `String?` signatures; each wraps a private `external` named `$kni`. +- **Generated KMP JNI sources carry their producer** ([#15](https://github.com/lemcoder/KonanPlugin/pull/15)). + The generated bindings were added to multiplatform JVM and Android source sets without a task dependency, + so anything but the compile, such as `androidSourcesJar` or `jvmSourcesJar` when publishing, failed with + *"uses this output of task ':generateJni…' without declaring an explicit or implicit dependency"*. +- **Tasks needing Kotlin/Native run after KGP's download** ([#16](https://github.com/lemcoder/KonanPlugin/issues/16)). + With `konanHome.set(kotlinNativeDistribution())`, `generateJni`, `konanClang`, + `linkJni` and the other tasks needing Kotlin/Native could run before the Kotlin plugin's + `downloadKotlinNativeDistribution` and fail on a machine without that distribution. They now depend on it + when it exists (Kotlin 2.3 and newer) and `konanHome` is the directory it installs. Builds that added this + `dependsOn` themselves can drop it. + +## 2.0.0-alpha03 + +### Added +- `cmake { sources }`: declared sources make a CMake build up to date when they have not changed ([#13](https://github.com/lemcoder/KonanPlugin/issues/13)). + +### Fixed +- Android packaging (APK, AAR) builds only the Android targets' libraries, not the host's + ([#12](https://github.com/lemcoder/KonanPlugin/issues/12)). +- A target named twice registers its tasks once. +- Kotlin/Native's LLVM is downloaded on a machine that has never had it, instead of failing the first + compile. From d63f9c027dc830efca2fc4a4e93b75aef11d4ebd Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 3 Nov 2026 01:13:32 +0100 Subject: [PATCH 55/57] ci: drop the unofficial Linux arm64 job It ran on voize-gmbh's unofficial Kotlin/Native build, where KGP knows no host; CI now tests Linux x64 on JetBrains' distribution only. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 62 +++---------------- .../io/github/lemcoder/TestKitSupport.kt | 6 +- 2 files changed, 11 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 885f1a7..4ca0925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,25 +12,10 @@ concurrency: jobs: test: - name: test (${{ matrix.name }}) - runs-on: ${{ matrix.os }} - # Linux arm64 has no official Kotlin/Native distribution: it runs on an unofficial build and may fail - # without failing the workflow, until it is known to pass. - continue-on-error: ${{ matrix.experimental }} - strategy: - fail-fast: false - matrix: - include: - - name: linux-x64 - os: ubuntu-24.04 - experimental: false - konan-version: "2.4.20" - - name: linux-arm64 - os: ubuntu-24.04-arm - experimental: true - konan-version: "2.4.21" + name: test (linux-x64) + runs-on: ubuntu-24.04 env: - KONAN_VERSION: ${{ matrix.konan-version }} + KONAN_VERSION: "2.4.20" steps: - uses: actions/checkout@v4 @@ -52,12 +37,11 @@ jobs: uses: actions/cache@v4 with: path: ~/.konan - key: konan-${{ matrix.name }}-${{ matrix.konan-version }} + key: konan-linux-x64-${{ env.KONAN_VERSION }} # The tests find the distribution at ~/.konan/kotlin-native-prebuilt--, where the Kotlin # Gradle plugin installs it; without it they skip. - - name: Install Kotlin/Native (JetBrains) - if: matrix.name == 'linux-x64' + - name: Install Kotlin/Native run: | dist=kotlin-native-prebuilt-linux-x86_64-$KONAN_VERSION if [ ! -d ~/.konan/$dist ]; then @@ -65,41 +49,11 @@ jobs: curl -sSfL "https://github.com/JetBrains/kotlin/releases/download/v$KONAN_VERSION/$dist.tar.gz" | tar -xz -C ~/.konan fi - # voize-gmbh's unofficial Linux arm64 build, pinned by checksum. JetBrains hosts no LLVM or libffi for - # this host, so they go into the dependencies directory by hand, marked as extracted. - - name: Install Kotlin/Native (voize-gmbh, unofficial) - if: matrix.name == 'linux-arm64' - run: | - release=https://github.com/voize-gmbh/kotlin/releases/download/v2.4.21-linux-arm64 - dist=kotlin-native-prebuilt-linux-aarch64-$KONAN_VERSION - deps=~/.konan/dependencies - mkdir -p "$deps" downloads - if [ ! -d ~/.konan/$dist ]; then - cd downloads - cat > SHA256SUMS <<'EOF' - e7dfc692c1e0b1d0ad9ada67031a67b0544974ba77220445c21efc7949281ea3 kotlin-native-prebuilt-linux-aarch64-2.4.21.tar.gz - ad212a7541b98ed2a4ebb3b510f4cdebf2919b8b0cc9dd5a321558a5f4282b13 libffi-3.4.6-1-linux-aarch64.tar.gz - 35e9a2566bf7cb44de427669d010117e98148ca5ea1a4ffef291218581d376a9 llvm-21-aarch64-linux-essentials-1.tar.gz - EOF - sed -i 's/^ *//' SHA256SUMS - for f in $(awk '{print $2}' SHA256SUMS); do curl -sSfLO "$release/$f"; done - sha256sum -c SHA256SUMS - tar -xzf llvm-21-aarch64-linux-essentials-1.tar.gz -C "$deps" - tar -xzf libffi-3.4.6-1-linux-aarch64.tar.gz -C "$deps" - for n in llvm-21-aarch64-linux-essentials-1 libffi-3.4.6-1-linux-aarch64; do - grep -qx "$n" "$deps/.extracted" 2>/dev/null || echo "$n" >> "$deps/.extracted" - done - tar -xzf $dist.tar.gz -C ~/.konan - cd .. && rm -rf downloads - fi - - name: Check Kotlin/Native run: | - home=$(ls -d ~/.konan/kotlin-native-prebuilt-linux-*-$KONAN_VERSION) + home=~/.konan/kotlin-native-prebuilt-linux-x86_64-$KONAN_VERSION "$home/bin/kotlinc-native" -version - # The default Kotlin/Native version is what the tests and kotlin-native-utils use; the arm64 build - # exists for 2.4.21 only. - name: Test run: ./gradlew test -PkotlinNativeVersion=$KONAN_VERSION --continue @@ -117,7 +71,7 @@ jobs: if outcome != "passed": detail = next(c for c in case if c.tag == outcome).get("message", "") or "" rows.append(f"| {outcome} | {case.get('name')} | {detail.splitlines()[0][:150] if detail else ''} |") - print(f"### ${{ matrix.name }}: {totals[0]} passed, {totals[1]} failed, {totals[2]} skipped\n") + print(f"### linux-x64: {totals[0]} passed, {totals[1]} failed, {totals[2]} skipped\n") if rows: print("| Outcome | Test | Message |\n|---|---|---|") print("\n".join(rows)) @@ -126,5 +80,5 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: test-reports-${{ matrix.name }} + name: test-reports-linux-x64 path: build/reports/tests/test diff --git a/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt index 89a3d12..af4eec1 100644 --- a/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt +++ b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt @@ -27,9 +27,9 @@ internal fun testRunner(): GradleRunner = GradleRunner.create().apply { } /** - * Whether Kotlin knows the build host. Kotlin/Native has no Linux arm64 host: CI runs there on an - * unofficial distribution, which the plugin can use, but KGP and `kotlin-native-utils` from Maven throw - * "Unknown host target" there and build no Kotlin/Native target. + * Whether Kotlin knows the build host. Kotlin/Native has no Linux arm64 host: an unofficial distribution + * runs the plugin there, but KGP and `kotlin-native-utils` from Maven throw "Unknown host target" and build + * no Kotlin/Native target. */ internal val kotlinKnowsHost: Boolean by lazy { runCatching { org.jetbrains.kotlin.konan.target.HostManager.host }.isSuccess From 45d7e3398794b5d9d437260b0e27ad48b0ed3cce Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 3 Nov 2026 00:26:52 +0100 Subject: [PATCH 56/57] feat: pointers to records cross JNI as typed handles A pointer to a named struct or union was a bare Long: any Long compiled where a tsf* was expected, and a NULL result was 0L that the next call dereferenced. Each such record now gets @JvmInline value class Pointer(val address: Long), named by the record's tag (or an anonymous record's typedef), so node_t* and struct node* are one NodePointer. Parameters take the handle non-null; results are nullable, NULL being null. The external keeps the Long behind the $kni wrapper strings already use: Kotlin mangles the JVM name of a function with a value-class parameter, which JNI could not resolve. Typedef'd void*, function pointers and pointers to pointers are unchanged. Bindings that passed these pointers as Long must use the handle type. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++ README.md | 18 ++++- .../lemcoder/index/exporter/JniGenerator.kt | 81 +++++++++++++++---- .../index/GenerateFromNativeIndexTest.kt | 10 ++- src/test/resources/nativeindex/sample.h | 1 + 5 files changed, 98 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c0d0f75..1ef88ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,6 +145,11 @@ UTF-8, so πŸ‘ reached C as two surrogates on HotSpot, and `NewStringUTF` given CheckJNI aborts (#14). A `const char*` crosses as a `ByteArray` of UTF-8 behind a Kotlin wrapper, around a private `external` named `$kni`: `$` cannot occur in a C identifier, so it clashes with no binding. +**A pointer to a named record is a typed handle** (`@JvmInline value class Pointer(val address: Long)`), and +again only the Kotlin wrapper sees it: a function with a value-class parameter gets a mangled JVM name, so the +`external` keeps the `Long` under `$kni`. The class is named by the record's tag (`node_t*` and `struct node*` +are one `NodePointer`); results are nullable, parameters are not. + Bindings are `internal` by default and each carries **`@JvmName`**. JNI resolves the C symbol from the unmangled method name, so a mangled internal name compiles, links, and dies on the first call. diff --git a/README.md b/README.md index 119c7ef..d93d407 100644 --- a/README.md +++ b/README.md @@ -511,7 +511,23 @@ plugin writes both sides of each JNI bridge from the C declarations: | struct by value (argument or result) | `ByteArray` | the struct's bytes | | pointer to pointer (`handle_t**` out-parameter) | `LongArray?` | addresses, copied into a C pointer array and back unless `const` | | `void*`, `const void*` argument | `java.nio.Buffer?` | a direct buffer's start address (`GetDirectBufferAddress`); a heap buffer throws | -| opaque handle, typedef'd `void*`, struct or function pointer, any pointer result | `Long` | the address | +| pointer to a named struct or union (`tsf*`, `struct node*`, opaque or not) | `TsfPointer`, `NodePointer`, …; `…?` as a result | the address, through a generated wrapper | +| typedef'd `void*`, function pointer, other pointer results | `Long` | the address | + +A pointer to a struct or union gets a typed handle named after the record's tag (or, for an anonymous +one, its typedef), so `tsf*` and `const tsf*` are both a `TsfPointer`: + +```kotlin +@JvmInline internal value class TsfPointer(val address: Long) + +internal fun tsf_load_filename(filename: String?): TsfPointer? // NULL is null +internal fun tsf_note_on(f: TsfPointer, preset_index: Int, key: Int, vel: Float): Int +``` + +Results are nullable, so a failed call cannot be passed on unchecked; parameters take a non-null handle, +so a stray `Long` does not compile. `address` is the raw pointer for other native APIs, and +`TsfPointer(0L)` passes NULL deliberately. A handle says nothing about who owns the memory: freeing it +(`tsf_close`) and not using it afterwards stays the caller's job. Pointers to pointers stay `LongArray?`. Enum constants and integer, floating-point and string macros become `const val`s. Functions that cannot be bound β€” variadic ones, `long double` β€” are listed with the reason at the end of the generated diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt index 996dde4..0eed692 100644 --- a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -23,7 +23,12 @@ import java.io.File * - structs by value: a `ByteArray` holding the struct's bytes; * - `void*` and `const void*` arguments, spelled as such: a direct `java.nio.Buffer?`, whose start * address β€” not its position β€” is passed; a heap buffer is an `IllegalArgumentException`; - * - every other pointer (opaque handles, typedef'd `void*`, structs, function pointers, results): its + * - pointers to a named struct or union (`tsf*`, `struct node*`, opaque or not): a typed handle, + * `@JvmInline value class Pointer(val address: Long)`, named after the record. Parameters take it + * non-null, results are `null` for NULL, so neither an unrelated `Long` nor a failed call's NULL reaches + * C unnoticed. The `external` still takes the `Long`, behind a wrapper: Kotlin mangles the JVM name of + * a function with value-class parameters, which JNI could not resolve; + * - every other pointer (typedef'd `void*`, function pointers, pointers to primitives as results): its * address as a `Long`. * * Enum constants and the integer, floating-point and string macro constants become `const val`s. @@ -64,14 +69,17 @@ object JniGenerator : BindingGenerator { /** One C function, decided: how each parameter and the result cross. */ private class Binding(val function: FunctionDecl, val params: List>, val result: Crossing) { - /** Strings cross as bytes, so the `external` takes and returns those behind a Kotlin wrapper. */ - val crossesStrings get() = result == Crossing.CString || params.any { it.second == Crossing.CString } + /** + * Strings cross as bytes and handles as their address, so the `external` takes and returns those + * behind a Kotlin wrapper. + */ + val needsWrapper get() = (params.map { it.second } + result).any { it == Crossing.CString || it is Crossing.Handle } /** * The JVM name of the `external`: the C function's, or with a wrapper `$kni` β€” `$` cannot occur * in a C identifier, so it names no other binding. */ - val nativeName get() = if (crossesStrings) "${function.name}\$kni" else function.name + val nativeName get() = if (needsWrapper) "${function.name}\$kni" else function.name } /** How one value crosses the JNI boundary. */ @@ -102,7 +110,17 @@ object JniGenerator : BindingGenerator { class Primitive(val primitive: JvmPrimitive) : Crossing(primitive.kotlinName, primitive.jniName) /** A pointer as an address; [cType] is how C spells the pointer, for the cast. */ - class Address(val cType: String) : Crossing(JvmPrimitive.LONG.kotlinName, JvmPrimitive.LONG.jniName) + open class Address(val cType: String, kotlinType: String = JvmPrimitive.LONG.kotlinName) : + Crossing(kotlinType, JvmPrimitive.LONG.jniName, JvmPrimitive.LONG.kotlinName) + + /** + * A pointer to the record [record] as its typed handle, [className]; `null` for NULL when [nullable] + * (results). Crosses JNI as an [Address]. + */ + class Handle(cType: String, val record: String, val nullable: Boolean) : + Address(cType, handleClassName(record) + if (nullable) "?" else "") { + val className get() = handleClassName(record) + } /** `const char*` as a `String?`, through its NUL-terminated UTF-8 bytes. */ object CString : Crossing("String?", "jbyteArray", "${JvmPrimitive.BYTE.kotlinName}Array?") @@ -151,18 +169,23 @@ object JniGenerator : BindingGenerator { private fun result(type: Type): Crossing = when (val t = resolved(type)) { VoidType -> Crossing.Void - is PointerType -> - if (resolved(t.pointeeType) == CharType && t.pointeeIsConst) Crossing.CString else Crossing.Address(spelling(type)) + is PointerType -> when { + resolved(t.pointeeType) == CharType && t.pointeeIsConst -> Crossing.CString + else -> handleRecord(t.pointeeType)?.let { Crossing.Handle(spelling(type), it, nullable = true) } + ?: Crossing.Address(spelling(type)) + } is RecordType -> Crossing.StructBytes(spelling(type)) else -> primitive(t) } private fun pointer(declared: Type, pointer: Type): Crossing { - val (pointee, isConst) = when (pointer) { - is PointerType -> resolved(pointer.pointeeType) to pointer.pointeeIsConst - is ArrayType -> resolved(pointer.elemType) to false + val declaredPointee = when (pointer) { + is PointerType -> pointer.pointeeType + is ArrayType -> pointer.elemType else -> error("not a pointer") } + val pointee = resolved(declaredPointee) + val isConst = pointer is PointerType && pointer.pointeeIsConst if (pointee == CharType && isConst) return Crossing.CString val cType = spelling(declared) val element = when (pointee) { @@ -175,10 +198,20 @@ object JniGenerator : BindingGenerator { element != null -> Crossing.PrimitiveArray(element, cType, isConst) pointee == VoidType && declared !is Typedef -> Crossing.DirectBuffer(cType) pointee is PointerType -> Crossing.AddressArray(cType, isConst) - else -> Crossing.Address(cType) + else -> handleRecord(declaredPointee)?.let { Crossing.Handle(cType, it, nullable = false) } ?: Crossing.Address(cType) } } + /** + * The name a pointer to [pointee] is typed by: its struct or union's tag, else β€” an anonymous record β€” + * the typedef naming it. `null` for anything else, which stays a `Long`. + */ + private fun handleRecord(pointee: Type): String? { + val record = resolved(pointee) as? RecordType ?: return null + if (!record.decl.isAnonymous) return record.decl.spelling.removePrefix("struct ").removePrefix("union ") + return (pointee as? Typedef)?.def?.name + } + private fun primitive(type: Type): Crossing.Primitive = when (type) { CharType -> Crossing.Primitive(JvmPrimitive.BYTE) is BoolType -> Crossing.Primitive(JvmPrimitive.BOOLEAN) @@ -234,23 +267,38 @@ object JniGenerator : BindingGenerator { appendLine("/** A string as the NUL-terminated UTF-8 C reads; JNI's own string functions speak modified UTF-8. */") appendLine("private fun kniUtf8(s: String): ByteArray = (s + '\\u0000').encodeToByteArray()") } + val visibilityName = visibility.name.lowercase() + bound.flatMap { b -> b.params.map { it.second } + b.result }.filterIsInstance() + .distinctBy { it.record }.sortedBy { it.className }.forEach { h -> + appendLine() + appendLine("/** The address of a C `${h.record}`; a function that may return NULL returns `null` instead. */") + appendLine("@JvmInline") + appendLine("$visibilityName value class ${h.className}(val address: Long)") + } bound.forEach { b -> val params = b.params.joinToString { (name, crossing) -> "${kotlinIdentifier(name)}: ${crossing.kotlinType}" } - val visibilityName = visibility.name.lowercase() appendLine() appendLine("/** C: `${cSignature(b.function)}` */") // JNI resolves the C symbol from the JVM method name; without @JvmName an internal function // could be mangled. appendLine("""@JvmName("${b.function.name}")""") - if (!b.crossesStrings) { + if (!b.needsWrapper) { appendLine("$visibilityName external fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType}") return@forEach } val native = "`${b.nativeName}`" val arguments = b.params.joinToString { (name, crossing) -> - if (crossing == Crossing.CString) "${kotlinIdentifier(name)}?.let(::kniUtf8)" else kotlinIdentifier(name) + when (crossing) { + Crossing.CString -> "${kotlinIdentifier(name)}?.let(::kniUtf8)" + is Crossing.Handle -> "${kotlinIdentifier(name)}.address" + else -> kotlinIdentifier(name) + } + } + val decode = when (val result = b.result) { + Crossing.CString -> "?.decodeToString()" + is Crossing.Handle -> ".let { if (it == 0L) null else ${result.className}(it) }" + else -> "" } - val decode = if (b.result == Crossing.CString) "?.decodeToString()" else "" appendLine("$visibilityName fun ${kotlinIdentifier(b.function.name)}($params): ${b.result.kotlinType} = $native($arguments)$decode") appendLine() val externalParams = b.params.joinToString { (name, crossing) -> "${kotlinIdentifier(name)}: ${crossing.externalType}" } @@ -450,6 +498,9 @@ object JniGenerator : BindingGenerator { private fun kotlinName(cName: String?, index: Int): String = cName?.takeIf { it.isNotEmpty() && it.all { c -> c == '_' || c.isLetterOrDigit() } && !it.first().isDigit() } ?: "p$index" + /** `tsf` β†’ `TsfPointer`: the record's name, capitalized as a Kotlin class, and `Pointer`. */ + private fun handleClassName(record: String) = record.replaceFirstChar { it.uppercaseChar() } + "Pointer" + private fun kotlinIdentifier(name: String) = if (name in KOTLIN_KEYWORDS) "`$name`" else name private val KOTLIN_KEYWORDS = setOf( diff --git a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt index 924a1df..198fc02 100644 --- a/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -58,7 +58,15 @@ class GenerateFromNativeIndexTest { assertContains(kotlin, "internal fun sample_name(): String? = `sample_name\$kni`()?.decodeToString()") assertContains(kotlin, "@JvmName(\"sample_name\\\$kni\")\nprivate external fun `sample_name\$kni`(): ByteArray?") assertContains(kotlin, "internal fun sample_echo(text: String?): String? = `sample_echo\$kni`(text?.let(::kniUtf8))?.decodeToString()") - assertContains(kotlin, "internal external fun sample_buffer(handle: Long, c: Int, s: Int): ByteArray") + // Pointers to records are typed handles, unwrapped for the external (Kotlin would mangle its name). + assertContains(kotlin, "@JvmInline\ninternal value class OpaquePointer(val address: Long)") + assertContains(kotlin, "internal fun sample_buffer(handle: OpaquePointer, c: Int, s: Int): ByteArray = `sample_buffer\$kni`(handle.address, c, s)") + assertContains(kotlin, "@JvmName(\"sample_buffer\\\$kni\")\nprivate external fun `sample_buffer\$kni`(handle: Long, c: Int, s: Int): ByteArray") + // Named by the struct's tag whichever way it is spelled: node_t is struct node. + assertContains(kotlin, "internal fun sample_scale(node: NodePointer, factor: Double): Double = `sample_scale\$kni`(node.address, factor)") + assertContains(kotlin, "internal fun sample_packet_size(p: PacketPointer): Long") + // NULL comes back as null. + assertContains(kotlin, "internal fun sample_create(): OpaquePointer? = `sample_create\$kni`().let { if (it == 0L) null else OpaquePointer(it) }") assertContains(kotlin, "internal external fun sample_bits(f: ByteArray): ByteArray") assertContains(kotlin, "internal external fun sample_sort(base: java.nio.Buffer?, n: Long, cmp: Long): Unit") // A typedef'd void* is a handle, not memory. diff --git a/src/test/resources/nativeindex/sample.h b/src/test/resources/nativeindex/sample.h index 5d37096..1c17703 100644 --- a/src/test/resources/nativeindex/sample.h +++ b/src/test/resources/nativeindex/sample.h @@ -55,6 +55,7 @@ struct buffer sample_buffer(struct opaque *handle, enum color c, size_class s); int sample_log(const char *format, ...); size_t sample_packet_size(const struct packet *p); bits_t sample_bits(struct flags f); +struct opaque *sample_create(void); int sample_open(struct opaque **out); int sample_peek(const struct opaque *const *handles, size_t n); typedef void *sample_handle; From 69ac93e73f640a99f11d820c5fc90f8bd3375ac3 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 3 Nov 2026 01:16:45 +0100 Subject: [PATCH 57/57] test: typed handles round-trip through JNI on the JVM Builds a C counter, passes its handle back, and gets NULL as null: a wrapper whose $kni external did not resolve would fail the run. Co-Authored-By: Claude Opus 5.5 --- .../lemcoder/KonanPluginFunctionalTest.kt | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 1a8fff4..96fc869 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -768,6 +768,55 @@ class KonanPluginFunctionalTest { assertContains(output, "echo=true") } + @Test + fun `pointers to records cross JNI as typed handles, NULL as null`() { + val home = realKonanHome() + File(projectDir, "native").mkdirs() + File(projectDir, "native/counter.c").writeText( + """ + #include + struct counter { int n; }; + typedef struct counter counter_t; + struct counter* counter_new(int start) { + if (start < 0) return 0; + struct counter* c = malloc(sizeof *c); + c->n = start; + return c; + } + int counter_bump(counter_t* c) { return ++c->n; } + void counter_free(struct counter* c) { free(c); } + """.trimIndent() + ) + File(projectDir, "src/main/kotlin").mkdirs() + // Each call goes through a wrapper around a `$kni` external: a JNI name that does not resolve fails here. + File(projectDir, "src/main/kotlin/Main.kt").writeText( + """ + import example.CounterPointer + import example.counter_bump + import example.counter_free + import example.counter_new + fun main() { + val c: CounterPointer = counter_new(41) ?: error("counter_new returned NULL") + println("bump=" + counter_bump(c)) + println("null=" + (counter_new(-1) == null)) + counter_free(c) + } + """.trimIndent() + ) + write("""kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")${"\n"}application""", "config.konanHome.set(file(\"$home\"))\n$mymath") + File(projectDir, "mymath.h").writeText( + "struct counter;\ntypedef struct counter counter_t;\n" + + "struct counter* counter_new(int start);\nint counter_bump(counter_t* c);\nvoid counter_free(struct counter* c);\n" + ) + File(projectDir, "build.gradle.kts").appendText("repositories { mavenCentral() }\napplication { mainClass.set(\"MainKt\") }\n") + + val output = runner("run", "-q").withPluginClasspathAndKgp().build().output + + // counter_t* and struct counter* are one CounterPointer; the handle reached C as the address it came from. + assertContains(output, "bump=42") + assertContains(output, "null=true") + } + @Test fun `Android libraries compile and link with the NDK, its C++ runtime inside`() { val home = realKonanHome()