diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4ca0925 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +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 (linux-x64) + runs-on: ubuntu-24.04 + env: + KONAN_VERSION: "2.4.20" + 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-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 + run: | + dist=kotlin-native-prebuilt-linux-x86_64-$KONAN_VERSION + if [ ! -d ~/.konan/$dist ]; then + mkdir -p ~/.konan + curl -sSfL "https://github.com/JetBrains/kotlin/releases/download/v$KONAN_VERSION/$dist.tar.gz" | tar -xz -C ~/.konan + fi + + - name: Check Kotlin/Native + run: | + home=~/.konan/kotlin-native-prebuilt-linux-x86_64-$KONAN_VERSION + "$home/bin/kotlinc-native" -version + + - 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"### linux-x64: {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-linux-x64 + path: build/reports/tests/test 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. diff --git a/CLAUDE.md b/CLAUDE.md index 6c2cf52..1ef88ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,74 +9,314 @@ 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. -- `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. + 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 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). +- **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. +- **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), `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. - 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 handling. +## Machine paths: `konan { config { } }` + +- **The single source of every path the plugin needs from the machine** β€” `konanHome`, `jextract`, + `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`. +- **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 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. + ## 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`. + 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 + 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` + (`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 + `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)` (`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 + `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 - interop-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. + 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. + 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`; + `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 + `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 - **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 - `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. +- **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. ## 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. +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`. + +**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. + +**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. + +## 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. +- **`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 + 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 + 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. `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 `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`. + +## 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 -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. +- **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 + 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. + +## 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` β†’ + `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 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`. +## The index exporter + +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 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 + 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.** `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 + 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 + loaded libclang there, and a native library loads in one classloader per JVM. + ## 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. -Portal versions are immutable β€” bump and publish, never reuse an alpha. Consumers with `mavenLocal()` +`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 (`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. `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 3d63da6..d93d407 100644 --- a/README.md +++ b/README.md @@ -1,320 +1,706 @@ # 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. -- 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). -- Zero runtime dependencies (only requires Gradle API). -- Support for custom compiler arguments. +- 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, 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. +- 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`: ```kotlin plugins { - id("io.github.lemcoder.konanplugin") version "1.2.0-alpha" + id("io.github.lemcoder.konanplugin") version "2.0.0-alpha04" } ``` -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`. - -## 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. +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). -| 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. | +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). -## `jvmInterops { }` β€” JNI bindings +### 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. -Declared like a cinterop, on the compilation it belongs to, and driven by the same `.def` the native -targets bind: +`kotlinNativeDistribution()` names the distribution of your project's Kotlin version, the one the Kotlin +plugin downloads for a native target: ```kotlin -import io.github.lemcoder.interop.jvmInterops +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/). +Kotlin/Native bindings (KGP's cinterop, the klib) always use your project's Kotlin, whatever `konanHome` is. -kotlin { - jvm() +The plugin itself is tested on Gradle 8.7 with Kotlin 2.0.0 and on Gradle 9.8 with Kotlin 2.4.20. - jvm().compilations["main"].jvmInterops { +## Configuration +Everything lives in one block: the **libraries** the project builds, and the **bindings** that reach +them from Kotlin. + +```kotlin +import io.github.lemcoder.model.NativeTarget + +konan { + val mymath = libraries.create("mymath") { + targets(NativeTarget.MACOS_ARM64, NativeTarget.ANDROID_ARM64, NativeTarget.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: + +| 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 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 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 { }` +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 { }` | +| `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 -jvmInterops { - create("mymath") { - defFile(project.file("src/main/nativeInterop/mymath.def")) - targets.set(listOf(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64)) +konan { + config { + konanHome.set(kotlinNativeDistribution()) // ~/.konan/kotlin-native-prebuilt-- + jextract.set(file("/opt/jextract-25/bin/jextract")) } } ``` -| 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, …)`. | +Machine paths belong in `~/.gradle/gradle.properties` rather than a shared build script: -### How far it goes depends on the library +```properties +konan.config.jextract=/Users/me/opt/jextract-25/bin/jextract +konan.config.cmake=/opt/homebrew/bin/cmake +``` -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`. +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: -- **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. +``` +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 +``` -Preferring the `konanConfig` output over a def entry keeps the def portable β€” naming the archive in -it would hardcode a target directory. +`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 +clang from `sourceDirectory`, by Zig, or by any other build system. Bindings see the same archives +either way. + +| Property | Type | Default | Description | +|-------------------|---------------------|----------------------------------|-------------| +| `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. | +| `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. | + +#### Building with Zig β€” `zig { }` +```kotlin +create("mymath") { + targets(NativeTarget.MACOS_ARM64, NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) + zig { } // runs native/build.zig once per target +} +``` -### Letting another build link it β€” `externalNativeBuild` +`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, … })`). -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: +| Property | Type | Default | Description | +|--------------------|----------------|---------------------|-------------| +| `projectDirectory` | `Directory` | `sourceDirectory` | Where `build.zig` is. | +| `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. | -```kotlin -create("koinference") { - packageName.set("com.example.native") +Android targets need a libc file for the NDK's bionic, which Zig does not bundle. - externalNativeBuild { - cmake { - path.set(file("native/CMakeLists.txt")) - preset.set("macosArm64") // or buildDirectory, for preset-less projects - targets.add("koinference-jni") - arguments.add("-DMY_OWN_SWITCH=ON") - } - } +#### Building with CMake β€” `cmake { }` +```kotlin +create("mymath") { + targets(NativeTarget.host, NativeTarget.LINUX_X64, NativeTarget.ANDROID_ARM64) + cmake { } // native/CMakeLists.txt: add_library(${KONAN_LIB_NAME} STATIC …) } ``` -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. +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`. -Android needs a library per ABI rather than one for the host, so declare them: +#### 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. 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 | +|--------------|---------------------|--------------------------|-------------| +| `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. | + +#### Building with Make β€” `make { }` ```kotlin -externalNativeBuild { - cmake { - path.set(file("native/CMakeLists.txt")) - targets.add("koinference-jni") +create("mymath") { + targets(NativeTarget.LINUX_X64, NativeTarget.ANDROID_ARM64) + make { directory.set(file("native")) } // make -C native -j CC=… CXX=… AR=… KONAN_…=… +} +``` - abi("arm64-v8a") { preset.set("androidNativeArm64") } - abi("x86_64") { preset.set("androidNativeX64") } +`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_OUTPUT_DIR)` cross-compiles unchanged: + +```make +OUT := $(KONAN_OUTPUT_DIR) +all: + $(CC) -c -fPIC mymath.c -o $(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 +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. | +| `sources`, `producedLibrary(target, file)` | | β€” | As for `external { }`. | + +`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 +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`, 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. | + +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//`: +`cc` and `c++` wrap the clang `run_konan` would run, with the target's link flags when linking. + +**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 +create("mymath") { + prebuilt { + library(NativeTarget.MACOS_ARM64, file("vendor/macos/libmymath.a")) + library(NativeTarget.MINGW_X64, file("vendor/windows/mymath.lib")) } } ``` -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. +`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)` +when the build leaves its archive somewhere of its own. -The plugin supplies what only it knows, as cache entries your `CMakeLists.txt` reads: +#### 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_JNI_STUB_DIR` | directory holding the generated `.c` stub | -| `KONAN_JNI_LIB_NAME` | the name the generated bindings will `System.loadLibrary` | +| `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. 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 | +| `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") { + targets(NativeTarget.LINUX_X64) + external { + step { + 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")) + } + sources.from("native/src", "native/Cargo.toml") + producedLibrary(NativeTarget.LINUX_X64, file("native/target/x86_64-unknown-linux-gnu/release/libmymath.a")) + } +} +``` -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. +### `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`, 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. | +| `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` | `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 { }`, `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. 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. + +#### 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 { }`, `make { }` and `external { }`. + +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. + +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 +create("koinference") { + packageName.set("com.example.native") + jni { + cmake { + path.set(file("native/CMakeLists.txt")) + preset.set("macosArm64") // or buildDirectory, for preset-less projects + buildTargets.add("koinference-jni") + arguments.add("-DMY_OWN_SWITCH=ON") + } + } +} +``` + +`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") }` β€” 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 ``` -### 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. +| Property | Type | Default | Description | +|------------------|------------------|--------------------|-------------| +| `path` | `File` | β€” | The `CMakeLists.txt`. Required unless `preset` is set. | +| `preset` | `String` | β€” | A configure preset from `CMakePresets.json`. | +| `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. | +| `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`. | + +##### Any build system β€” `jni { external { } }` +A list of commands, run in order once per JNI target (`jni.targets`): -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. +```kotlin +jni { + external { + step { + executable.set(Executable.File(file("/usr/bin/make"))) + arguments.addAll("-C", "native/jni") + argumentsFor(NativeTarget.ANDROID_ARM64, "ARCH=arm64") + } + sources.from("native") // what it reads; omit to always run + producedLibrary(NativeTarget.MACOS_ARM64, file("native/jni/out/libstub.dylib")) + } +} +``` -Platform-specific link arguments go in `linkerArgsFor`; `-framework` in `additionalLinkerArgs` would -reach the Android linker, which rejects it. +`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. -## Usage +| 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. | -### Static library only -```kotlin -import org.jetbrains.kotlin.konan.target.KonanTarget +### 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. -konanConfig { - targets(KonanTarget.LINUX_X64, KonanTarget.MINGW_X64, KonanTarget.MACOS_ARM64) - libName.set("mylib") +Declare more JVM targets to ship one jar for several platforms β€” Linux libraries cross-link from a Mac: - sourceDir.set("native/src") - headerDir.set("native/include") - outputDir.set("native/lib") - - additionalCompilerArgs.addAll("-O2", "-Wall") -} +```kotlin +val mymath = libraries.create("mymath") { targets(NativeTarget.host, NativeTarget.LINUX_X64) } +bindings { create("mymath") { library(mymath); jni { targets(NativeTarget.host, NativeTarget.LINUX_X64) } } } ``` -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. +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. -### Android, with JNI bindings -AGP modules declare interops on the project; AGP wiring is automatic: +### 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: -```kotlin -import org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.interop.jvmInterops +| 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?` | 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` | +| `void*`, `const void*` argument | `java.nio.Buffer?` | a direct buffer's start address (`GetDirectBufferAddress`); a heap buffer throws | +| 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 | -konanConfig { - targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) // device + emulator - libName.set("mymath") -} +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`: -jvmInterops { - create("mymath") { - defFile(project.file("src/main/nativeInterop/mymath.def")) - includeDirs.from(file("native")) - targets.set(listOf(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64)) - } -} +```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 ``` -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`. +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?`. -## 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. | +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. -On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks automatically, so -`assembleDebug` alone runs the whole chain. +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 `jni { visibility.set(BindingVisibility.PUBLIC) }` if the wrapper is in a +different Gradle module. -### 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 - ``` +### `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; in a multiplatform project they replace them on +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 | +|--------------------|-------------|----------------------|-------------| +| `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` | `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: `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". | + +#### 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 { 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_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 [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. + +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, 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: + +- `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` runs FFM and JNI side by side on an emulator. + +#### 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. 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 +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. -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 -``` +## Tasks +| Task | Description | +|-------------------------------|-------------| +| `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. | +| `linkJni` | Links it for one target. | +| `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`, `generateFfmAndroid` | jextract's bindings: on the JDK's FFM, and on PanamaPort's for Android. | +| `cinterop` | KGP's own cinterop, declared by the plugin. | -### Output -After running the task: -```plaintext -native/lib/ -β”œβ”€β”€ linux_x64/ -β”‚ └── lib.a -β”œβ”€β”€ mingw_x64/ -β”‚ └── lib.a -``` +On Android these are ordered ahead of `preBuild` and the Kotlin compile tasks automatically, so +`assembleDebug` alone runs the whole chain. ## 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. - `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. +- `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` 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. + +- `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` 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 + `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`), + `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. +- 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 `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…`. +- 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. +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 936890a..08324f0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,27 +1,28 @@ -repositories { - google() - mavenCentral() -} +import java.net.HttpURLConnection +import java.net.URI plugins { - id("com.gradle.plugin-publish") version "1.3.0" `kotlin-dsl` + id("com.gradle.plugin-publish") version "2.2.1" + // The index exporter and its generators: buildSrc/src/main/kotlin/native-index.gradle.kts + 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-alpha04" 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") @@ -31,18 +32,26 @@ 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")) } } } +val kgpForTests: Configuration = configurations.create("kgpForTests") + 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.2.10") + // -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" }) + // 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,8 +61,48 @@ 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.named("test") { +tasks.test { useJUnitPlatform() -} \ No newline at end of file + // 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) + val gradleVersion = providers.gradleProperty("testGradleVersion").orElse("") + 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) } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..33fa30f --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + `kotlin-dsl` +} + +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 new file mode 100644 index 0000000..ef6b650 --- /dev/null +++ b/buildSrc/src/main/kotlin/native-index.gradle.kts @@ -0,0 +1,105 @@ +// 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. +// +// 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").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 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" + (sourceSets.getByName("main").extensions.getByName("kotlin") as SourceDirectorySet).srcDir(protocol) + + 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, 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) + } + } + + 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(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 new file mode 100644 index 0000000..669706e --- /dev/null +++ b/doc/interop-architecture.md @@ -0,0 +1,363 @@ +# KonanPlugin: Interop Architecture + +Status: draft Β· 2026-10-05 Β· components settled; generators built and verified end to end; DSL, +packaging and provisioning still open. + +## 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. + +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: + +| 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 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 | +| --- | --- | --- | --- | +| 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`), 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, 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` | +| 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 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** (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
.def + cinterop command line β†’ cinterop's NativeIndex"] + NA[["Native artifacts per target
headers Β· .a Β· .so / .dylib / .dll"]] + 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"] + + 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 | `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`, 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` | +| 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: + +- **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) or a consumer needs a + shared library the provider does not produce (FFM). + +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.** 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 + +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(NativeTarget.MACOS_ARM64, NativeTarget.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 + } + } +} +``` + +| 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 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 +`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. +- **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. +- **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. + +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, 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 +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). + +``` +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 ── +``` + +**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). + +**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 | +| --- | --- | --- | --- | --- | +| `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 (`-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. + +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/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 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 | + | `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. +- **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 + +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 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`. + +| 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, 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 | + +Gaps that remain: + +- **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 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 + +- [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. 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? +- [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 502bebc..54f21d0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,39 +1,43 @@ # 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 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) | `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` | +| [`jvm/`](jvm) | a library (.a) + a binding β†’ JNI bindings and library | full JNI path, end to end | `../../gradlew -p jvm run` | +| [`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` 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)`. +Every example builds the plugin from source via `includeBuild`, with the repository's wrapper. -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` also needs +`konan.config.cargo` and `konan.config.jextract`, e.g. in `~/.gradle/gradle.properties`. `android` and +`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. ## 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 β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ## 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 +46,18 @@ 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 - on Android needs an emulator/device and an AGP module β€” see [`android/README.md`](android/README.md). +- `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` 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/README.md b/examples/android/README.md index ef5e6bf..9e74a30 100644 --- a/examples/android/README.md +++ b/examples/android/README.md @@ -1,81 +1,48 @@ -# 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 - -- A Kotlin/Native distribution (auto-detected, or `KONAN_HOME`). -- 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 -``` +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. -## 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 -konanConfig { - targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) - libName.set("mymath") - jvmInterop { packageName.set("example") } -} +```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 ``` -- `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`. - -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 -`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 -`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 f0cc99d..0c4d6d6 100644 --- a/examples/android/build.gradle.kts +++ b/examples/android/build.gradle.kts @@ -1,50 +1,79 @@ -// 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. +// 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 org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.interop.jvmInterops +import io.github.lemcoder.model.NativeTarget 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") -} +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 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")) + } + } -// 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)) + bindings { + 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 auto-wires the generated Kotlin bridges + per-ABI jniLibs into AGP and -// orders generateJvmInteropMymath / linkJvmInteropMymath / runKonanClang 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 a4b76b9..0000000 Binary files a/examples/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ 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/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 3b16370..0000000 --- a/examples/android/src/main/kotlin/example/Api.kt +++ /dev/null @@ -1,6 +0,0 @@ -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 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/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts index f1f9ed3..d0838a5 100644 --- a/examples/jvm/build.gradle.kts +++ b/examples/jvm/build.gradle.kts @@ -1,45 +1,32 @@ -import io.github.lemcoder.abiDir -import io.github.lemcoder.hostKonanTarget -import org.jetbrains.kotlin.konan.target.KonanTarget -import io.github.lemcoder.interop.jvmInterops +import io.github.lemcoder.model.NativeTarget plugins { - kotlin("jvm") version "2.3.10" + kotlin("jvm") version "2.4.20" id("io.github.lemcoder.konanplugin") application } repositories { mavenCentral() } -val host = hostKonanTarget() - -// konanConfig compiles, nothing else: native/*.c -> build/native//libmymath.a. -konanConfig { - targets(host) - libName.set("mymath") -} - -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 { + // 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, + // 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")) + defFile.set(file("src/main/nativeInterop/mymath.def")) + includeDirs.from("native") + library(mymath) } } } -application { mainClass.set("example.MainKt") } +kotlin { jvmToolchain(17) } -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 - ) -} +// 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") } 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/examples/kmp/README.md b/examples/kmp/README.md new file mode 100644 index 0000000..be72826 --- /dev/null +++ b/examples/kmp/README.md @@ -0,0 +1,87 @@ +# 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, 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: + +```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/android/src/main/kotlin/com/example/jniapp/MainActivity.kt b/examples/kmp/androidApp/src/main/kotlin/com/example/kmpapp/MainActivity.kt similarity index 57% rename from examples/android/src/main/kotlin/com/example/jniapp/MainActivity.kt rename to examples/kmp/androidApp/src/main/kotlin/com/example/kmpapp/MainActivity.kt index 553fb64..bc3c928 100644 --- a/examples/android/src/main/kotlin/com/example/jniapp/MainActivity.kt +++ b/examples/kmp/androidApp/src/main/kotlin/com/example/kmpapp/MainActivity.kt @@ -1,21 +1,21 @@ -package com.example.jniapp +package com.example.kmpapp import android.app.Activity import android.os.Bundle import android.view.Gravity import android.widget.TextView -import example.add -import example.scale +import com.example.shared.add +import com.example.shared.scale class MainActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Calls into native C through the generated JNI bridges (libexamplestubs.so). + // The shared module's common API, here over JNI into libmymathstubs.so. val text = buildString { - appendLine("JNI from Kotlin/Native cinterop:") - appendLine("my_add(2, 3) = ${add(2, 3)}") - appendLine("my_scale(4.0) = ${scale(4.0)}") + appendLine("C from Kotlin Multiplatform:") + appendLine("add(2, 3) = ${add(2, 3)}") + appendLine("scale(4.0) = ${scale(4.0)}") } setContentView(TextView(this).apply { 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/android/native/mymath.c b/examples/kmp/shared/native/mymath.c similarity index 100% rename from examples/android/native/mymath.c rename to examples/kmp/shared/native/mymath.c diff --git a/examples/android/native/mymath.h b/examples/kmp/shared/native/mymath.h similarity index 100% rename from examples/android/native/mymath.h rename to examples/kmp/shared/native/mymath.h 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/android/src/main/nativeInterop/mymath.def b/examples/kmp/shared/src/nativeInterop/cinterop/mymath.def similarity index 59% rename from examples/android/src/main/nativeInterop/mymath.def rename to examples/kmp/shared/src/nativeInterop/cinterop/mymath.def index 4acf40c..48f3007 100644 --- a/examples/android/src/main/nativeInterop/mymath.def +++ b/examples/kmp/shared/src/nativeInterop/cinterop/mymath.def @@ -1,3 +1,3 @@ headers = mymath.h headerFilter = mymath.h -package = example +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/examples/native/build.gradle.kts b/examples/native/build.gradle.kts index e596843..c0a076e 100644 --- a/examples/native/build.gradle.kts +++ b/examples/native/build.gradle.kts @@ -1,38 +1,33 @@ -// 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 KGP's own cinterop (no JNI). -import org.jetbrains.kotlin.konan.target.KonanTarget +import io.github.lemcoder.model.NativeTarget plugins { - kotlin("multiplatform") version "2.3.10" + kotlin("multiplatform") version "2.4.20" id("io.github.lemcoder.konanplugin") } 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 { - 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") +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 + // 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/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/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/gradle.properties b/gradle.properties index ac1a35f..5acd404 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,8 @@ -org.gradle.configuration-cache=true \ No newline at end of file +org.gradle.configuration-cache=true +# 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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9..5097068 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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/scripts/run-android.sh b/scripts/run-android.sh index 53fc36a..7acac59 100755 --- a/scripts/run-android.sh +++ b/scripts/run-android.sh @@ -2,14 +2,13 @@ # # 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. 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] # -# -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 +20,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 +34,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 +112,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/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt new file mode 100644 index 0000000..ce4e51e --- /dev/null +++ b/src/exporterProtocol/kotlin/io/github/lemcoder/index/ExporterProtocol.kt @@ -0,0 +1,144 @@ +package io.github.lemcoder.index + +import io.github.lemcoder.model.BindingVisibility +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: BindingVisibility = BindingVisibility.INTERNAL) : + 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. + * + * @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, + 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, +} + +/** + * 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) } + flag(Flag.FFM_API, request.api) + } + } + 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 = BindingVisibility.valueOf(required(Flag.VISIBILITY)), + ) + Kind.JEXTRACT -> GeneratorRequest.Jextract( + 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()) + } + } + + 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"), + FFM_API("--ffm-api"), + } +} 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/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/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/BindingGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt new file mode 100644 index 0000000..7ae0666 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/BindingGenerator.kt @@ -0,0 +1,54 @@ +package io.github.lemcoder.index.exporter + +import io.github.lemcoder.index.GeneratorRequest +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 { + /** Writes the output [request] asks for into [outputDirectory], which starts out empty. */ + fun generate(request: R, 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) +} + +/** + * 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( + /** The bindings' package, decided the way cinterop decides it: `-pkg`, the `.def`'s `package`, or its file name. */ + val packageName: String, + /** The `.def`'s lines after `---`: C code every translation unit of the library sees. */ + val preambleLines: List, + 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 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) + diff --git a/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt new file mode 100644 index 0000000..3060c64 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/DumpGenerator.kt @@ -0,0 +1,118 @@ +package io.github.lemcoder.index.exporter + +import org.jetbrains.kotlin.native.interop.indexer.* +import io.github.lemcoder.index.GeneratorRequest +import java.io.File + +/** + * 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. + */ +object DumpGenerator : BindingGenerator { + override fun generate(request: GeneratorRequest.Dump, context: GenerationContext, outputDirectory: File) { + outputDirectory.resolve("index.txt").writeText(dumpNativeIndex(context.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) } + 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)}${access(f)}" +} + +private fun renderGlobal(g: GlobalDecl): String { + val const = if (g.isConst) "const " else "" + return "global $const${g.fullName}: ${render(g.type)}${access(g)}" +} + +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/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..3747cd1 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JextractGenerator.kt @@ -0,0 +1,126 @@ +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 + +/** + * 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. + * + * 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) { + val library = request.library + val jextract = request.jextract?.absolutePath ?: "jextract" + val headerClass = request.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" } + val sources = outputDirectory.resolve("java").walk().filter { it.extension == "java" }.toList() + when (request.api) { + // 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() + .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). + * + * 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/JniGenerator.kt b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt new file mode 100644 index 0000000..0eed692 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/JniGenerator.kt @@ -0,0 +1,511 @@ +package io.github.lemcoder.index.exporter + +import io.github.lemcoder.model.BindingVisibility +import io.github.lemcoder.index.GeneratorRequest +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, 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 + * 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; + * - `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`; + * - 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. + * + * 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. + * + * The request names the library `System.loadLibrary` loads β€” the plugin owns that name β€” and the + * bindings' visibility. + */ +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" } + + 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("${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("${GeneratorRequest.Jni.C_DIR}/$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) { + /** + * 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 (needsWrapper) "${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. */ + 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, 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. */ + 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?") + + /** + * 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: 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") + + 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 -> 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 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) { + CharType -> JvmPrimitive.BYTE + is IntegerType -> JvmPrimitive.integer(pointee.size) + is FloatingType -> JvmPrimitive.floating(pointee.size) + else -> null + } + 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 -> 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) + 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}") + } + + /** [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: BindingVisibility, + constants: List>, + 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, 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") } + 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()") + } + 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}" } + 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.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) -> + 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 -> "" + } + 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() + 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 || it.second is Crossing.DirectBuffer } }) { + 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(b.nativeName)}(${(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.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\");") + 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" + is Crossing.DirectBuffer -> "(${c.cType})d$i" + Crossing.CString -> { + 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;") + "(${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" + } + 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(" 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;") + 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, 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. + b.params.indices.reversed().forEach { i -> + when (val c = b.params[i].second) { + 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);") + } + 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 -> {} + } + } + if (b.result !is Crossing.Void) appendLine(" return result;") + 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) } + + /** 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" + + /** `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( + "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 new file mode 100644 index 0000000..0979396 --- /dev/null +++ b/src/indexExporter/kotlin/io/github/lemcoder/index/exporter/NativeIndexExporter.kt @@ -0,0 +1,62 @@ +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 +import org.jetbrains.kotlin.native.interop.gen.jvm.buildNativeLibrary +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 the + * generator an [ExporterInvocation] asks for. Its command line is that invocation's [ExporterInvocation.toArgs]. + */ +fun main(args: Array) { + val invocation = ExporterInvocation.parse(args) + val context = context(invocation.cinteropArgs.toTypedArray()) + val output = invocation.outputDirectory + output.deleteRecursively() + output.mkdirs() + generate(invocation.request, context, output) +} + +/** + * 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; 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(cinteropArgs) + require(arguments.library.isEmpty()) { "Dependency klibs (-library) are not supported by the index exporter yet." } + + val tool = toolConfig(arguments) + val defFile = arguments.def?.let(::File) + val def = DefFile(defFile, tool.target) + + // 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( + packageName = packageName, + preambleLines = def.defHeaderLines, + ) { + // As cinterop's own entry point does around its run; the index is plain objects once built. + NativeMemoryAllocator.init() + JvmCInteropCallbacks.init() + try { + // No dependency klibs, so no headers are imported from elsewhere. + val library = buildNativeLibrary(tool, def, arguments, ImportsImpl(emptyMap())) + val index = nativeIndex(library, arguments) + IndexedLibrary(index, headers = library.includes.map { it.headerPath }, compilerArgs = library.compilerArgs) + } finally { + JvmCInteropCallbacks.dispose() + NativeMemoryAllocator.dispose() + } + } +} 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..559a840 --- /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").orEmpty().lowercase() + | val arch = when (System.getProperty("os.arch").orEmpty().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/JniToolchain.kt b/src/main/kotlin/io/github/lemcoder/JniToolchain.kt deleted file mode 100644 index f8da19d..0000000 --- a/src/main/kotlin/io/github/lemcoder/JniToolchain.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.github.lemcoder - -import io.github.lemcoder.jvm.JvmInteropSupport - -/** - * 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 = JvmInteropSupport.detectJniHome().absolutePath diff --git a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt index 6f18574..4ebc809 100644 --- a/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt +++ b/src/main/kotlin/io/github/lemcoder/KonanPlugin.kt @@ -1,78 +1,48 @@ 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.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.wiring.AgpWiring +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 -/** - * 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 - * pass to `mustRunAfter` unconditionally. - */ -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) + val konan = project.extensions.create("konan", KonanExtension::class.java) + // 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)) + // 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. + 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)) + AgpWiring.ndkDirectory(project, konan.config.androidGradlePluginNdk) + project.registerLibraries(konan) + project.registerBindings(konan) } -} - -/** 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() } + private companion object { + const val DOWNLOAD_KOTLIN_NATIVE = "downloadKotlinNativeDistribution" } } 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 deleted file mode 100644 index 4f76246..0000000 --- a/src/main/kotlin/io/github/lemcoder/KonanTargets.kt +++ /dev/null @@ -1,45 +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. - */ - -/** 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 - -/** 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`. */ -val KonanTarget.taskSuffix: String get() = name.replaceFirstChar { it.uppercase() } - -/** `jniLibs/` subdirectory: Android ABI names, otherwise the target name. */ -val KonanTarget.abiDir: String - get() = when (name) { - "android_arm64" -> "arm64-v8a" - "android_arm32" -> "armeabi-v7a" - "android_x64" -> "x86_64" - "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/RunKonanClangTask.kt b/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt deleted file mode 100644 index 6a5a7f7..0000000 --- a/src/main/kotlin/io/github/lemcoder/RunKonanClangTask.kt +++ /dev/null @@ -1,177 +0,0 @@ -package io.github.lemcoder - -import org.jetbrains.kotlin.konan.target.KonanTarget - - -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 -import javax.inject.Inject - -/** - * 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. - */ -abstract class RunKonanClangTask @Inject constructor( - private val exec: ExecOperations, - private val fs: FileSystemOperations, - private val objects: ObjectFactory, -) : DefaultTask() { - - /** 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) - abstract val sourceFiles: ConfigurableFileCollection - - /** Directories that include `.h` header files */ - @get:InputFiles - @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 - - /** Kotlin/Native target platforms to compile for */ - @get:Input - abstract val targets: ListProperty - - @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 { "lib${it}.a" } - - @TaskAction - fun compileAndLink() { - val workingDir = workingDir.asFile.getOrElse(temporaryDir) - targets.get().forEach { target -> - // 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 { - executable(runKonan.asFile.get()) - args("clang", "clang", target.name, "@args") - workingDir(compileDir) - } - - // verify output - logger.lifecycle(compileResult.output) - compileResult.assertNormalExitValue() - - // move compiled files to output directory - val outDir = outputDir.get().dir(target.name) - 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 { - executable(runKonan.asFile.get()) - args("llvm", "llvm-ar", "@args") - workingDir(linkDir) - } - - logger.lifecycle(linkResult.output) - linkResult.assertNormalExitValue() - - fs.delete { delete(outDir) } - fs.sync { - from(linkDir) { - include("**/*.a") - } - into(outDir) - } - - } - } -} \ No newline at end of file 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/KonanExtension.kt b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt new file mode 100644 index 0000000..645d8d4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/KonanExtension.kt @@ -0,0 +1,57 @@ +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 + +/** + * 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(NativeTarget.MACOS_ARM64, NativeTarget.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 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 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 androidApiLevel: Property + + /** Native libraries this project builds or brings in, 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) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt new file mode 100644 index 0000000..6738245 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingConsumer.kt @@ -0,0 +1,54 @@ +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.libraries.NativeArtifacts +import io.github.lemcoder.wiring.GradlePluginId +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 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 `package`, else β€” as cinterop does β€” + * one named after the `.def` file. + */ + val packageName: Provider = binding.packageName.orElse( + 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/BindingLibrarySpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt new file mode 100644 index 0000000..3ca0c83 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibrarySpec.kt @@ -0,0 +1,70 @@ +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 + + /** + * [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. */ + 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/bindings/BindingLibraryWiring.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt new file mode 100644 index 0000000..e683a37 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingLibraryWiring.kt @@ -0,0 +1,122 @@ +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.dsl.builds.selectOne +import io.github.lemcoder.kotlin.KotlinGradlePlugin +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.capitalized +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") }) + } + + /** 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.distinctTargets.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: () -> 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 -> targets.zip(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, + ), + )).toMap() + } + 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) + } + + /** + * 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, + // 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) + if (kmpJvm) KotlinGradlePlugin.addKmpJvmResources(project, resources) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingSpec.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingSpec.kt new file mode 100644 index 0000000..a495f33 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/BindingSpec.kt @@ -0,0 +1,60 @@ +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 +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property + +/** + * 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/bindings/Bindings.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/Bindings.kt new file mode 100644 index 0000000..02609c4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/Bindings.kt @@ -0,0 +1,27 @@ +package io.github.lemcoder.dsl.bindings + +import io.github.lemcoder.dsl.KonanExtension +import org.gradle.api.Action +import org.gradle.api.Project + +/** + * 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. + */ +internal fun Project.registerBindings(konan: KonanExtension) { + konan.bindings.all(object : Action { + override fun execute(binding: BindingSpec) = registerBinding(konan, binding) + }) +} + +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")) + + val consumers = bindingConsumers(BindingContext(this, konan, binding)) + consumers.forEach { it.prepare() } + afterEvaluate { + consumers.filter { it.isUsed() }.forEach { it.register() } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt new file mode 100644 index 0000000..216294d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/DefFile.kt @@ -0,0 +1,56 @@ +package io.github.lemcoder.dsl.bindings + +import java.io.File + +/** + * 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. + * @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 -> + val roots = libraryPaths.map { p -> File(p).takeIf { it.isAbsolute } ?: base.resolve(p) } + (roots + base).map { it.resolve(name) }.firstOrNull { it.isFile } + } + + companion object { + private val SEPARATOR = Regex("""(?m)^-{3,}\s*$""") + + 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() } + .filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') } + .associate { it.substringBefore('=').trim() to it.substringAfter('=').trim() } + + fun tokens(key: String) = properties[key]?.split(' ')?.filter { it.isNotBlank() } ?: emptyList() + + return DefFile( + packageName = properties["package"], + staticLibraries = tokens("staticLibraries"), + libraryPaths = tokens("libraryPaths"), + linkerOpts = tokens("linkerOpts"), + ) + } + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/JarBundle.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/JarBundle.kt new file mode 100644 index 0000000..60c41e0 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/JarBundle.kt @@ -0,0 +1,35 @@ +package io.github.lemcoder.dsl.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 + +/** + * 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/dsl/bindings/KotlinNativeConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/KotlinNativeConsumer.kt new file mode 100644 index 0000000..a335fd0 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/KotlinNativeConsumer.kt @@ -0,0 +1,14 @@ +package io.github.lemcoder.dsl.bindings + +import io.github.lemcoder.kotlin.KotlinGradlePlugin +import io.github.lemcoder.wiring.GradlePluginId + +/** + * 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() = KotlinGradlePlugin.declareCinterops(context.project, context.binding, context.artifacts()) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt new file mode 100644 index 0000000..2dde309 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/LinkSharedLibraryTask.kt @@ -0,0 +1,142 @@ +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 +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.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 + +/** + * 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( + private val exec: ExecOperations, +) : DefaultTask(), UsesKonanDistribution { + /** The Kotlin/Native distribution whose clang links; resolved when the task runs. */ + @get:Internal abstract override val konanHome: Property + @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 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:Optional @get:PathSensitive(PathSensitivity.NONE) abstract val staticLibrary: RegularFileProperty + @get:Input @get:Optional abstract val additionalLinkerArgs: ListProperty + + /** 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 nativeTarget = target.get() + val outDir = outputDirectory.get().asFile + outDir.deleteRecursively(); outDir.mkdirs() + val outLib = outDir.resolve(nativeTarget.sharedLibraryName(libraryBaseName.get())) + 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") + addAll(includeArguments()) + jniIncludeDirs.files.forEach { add("-I${it.absolutePath}") } + addAll(distribution.linkArguments(nativeTarget, exec)) + addAll(inputs(nativeTarget, stubCFile.orNull?.asFile?.absolutePath)) + add("-o"); add(outLib.absolutePath) + // A C++ runtime, extra -L, frameworks: all caller-supplied. + addAll(additionalLinkerArgs.getOrElse(emptyList())) + } + return distribution.clang(nativeTarget, arguments) + } + + /** + * 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/FfmConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt new file mode 100644 index 0000000..e8ba0e4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmConsumer.kt @@ -0,0 +1,171 @@ +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.NativeTarget +import io.github.lemcoder.model.taskName +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.file.Directory +import org.gradle.api.provider.Provider +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 β€” 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 + * 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 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. */ + 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 + ffm.libraryName.convention(binding.library.map { it.name }.orElse(bindingName)) + library = BindingLibraryWiring(context, ffm, "ffm") + // 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 } + 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 + + override fun register() { + val ffm = binding.ffm + val artifacts = context.artifacts() + val libraryName = ffm.libraryName.get() + 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) generateAndroid = 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) + 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 }) + } + + private fun registerGeneration(api: FfmApi, libraryName: String): TaskProvider { + val ffm = binding.ffm + val packageName = context.packageName + 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" + (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) + request.set(GeneratorRequest.Jextract(library = libraryName, api = api)) + jextract.set(config.readConfigField(ConfigField.JEXTRACT, path)) + extraCinteropArgs.addAll(packageName.map { listOf("-pkg", it) }) + outputDirectory.set(generatedRoot(api)) + } + 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) { + val javaSources = generate.flatMap { it.outputDirectory.dir("java") } + 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) + } + + /** + * `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, loadable: List, warnAboutAndroid: Boolean): List { + 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; " + + "they are generated for ${loadable.joinToString { it.konanName }}." + } + return declared + } + val skipped = libraryTargets.filter { it.isAndroid && it.architecture !in LP64 } + 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." + ) + } + return libraryTargets.filter { it in loadable }.ifEmpty { loadable } + } + + 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/dsl/bindings/ffm/FfmLibraryBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt new file mode 100644 index 0000000..abd3a48 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/ffm/FfmLibraryBuild.kt @@ -0,0 +1,75 @@ +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 +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, +): 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 emptyMap() + } + 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.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." + dependsOn(artifacts.builtBy(target)) + 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)) + 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): Map> { + 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 + } + 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/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/JniConsumer.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt new file mode 100644 index 0000000..703602c --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/JniConsumer.kt @@ -0,0 +1,108 @@ +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.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.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) { !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) } + } + + /** + * `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/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/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/bindings/jni/KonanLinkStubBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt new file mode 100644 index 0000000..5e4f0e9 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/bindings/jni/KonanLinkStubBuild.kt @@ -0,0 +1,84 @@ +package io.github.lemcoder.dsl.bindings.jni + +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 +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 + +/** + * 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, + generate: TaskProvider, + artifacts: NativeArtifacts?, + targets: List, +): Map> { + val bindingName = binding.name + val jni = binding.jni + val output = generate.flatMap { it.outputDirectory } + 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}." + dependsOn(generate) + 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)) + 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" }) }) + 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(output.map { it.dir(GeneratorRequest.Jni.INCLUDE_DIR) }) + additionalLinkerArgs.set( + jni.linkerOptions.zip(jni.targetLinkerOptions) { common, perTarget -> + defLinkerOpts(binding) + common + perTarget[target].orEmpty() + } + ) + 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(), staticLibraries or linkerOpts in the .def, so " + + "only the JNI bindings and the .c stub were generated β€” link them from your own build." + ) + false + } + } + } + } +} + +/** 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: the library's for [target], else the `.def`'s `staticLibraries`. */ +private fun archive(binding: BindingSpec, artifacts: NativeArtifacts?, target: NativeTarget): File? { + 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/dsl/builds/AndroidNdkToolchain.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt new file mode 100644 index 0000000..9152a94 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/AndroidNdkToolchain.kt @@ -0,0 +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.NativeTarget +import org.gradle.api.provider.Provider + +/** + * 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 resolved = ndk.map { AndroidNdk.of(it) } + return ToolchainFiles( + 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 = resolved.map { it.home }, + ) + } +} 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/builds/CMakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt new file mode 100644 index 0000000..fd3189e --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeCommands.kt @@ -0,0 +1,81 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.dsl.config.ConfigField +import io.github.lemcoder.dsl.config.konanConfig +import org.gradle.api.Project +import org.gradle.api.file.Directory +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` 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, 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: `/`. + */ +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 + // 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 } + // 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 }) + ) + ) + + command { + executable.set(cmakeExecutable) + arguments.addAll( + preset.map { listOf("--preset", it) }.orElse( + 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) { + 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" }) } + // 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(cmakeExecutable) + 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() }) + } + 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 new file mode 100644 index 0000000..a21da30 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CMakeSettings.kt @@ -0,0 +1,102 @@ +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 +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import javax.inject.Inject + +/** + * 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 + * produces mismatched C++ runtimes and missing compiler-rt. + * + * Either name a [preset] or let the plugin configure [path] into [buildDirectory] directly; everything + * else has a default. + * + * [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) : ExternalBuildSettings { + + init { + toolchain.convention(ExternalToolchain.AUTO) + jobs.convention(Runtime.getRuntime().availableProcessors()) + } + + /** + * 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. */ + fun target(target: NativeTarget, action: Action) { + action.execute(targetSettings.getOrPut(target) { objects.newInstance(CMakeTargetSettings::class.java) }) + } + + /** The per-target settings declared. */ + internal val targetSettings = linkedMapOf() + + /** The `CMakeLists.txt` to configure. Required unless [preset] is set. */ + abstract val path: RegularFileProperty + + /** A configure preset from the project's `CMakePresets.json`. */ + abstract val preset: Property + + /** + * 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 + + /** + * 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 + + /** 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 + + /** + * `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 + + /** + * 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]. */ +abstract class CMakeTargetSettings { + /** Configure preset for this target. */ + abstract val preset: Property + + /** 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. */ + abstract val arguments: ListProperty +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoCommands.kt new file mode 100644 index 0000000..93e02fb --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoCommands.kt @@ -0,0 +1,38 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.dsl.config.ConfigField +import io.github.lemcoder.dsl.config.konanConfig +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider + +/** + * [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 ExternalBuildTask.addCargoCommands(cargo: CargoSettings, targetDirectory: Provider, context: ExternalBuildContext) { + val target = context.target + val triple = target.rustTriple() + val manifest = cargo.manifest + + 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) }) + 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 }) + } + } + 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/dsl/builds/CargoSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoSettings.kt new file mode 100644 index 0000000..e5f3fed --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/CargoSettings.kt @@ -0,0 +1,43 @@ +package io.github.lemcoder.dsl.builds + +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 : ExternalBuildSettings { + init { + 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 + + /** + * 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/builds/Executables.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/Executables.kt new file mode 100644 index 0000000..57414e2 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/Executables.kt @@ -0,0 +1,10 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.model.Executable + +/** 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 + is Executable.OnPath -> name + is Executable.Configured -> value.require().absolutePath +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt new file mode 100644 index 0000000..d873c71 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuild.kt @@ -0,0 +1,182 @@ +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 +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, + /** + * 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]. */ + 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 limit = ExternalBuildLimit.register(project) + val toolchain = toolchain + val konan = project.extensions.getByType(KonanExtension::class.java) + 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 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), + ) + } 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. + val buildOutput = staging ?: targetOutput + val contract: Map> = mapOf( + ExternalVariable.LIB_NAME to output.libraryName, + 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 (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(name, ExternalBuildTask::class.java) { + 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) + outputDirectory.set(targetOutput) + staging?.let { + archiveStagingDirectory.set(it) + archiveTarget.set(target) + } + 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/dsl/builds/ExternalBuildBlocks.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt new file mode 100644 index 0000000..ea5dc39 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildBlocks.kt @@ -0,0 +1,43 @@ +package io.github.lemcoder.dsl.builds + +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/dsl/builds/ExternalBuildContract.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt new file mode 100644 index 0000000..b0c2c7b --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildContract.kt @@ -0,0 +1,100 @@ +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 + +/** 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 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 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"), + + /** 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_ABI"), + + /** A library's `sourceDirectory`; libraries only. */ + SOURCE_DIR("KONAN_SOURCE_DIR"), + + /** The directory holding the generated `.c` stub; JNI only. */ + JNI_STUB_DIR("KONAN_JNI_STUB_DIR"), + + /** The directory holding `jni.h`, which the plugin ships, so no JDK is needed; JNI only. */ + JNI_INCLUDE_DIR("KONAN_JNI_INCLUDE_DIR"), +} + +/** + * 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"), + + /** 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"), + + /** 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. */ +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 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( + 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 }, + ) + listOfNotNull(androidNdk?.let { ndk -> ToolchainVariable.ANDROID_NDK_HOME to ndk.map { it.absolutePath } }) +} 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/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt new file mode 100644 index 0000000..9807565 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalBuildTask.kt @@ -0,0 +1,151 @@ +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 +import javax.inject.Inject +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 + +/** + * 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. + * + * 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(), 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. */ + @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 + + /** 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 } + } + + fun command(configure: ExternalCommand.() -> Unit) { + commands += objects.newInstance(ExternalCommand::class.java).apply(configure) + } + + @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 -> + 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}" + } + } + 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. */ +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/dsl/builds/ExternalToolchain.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt new file mode 100644 index 0000000..179f5ec --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ExternalToolchain.kt @@ -0,0 +1,28 @@ +package io.github.lemcoder.dsl.builds + +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 β€” and for Android, the NDK in + * `konan.config.androidNdk` when one is set. + */ + 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/main/kotlin/io/github/lemcoder/dsl/builds/MakeCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeCommands.kt new file mode 100644 index 0000000..a96b550 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeCommands.kt @@ -0,0 +1,24 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.dsl.config.ConfigField +import io.github.lemcoder.dsl.config.konanConfig + +/** 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(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" }) + // 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/dsl/builds/MakeSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeSettings.kt new file mode 100644 index 0000000..a3cc6d1 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/MakeSettings.kt @@ -0,0 +1,62 @@ +package io.github.lemcoder.dsl.builds + +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 : ExternalBuildSettings { + init { + 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 + + /** 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/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/builds/RustTargets.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/RustTargets.kt new file mode 100644 index 0000000..3c9f749 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/RustTargets.kt @@ -0,0 +1,24 @@ +package io.github.lemcoder.dsl.builds + +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/dsl/builds/Selection.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/Selection.kt new file mode 100644 index 0000000..c115c46 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/Selection.kt @@ -0,0 +1,10 @@ +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 + * `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, 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/dsl/builds/StepsSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/StepsSettings.kt new file mode 100644 index 0000000..4a85bf0 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/StepsSettings.kt @@ -0,0 +1,80 @@ +package io.github.lemcoder.dsl.builds + +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 +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 + +/** + * Any build system, as a list of commands run once per target β€” for a library (`libraries { external + * { } }`), 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 StepsSettings @Inject constructor(private val objects: ObjectFactory) : ExternalBuildSettings { + 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. + */ + abstract val sources: ConfigurableFileCollection + + /** 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(StepSpec::class.java).also { action.execute(it) } + } + + internal val steps = mutableListOf() +} + +/** One command of a [StepsSettings] build. */ +abstract class StepSpec { + /** 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: 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: NativeTarget, arguments: Provider>) = targetArguments.put(target, arguments) +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigCommands.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigCommands.kt new file mode 100644 index 0000000..b9ebd48 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigCommands.kt @@ -0,0 +1,36 @@ +package io.github.lemcoder.dsl.builds + +import io.github.lemcoder.dsl.config.ConfigField +import io.github.lemcoder.dsl.config.konanConfig +import io.github.lemcoder.model.TargetFamily +import org.gradle.api.file.Directory +import org.gradle.api.provider.Provider + +/** + * [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 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 prefix = work.map { it.dir(target.konanName) } + + 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) }) + 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 }) + } + 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. + 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/dsl/builds/ZigSettings.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigSettings.kt new file mode 100644 index 0000000..688fd7d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigSettings.kt @@ -0,0 +1,38 @@ +package io.github.lemcoder.dsl.builds + +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 : ExternalBuildSettings { + init { + optimize.convention(ZigOptimize.RELEASE_FAST) + } + + /** The directory holding `build.zig`. Defaults to the library's `sourceDirectory`. */ + abstract val projectDirectory: DirectoryProperty + + /** + * `-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/dsl/builds/ZigTargets.kt b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigTargets.kt new file mode 100644 index 0000000..264b4ab --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/builds/ZigTargets.kt @@ -0,0 +1,25 @@ +package io.github.lemcoder.dsl.builds + +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 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) { + 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.25" + 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/dsl/config/ConfigField.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt new file mode 100644 index 0000000..0a6003d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigField.kt @@ -0,0 +1,92 @@ +package io.github.lemcoder.dsl.config + +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"), + ANDROID_NDK("androidNdk", "ANDROID_NDK_HOME", Kind.DIRECTORY, "/path/to/android-sdk/ndk/"), + ; + + 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"), + + /** `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"), +} + +/** + * 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})" + ConfigSource.ANDROID_GRADLE_PLUGIN -> "$raw (${source.description}, android.ndkVersion)" + } + + 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/dsl/config/ConfigReport.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigReport.kt new file mode 100644 index 0000000..789e7ee --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/ConfigReport.kt @@ -0,0 +1,61 @@ +package io.github.lemcoder.dsl.config + +import io.github.lemcoder.dsl.builds.ExternalBuildTask +import io.github.lemcoder.kotlin.UsesKonanDistribution +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/dsl/config/KonanConfig.kt b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt new file mode 100644 index 0000000..24e760e --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/config/KonanConfig.kt @@ -0,0 +1,132 @@ +package io.github.lemcoder.dsl.config + +import io.github.lemcoder.dsl.KonanExtension +import io.github.lemcoder.kotlin.KonanDistribution +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.model.ObjectFactory +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory + +/** + * `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, objects: ObjectFactory) { + /** 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 + + /** + * 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`. 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, + * 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 = when (field) { + ConfigField.KONAN_HOME -> konanHomeHint() + ConfigField.ANDROID_NDK -> + "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)) + } + + /** [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 + ConfigField.ANDROID_NDK -> androidNdk + } + + /** 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/libraries/CompileNativeLibraryTask.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt new file mode 100644 index 0000000..b405be5 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/CompileNativeLibraryTask.kt @@ -0,0 +1,123 @@ +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 +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 +import org.gradle.api.file.FileSystemOperations +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.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 +import org.gradle.process.ExecOperations +import org.gradle.work.DisableCachingByDefault + +/** + * 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 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, +) : DefaultTask(), UsesKonanDistribution { + + /** C and C++ source files to compile to object files */ + @get:InputFiles + @get:PathSensitive(RELATIVE) + abstract val sourceFiles: ConfigurableFileCollection + + /** Directories that include `.h` header files */ + @get:InputFiles + @get:PathSensitive(RELATIVE) + abstract val includeDirs: ConfigurableFileCollection + + /** 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 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 an Android target compiles for. */ + @get:Input @get:Optional abstract val androidApiLevel: Property + + @get:Input abstract val target: Property + + @get:Input + @get:Optional + abstract val arguments: ListProperty + + @get:Input + abstract val libName: Property + + /** Where the archive goes: `lib.a`, and nothing else. */ + @get:OutputDirectory abstract val outputDirectory: DirectoryProperty + + @TaskAction + 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() + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt new file mode 100644 index 0000000..20363f5 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/KonanClangBuild.kt @@ -0,0 +1,47 @@ +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 +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") + +/** + * 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): Map> = + 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 " + + (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()) + + 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) + } + } diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt new file mode 100644 index 0000000..95f5429 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/Libraries.kt @@ -0,0 +1,32 @@ +package io.github.lemcoder.dsl.libraries + +import io.github.lemcoder.dsl.KonanExtension +import org.gradle.api.Action +import org.gradle.api.Project + +/** + * 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}")) + + val lifecycle = tasks.register(NativeArtifacts.taskName(library.name)) { + group = "konan" + description = "Build the '${library.name}' library: a static archive per target." + } + afterEvaluate { + 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 new file mode 100644 index 0000000..4262ef4 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/LibraryBuilds.kt @@ -0,0 +1,44 @@ +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.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, 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): Map> = + 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.distinctTargets.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 { + val name = library.name + return ExternalBuildOutput( + owner = "konan.libraries.$name", + name = name, + side = "", + targets = library.distinctTargets.getOrElse(emptyList()), + libraryName = provider { name }, + 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/NativeArtifacts.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt new file mode 100644 index 0000000..5637740 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeArtifacts.kt @@ -0,0 +1,56 @@ +package io.github.lemcoder.dsl.libraries + +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 +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider + +/** + * 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>, + /** `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) } + + companion object { + /** The one place the layout is written: `/lib.a` under a library's output directory. */ + 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 = taskName("compileNativeLibrary", name) + + fun of(project: Project, library: NativeLibrarySpec) = NativeArtifacts( + name = library.name, + outputDirectory = library.outputDirectory, + 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 new file mode 100644 index 0000000..cc7ec01 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/NativeLibrarySpec.kt @@ -0,0 +1,92 @@ +package io.github.lemcoder.dsl.libraries + +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.NativeTarget +import org.gradle.api.tasks.TaskProvider +import javax.inject.Inject +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 + +/** + * 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 + * 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 { + 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 + + /** + * [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. */ + 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)) + + /** 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) = 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) = 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) = 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.libraries.keySet().map { it.toList() }) + } +} diff --git a/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt new file mode 100644 index 0000000..a6280a3 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/dsl/libraries/PrebuiltLibrary.kt @@ -0,0 +1,66 @@ +package io.github.lemcoder.dsl.libraries + +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 +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFiles +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 + +/** + * `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.libraries.getOrElse(emptyMap()) + 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 }}." + } + 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), PlacePrebuiltFilesTask::class.java) { + group = "konan" + description = "Place the prebuilt '$libraryName' archives where consumers look for them." + place(placements) + } +} + +/** + * 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 PlacePrebuiltFilesTask : DefaultTask() { + @get:InputFiles @get:PathSensitive(PathSensitivity.NONE) abstract val sources: ConfigurableFileCollection + + /** Each prebuilt file and where it goes. */ + @get:Input abstract val placements: MapProperty + + @get:OutputFiles abstract val destinations: ConfigurableFileCollection + + fun place(placements: Map) { + sources.from(placements.keys) + destinations.from(placements.values) + this.placements.set(placements) + } + + @TaskAction + 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/index/GenerateFromNativeIndexTask.kt b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt new file mode 100644 index 0000000..721b310 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTask.kt @@ -0,0 +1,101 @@ +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.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 +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.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 + +/** + * 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. + */ +@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(), UsesKonanDistribution { + @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val defFile: RegularFileProperty + + /** 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 + + @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 + + /** 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 + abstract val request: Property + + /** Replaced on every run with the generator's output. */ + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val request = request.get() + exec.runIndexExporter( + 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 + 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().konanName) + includeDirs.files.forEach { add("-compiler-option"); add("-I${it.absolutePath}") } + compilerOptions.getOrElse(emptyList()).forEach { add("-compiler-option"); add(it) } + addAll(extraCinteropArgs.getOrElse(emptyList())) + }, + 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/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/kotlin/io/github/lemcoder/interop/CMakeTasks.kt b/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt deleted file mode 100644 index 7e3582d..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/CMakeTasks.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.github.lemcoder.interop - -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 java.io.File -import javax.inject.Inject - -/** Configures the external CMake build that compiles the generated JNI stub. */ -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. */ -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/interop/DefFile.kt b/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt deleted file mode 100644 index 558bb33..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/DefFile.kt +++ /dev/null @@ -1,74 +0,0 @@ -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. - * - * @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 -> - val roots = libraryPaths.map { p -> File(p).takeIf { it.isAbsolute } ?: base.resolve(p) } - (roots + base).map { it.resolve(name) }.firstOrNull { it.isFile } - } - - companion object { - private val SEPARATOR = Regex("""(?m)^-{3,}\s*$""") - - 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() - .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() - } - - 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/ExternalNativeBuild.kt b/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt deleted file mode 100644 index 06c568d..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt +++ /dev/null @@ -1,100 +0,0 @@ -package io.github.lemcoder.interop - -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 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. - * - * 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. - * - * 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. - */ -abstract class CMakeSettings @Inject constructor(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) - } - - /** 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)) - } - - /** The `CMakeLists.txt` to configure. Required unless [preset] is set. */ - abstract val path: RegularFileProperty - - /** A configure preset from the project's `CMakePresets.json`. */ - 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. - */ - abstract val buildDirectory: DirectoryProperty - - /** - * Directory holding the linked JNI library, if the build does not leave it in [buildDirectory] - * (`CMAKE_LIBRARY_OUTPUT_DIRECTORY`). - */ - abstract val libraryDirectory: DirectoryProperty - - /** Targets to build. Defaults to whatever the build's default target is. */ - abstract val targets: ListProperty - - /** 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 -} - -/** 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. */ - abstract val preset: Property - - /** Build directory for this ABI; defaults the same way as the enclosing block's. */ - abstract val buildDirectory: DirectoryProperty - - /** Extra configure arguments for this ABI only. */ - abstract val arguments: ListProperty -} 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 f369a14..0000000 --- a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt +++ /dev/null @@ -1,401 +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()) - 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) - } - - 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, jniLibsRoot) - } - - // 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 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. - */ - 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() } - 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/jvm/GenerateJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt deleted file mode 100644 index dd9c9d6..0000000 --- a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt +++ /dev/null @@ -1,151 +0,0 @@ -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 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.provider.ListProperty -import org.gradle.api.provider.Provider -import org.gradle.api.provider.Property -import java.io.File -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 javax.inject.Inject - -/** - * Generates the JNI `.c` stub and the runtime-free Kotlin bindings from a cinterop `.def`. - * - * 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. - */ -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 - - @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 - - @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. */ - @get:Input abstract val internalBindings: 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/${JvmInteropSupport.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) } - - /** Directory holding the generated Kotlin bindings. */ - @get:Internal - val kotlinSourceDirectory: Provider get() = outputDirectory.dir(KOTLIN_DIR) - - @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}." - ) - - // 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") - } - 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 { - 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 deleted file mode 100644 index e9b8231..0000000 --- a/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt +++ /dev/null @@ -1,95 +0,0 @@ -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. */ -internal object JvmInteropSupport { - - // --- 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 } - } - - /** 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." - - 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/jvm/LinkJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt deleted file mode 100644 index 53926fa..0000000 --- a/src/main/kotlin/io/github/lemcoder/jvm/LinkJvmInteropTask.kt +++ /dev/null @@ -1,98 +0,0 @@ -package io.github.lemcoder.jvm - -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.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.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.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 - -/** Compiles the generated `.c` and statically links the target's `.a` into a shared JNI library. */ -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 - /** 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 - @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) - ?: JvmInteropSupport.findKonanHome() - ?: error(JvmInteropSupport.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 outDir = outputDirectory.get().asFile - outDir.deleteRecursively(); outDir.mkdirs() - val outLib = outDir.resolve(tgt.sharedLibraryName(stubBaseName.get())) - - 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}") } - 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") } - add("--unwindlib=none") - } else { - jniIncludeDirs.getOrElse(emptyList()).forEach { add("-I$it") } - } - add(stubCFile.get().asFile.absolutePath) - add(staticLibrary.get().asFile.absolutePath) - 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) } - } - 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". - addAll(additionalLinkerArgs.getOrElse(emptyList())) - } - - val result = exec.execCapture { commandLine(cmd) } - logger.lifecycle(result.output) - result.assertNormalExitValue() - logger.lifecycle("JNI stub 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/kotlin/IndexExporter.kt b/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt new file mode 100644 index 0000000..561ef7a --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/IndexExporter.kt @@ -0,0 +1,70 @@ +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.api.InvalidUserDataException +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 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 + * 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. + * + * 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, + distribution: KonanDistribution, + workDir: File, +) { + val home = distribution.home + check(distribution.compilerJar.isFile) { "Not a Kotlin/Native distribution: $home" } + + 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/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, cinteropArgs) + val result = javaExecCapture { + classpath(distribution.compilerJar, exporterJar) + mainClass.set(EXPORTER_MAIN) + args(invocation.toArgs()) + systemProperty("konan.home", home.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}" } +} + +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 new file mode 100644 index 0000000..e42046d --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanDistribution.kt @@ -0,0 +1,178 @@ +package io.github.lemcoder.kotlin + +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 io.github.lemcoder.model.TargetFamily +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 + +/** + * 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, + /** 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") + + /** 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 (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. 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) { + 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}" } + 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() } + } + + /** + * 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 + // 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 + 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 + + /** The Android NDK clang resource directory, which holds compiler-rt, from the distribution's dependencies. */ + fun ndkResourceDirectory(): File? = + ndkToolchain()?.resolve("lib64/clang")?.listFiles { f -> f.isDirectory }?.maxByOrNull { it.name } + + /** 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 { + + /** + * 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 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") + } + + private val properties by lazy { + Properties().apply { + KonanDistribution::class.java.getResourceAsStream("/io/github/lemcoder/index/kotlin-native.properties") + .use { load(checkNotNull(it)) } + } + } + + /** + * 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/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/kotlin/KonanToolchain.kt b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt new file mode 100644 index 0000000..6ade4d1 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KonanToolchain.kt @@ -0,0 +1,150 @@ +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.HostOs +import io.github.lemcoder.model.NativeTarget +import io.github.lemcoder.model.TargetFamily +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 +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 + +/** + * 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 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, ranlib, cmakeToolchainFile) + + 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 { + project.tasks.register(name, KonanToolchainTask::class.java) { + group = "konan" + description = "Write Kotlin/Native's compilers for ${target.konanName} as files external builds can use." + // An input so another distribution regenerates the wrappers. + distribution.set(konanHome.map { it.raw.orEmpty() }) + 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(), 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 = distribution() + 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 + 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") + 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)) + } + + /** + * 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, ranlibFile: 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\")") + 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)") + } + } + + 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 new file mode 100644 index 0000000..60aa719 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/KotlinGradlePlugin.kt @@ -0,0 +1,150 @@ +package io.github.lemcoder.kotlin + +import io.github.lemcoder.dsl.bindings.BindingSpec +import io.github.lemcoder.dsl.libraries.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 +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.SourceSetContainer +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 + +/** + * 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 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<*>, 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 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) } + } + } + + /** 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: 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 } + .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`. 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 { + @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) } + } + } + + /** 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) + } + + /** …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 } + + /** + * 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 + 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) + 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(nativeTarget).get().asFile.absolutePath, + ) + project.tasks.named(interopProcessingTaskName).configure { dependsOn(artifacts.builtBy(nativeTarget)) } + } + } + } + } +} + +/** `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/kotlin/Xcode.kt b/src/main/kotlin/io/github/lemcoder/kotlin/Xcode.kt new file mode 100644 index 0000000..c9bbaa2 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/kotlin/Xcode.kt @@ -0,0 +1,32 @@ +package io.github.lemcoder.kotlin + +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/model/Executable.kt b/src/main/kotlin/io/github/lemcoder/model/Executable.kt new file mode 100644 index 0000000..17e2389 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/Executable.kt @@ -0,0 +1,19 @@ +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. */ +sealed class Executable : Serializable { + /** This exact file. */ + data class File(val file: java.io.File) : Executable() + + /** + * 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/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..70352a6 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/model/Naming.kt @@ -0,0 +1,8 @@ +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 ?: "") 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/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/ExecCapture.kt b/src/main/kotlin/io/github/lemcoder/util/ExecCapture.kt index b374a7c..2d26607 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( +internal 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() +internal 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) @@ -26,10 +39,10 @@ fun ExecOperations.execCapture( } -sealed class ExecCaptureResult( +internal sealed class ExecCaptureResult( val output: String, private val result: ExecResult, ) : 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/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/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/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt new file mode 100644 index 0000000..a0e0950 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/AgpWiring.kt @@ -0,0 +1,123 @@ +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.file.Directory +import org.gradle.api.provider.Property +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 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: 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) } + 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) { + project.extensions.findByType(AndroidComponentsExtension::class.java)?.onVariants { variant -> + 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) + } + } + 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. + */ + /** + * 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) { + 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() } + } + + /** + * 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". [builds] are the + * Android targets' builds only, as anything `dependsOn` takes. + */ + 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 -> + 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 -> + if (enabled()) variant.sources.jniLibs?.addStaticSourceDirectory(project.file(relative).apply { mkdirs() }.path) + } + } + project.tasks.matching { it.name.contains("JniLibFolders") || it.name.contains("MergeJniLib") } + .configureEach { if (enabled()) dependsOn(builds) } + } +} 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..c5e5826 --- /dev/null +++ b/src/main/kotlin/io/github/lemcoder/wiring/GradlePluginId.kt @@ -0,0 +1,21 @@ +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"), + /** 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"), +} + +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/resources/io/github/lemcoder/dsl/bindings/jni/jni.h b/src/main/resources/io/github/lemcoder/dsl/bindings/jni/jni.h new file mode 100644 index 0000000..4c34313 --- /dev/null +++ b/src/main/resources/io/github/lemcoder/dsl/bindings/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/main/resources/io/github/lemcoder/index/kotlin-native.properties b/src/main/resources/io/github/lemcoder/index/kotlin-native.properties new file mode 100644 index 0000000..5937a0d --- /dev/null +++ b/src/main/resources/io/github/lemcoder/index/kotlin-native.properties @@ -0,0 +1,2 @@ +version=${kotlinNativeVersion} +supported=${kotlinNativeVersions} 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/KonanPluginFunctionalTest.kt b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt index 84a2f93..96fc869 100644 --- a/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt +++ b/src/test/kotlin/io/github/lemcoder/KonanPluginFunctionalTest.kt @@ -1,172 +1,1078 @@ package io.github.lemcoder -import org.gradle.testkit.runner.GradleRunner +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 +import java.util.jar.JarOutputStream +import java.util.jar.Manifest 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 +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assumptions.assumeTrue /** - * 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 = 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) - // 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 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 io.github.lemcoder.model.NativeTarget\nimport io.github.lemcoder.model.Executable\n\n" + + "plugins {\n${plugins.trimIndent().prependIndent(" ")}\n}\n\n" + + "konan {\n config.konanHome.set(file(\"${konanStub()}\"))\n$installedTools${konan.trimIndent().prependIndent(" ")}\n}\n" + ) } - private fun writeBuild(name: String, konanConfig: String) { - settingsFile.writeText("""rootProject.name = "$name"""") - buildFile.writeText( + /** 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 { + create("mymath") { + defFile.set(file("mymath.def")) + library(mymath) + } + } + """.trimIndent() + + @Test + fun `config reads the build script, then Gradle properties, then the environment`() { + write( + """id("io.github.lemcoder.konanplugin")""", """ - import org.jetbrains.kotlin.konan.target.KonanTarget - import io.github.lemcoder.interop.jvmInterops + 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 + // 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, "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 - plugins { - id("io.github.lemcoder.konanplugin") + 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) } }""") + + val result = runner("compileNativeLibraryMymath", "--dry-run").build() + + 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(NativeTarget.MACOS_ARM64); zig { } } }""", + ) + + val result = runner("compileNativeLibraryMymath", "--dry-run").build() + + 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(NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) + external { + step { + executable.set(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_OUTPUT_DIR/lib${'$'}KONAN_LIB_NAME.a\"\n" + ) + + val first = runner("compileNativeLibraryMymath").build() + val second = runner("compileNativeLibraryMymath").build() - konanConfig { - konanPath.set("${konanStub()}") - ${konanConfig.trimIndent().prependIndent(" ")} + 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 `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) } + /** 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 + } - /** 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" + @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 `registers runKonanClang and configures it without a real toolchain`() { - writeBuild( - "konan-fixture", + 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( """ - targets(KonanTarget.MACOS_ARM64) - libName.set("mymath") + 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("runKonanClang", "--dry-run").build() + 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) + } - // 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}" + @Test + 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) } }""", ) + 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").withEnvironment(System.getenv() - "ANDROID_NDK_HOME").buildAndFail() + + assertContains(result.output, "konan.config.androidNdk is not set β€” :konanClangMymathAndroid_arm64 needs it.") + assertContains(result.output, "/ndk/") } @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 `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 - val result = runner("runKonanClang", "--dry-run").build() + // Without a count, Unix Makefiles runs every compile at once. + assertTrue(output.lines().any { it.startsWith("ARGS=[--build") && it.contains("--parallel, 3") }, output) + } - assertTrue( - result.output.contains(":runKonanClang SKIPPED"), - "expected runKonanClang to configure from conventions alone, got:\n${result.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 `an interop declared on the project registers generate and per-target link tasks`() { - writeInteropBuild( - "interop-fixture", - konanConfig = """ - targets(KonanTarget.MACOS_ARM64) - libName.set("mymath") + 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 { + library(NativeTarget.LINUX_X64, file("libs/libmymath-linux.a")) + library(NativeTarget.MINGW_X64, file("libs/mymath.lib")) + } + } + } """, - interops = """ - create("mymath") { - defFile(project.file("mymath.def")) + ) + + 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(NativeTarget.LINUX_X64, NativeTarget.MACOS_ARM64) + prebuilt { library(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(", NativeTarget.MACOS_ARM64", "")) + assertTrue("no such file" in runner("compileNativeLibraryMymath").buildAndFail().output) + } - val result = runner("linkJvmInteropMymath", "--dry-run").build() + /** 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("\\", "/") + } - assertTrue( - result.output.contains(":generateJvmInteropMymath SKIPPED"), - "expected the generate task in the dry-run graph, got:\n${result.output}" + @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_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" ) - assertTrue( - result.output.contains(":linkJvmInteropMymathMacos_arm64 SKIPPED"), - "expected a per-target link task in the dry-run graph, got:\n${result.output}" + write( + """id("io.github.lemcoder.konanplugin")""", + """ + config.konanHome.set(file("$home")) + libraries { + create("mymath") { + targets(NativeTarget.LINUX_X64) + make { directory.set(file("native")) } + } + } + """, ) - // 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}" + + 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 `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 `interop targets are independent of the konanConfig ones`() { - writeInteropBuild( - "android-fixture", - konanConfig = """ - targets(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64) - libName.set("mymath") + 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")) + } + } + } + } """, - interops = """ - create("mymath") { - defFile(project.file("mymath.def")) - targets.set(listOf(KonanTarget.ANDROID_ARM64, KonanTarget.ANDROID_X64)) + ) + + 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 `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() } + 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_LIB_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() } + 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_LIB_NAME} SHARED mymath.c) + """.trimIndent() + ) + write( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + """ + config.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("linkJvmInteropMymath", "--dry-run").build() + 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 - assertTrue( - result.output.contains(":linkJvmInteropMymathAndroid_arm64 SKIPPED"), - "expected an arm64 link task, got:\n${result.output}" + @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" ) - assertTrue( - result.output.contains(":linkJvmInteropMymathAndroid_x64 SKIPPED"), - "expected an x64 link task, got:\n${result.output}" + 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")""", + """ + config.cargo.set(file("${cargo.absolutePath.replace("\\", "/")}")) + libraries { + create("mymath") { + targets(NativeTarget.${host.name}) + cargo { + 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 `an external build replaces the plugin's link tasks`() { - writeInteropBuild( - "external-fixture", - konanConfig = """ - libName.set("mymath") + 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( + """ + 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")""", + """ + config.zig.set(file("${zig.replace("\\", "/")}")) + libraries { + create("mymath") { + targets(NativeTarget.MACOS_ARM64, NativeTarget.LINUX_X64, NativeTarget.MINGW_X64) + zig { } + } + } """, - interops = """ - create("mymath") { - defFile(project.file("mymath.def")) - externalNativeBuild { - cmake { - path.set(project.file("CMakeLists.txt")) - targets.add("mymath-jni") + ) + + runner("compileNativeLibraryMymath").build() + + 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 `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("tasks", "--all").build() + + 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 `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("linkJniMymath", "--dry-run").withPluginClasspathAndKgp().build() + + // 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}") + } + } + + @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 `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() + val ndk = realNdk() + File(projectDir, "native").mkdirs() + // 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 + extern "C" int my_add(int a, int b) { + 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() + ) + // 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)") + .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", + ) + + // --no-undefined: a missing runtime would fail here, not when the app loads the library. + val result = runner("linkJniMymathAndroid_arm64").withPluginClasspathAndKgp().build() + + 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 + 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() + 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\"))\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. + 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) + + 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 `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_OUTPUT_DIR/lib${'$'}KONAN_LIB_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 `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 `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()")) + File(projectDir, "build.gradle.kts").appendText("kotlin { jvm() }\nrepositories { mavenCentral() }\n") + + val result = runner("jvmJar", "--dry-run").withPluginClasspathAndKgp().build() + + 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}") + } + + @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( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + 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 != NativeTarget.LINUX_ARM64) assertFalse("linkJniMymathLinux_arm64" in result.output, "not loadable here:\n${result.output}") + } + + @Test + 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")) + buildTargets.add("mymath-jni") + } } } } @@ -174,34 +1080,248 @@ 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}" + 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}") + } + + @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") + // 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")) + jni { cmake { path.set(file("CMakeLists.txt")) } } + } + } + """, ) - assertFalse( - result.output.contains(":linkJvmInteropMymathMacos_arm64"), - "expected no plugin link task when an external build owns the link, got:\n${result.output}" + File(projectDir, "CMakeLists.txt").writeText( + """ + cmake_minimum_required(VERSION 3.16) + project(contract NONE) + file(WRITE "${'$'}{CMAKE_LIBRARY_OUTPUT_DIRECTORY}/contract.txt" "${'$'}{KONAN_LIB_NAME}|${'$'}{KONAN_JNI_STUB_DIR}|${'$'}{KONAN_JNI_INCLUDE_DIR}") + """.trimIndent() ) + + runner("linkJniMymath").withPluginClasspathAndKgp().build() + + 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 `konanConfig alone registers no interop tasks`() { - writeBuild( - "no-interop-fixture", + 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")""", """ - targets(KonanTarget.MACOS_ARM64) - libName.set("mymath") + config.konanHome.set(file("$home")) + bindings { + create("mymath") { + defFile.set(file("mymath.def")) + jni { + external { + step { + executable.set(Executable.File(file("/bin/sh"))) + arguments.add("build.sh") + workingDirectory.set(layout.projectDirectory) + } + producedLibrary(NativeTarget.${host.name}, 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_LIB_NAME\" \"\$KONAN_JNI_STUB_DIR\" > out/built\n" + ) + + val result = runner("linkJniMymath").withPluginClasspathAndKgp().build() + + 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('|') + assertEquals(host.konanName, target) + assertTrue(library.name.contains(name), "${library.name} is named after $name") + 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_OUTPUT_DIR/${host.sharedLibraryName("x").replace("x", "${'$'}KONAN_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 `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_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() + 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 { library(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( + """kotlin("jvm")${"\n"}id("io.github.lemcoder.konanplugin")""", + mymath.replace("library(mymath)", "library(mymath)\n ffm()"), + ) + + val result = runner("compileJava", "linkFfmMymath", "--dry-run").withPluginClasspathAndKgp().build() + + 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 + 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") + } - val result = runner("tasks", "--group=interop").build() + @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() } - assertFalse( - result.output.contains("generateJvmInterop"), - "expected no interop tasks without a jvmInterops block, got:\n${result.output}" + 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.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") + 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(":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(":konanClangMymath$hostSuffix") < order.indexOf(":cinteropMymath$targetSuffix"), "$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..af4eec1 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/TestKitSupport.kt @@ -0,0 +1,38 @@ +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)) +} + +/** + * 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) +} + +/** + * 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 +} + +private object TestKitSupport diff --git a/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt b/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt new file mode 100644 index 0000000..3c901b3 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/dsl/bindings/DefFileTest.kt @@ -0,0 +1,29 @@ +package io.github.lemcoder.dsl.bindings + +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( + """ + 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/kotlin/io/github/lemcoder/dsl/config/ConfigValueTest.kt b/src/test/kotlin/io/github/lemcoder/dsl/config/ConfigValueTest.kt new file mode 100644 index 0000000..8466973 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/dsl/config/ConfigValueTest.kt @@ -0,0 +1,36 @@ +package io.github.lemcoder.dsl.config + +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/index/ExporterProtocolTest.kt b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt new file mode 100644 index 0000000..c2fea47 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/index/ExporterProtocolTest.kt @@ -0,0 +1,26 @@ +package io.github.lemcoder.index + +import io.github.lemcoder.model.BindingVisibility +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 = 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 + 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 new file mode 100644 index 0000000..198fc02 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/index/GenerateFromNativeIndexTest.kt @@ -0,0 +1,230 @@ +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.assertFalse +import kotlin.test.assertTrue + +/** + * 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.defaultVersion] downloads it. Every other supported version installed under `~/.konan` + * must generate the same JNI bindings. + */ +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 `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 = loadNativeLibrary(\"samplestubs\")") + assertContains(kotlin, "@JvmName(\"sample_sum\")\ninternal external fun sample_sum(values: IntArray?, count: Long): Int") + // 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()") + // 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. + 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 + 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);") + // 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);") + } + + @Test + fun `the jextract generator binds what cinterop indexed, typedef'd records included`() { + val jextract = System.getenv("JEXTRACT") + assumeTrue(jextract != null && File(jextract).canExecute(), "Set JEXTRACT to a jextract executable") + assumeInstalled() + val projectDir = project( + exportTask(GeneratorRequest.Jextract(library = "sample", jextract = File(jextract)), "ffm_sample", sampleDef, sampleDef.parentFile), + ) + runner(projectDir, "generate_ffm_sample").build() + + val java = projectDir.resolve("build/generated/ffm_sample/java/sample") + // cinterop folds `typedef union { … } bits_t` into a record; jextract only binds it as a typedef. + assertTrue(java.resolve("bits_t.java").isFile, "bits_t, returned by sample_bits, must be bound") + val header = java.resolve("sample.java").readText() + listOf("sample_sum", "sample_name", "sample_bits", "sample_log", "SAMPLE_VERSION", "RED", "sample_global") + .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() + } + + private fun jni(): File { + assumeInstalled() + return generated().getValue("jni_sample") + } + + private fun assumeInstalled() = + 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 + + 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(), version = version).build() + names.associateWith { projectDir.resolve("build/generated/$it") } + } + + 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(NativeTarget.host) + 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 = BindingVisibility.${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"""") + projectDir.resolve("build.gradle.kts").writeText( + (listOf( + "import io.github.lemcoder.model.NativeTarget", + "import io.github.lemcoder.index.GenerateFromNativeIndexTask", + "import io.github.lemcoder.model.BindingVisibility", + "import io.github.lemcoder.index.GeneratorRequest", + "plugins { id(\"io.github.lemcoder.konanplugin\") }", + ) + tasks).joinToString("\n") + ) + return projectDir + } + + 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/NativeTargetTest.kt b/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt new file mode 100644 index 0000000..f51d1e9 --- /dev/null +++ b/src/test/kotlin/io/github/lemcoder/model/NativeTargetTest.kt @@ -0,0 +1,44 @@ +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 + * 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`() { + assumeTrue(kotlinKnowsHost, "Kotlin/Native does not support this host") + assertEquals(HostManager.host.name, NativeTarget.host.konanName) + } +} 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..1c17703 --- /dev/null +++ b/src/test/resources/nativeindex/sample.h @@ -0,0 +1,62 @@ +// 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); +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); +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; +void sample_release(sample_handle handle);