Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/src/reference/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,40 @@ settings:
entryPoint: com.example.MainKt.main
```

##### 'settings.native.cinterop'

`settings:native:cinterop` configures C/Objective-C interop for native targets.

Each cinterop module is defined by a name and supports the following attributes:

| Attribute | Description |
|----------------|-------------------------------------------|
| `defFile` | Path to the Kotlin/Native definition (`.def`) file. |
| `packageName` | The Kotlin package name for the generated bindings. |
| `compilerOpts` | A list of flags to be passed to the C compiler (e.g., `-I/path/to/includes`). |
| `linkerOpts` | A list of C/C++ source files (`.c`, `.cpp`) to be compiled and/or flags to be passed to the linker (e.g., `-lm`). |

Example:

```yaml
# Configure cinterop for a native module
settings:
native:
cinterop:
hello:
defFile: cinterop/hello.def
packageName: com.example.hello
linkerOpts:
- cinterop/hello.c
libfoo:
defFile: external/libfoo/api.def
packageName: com.example.libfoo
compilerOpts:
- -I/usr/local/include
linkerOpts:
- -lfoo
```

### `settings.springBoot`

`settings.springBoot` configures the Spring Boot framework (JVM platform only).
Expand Down
51 changes: 51 additions & 0 deletions docs/src/user-guide/advanced/native-interop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Native Interoperability with C

Amper provides a powerful and flexible way to interoperate with C and Objective-C code in your native projects using the Kotlin/Native `cinterop` tool. This guide explains how to configure and use this feature.

## How it Works

The `cinterop` process in Amper allows you to create Kotlin bindings for C libraries by defining modules in your `module.yaml` file.
Each module references a `.def` file that describes the C API and can include additional compiler and linker options.

## Configuration

The `cinterop` settings are configured under `settings.native.cinterop` in your `module.yaml`.

```yaml
settings:
native:
cinterop:
<module-name>:
defFile: <path-to-def-file>
packageName: <package-name>
compilerOpts:
- <flag1>
linkerOpts:
- <source-file.c>
- <linker-flag>
```

### API Details

| Option | Description |
| :--- | :--- |
| `defFile` | Path to the Kotlin/Native definition (`.def`) file. **Optional** if the module is discovered by convention. |
| `packageName` | The Kotlin package name for the generated bindings. |
| `compilerOpts`| A list of flags to be passed to the C compiler (e.g., `-I/path/to/includes`). |
| `linkerOpts` | A list of C/C++ source files (`.c`, `.cpp`) to be compiled and/or flags to be passed to the linker (e.g., `-lm`). |

### Example

To create a cinterop module, define it in your module.yaml with the path to your .def file:

**`module.yaml`:**
```yaml
settings:
native:
cinterop:
# Define a completely new 'bar' module
bar:
defFile: external/libbar/api.def
packageName: com.example.bar
linkerOpts:
- external/libbar/bar.c
14 changes: 14 additions & 0 deletions examples/ktor-native-sample/module.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
product:
type: linux/app
platforms:
- linuxX64

settings:
kotlin:
serialization: json
native:
entryPoint: org.jetbrains.amper.ktor.main

dependencies:
- io.ktor:ktor-server-core:3.2.3
- io.ktor:ktor-server-cio:3.2.3
20 changes: 20 additions & 0 deletions examples/ktor-native-sample/src/Application.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package org.jetbrains.amper.ktor

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.cio.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun main() {
embeddedServer(CIO, port = 8080, host = "0.0.0.0", module = Application::module)
.start(wait = true)
}

fun Application.module() {
configureRouting()
}
17 changes: 17 additions & 0 deletions examples/ktor-native-sample/src/Routing.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package org.jetbrains.amper.ktor

import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello World!")
}
}
}
11 changes: 11 additions & 0 deletions examples/native-cinterop/module.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
product:
type: linux/app
platforms: [ linuxX64 ]

settings:
native:
entryPoint: 'org.jetbrains.amper.samples.cinterop.main'
cinterop:
hello:
defFile: 'resources/cinterop/hello.def'
packageName: 'org.jetbrains.amper.samples.cinterop.hello'
1 change: 1 addition & 0 deletions examples/native-cinterop/resources/cinterop/hello.def
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
headers = src/c/hello.c
5 changes: 5 additions & 0 deletions examples/native-cinterop/src/c/hello.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#include <stdio.h>

void sayHello(const char* name) {
printf("Hello. %s from C!\n", name);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.jetbrains.amper.samples.cinterop

import kotlinx.cinterop.*
import org.jetbrains.amper.samples.cinterop.hello.* // Import the generated bindings

fun main() {
println("Hello, John Doe from Kotlin!")
sayHello("John Doe")
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import org.jetbrains.amper.jdk.provisioning.JdkProvider
import org.jetbrains.amper.jvm.getDefaultJdk
import org.jetbrains.amper.processes.ArgsMode
import org.jetbrains.amper.processes.LoggingProcessOutputListener
import org.jetbrains.amper.processes.ProcessResult
import org.jetbrains.amper.processes.runJava
import org.jetbrains.amper.telemetry.setListAttribute
import org.jetbrains.amper.telemetry.spanBuilder
Expand Down Expand Up @@ -72,31 +73,7 @@ class KotlinNativeCompiler(
logger.debug("konanc ${ShellQuoting.quoteArgumentsPosixShellWay(args)}")

withKotlinCompilerArgFile(args, tempRoot) { argFile ->
val konanLib = kotlinNativeHome / "konan" / "lib"

// We call konanc via java because the konanc command line doesn't support spaces in paths:
// https://youtrack.jetbrains.com/issue/KT-66952
// TODO in the future we'll switch to kotlin tooling api and remove this raw java exec anyway
val result = processRunner.runJava(
jdk = jdk,
workingDir = kotlinNativeHome,
mainClass = "org.jetbrains.kotlin.cli.utilities.MainKt",
classpath = listOf(
konanLib / "kotlin-native-compiler-embeddable.jar",
konanLib / "trove4j.jar",
),
programArgs = listOf("konanc", "@${argFile}"),
// JVM args partially copied from <kotlinNativeHome>/bin/run_konan
argsMode = ArgsMode.ArgFile(tempRoot = tempRoot),
jvmArgs = listOf(
"-ea",
"-XX:TieredStopAtLevel=1",
"-Dfile.encoding=UTF-8",
"-Dkonan.home=$kotlinNativeHome",
),
outputListener = LoggingProcessOutputListener(logger),
)

val result = processRunner.runNativeCommand("konanc", listOf("@$argFile"), ArgsMode.ArgFile(tempRoot))
// TODO this is redundant with the java span of the external process run. Ideally, we
// should extract higher-level information from the raw output and use that in this span.
span.setProcessResultAttributes(result)
Expand All @@ -112,4 +89,61 @@ class KotlinNativeCompiler(
}
}
}

suspend fun cinterop(
processRunner: ProcessRunner,
args: List<String>,
module: AmperModule,
) {
spanBuilder("cinterop")
.setAmperModule(module)
.setListAttribute("args", args)
.setAttribute("version", kotlinVersion)
.use { span ->
logger.debug("cinterop ${ShellQuoting.quoteArgumentsPosixShellWay(args)}")

val result = processRunner.runNativeCommand("cinterop", args, ArgsMode.CommandLine)
span.setProcessResultAttributes(result)

if (result.exitCode != 0) {
val errors = result.stderr
.lines()
.filter { it.startsWith("error: ") || it.startsWith("exception: ") }
.joinToString("\n")
val errorsPart = if (errors.isNotEmpty()) ":\n\n$errors" else ""
userReadableError("Kotlin native 'cinterop' failed$errorsPart")
}
}
}

private suspend fun ProcessRunner.runNativeCommand(
commandName: String,
commandArgs: List<String>,
argsMode: ArgsMode
): ProcessResult {
val konanLib = kotlinNativeHome / "konan" / "lib"

// We call konanc via java because the konanc command line doesn't support spaces in paths:
// https://youtrack.jetbrains.com/issue/KT-66952
// TODO in the future we'll switch to kotlin tooling api and remove this raw java exec anyway
return runJava(
jdk = jdk,
workingDir = kotlinNativeHome,
mainClass = "org.jetbrains.kotlin.cli.utilities.MainKt",
classpath = listOf(
konanLib / "kotlin-native-compiler-embeddable.jar",
konanLib / "trove4j.jar",
),
programArgs = listOf(commandName) + commandArgs,
argsMode = argsMode,
// JVM args partially copied from <kotlinNativeHome>/bin/run_konan
jvmArgs = listOf(
"-ea",
"-XX:TieredStopAtLevel=1",
"-Dfile.encoding=UTF-8",
"-Dkonan.home=$kotlinNativeHome",
),
outputListener = LoggingProcessOutputListener(logger),
)
}
}
111 changes: 111 additions & 0 deletions sources/amper-cli/src/org/jetbrains/amper/tasks/native/CinteropTask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
*/

package org.jetbrains.amper.tasks.native

import org.jetbrains.amper.ProcessRunner
import org.jetbrains.amper.compilation.downloadNativeCompiler
import org.jetbrains.amper.compilation.serializableKotlinSettings
import org.jetbrains.amper.core.AmperUserCacheRoot
import org.jetbrains.amper.core.extract.cleanDirectory
import org.jetbrains.amper.engine.BuildTask
import org.jetbrains.amper.engine.TaskGraphExecutionContext
import org.jetbrains.amper.frontend.AmperModule
import org.jetbrains.amper.frontend.Platform
import org.jetbrains.amper.frontend.TaskName
import org.jetbrains.amper.frontend.isDescendantOf
import org.jetbrains.amper.incrementalcache.IncrementalCache
import org.jetbrains.amper.jdk.provisioning.JdkProvider
import org.jetbrains.amper.tasks.TaskOutputRoot
import org.jetbrains.amper.tasks.TaskResult
import org.jetbrains.amper.tasks.artifacts.ArtifactTaskBase
import org.jetbrains.amper.util.BuildType
import org.slf4j.LoggerFactory
import java.nio.file.Path

/**
* A task that runs the Kotlin/Native cinterop tool.
*/
internal class CinteropTask(
override val module: AmperModule,
override val platform: Platform,
private val userCacheRoot: AmperUserCacheRoot,
private val taskOutputRoot: TaskOutputRoot,
private val incrementalCache: IncrementalCache,
override val taskName: TaskName,
override val isTest: Boolean,
override val buildType: BuildType,
private val defFile: Path,
private val packageName: String?,
private val compilerOpts: List<String>,
private val linkerOpts: List<String>,
private val jdkProvider: JdkProvider,
private val processRunner: ProcessRunner,
): ArtifactTaskBase(), BuildTask {
init {
require(platform.isLeaf)
require(platform.isDescendantOf(Platform.NATIVE))
}

override suspend fun run(dependenciesResult: List<TaskResult>, executionContext: TaskGraphExecutionContext): TaskResult {
// For now, we assume a single fragment. This might need to be adjusted.
val fragment = module.fragments.first { it.platforms.contains(platform) && it.isTest == isTest }
val kotlinUserSettings = fragment.serializableKotlinSettings()

val configuration = mapOf(
"kotlin.version" to kotlinUserSettings.compilerVersion,
"def.file" to defFile.toString(),
"package.name" to packageName,
"compiler.opts" to compilerOpts.joinToString(" "),
"linker.opts" to linkerOpts.joinToString(" ")
).mapNotNull { (k, v) -> v?.let { k to it } }.toMap()
val inputs = listOf(defFile) + linkerOpts.map { module.source.moduleDir.resolve(it) }

val artifact = incrementalCache.execute(taskName.name, configuration, inputs) {
cleanDirectory(taskOutputRoot.path)

val outputKLib = taskOutputRoot.path.resolve(defFile.toFile().nameWithoutExtension + ".klib")

val nativeCompiler = downloadNativeCompiler(kotlinUserSettings.compilerVersion, userCacheRoot, jdkProvider)
val args = buildList {
add("-def")
add(defFile.toString())
add("-o")
add(outputKLib.toString())
add("-target")
add(platform.nameForCompiler)
if (packageName != null) {
add("-pkg")
add(packageName)
}
add("-compiler-option")
add("-I${module.source.moduleDir}")
compilerOpts.forEach {
add(it)
}
linkerOpts.forEach {
add("-linker-option")
add(it)
}
}

logger.info("Running cinterop for '${defFile.fileName}'...")
nativeCompiler.cinterop(processRunner, args, module)

return@execute IncrementalCache.ExecutionResult(listOf(outputKLib))
}.outputFiles.singleOrNull()

return Result(
compiledKlib = artifact,
taskName = taskName,
)
}

class Result(
val compiledKlib: Path?,
val taskName: TaskName,
) : TaskResult

private val logger = LoggerFactory.getLogger(javaClass)
}
Loading