From ac69866c8596d526ce45f3177e5d2ad5cd4e99e1 Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Thu, 6 Aug 2026 14:59:57 +0200 Subject: [PATCH 1/4] Model extension functions instead of packing the receiver into the name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generator emitting `fun Foo.asRaw()` had nowhere to put `Foo`, so it put it in `KtFun::name` — the string `"Foo.asRaw"`. That renders correctly and is invisible until something tries to read the name as a name, which the new `InvalidIdentifier` check does: it reported every such declaration as an invalid identifier, correctly. Add `receiver: Option` to `KtFun` and `KtFunSig`, with a `.receiver(ty)` builder. The renderer emits it after the generic parameter list and before the name, through the import set like any other type, so `name` goes back to being a plain identifier. `KtFun::signature()` carries the receiver across. Dropping it there would silently turn an extension into a member — the same class of lossy conversion this change exists to close. `DuplicateFunction` now keys on the receiver too. Kotlin dispatches an extension on its receiver, so `Foo.f()` and `Bar.f()` are two declarations in one package; keying on the name and parameter types alone reported them as a redeclaration. The diagnostic prints the same form it keys on, `io.p.Cb.asRaw()`. Six tests: rendering with and without generics, the identifier check passing on an extension, `signature()` retention, and the three duplicate-key cases (same receiver collides, different receivers do not, an extension does not collide with a member of that name). --- src/model.rs | 27 +++++++++++++-- src/render.rs | 8 +++++ src/tests.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++++ src/validate.rs | 24 +++++++++++--- 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/src/model.rs b/src/model.rs index 8c01eac..80bbd95 100644 --- a/src/model.rs +++ b/src/model.rs @@ -148,6 +148,10 @@ pub struct KtFunSig { pub kdoc: Option, /// Generic type-variable names: `["R"]` → `fun …`. pub generics: Vec, + /// Extension receiver: `Some(Foo)` → `fun Foo.name(…)`. A separate field + /// rather than part of `name`, so `name` stays a plain identifier that can + /// be checked as one. + pub receiver: Option, pub params: Vec, pub ret: Option, } @@ -160,6 +164,7 @@ impl KtFunSig { annotations: Vec::new(), kdoc: None, generics: Vec::new(), + receiver: None, params: Vec::new(), ret: None, } @@ -168,6 +173,11 @@ impl KtFunSig { self.vis = v; self } + /// Make this an extension function on `ty`: `fun Foo.name(…)`. + pub fn receiver(mut self, ty: KtType) -> Self { + self.receiver = Some(ty); + self + } pub fn annotation(mut self, a: impl Into) -> Self { self.annotations.push(a.into()); self @@ -200,6 +210,7 @@ impl From for KtFun { annotations: s.annotations, kdoc: s.kdoc, generics: s.generics, + receiver: s.receiver, params: s.params, ret: s.ret, body: KtBody::None, @@ -826,6 +837,9 @@ pub struct KtFun { pub kdoc: Option, /// Generic type-variable names: `["R"]` → `fun …`. pub generics: Vec, + /// Extension receiver: `Some(Foo)` → `fun Foo.name(…)`. See + /// [`KtFunSig::receiver`]. + pub receiver: Option, pub params: Vec, pub ret: Option, pub body: KtBody, @@ -840,6 +854,7 @@ impl KtFun { annotations: Vec::new(), kdoc: None, generics: Vec::new(), + receiver: None, params: Vec::new(), ret: None, body: KtBody::None, @@ -850,6 +865,11 @@ impl KtFun { self.vis = v; self } + /// Make this an extension function on `ty`: `fun Foo.name(…)`. + pub fn receiver(mut self, ty: KtType) -> Self { + self.receiver = Some(ty); + self + } /// Add a modifier keyword (`override`, `inline`, `operator`, …). /// /// # Panics @@ -903,9 +923,9 @@ impl KtFun { self } - /// This function's signature: same name, generics, parameters and return - /// type, with the body and modifiers dropped. What a concrete member looks - /// like as an interface abstract. + /// This function's signature: same name, generics, receiver, parameters and + /// return type, with the body and modifiers dropped. What a concrete member + /// looks like as an interface abstract. pub fn signature(&self) -> KtFunSig { KtFunSig { name: self.name.clone(), @@ -913,6 +933,7 @@ impl KtFun { annotations: self.annotations.clone(), kdoc: self.kdoc.clone(), generics: self.generics.clone(), + receiver: self.receiver.clone(), params: self.params.clone(), ret: self.ret.clone(), } diff --git a/src/render.rs b/src/render.rs index 4c0f368..53d2c64 100644 --- a/src/render.rs +++ b/src/render.rs @@ -434,6 +434,8 @@ struct SigView<'a> { /// `external` renders ahead of the other modifiers; it lives on the body. external: bool, generics: &'a [String], + /// Extension receiver, rendered as `Recv.` before the name. + receiver: Option<&'a KtType>, name: &'a str, params: &'a [KtParam], ret: Option<&'a KtType>, @@ -448,6 +450,7 @@ impl<'a> From<&'a KtFun> for SigView<'a> { modifiers: &f.modifiers, external: matches!(f.body, KtBody::External), generics: &f.generics, + receiver: f.receiver.as_ref(), name: &f.name, params: &f.params, ret: f.ret.as_ref(), @@ -464,6 +467,7 @@ impl<'a> From<&'a KtFunSig> for SigView<'a> { modifiers: &[], external: false, generics: &f.generics, + receiver: f.receiver.as_ref(), name: &f.name, params: &f.params, ret: f.ret.as_ref(), @@ -494,6 +498,10 @@ fn render_fun_signature(f: &SigView<'_>, level: usize, imports: &mut ImportSet, if !f.generics.is_empty() { out.push_str(&format!("<{}> ", f.generics.join(", "))); } + if let Some(recv) = f.receiver { + out.push_str(&recv.render(imports)); + out.push('.'); + } out.push_str(f.name); let ps: Vec = f .params diff --git a/src/tests.rs b/src/tests.rs index e7f42f1..4f98df1 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -462,6 +462,42 @@ fn same_package_types_need_no_import() { assert!(src.contains("a: Local"), "{src}"); } +#[test] +fn an_extension_receiver_renders_before_the_name() { + // Generics first, then the receiver, then the bare name — and the receiver + // goes through the import set like any other type. + let f = KtFun::new("asRaw") + .generic("R") + .receiver(KtType::generic("io.other.Cb", [KtType::var_("R")])) + .returns(KtType::cls("io.p.CbRaw")) + .expr_body(KtCode::new().line("CbRaw { }")); + let src = render::render_one(&f.into(), "io.p"); + assert!(src.contains("fun Cb.asRaw(): CbRaw"), "{src}"); + assert!(src.contains("import io.other.Cb"), "{src}"); +} + +#[test] +fn an_extension_functions_name_is_still_a_plain_identifier() { + // The point of the field: the receiver is not smuggled into `name`, so the + // identifier check sees `asRaw` and passes. + let file = KtFile::new("io.p").decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.p.Cb")) + .expr_body(KtCode::new().line("CbRaw { }")), + ); + assert_eq!(file.validate(), vec![]); +} + +#[test] +fn signature_keeps_the_extension_receiver() { + // `signature()` drops the body and modifiers; losing the receiver too would + // silently turn an extension into a member. + let f = KtFun::new("asRaw") + .receiver(KtType::cls("io.p.Cb")) + .body(KtCode::new()); + assert!(f.signature().receiver.is_some()); +} + #[test] fn type_construction_covers_metadata_shapes() { let mut imp = ImportSet::new("p"); @@ -1180,6 +1216,57 @@ fn functions_with_identical_parameter_types_collide() { assert!(diags[0].message.contains("`send(Int)`"), "{diags:#?}"); } +#[test] +fn extensions_on_different_receivers_are_not_duplicates() { + // Kotlin dispatches an extension on its receiver, so these are two + // declarations. Keying the overload on the name alone reported them as a + // redeclaration. + let f = KtFile::new("io.p") + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.p.Cb")) + .body(KtCode::new()), + ) + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.p.Other")) + .body(KtCode::new()), + ); + assert_eq!(f.validate(), vec![]); +} + +#[test] +fn two_extensions_on_the_same_receiver_are_duplicates() { + let f = KtFile::new("io.p") + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.p.Cb")) + .body(KtCode::new()), + ) + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.p.Cb")) + .body(KtCode::new()), + ); + let diags = f.validate(); + assert_eq!(diags.len(), 1, "{diags:#?}"); + assert_eq!(diags[0].check, Check::DuplicateFunction); + assert!(diags[0].message.contains("`io.p.Cb.asRaw()`"), "{diags:#?}"); +} + +#[test] +fn an_extension_does_not_collide_with_a_member_of_the_same_name() { + // `fun Cb.asRaw()` and `fun asRaw()` are different declarations. + let f = KtFile::new("io.p") + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.p.Cb")) + .body(KtCode::new()), + ) + .decl(KtFun::new("asRaw").body(KtCode::new())); + assert_eq!(f.validate(), vec![]); +} + #[test] fn duplicates_inside_a_class_body_are_found() { // Where nearly all generated functions actually live — and where the old diff --git a/src/validate.rs b/src/validate.rs index 498868a..efa4579 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -28,7 +28,7 @@ use super::{ ident::{is_valid_kotlin_package, is_writable_kotlin_ident}, model::{ KtBody, KtClass, KtClassKind, KtClassModifier, KtCompanion, KtCtorParam, KtDecl, KtFile, - KtParam, + KtFun, KtParam, }, slot::KtPropertyValue, }; @@ -318,7 +318,7 @@ fn check_scope<'a>( ) { let mut types: BTreeSet<&str> = BTreeSet::new(); let mut values: BTreeSet<&str> = BTreeSet::new(); - let mut funs: BTreeSet<(&str, String)> = BTreeSet::new(); + let mut funs: BTreeSet = BTreeSet::new(); let mut raws: BTreeSet<&str> = BTreeSet::new(); // `val`/`var` constructor parameters are properties of the class, so they @@ -360,12 +360,12 @@ fn check_scope<'a>( if f.name.is_empty() { continue; } - let sig = param_signature(&f.params); - if !funs.insert((&f.name, sig.clone())) { + let sig = fun_signature(f); + if !funs.insert(sig.clone()) { d.push( Check::DuplicateFunction, scope, - format!("duplicate function `{}({sig})`", f.name), + format!("duplicate function `{sig}`"), ); } } @@ -396,6 +396,20 @@ fn check_scope<'a>( /// A function's parameter types as written, which is the only signature the /// model can offer — see the note on [`check_scope`]. +/// A function's overload identity, as it reads in a diagnostic: +/// `f(Int)`, or `Foo.f(Int)` for an extension function. +/// +/// The receiver is part of the identity because Kotlin dispatches an extension +/// on it — `Foo.f()` and `Bar.f()` are two declarations in one package, not a +/// redeclaration. +fn fun_signature(f: &KtFun) -> String { + let params = param_signature(&f.params); + match &f.receiver { + Some(r) => format!("{r}.{}({params})", f.name), + None => format!("{}({params})", f.name), + } +} + fn param_signature(params: &[KtParam]) -> String { params .iter() From f34c71239a4d868cef8d599984f5d9bae16565ca Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Thu, 6 Aug 2026 15:07:39 +0200 Subject: [PATCH 2/4] Demonstrate extension functions in the examples; fix two doc comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the receiver change, plus the example coverage it was missing. showcase now renders every form the receiver enables: * a top-level extension, `fun Sample.summary(): String` * a generic one, showing the order generics / receiver / name: `fun List.mapValues(...)` * a member extension inside a class * an abstract member extension in an interface, carried by KtFunSig, with its override in the implementor — so the signature type is shown keeping the receiver, not just the concrete one invalid gains two extensions on one receiver, which collide, alongside a third on a different receiver that does not. That demonstrates the receiver-aware overload key and the diagnostic form it prints: `duplicate function `io.example.Codec.asRaw()``. Two doc-comment fixes: * `param_signature`'s doc had been left attached to the new `fun_signature`, so one function carried two stacked doc paragraphs and the other had none. * The limitation note on `check_scope` explains that parameter types are compared as written. The receiver is compared the same way and is now part of the key, so the note says so. --- README.md | 4 +-- examples/invalid.rs | 18 +++++++++++++ examples/showcase.rs | 55 ++++++++++++++++++++++++++++++++++++++- src/validate.rs | 15 ++++++----- tests/golden/invalid.txt | 7 +++-- tests/golden/showcase.txt | 10 +++++++ 6 files changed, 97 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0f818aa..a0cf1bc 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ cargo run --example invalid # a broken model, and what the validator says `showcase` is a small but complete generator — it builds `KtFile` fragments, merges them so each package collapses to one file, and renders classes, objects, enums, data and value classes, sealed interfaces, `fun interface`s, -type aliases, properties with accessors and delegates, `external` natives, and -raw blocks. `invalid` shows the two ways a mistake surfaces: as a diagnostic +type aliases, extension functions, properties with accessors and delegates, +`external` natives, and raw blocks. `invalid` shows the two ways a mistake surfaces: as a diagnostic from the validator, or as a builder that refuses to construct the value at all. To accept an intended change to either output: diff --git a/examples/invalid.rs b/examples/invalid.rs index ae18be1..3a4387d 100644 --- a/examples/invalid.rs +++ b/examples/invalid.rs @@ -96,6 +96,24 @@ fn broken_declarations() -> KtFile { .returns(KtType::long()) .body(KtCode::new()), ) + // Extensions are keyed on their receiver, so these two collide while + // the same pair on different receivers would not. + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.example.Codec")) + .body(KtCode::new()), + ) + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.example.Codec")) + .body(KtCode::new()), + ) + // ...as here: same name, different receiver, no diagnostic. + .decl( + KtFun::new("asRaw") + .receiver(KtType::cls("io.example.Other")) + .body(KtCode::new()), + ) // Names that are not legal Kotlin identifiers. .decl( KtClass::class_("My-Class") diff --git a/examples/showcase.rs b/examples/showcase.rs index 7402a43..124b30d 100644 --- a/examples/showcase.rs +++ b/examples/showcase.rs @@ -247,6 +247,23 @@ fn session_fragment() -> KtFile { .line("return acc"), ), ) + // The interface's member extension, implemented. + .member( + KtFun::new("label") + .vis(KtVis::Public) + .modifier("override") + .receiver(KtType::cls("Sample")) + .returns(KtType::string()) + .expr_body(KtCode::new().line("\"${keyExpr}@${describe()}\"")), + ) + // A member extension of the class itself. + .member( + KtFun::new("toSample") + .vis(KtVis::Public) + .receiver(KtType::byte_array()) + .returns(KtType::cls("Sample")) + .expr_body(KtCode::new().line("Sample(describe(), this)")), + ) // A nested class — its own scope, so its members may reuse names. .member( KtClass::class_("Config") @@ -270,7 +287,15 @@ fn session_fragment() -> KtFile { // `KtFunSig` is exactly that. let describable = KtClass::interface_("Describable") .vis(KtVis::Public) - .member(KtFunSig::new("describe").returns(KtType::string())); + .member(KtFunSig::new("describe").returns(KtType::string())) + // A member extension: abstract here, supplied by the implementor. + // `KtFunSig` carries a receiver too, so a signature does not quietly + // become a plain member. + .member( + KtFunSig::new("label") + .receiver(KtType::cls("Sample")) + .returns(KtType::string()), + ); // A `fun interface` (SAM) — its single method cannot carry a body. let handler = KtFunInterface::new( @@ -283,6 +308,32 @@ fn session_fragment() -> KtFile { .type_param("out R") .kdoc("Invoked from the native thread for each reply."); + // Top-level extension functions. The receiver is a type, not part of the + // name, so it resolves through the import set and the name stays a plain + // identifier the validator can check as one. + let summary = KtFun::new("summary") + .vis(KtVis::Public) + .receiver(KtType::cls("Sample")) + .returns(KtType::string()) + .expr_body(KtCode::new().line("\"$keyExpr (${payload.size} bytes)\"")); + + // Generics render before the receiver, which renders before the name. + let map_replies = KtFun::new("mapValues") + .vis(KtVis::Public) + .generic("R") + .receiver(KtType::generic("List", [KtType::cls("Reply")])) + .param(KtParam::new( + "transform", + KtType::lambda( + [("sample".to_string(), KtType::cls("Sample"))], + KtType::var_r(), + ), + )) + .returns(KtType::generic("List", [KtType::var_r()])) + .expr_body( + KtCode::new().line("filterIsInstance().map { transform(it.sample) }"), + ); + KtFile::new("io.example.api") // FQNs named only from raw body text, which the model cannot see. .imports(["io.example.api.internal.JNINative".to_string()]) @@ -290,6 +341,8 @@ fn session_fragment() -> KtFile { .decl(describable) .decl(handler) .decl(session) + .decl(summary) + .decl(map_replies) .decl(KtDecl::TypeAlias { vis: KtVis::Public, name: "SampleList".to_string(), diff --git a/src/validate.rs b/src/validate.rs index efa4579..2a96f14 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -304,11 +304,12 @@ impl KtFile { /// Keeping types and values apart is what allows `class Foo` and `val Foo` to /// coexist, which Kotlin permits and this check used to reject. /// -/// **Limitation.** There is no type resolver here, so parameter types are -/// compared *as written*: `io.p.Foo` and `Foo` are different keys even when -/// they name the same type, and `fun f(x: T)` does not collide with -/// `fun f(x: R)`. The check therefore misses some real duplicates. It is a -/// net, not a proof — and a net that never catches a fish it shouldn't. +/// **Limitation.** There is no type resolver here, so a function's parameter +/// types — and its extension receiver — are compared *as written*: `io.p.Foo` +/// and `Foo` are different keys even when they name the same type, and +/// `fun f(x: T)` does not collide with `fun f(x: R)`. The check +/// therefore misses some real duplicates. It is a net, not a proof — and a net +/// that never catches a fish it shouldn't. fn check_scope<'a>( decls: &'a [KtDecl], ctor_params: &'a [KtCtorParam], @@ -394,8 +395,6 @@ fn check_scope<'a>( } } -/// A function's parameter types as written, which is the only signature the -/// model can offer — see the note on [`check_scope`]. /// A function's overload identity, as it reads in a diagnostic: /// `f(Int)`, or `Foo.f(Int)` for an extension function. /// @@ -410,6 +409,8 @@ fn fun_signature(f: &KtFun) -> String { } } +/// A function's parameter types as written, which is the only signature the +/// model can offer — see the note on [`check_scope`]. fn param_signature(params: &[KtParam]) -> String { params .iter() diff --git a/tests/golden/invalid.txt b/tests/golden/invalid.txt index 3082d35..168409b 100644 --- a/tests/golden/invalid.txt +++ b/tests/golden/invalid.txt @@ -6,6 +6,7 @@ error [invalid-identifier] in `io.example.broken/My-Class/object`: parameter nam error [duplicate-type] in `io.example.broken`: duplicate type `Session` error [duplicate-type] in `io.example.broken`: duplicate type `Describable` error [duplicate-function] in `io.example.broken`: duplicate function `send(Int)` +error [duplicate-function] in `io.example.broken`: duplicate function `io.example.Codec.asRaw()` error [duplicate-value] in `io.example.broken/Holder`: duplicate value `id` error [duplicate-type] in `io.example.broken/Outer`: companion object `Factory` collides with another type of that name error [duplicate-raw] in `io.example.broken`: duplicate raw block `__loader` @@ -17,7 +18,7 @@ error [import-collision] in `io.example.broken`: import simple-name collision: ` error [invalid-package] in `io..example.object`: `io..example.object` is not a valid Kotlin package path === merging refuses to produce anything === -16 Kotlin validation error(s): +17 Kotlin validation error(s): error [invalid-package] in `io..example.object`: `io..example.object` is not a valid Kotlin package path error [invalid-identifier] in `io.example.broken`: class name `My-Class` is not a valid Kotlin identifier error [invalid-identifier] in `io.example.broken/My-Class`: constructor parameter name `2fast` is not a valid Kotlin identifier @@ -26,6 +27,7 @@ error [invalid-package] in `io..example.object`: `io..example.object` is not a v error [duplicate-type] in `io.example.broken`: duplicate type `Session` error [duplicate-type] in `io.example.broken`: duplicate type `Describable` error [duplicate-function] in `io.example.broken`: duplicate function `send(Int)` + error [duplicate-function] in `io.example.broken`: duplicate function `io.example.Codec.asRaw()` error [duplicate-value] in `io.example.broken/Holder`: duplicate value `id` error [duplicate-type] in `io.example.broken/Outer`: companion object `Factory` collides with another type of that name error [duplicate-raw] in `io.example.broken`: duplicate raw block `__loader` @@ -36,12 +38,13 @@ error [invalid-package] in `io..example.object`: `io..example.object` is not a v error [import-collision] in `io.example.broken`: import simple-name collision: `io.example.a.Codec` and `io.example.b.Codec` === the same model under `warn_all()` === -merged 2 file(s) with 16 warning(s); generation continues +merged 2 file(s) with 17 warning(s); generation continues === one check downgraded, another switched off === error [duplicate-type] in `io.example.broken`: duplicate type `Session` error [duplicate-type] in `io.example.broken`: duplicate type `Describable` warning [duplicate-function] in `io.example.broken`: duplicate function `send(Int)` +warning [duplicate-function] in `io.example.broken`: duplicate function `io.example.Codec.asRaw()` error [duplicate-value] in `io.example.broken/Holder`: duplicate value `id` error [duplicate-type] in `io.example.broken/Outer`: companion object `Factory` collides with another type of that name error [duplicate-raw] in `io.example.broken`: duplicate raw block `__loader` diff --git a/tests/golden/showcase.txt b/tests/golden/showcase.txt index 63136f3..454afe4 100644 --- a/tests/golden/showcase.txt +++ b/tests/golden/showcase.txt @@ -72,6 +72,8 @@ public abstract class NativeHandle(initialPtr: Long) : AutoCloseable { public interface Describable { fun describe(): String + + fun Sample.label(): String } /** Invoked from the native thread for each reply. */ @@ -114,6 +116,10 @@ public open class Session(initialPtr: Long) : NativeHandle(initialPtr), Describa return acc } + public override fun Sample.label(): String = "${keyExpr}@${describe()}" + + public fun ByteArray.toSample(): Sample = Sample(describe(), this) + public class Config { val describe = "config" } @@ -124,6 +130,10 @@ public open class Session(initialPtr: Long) : NativeHandle(initialPtr), Describa } } +public fun Sample.summary(): String = "$keyExpr (${payload.size} bytes)" + +public fun List.mapValues(transform: (sample: Sample) -> R): List = filterIsInstance().map { transform(it.sample) } + public typealias SampleList = List --- kotlin/io/example/api/internal.kt --- From 28cef3396d13490a1d995727c16cd97edd0471fd Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Thu, 6 Aug 2026 15:26:40 +0200 Subject: [PATCH 3/4] Parenthesize a function-type extension receiver; document the break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the receiver change. A function type in receiver position needs its own parentheses, or the `.` binds to the return type and the output does not compile: fun (value: Int) -> String.asRaw() // before, uncompilable fun ((value: Int) -> String).asRaw() // after `KtType::render` only parenthesizes a function type when it is nullable, which is why the nullable form was already correct and the far more likely non-nullable one was not. Receiver position is its own syntactic context, so it gets its own method — `KtType::render_receiver` — rather than a special case buried in the renderer. showcase now emits one, so the golden covers it. The PR description claimed the change was additive and left existing callers compiling. That is not true for anyone constructing `KtFun` or `KtFunSig` with a struct literal, and v0.1.0 is published, so the claim mattered. CHANGELOG gains an Unreleased section recording it. That section covers every breaking change since 0.1.0, not just this one: the validation umbrella restructured `KtClassKind`, moved companion objects to `KtCompanion`, split supertypes, made `KtFunInterface::method` a `KtFunSig` and turned `external` into a body kind. The changelog stopped at 0.1.0 and so described a crate that no longer exists. Cargo.toml is left at 0.1.0 deliberately — the release version is a call for the maintainer, and the changelog now says what it needs to be. --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++ examples/showcase.rs | 19 +++++++++++++++ src/render.rs | 4 ++- src/tests.rs | 51 +++++++++++++++++++++++++++++++++++++++ src/types.rs | 24 ++++++++++++++++++ tests/golden/showcase.txt | 7 ++++++ 6 files changed, 145 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 833369e..612258c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,47 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +The next release is **breaking**, so it needs a `0.2.0` version bump: under +SemVer a `0.x` crate signals incompatibility by raising the minor. Several +changes since `0.1.0` alter the public model, this one included. + +### Added + +- Generation-time validation: `KtFile::validate` / `validate_with`, + `Diagnostic`, `Check`, `Severity` and `ValidationPolicy`, run by + `merge_files` and `write_files`. `merge_files_with` / `write_files_with` + expose per-check severities and return surviving warnings. +- Kotlin identifier utilities: `is_valid_kotlin_ident`, `mangle_kotlin_ident`, + `escape_kotlin_ident`, `is_escaped_kotlin_ident`, `is_writable_kotlin_ident`, + `is_kotlin_hard_keyword`, `KOTLIN_HARD_KEYWORDS`, plus the package-path + equivalents. +- Extension functions: `receiver` on `KtFun` and `KtFunSig`, with + `KtType::render_receiver` for the parentheses a function-type receiver needs. +- `open` and `sealed` classes, named companion objects, and `KtFunSig` for + abstract members. +- `KOTLIN_BANNER` and `merged_file_path` are exported; both were previously + `pub` inside private modules and unreachable. +- Two runnable examples, `showcase` and `invalid`, pinned by golden files. + +### Changed — breaking + +- `KtClassKind` variants carry their own data, so constructor parameters, + enum entries and the class modifier live on the kind rather than on + `KtClass`. `Plain`/`Abstract` became `Class { modifier }`, and `Companion` + and `ValueInline` are gone. +- A companion object is `KtCompanion`, reached only through + `KtClass::companion`; `KtClass::companion_object()` is removed. +- Supertypes are `KtSupertypes { superclass, interfaces }`; + `KtClass::supertype()` is replaced by `extends` and `implements`. +- `KtFunInterface::method` is a `KtFunSig`, which cannot carry a body. +- `external` is a `KtBody` variant set by `KtFun::external()`, not a modifier + string. +- `KtFun` and `KtFunSig` gained a `receiver` field. Callers using the builders + are unaffected; a caller constructing either with a **struct literal** must + add it. + ## 0.1.0 Initial release. diff --git a/examples/showcase.rs b/examples/showcase.rs index 124b30d..32afa2c 100644 --- a/examples/showcase.rs +++ b/examples/showcase.rs @@ -334,6 +334,17 @@ fn session_fragment() -> KtFile { KtCode::new().line("filterIsInstance().map { transform(it.sample) }"), ); + // An extension on a *function type*. The receiver needs parentheses here + // or the `.` would bind to the return type instead. + let as_raw = KtFun::new("asRaw") + .vis(KtVis::Internal) + .receiver(KtType::lambda( + [("sample".to_string(), KtType::cls("Sample"))], + KtType::unit(), + )) + .returns(KtType::cls("io.example.api.internal.RawSink")) + .expr_body(KtCode::new().line("RawSink { raw -> this(raw) }")); + KtFile::new("io.example.api") // FQNs named only from raw body text, which the model cannot see. .imports(["io.example.api.internal.JNINative".to_string()]) @@ -343,6 +354,7 @@ fn session_fragment() -> KtFile { .decl(session) .decl(summary) .decl(map_replies) + .decl(as_raw) .decl(KtDecl::TypeAlias { vis: KtVis::Public, name: "SampleList".to_string(), @@ -396,7 +408,14 @@ fn natives_fragment() -> KtFile { ), }; + let raw_sink = KtFunInterface::new( + "RawSink", + KtFunSig::new("accept").param(KtParam::new("reply", KtType::cls("io.example.api.Reply"))), + ) + .vis(KtVis::Internal); + KtFile::new("io.example.api.internal") + .decl(raw_sink) .decl(natives) .decl(loader) // An FQN referenced only from raw text the model cannot see. diff --git a/src/render.rs b/src/render.rs index 53d2c64..fe803e6 100644 --- a/src/render.rs +++ b/src/render.rs @@ -499,7 +499,9 @@ fn render_fun_signature(f: &SigView<'_>, level: usize, imports: &mut ImportSet, out.push_str(&format!("<{}> ", f.generics.join(", "))); } if let Some(recv) = f.receiver { - out.push_str(&recv.render(imports)); + // Receiver position, not ordinary type position: a function type needs + // parentheses here (see `KtType::render_receiver`). + out.push_str(&recv.render_receiver(imports)); out.push('.'); } out.push_str(f.name); diff --git a/src/tests.rs b/src/tests.rs index 4f98df1..2a9e89a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1706,3 +1706,54 @@ fn merging_carries_a_banner_override_into_the_merged_file() { .expect("merge"); assert_eq!(merged[0].banner.as_deref(), Some("// first")); } + +#[test] +fn a_function_type_receiver_is_parenthesized() { + // `fun (Int) -> String.ext()` parses the `.` against the return type and + // does not compile; the receiver needs its own parentheses. + let f = KtFun::new("asRaw") + .receiver(KtType::lambda( + [("value".to_string(), KtType::int())], + KtType::string(), + )) + .body(KtCode::new()); + let src = render::render_one(&f.into(), "io.p"); + assert!( + src.contains("fun ((value: Int) -> String).asRaw()"), + "{src}" + ); +} + +#[test] +fn a_nullable_function_type_receiver_is_not_double_parenthesized() { + // `KtType::render` already wraps a nullable function type. + let f = KtFun::new("asRaw") + .receiver( + KtType::lambda([("value".to_string(), KtType::int())], KtType::string()).nullable(), + ) + .body(KtCode::new()); + let src = render::render_one(&f.into(), "io.p"); + assert!( + src.contains("fun ((value: Int) -> String)?.asRaw()"), + "{src}" + ); +} + +#[test] +fn a_named_receiver_is_rendered_unchanged() { + let mut imports = ImportSet::new("io.p"); + assert_eq!( + KtType::cls("io.other.Cb").render_receiver(&mut imports), + "Cb" + ); + assert_eq!( + KtType::generic("List", [KtType::int()]).render_receiver(&mut imports), + "List" + ); + assert_eq!( + KtType::cls("io.other.Cb") + .nullable() + .render_receiver(&mut imports), + "Cb?" + ); +} diff --git a/src/types.rs b/src/types.rs index c4d3f54..822702b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -122,6 +122,30 @@ impl KtType { } } + /// Render in **extension-receiver** position — `fun .name()`. + /// + /// A function type needs parentheses there, or the `.` binds to its return + /// type instead: `fun ((Int) -> String).ext()`, never + /// `fun (Int) -> String.ext()`. A nullable one is already parenthesized by + /// [`Self::render`], so it is left alone. + /// + /// ``` + /// use kotlin_codegen::{ImportSet, KtType}; + /// let mut imports = ImportSet::new("io.p"); + /// let f = KtType::lambda([("x".to_string(), KtType::int())], KtType::string()); + /// assert_eq!(f.render_receiver(&mut imports), "((x: Int) -> String)"); + /// assert_eq!(KtType::string().render_receiver(&mut imports), "String"); + /// ``` + pub fn render_receiver(&self, imports: &mut ImportSet) -> String { + let rendered = self.render(imports); + match self { + KtType::Function { + nullable: false, .. + } => format!("({rendered})"), + _ => rendered, + } + } + /// Render to Kotlin source, registering imports in `imports`. pub fn render(&self, imports: &mut ImportSet) -> String { match self { diff --git a/tests/golden/showcase.txt b/tests/golden/showcase.txt index 454afe4..ff6fa29 100644 --- a/tests/golden/showcase.txt +++ b/tests/golden/showcase.txt @@ -19,6 +19,7 @@ public fun describeAll(items: List): String package io.example.api import io.example.api.internal.JNINative +import io.example.api.internal.RawSink import io.example.internal.Guard /** @@ -134,6 +135,8 @@ public fun Sample.summary(): String = "$keyExpr (${payload.size} bytes)" public fun List.mapValues(transform: (sample: Sample) -> R): List = filterIsInstance().map { transform(it.sample) } +internal fun ((sample: Sample) -> Unit).asRaw(): RawSink = RawSink { raw -> this(raw) } + public typealias SampleList = List --- kotlin/io/example/api/internal.kt --- @@ -142,6 +145,10 @@ package io.example.api.internal import io.example.api.Reply +internal fun interface RawSink { + fun accept(reply: Reply) +} + /** One-to-one with the exported Rust symbols. */ internal object JNINative { internal external fun sessionOpen(config: String): Long From 754f6dcbb6f20ae3afd4c929c5a5e2d1f780e88a Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Thu, 6 Aug 2026 15:36:50 +0200 Subject: [PATCH 4/4] Make the showcase asRaw proxy type-correct; parenthesize receivers in diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asRaw example did not type-check. RawSink's method takes a `Reply`, so the lambda parameter is a `Reply`, but the extension receiver takes a `Sample` — `this(raw)` passed the wrong type. A showcase that emits Kotlin which would not compile is worse than no showcase, since the whole point is to be read as a reference. Fixed by writing what the proxy idiom actually does: narrow the raw `Reply` before handing it to the typed callback. RawSink { raw -> if (raw is Reply.Value) this(raw.sample) } Also addresses the suppressed note on `fun_signature`: it formatted the receiver through `Display`, which does not parenthesize a non-nullable function type, so a duplicate-extension diagnostic printed `(value: Int) -> Unit.asRaw()` while the renderer would emit `((value: Int) -> Unit).asRaw()`. A diagnostic that does not read as the syntax it describes is a small trap. Both now go through one predicate, `KtType::needs_receiver_parens`, so they cannot drift. --- examples/showcase.rs | 6 +++++- src/tests.rs | 23 +++++++++++++++++++++++ src/types.rs | 22 +++++++++++++++++----- src/validate.rs | 4 +++- tests/golden/showcase.txt | 2 +- 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/examples/showcase.rs b/examples/showcase.rs index 32afa2c..ae9d468 100644 --- a/examples/showcase.rs +++ b/examples/showcase.rs @@ -343,7 +343,11 @@ fn session_fragment() -> KtFile { KtType::unit(), )) .returns(KtType::cls("io.example.api.internal.RawSink")) - .expr_body(KtCode::new().line("RawSink { raw -> this(raw) }")); + // The proxy adapts a typed callback to the raw one the natives call, + // so it has to narrow `Reply` to the `Sample` the receiver takes. + .expr_body( + KtCode::new().line("RawSink { raw -> if (raw is Reply.Value) this(raw.sample) }"), + ); KtFile::new("io.example.api") // FQNs named only from raw body text, which the model cannot see. diff --git a/src/tests.rs b/src/tests.rs index 2a9e89a..30408d5 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1757,3 +1757,26 @@ fn a_named_receiver_is_rendered_unchanged() { "Cb?" ); } + +#[test] +fn a_duplicate_on_a_function_type_receiver_reads_as_kotlin_syntax() { + // The diagnostic parenthesizes the receiver on the same rule the renderer + // uses, so what it prints matches what would be emitted. + let ext = || { + KtFun::new("asRaw") + .receiver(KtType::lambda( + [("value".to_string(), KtType::int())], + KtType::unit(), + )) + .body(KtCode::new()) + }; + let f = KtFile::new("io.p").decl(ext()).decl(ext()); + let diags = f.validate(); + assert_eq!(diags.len(), 1, "{diags:#?}"); + assert!( + diags[0] + .message + .contains("`((value: Int) -> Unit).asRaw()`"), + "{diags:#?}" + ); +} diff --git a/src/types.rs b/src/types.rs index 822702b..8aab4e7 100644 --- a/src/types.rs +++ b/src/types.rs @@ -138,14 +138,26 @@ impl KtType { /// ``` pub fn render_receiver(&self, imports: &mut ImportSet) -> String { let rendered = self.render(imports); - match self { - KtType::Function { - nullable: false, .. - } => format!("({rendered})"), - _ => rendered, + if self.needs_receiver_parens() { + format!("({rendered})") + } else { + rendered } } + /// Whether writing this type in receiver position needs parentheses added + /// around it. True only for a non-nullable function type — a nullable one + /// is already parenthesized by [`Self::render`] and by its `Display`. + pub(crate) fn needs_receiver_parens(&self) -> bool { + matches!( + self, + KtType::Function { + nullable: false, + .. + } + ) + } + /// Render to Kotlin source, registering imports in `imports`. pub fn render(&self, imports: &mut ImportSet) -> String { match self { diff --git a/src/validate.rs b/src/validate.rs index 2a96f14..ed832ed 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -400,10 +400,12 @@ fn check_scope<'a>( /// /// The receiver is part of the identity because Kotlin dispatches an extension /// on it — `Foo.f()` and `Bar.f()` are two declarations in one package, not a -/// redeclaration. +/// redeclaration. It is parenthesized on the same rule the renderer uses, so +/// the diagnostic reads as the syntax it is describing. fn fun_signature(f: &KtFun) -> String { let params = param_signature(&f.params); match &f.receiver { + Some(r) if r.needs_receiver_parens() => format!("({r}).{}({params})", f.name), Some(r) => format!("{r}.{}({params})", f.name), None => format!("{}({params})", f.name), } diff --git a/tests/golden/showcase.txt b/tests/golden/showcase.txt index ff6fa29..c516a0f 100644 --- a/tests/golden/showcase.txt +++ b/tests/golden/showcase.txt @@ -135,7 +135,7 @@ public fun Sample.summary(): String = "$keyExpr (${payload.size} bytes)" public fun List.mapValues(transform: (sample: Sample) -> R): List = filterIsInstance().map { transform(it.sample) } -internal fun ((sample: Sample) -> Unit).asRaw(): RawSink = RawSink { raw -> this(raw) } +internal fun ((sample: Sample) -> Unit).asRaw(): RawSink = RawSink { raw -> if (raw is Reply.Value) this(raw.sample) } public typealias SampleList = List