Publishing Kotlin to C#
The forward direction takes a Kotlin/Native library and generates a C# API for it. You write Kotlin, the plugin ships a .nupkg a C# consumer can reference directly, no code generation step on their side.
Pipeline
At build time:
KSP discovers public declarations. The KSP processor (
nuget-processor/) walks every public class, function, and property in the compiled Kotlin/Native source set.Ordinary sync callables are planned once. Each ordinary synchronous function, method, constructor, property, companion, object method, extension, and value-class member is classified into a
BridgeTypeand validated as aForwardCallablePlanorForwardPropertyPlan(ADR-062). Specialized protocols (suspend,Flow, lambda/callback, sealed helpers, generic declaration families) stay on named legacy routes.Dual projection from the plan. The same plan projects to CIR for C# (ADR-004) and to KotlinPoet
@CNameexports. A generation-time ABI contract check (ADR-055) compares both halves to the plan.CirRendereremitsInterop.cs. The C# source is generated once, at Kotlin build time, and shipped inside the package. There is no consumer-side codegen step, unlike theClangSharpPInvokeGenerator-based approach from earlier phases (see ADR-001).KotlinPoet emits
Bridges.kt. Kotlin-side@CNameexport wrappers are generated so every bridged declaration has a stable C ABI entry point.Kotlin/Native compiles and links shared libraries for each target platform.
packNugetpackages the generated C#, the native binaries, and metadata into a.nupkg.
Memory model
Kotlin/Native's GC and the .NET GC know nothing about each other. Every object that crosses the bridge needs an explicit ownership story:
Primitives are copied by value. No ownership concern.
Strings cross as UTF-8
const char*, copied immediately into a managedstringviaMarshal.PtrToStringUTF8. The pointer is never cached.Objects are pinned Kotlin-side with
StableRef.create(...), which returns an opaqueCOpaquePointer. The generated C# wrapper stores that pointer as_handleand implementsIDisposable; disposing releases theStableRef. See ADR-003.
Every time an object-typed property or return value crosses the bridge, the generated code creates a new wrapper around a new StableRef rather than caching or reusing an existing one. This mirrors how Kotlin/Native's ObjC and Swift exports behave. Identity is not preserved (cat.Brother != cat.Brother even when both point at the same Kotlin object), and disposing one wrapper never cascades to another. See Classes and objects and ADR-005 for the concrete generated shape.
What ships in the .nupkg
Running packNuget for test-library produces this layout:
contentFiles/cs/any/Interop.cs: the generated C# source, compiled directly into the consumer's own project (not a separate assembly). This is why the generated code has no external dependency beyond the .NET BCL: it becomes part of the consumer's compilation unit.runtimes/{rid}/native/: the compiled Kotlin/Native shared libraries, one per supported target (osx-arm64,win-x64, ...). The .NET runtime resolves the correct native asset for the host RID automatically via[DllImport].
No consumer-side build step, SDK, or tool is required beyond referencing the package.
Package layout and namespace mapping
The Gradle DSL configures the root of the generated namespace tree:
Kotlin sub-packages map relative to rootPackage, and the C# namespace root is the package's packageId. In test-library, rootPackage = "io.github.xxfast.kotlin.native.nuget.test", so:
Kotlin package | C# namespace |
|---|---|
|
|
|
|
|
|
|
|
Every generated declaration lands under its mapped namespace inside the single Interop.cs file.
By default every public declaration in the module is bridged, not only those under rootPackage. publish { include(...); exclude(...) } narrows that to an explicit package-prefix allowlist, and when include is left empty, rootPackage itself becomes the default scope.
The export set is not limited to the module's own files, either: it is a reachability closure that also walks into types declared in a dependency Gradle module (return types, parameter types, property types, type arguments of Flow<T> /collections, sealed subclasses, primary-constructor parameters), admitting each discovered type through the same include/exclude/rootPackage predicate. See The nuget {} DSL for the full predicate and the cross-module closure rules.
Diagnostics
Not every Kotlin construct can be expressed as C#. When the generator meets one it cannot bridge, it names the member and the reason, at the author's own Kotlin source, rather than emitting invalid Kotlin or a C# API whose signature lies about its contract. Every diagnostic carries a ForwardDiagnosticKind whose name encodes its severity:
SKIPPED_*: the member is warned about and omitted entirely from the generated C# API. Generation continues. This is the default for a construct the forward direction cannot express (an unsupported type, aMap/Setparameter, a nullableBooleanreturn, an unsupported generic/suspend combination, a value-class member inherited through interface delegation).INFO_*: the member still binds, under a documented assumption (for example,out/invariance on a class type parameter is dropped, but the member still generates).ERROR_*: generation fails before any C# is written. The only v1 case is two constructors that render an identical C# signature (ADR-034).
A Map parameter (see Collections) is skipped like this:
The message names the declaration, the reason, an actionable hint, and, when KSP can resolve it, the file and line of the Kotlin declaration that was skipped, something the reverse direction's RirDiagnostic cannot carry, since it works from compiled metadata rather than source. See each forward page's own Limitations section for which named diagnostic fires where.
Two more kinds cover the cross-module export closure (ADR-066; see The nuget {} DSL for the closure's own rules). A reachable dependency-module type outside the effective include/rootPackage scope is skipped, naming the exact fix, from test-library's Newsroom.sponsor(): Advertisement (dev.other.core.Advertisement sits outside rootPackage):
When at least one dependency-module type is admitted, the closure also emits one aggregate INFO_EXPORTED_FROM_DEPENDENCY line per KSP run rather than one line per type, naming the whole admitted set.