From 02663067a8b4ba4bbe47de57dc12d4b408ade14e Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 22 May 2026 12:22:50 -0500 Subject: [PATCH 1/3] [generator] Drop Kotlin hash-mangled siblings that collide on the C# side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: step (1) of dotnet/java-interop#1431. When Kotlin compiles a function whose parameter is an `@JvmInline value class`, the JVM-level name is mangled with a 7-char hash suffix, e.g. `tint-Rn_QMJI(J)V`. `KotlinFixups` already strips that suffix so the C# binding sees `Tint(long)` instead of an unspellable name. The problem: two distinct Kotlin overloads that take different inline classes erase to the *same* JVM signature, differing only in the hash. Jetpack Compose hits this constantly (`Color`, `TextUnit`, `Dp` all wrap `ULong`/`Long`/`Float`). For example: @JvmInline value class MyColor(val value: ULong) @JvmInline value class MyAlpha(val value: ULong) object Widgets { fun tint(color: MyColor) { } fun tint(alpha: MyAlpha) { } } `javap` shows two hash-distinct siblings with identical erased shape: public static void tint-Rn_QMJI(long); // MyColor public static void tint-uzYZ1wI(long); // MyAlpha After the existing rename both become `Tint(long)`, and `generator` emits something like: // Before: CS0111, build broken public static void Tint (long color) { ... } public static void Tint (long alpha) { ... } This change detects post-rename collisions inside `KotlinFixups`, keeps the first method deterministically, drops the rest, and emits a new `BG8C02` warning so the user can override the choice via Metadata.xml: // After: one overload kept, warning emitted public static void Tint (long color) { ... } // warning BG8C02: For type 'Widgets', the Kotlin name-mangled method // 'Tint' (originally 'tint-uzYZ1wI') has multiple hash-suffixed // siblings that erase to the same C# signature. Only the first will // be emitted; remove the duplicate via Metadata.xml to suppress this // warning. Non-colliding hash-mangled siblings (different arity, or different underlying primitive) still survive — e.g. `pad(MyDp)` and `pad(MyDp, MyDp)` both bind, and `tint(MyDp)` survives alongside one of the `tint(long)` overloads because they differ in raw type (`F` vs `J`). Step (2) of #1431 — projecting inline-class parameters as their own strongly-typed wrappers so all overloads can coexist — is left for a follow-up. Real Kotlin/Gradle fixture -------------------------- To exercise this against real Kotlin compiler output (not hand-written `.class` files), add a small Gradle project under `tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/`: * `build.gradle.kts` pins Kotlin 2.0.21 / JVM 17 and emits `.class` files into `$rootDir/classes`. * `src/main/kotlin/InlineClassCollisions.kt` declares the value classes and `Widgets` object shown above. * A new MSBuild target `BuildKotlinGradleProject` invokes the existing `build-tools/gradle/gradlew(.bat)` (no duplicate wrapper) with `-p kotlin-gradle classes` before `BeforeCompile`. The produced `.class` files are then embedded as test resources. * The `classes/` and `build/` outputs stay out of git via `kotlin-gradle/.gitignore`. A `.gitattributes` entry forces `gradlew`, `*.properties`, `*.kt`, and `*.kts` to LF so the shared wrapper script keeps working on Unix. Tests ----- * `KotlinFixupsTests`: 3 new unit tests cover the collision / no-collision / mixed cases with a `WarningCapture` helper that asserts on BG8C02. * `KotlinInlineClassCollisionTests`: 3 new tests load the freshly Gradle-built `Widgets.class` / `MyColor.class` and assert the expected JVM-level mangling, so we'd notice if Kotlin ever changed the scheme. All 5 `KotlinFixupsTests`, all 78 Bytecode tests, and all 453 generator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 6 ++ .../Resources.Designer.cs | 9 ++ src/Java.Interop.Localization/Resources.resx | 4 + .../Utilities/Report.cs | 1 + .../KotlinInlineClassCollisionTests.cs | 70 +++++++++++++ ...amarin.Android.Tools.Bytecode-Tests.csproj | 8 +- ...marin.Android.Tools.Bytecode-Tests.targets | 26 +++++ .../kotlin-gradle/.gitignore | 4 + .../kotlin-gradle/build.gradle.kts | 18 ++++ .../kotlin-gradle/settings.gradle.kts | 1 + .../src/main/kotlin/InlineClassCollisions.kt | 34 +++++++ .../Unit-Tests/KotlinFixupsTests.cs | 98 +++++++++++++++++++ .../KotlinFixups.cs | 40 +++++++- 13 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 tests/Xamarin.Android.Tools.Bytecode-Tests/KotlinInlineClassCollisionTests.cs create mode 100644 tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/.gitignore create mode 100644 tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/build.gradle.kts create mode 100644 tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts create mode 100644 tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/src/main/kotlin/InlineClassCollisions.kt diff --git a/.gitattributes b/.gitattributes index fa7720b69..fe3dfccc1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -35,3 +35,9 @@ Makefile eol=lf *.wixproj eol=crlf *.wxs eol=crlf *.rtf eol=crlf + +# Gradle wrapper must stay LF on all platforms (executed under Bash on Unix) +gradlew eol=lf +*.properties eol=lf +*.kt eol=lf +*.kts eol=lf diff --git a/src/Java.Interop.Localization/Resources.Designer.cs b/src/Java.Interop.Localization/Resources.Designer.cs index 81ed44908..12164c404 100644 --- a/src/Java.Interop.Localization/Resources.Designer.cs +++ b/src/Java.Interop.Localization/Resources.Designer.cs @@ -429,6 +429,15 @@ public static string Generator_BG8C01 { } } + /// + /// Looks up a localized string similar to For type '{0}', the Kotlin name-mangled method '{1}' (originally '{2}') has multiple hash-suffixed siblings that erase to the same C# signature. Only the first will be emitted; remove the duplicate via Metadata.xml to suppress this warning.. + /// + public static string Generator_BG8C02 { + get { + return ResourceManager.GetString("Generator_BG8C02", resourceCulture); + } + } + /// /// Looks up a localized string similar to Cannot generate Java wrapper for type '{0}'. Only 'class' types are supported.. /// diff --git a/src/Java.Interop.Localization/Resources.resx b/src/Java.Interop.Localization/Resources.resx index 69753f855..084f94f50 100644 --- a/src/Java.Interop.Localization/Resources.resx +++ b/src/Java.Interop.Localization/Resources.resx @@ -308,6 +308,10 @@ The following terms should not be translated: Metadata.xml, path. For type '{0}', base interface '{1}' is invalid. {0}, {1} - .NET types. + + For type '{0}', the Kotlin name-mangled method '{1}' (originally '{2}') has multiple hash-suffixed siblings that erase to the same C# signature. Only the first will be emitted; remove the duplicate via Metadata.xml to suppress this warning. + {0} - .NET type. {1} - C# method name. {2} - Original (mangled) JVM method name. + Cannot generate Java wrapper for type '{0}'. Only 'class' types are supported. {0} - Java type. diff --git a/src/Java.Interop.Tools.Generator/Utilities/Report.cs b/src/Java.Interop.Tools.Generator/Utilities/Report.cs index 76fa1bc74..448504966 100644 --- a/src/Java.Interop.Tools.Generator/Utilities/Report.cs +++ b/src/Java.Interop.Tools.Generator/Utilities/Report.cs @@ -74,6 +74,7 @@ public LocalizedMessage (int code, string value) public static LocalizedMessage WarningUnknownGenericConstraint => new LocalizedMessage (0x8B00, Localization.Resources.Generator_BG8B00); public static LocalizedMessage WarningBaseInterfaceNotFound => new LocalizedMessage (0x8C00, Localization.Resources.Generator_BG8C00); public static LocalizedMessage WarningBaseInterfaceInvalid => new LocalizedMessage (0x8C01, Localization.Resources.Generator_BG8C01); + public static LocalizedMessage WarningKotlinNameMangledCollision => new LocalizedMessage (0x8C02, Localization.Resources.Generator_BG8C02); public static void LogCodedErrorAndExit (LocalizedMessage message, params string [] args) => LogCodedErrorAndExit (message, null, null, args); diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/KotlinInlineClassCollisionTests.cs b/tests/Xamarin.Android.Tools.Bytecode-Tests/KotlinInlineClassCollisionTests.cs new file mode 100644 index 000000000..3a26fcd1f --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/KotlinInlineClassCollisionTests.cs @@ -0,0 +1,70 @@ +using System; +using System.Linq; +using NUnit.Framework; +using Xamarin.Android.Tools.Bytecode; + +namespace Xamarin.Android.Tools.BytecodeTests +{ + // Exercises the real Kotlin bytecode produced by the Gradle fixture under + // kotlin-gradle/ to confirm that the JVM-level mangling we expect (and that + // the generator's KotlinFixups must now de-collide) is actually what kotlinc + // emits for @JvmInline value-class parameters. See dotnet/java-interop#1431. + [TestFixture] + public class KotlinInlineClassCollisionTests : ClassFileFixture + { + [Test] + public void Widgets_HasCollidingHashMangledSiblings () + { + var klass = LoadClassFile ("Widgets.class"); + + // Kotlin emits one mangled method per inline-class overload: + // tint-(J)V for MyColor (ULong-backed) + // tint-(J)V for MyAlpha (ULong-backed) — collides with MyColor + // tint-(F)V for MyDp (Float-backed) — unique + var tints = klass.Methods + .Where (m => m.Name.StartsWith ("tint-", StringComparison.Ordinal)) + .ToList (); + + Assert.AreEqual (3, tints.Count, "Expected three `tint-` overloads from the Gradle fixture."); + + var longTints = tints.Where (m => m.Descriptor == "(J)V").ToList (); + Assert.AreEqual (2, longTints.Count, + "Expected two `tint-(J)V` siblings (MyColor + MyAlpha) — this is the multi-sibling collision case from dotnet/java-interop#1431."); + + Assert.AreEqual (1, tints.Count (m => m.Descriptor == "(F)V"), + "Expected one unique `tint-(F)V` (MyDp) that should survive deduplication."); + } + + [Test] + public void Widgets_HasNonCollidingHashMangledOverloads () + { + var klass = LoadClassFile ("Widgets.class"); + + var pads = klass.Methods + .Where (m => m.Name.StartsWith ("pad-", StringComparison.Ordinal)) + .ToList (); + + Assert.AreEqual (2, pads.Count); + CollectionAssert.AreEquivalent ( + new [] { "(F)F", "(FF)F" }, + pads.Select (m => m.Descriptor).ToArray (), + "`pad` overloads have distinct JVM signatures and should both survive after rename."); + } + + [Test] + public void InlineClasses_AreEmittedAsValueClasses () + { + // Sanity check that @JvmInline really produced a JvmInline annotation on + // the inline-class type — this is what step (2) of #1431 will key on. + var myColor = LoadClassFile ("MyColor.class"); + + var annotations = myColor.Attributes + .OfType () + .SelectMany (a => a.Annotations) + .Select (a => a.Type) + .ToList (); + + Assert.Contains ("Lkotlin/jvm/JvmInline;", annotations); + } + } +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj index b7cadb7fa..e3c1546b7 100644 --- a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.csproj @@ -25,7 +25,13 @@ - + + + + + + + diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets index 0f2fcdfc8..473099f18 100644 --- a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets @@ -5,6 +5,12 @@ + + + + + + @@ -34,6 +40,26 @@ + + + <_KotlinGradleProjectDir>$(MSBuildThisFileDirectory)kotlin-gradle + <_GradleWrapperDir>$(MSBuildThisFileDirectory)..\..\build-tools\gradle + <_GradlewCommand Condition=" '$(OS)' == 'Windows_NT' ">$(_GradleWrapperDir)\gradlew.bat + <_GradlewCommand Condition=" '$(OS)' != 'Windows_NT' ">$(_GradleWrapperDir)/gradlew + + + + + + ("compileKotlin") { + destinationDirectory.set(file("$rootDir/classes")) +} diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts b/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts new file mode 100644 index 000000000..6517d1d3a --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "kotlin-inline-class-fixtures" diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/src/main/kotlin/InlineClassCollisions.kt b/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/src/main/kotlin/InlineClassCollisions.kt new file mode 100644 index 000000000..53f900aa5 --- /dev/null +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/kotlin-gradle/src/main/kotlin/InlineClassCollisions.kt @@ -0,0 +1,34 @@ +@file:JvmName("InlineClassCollisionsKt") + +package xat.bytecode.tests + +// Two distinct Kotlin inline classes that erase to the same JVM primitive (long). +// Both `tint(MyColor)` and `tint(MyAlpha)` mangle to `tint-(J)V`, so they +// collide once class-parse drops the hash suffix. This is the exact scenario +// that Jetpack Compose triggers with Color/TextUnit/etc. and is the case +// step (1) of dotnet/java-interop#1431 must handle. +@JvmInline +value class MyColor(val value: ULong) + +@JvmInline +value class MyAlpha(val value: ULong) + +// A second inline class with a different backing primitive, so we can verify +// that *non*-colliding hash siblings still survive. +@JvmInline +value class MyDp(val value: Float) + +object Widgets { + + // Colliding pair: both erase to `tint-XXXXXXX(J)V`. + fun tint(color: MyColor) { /* no-op */ } + fun tint(alpha: MyAlpha) { /* no-op */ } + + // Distinct hash-mangled sibling of the same source name — should survive + // alongside one of the `tint(long)` overloads. + fun tint(dp: MyDp) { /* no-op */ } + + // A non-colliding pair: different arity, both hash-mangled. + fun pad(dp: MyDp): MyDp = dp + fun pad(dp1: MyDp, dp2: MyDp): MyDp = dp1 +} diff --git a/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs b/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs index ad3f650de..922174a5c 100644 --- a/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs +++ b/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Xml.Linq; +using Java.Interop.Tools.Generator; using Java.Interop.Tools.Generator.Transformation; using MonoDroid.Generation; using NUnit.Framework; @@ -35,5 +38,100 @@ public void CreateMethod_EnsureKotlinHashcodeFix () Assert.IsTrue (klass.Methods [0].IsFinal); Assert.IsFalse (klass.Methods [0].IsVirtual); } + + [Test] + public void CollidingHashSiblings_AreDeduplicated () + { + // Two Kotlin hash-mangled siblings that erase to the same C# signature + // (one `long` parameter). After the rename to `Add` both would collide, + // so we keep only the first. + var xml = XDocument.Parse (@" + + + + + + + + + "); + var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ()); + + using var warnings = CaptureWarnings (); + KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ()); + + Assert.AreEqual (1, klass.Methods.Count, "Duplicate hash-mangled sibling should have been removed."); + Assert.AreEqual ("Add", klass.Methods [0].Name); + Assert.IsTrue (warnings.Messages.Any (m => m.Contains ("BG8C02")), "Expected BG8C02 warning, got: " + string.Join (Environment.NewLine, warnings.Messages)); + } + + [Test] + public void NonCollidingHashSiblings_AreBothKept () + { + // Two siblings with distinct parameter lists: both should rename to `Add` + // and survive as overloads. + var xml = XDocument.Parse (@" + + + + + + + + + "); + var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ()); + + KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ()); + + Assert.AreEqual (2, klass.Methods.Count); + Assert.IsTrue (klass.Methods.All (m => m.Name == "Add")); + } + + [Test] + public void MixedCollidingAndUniqueHashSiblings () + { + // Three siblings of the same source-name: the long+long pair collide, + // the float arg is unique. Expect 2 methods to survive. + var xml = XDocument.Parse (@" + + + + + + + + + + + + "); + var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ()); + + KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ()); + + Assert.AreEqual (2, klass.Methods.Count); + Assert.IsTrue (klass.Methods.All (m => m.Name == "Add")); + CollectionAssert.AreEquivalent (new [] { "long", "float" }, klass.Methods.Select (m => m.Parameters [0].RawNativeType).ToArray ()); + } + + static WarningCapture CaptureWarnings () => new WarningCapture (); + + sealed class WarningCapture : IDisposable + { + readonly Action previous; + public List Messages { get; } = new List (); + + public WarningCapture () + { + previous = Report.OutputDelegate; + Report.OutputDelegate = (level, msg) => Messages.Add (msg); + } + + public void Dispose () + { + Report.OutputDelegate = previous; + } + } } } diff --git a/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs b/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs index 680da243c..af8f880a5 100644 --- a/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs +++ b/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Java.Interop.Tools.Generator; using MonoDroid.Generation; using MonoDroid.Utils; @@ -23,7 +24,9 @@ private static void FixupClass (ClassGen c) // We need to generate C# compatible names as well as prevent overriding // them as we cannot generate JCW for them. - foreach (var method in c.Methods.Where (m => m.IsKotlinNameMangled)) { + var mangled = c.Methods.Where (m => m.IsKotlinNameMangled).ToList (); + + foreach (var method in mangled) { // If the method is virtual, mark it as !virtual as it can't be overridden in Java if (!method.IsFinal) @@ -34,12 +37,45 @@ private static void FixupClass (ClassGen c) FixMethodName (method); } + + RemoveCollidingSiblings (c, mangled); } private static void FixupInterface (InterfaceGen gen) { - foreach (var method in gen.Methods.Where (m => m.IsKotlinNameMangled)) + var mangled = gen.Methods.Where (m => m.IsKotlinNameMangled).ToList (); + + foreach (var method in mangled) FixMethodName (method); + + RemoveCollidingSiblings (gen, mangled); + } + + // When multiple hash-mangled siblings of the same Kotlin source-name exist + // (common for Jetpack Compose `@Composable` functions with inline-class + // parameters), the rename above produces several methods with the same + // name and identical C#-erased parameter lists. Emitting them all causes + // CS0111 in the generated code. Until step 2 of dotnet/java-interop#1431 + // projects inline-class params as strongly-typed wrappers, drop the + // duplicates deterministically (keep the first) and warn so the user can + // override via Metadata.xml if desired. + private static void RemoveCollidingSiblings (GenBase gen, List renamed) + { + if (renamed.Count < 2) + return; + + foreach (var group in renamed.GroupBy (m => m.Name)) { + var kept = new List (); + + foreach (var method in group) { + if (kept.Any (k => k.Matches (method))) { + Report.LogCodedWarning (0, Report.WarningKotlinNameMangledCollision, method, gen.FullName, method.Name, method.JavaName); + gen.Methods.Remove (method); + } else { + kept.Add (method); + } + } + } } private static void FixMethodName (Method method) From 8e8d7a63538bc845e98e96065eb8dfb24308e4b8 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 22 May 2026 13:49:35 -0500 Subject: [PATCH 2/3] Address PR review feedback - KotlinFixups.RemoveCollidingSiblings: collide mangled methods against every other method on the type (not only other mangled siblings), so a mangled tint(MyInlineLong) collapsing to tint(long) collides with a pre-existing non-mangled tint(long) too. Mangled methods are still the only thing ever dropped. - Add MangledMethod_CollidesWithNonMangledOverload test for that case. - Mark the BG8C02-asserting tests [NonParallelizable] because they mutate the global Report.OutputDelegate, matching the convention used by other WarningCapture-style tests in the suite. - Bytecode-Tests.targets: use shared $(GradleWPath) / $(GradleArgs)/ EnvironmentVariables=JAVA_HOME pattern from Directory.Build.props, matching tools/java-source-utils. Removes the ad-hoc OS-switch wrapper selection and makes the Gradle invocation work in environments without java on PATH. - build.gradle.kts: drop jvmToolchain(17) so Gradle uses the JDK the caller already configured via JAVA_HOME (auto-provisioning fails in CI environments without download repositories). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...marin.Android.Tools.Bytecode-Tests.targets | 15 ++++---- .../kotlin-gradle/build.gradle.kts | 9 +++-- .../Unit-Tests/KotlinFixupsTests.cs | 28 ++++++++++++++- .../KotlinFixups.cs | 36 +++++++++---------- 4 files changed, 58 insertions(+), 30 deletions(-) diff --git a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets index 473099f18..c973c22db 100644 --- a/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets +++ b/tests/Xamarin.Android.Tools.Bytecode-Tests/Xamarin.Android.Tools.Bytecode-Tests.targets @@ -41,23 +41,22 @@ <_KotlinGradleProjectDir>$(MSBuildThisFileDirectory)kotlin-gradle - <_GradleWrapperDir>$(MSBuildThisFileDirectory)..\..\build-tools\gradle - <_GradlewCommand Condition=" '$(OS)' == 'Windows_NT' ">$(_GradleWrapperDir)\gradlew.bat - <_GradlewCommand Condition=" '$(OS)' != 'Windows_NT' ">$(_GradleWrapperDir)/gradlew - + m.Parameters [0].RawNativeType).ToArray ()); } + [Test, NonParallelizable] + public void MangledMethod_CollidesWithNonMangledOverload () + { + // A pre-existing non-mangled overload `add(long)` plus a mangled + // `add-AAAAAAA(long)` that also reduces to `add(long)` after rename. + // The mangled one is the duplicate -- drop it, keep the non-mangled. + var xml = XDocument.Parse (@" + + + + + + + + + "); + var klass = XmlApiImporter.CreateClass (xml.Root, xml.Root.Element ("class"), new CodeGenerationOptions ()); + + using var warnings = CaptureWarnings (); + KotlinFixups.Fixup (new [] { (GenBase) klass }.ToList ()); + + Assert.AreEqual (1, klass.Methods.Count, "Mangled duplicate should have been removed, leaving the pre-existing non-mangled method."); + Assert.AreEqual ("add", klass.Methods [0].JavaName, "The kept method should be the non-mangled one."); + Assert.IsTrue (warnings.Messages.Any (m => m.Contains ("BG8C02")), "Expected BG8C02 warning."); + } + static WarningCapture CaptureWarnings () => new WarningCapture (); sealed class WarningCapture : IDisposable diff --git a/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs b/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs index af8f880a5..f17924a72 100644 --- a/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs +++ b/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs @@ -51,30 +51,30 @@ private static void FixupInterface (InterfaceGen gen) RemoveCollidingSiblings (gen, mangled); } - // When multiple hash-mangled siblings of the same Kotlin source-name exist - // (common for Jetpack Compose `@Composable` functions with inline-class - // parameters), the rename above produces several methods with the same - // name and identical C#-erased parameter lists. Emitting them all causes + // After the rename above, hash-mangled siblings can collide with each + // other AND with pre-existing non-mangled overloads. Both cases produce // CS0111 in the generated code. Until step 2 of dotnet/java-interop#1431 // projects inline-class params as strongly-typed wrappers, drop the - // duplicates deterministically (keep the first) and warn so the user can - // override via Metadata.xml if desired. + // mangled duplicates deterministically and warn so the user can + // override via Metadata.xml if desired. Non-mangled methods are always + // kept; only mangled methods are ever removed. private static void RemoveCollidingSiblings (GenBase gen, List renamed) { - if (renamed.Count < 2) + if (renamed.Count == 0) return; - foreach (var group in renamed.GroupBy (m => m.Name)) { - var kept = new List (); - - foreach (var method in group) { - if (kept.Any (k => k.Matches (method))) { - Report.LogCodedWarning (0, Report.WarningKotlinNameMangledCollision, method, gen.FullName, method.Name, method.JavaName); - gen.Methods.Remove (method); - } else { - kept.Add (method); - } - } + foreach (var method in renamed) { + // Collide against every other method on the type, not just other + // mangled siblings, so `tint(MyInlineLong)` collapsing to + // `tint(long)` collides with a pre-existing non-mangled + // `tint(long)` too. + var conflict = gen.Methods.FirstOrDefault (other => other != method && + other.Name == method.Name && other.Matches (method)); + if (conflict == null) + continue; + + Report.LogCodedWarning (0, Report.WarningKotlinNameMangledCollision, method, gen.FullName, method.Name, method.JavaName); + gen.Methods.Remove (method); } } From 32071909f547efac8992b308ddd8fadf92a2c43f Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Fri, 22 May 2026 14:03:25 -0500 Subject: [PATCH 3/3] Keep first colliding sibling, not last The previous fix in 8e8d7a63 walked all of gen.Methods when looking for a conflict and removed the *current* mangled method whenever any other method matched -- including ones that appeared LATER in source. For two mangled siblings A and B, iterating A would find B (later) and remove A, leaving B as the survivor. That contradicted the warning text ('Only the first will be emitted') and was non-deterministic w.r.t. the intended Metadata.xml escape hatch. Fix: only treat the current mangled method as the duplicate when a matching method appears EARLIER in source order (TakeWhile(m => m != method)). This guarantees the first-declared overload always wins, whether the earlier method is mangled or non-mangled. Strengthened CollidingHashSiblings_AreDeduplicated to assert the kept method's JavaName is dd-AAAAAAA (not just any method named Add). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Unit-Tests/KotlinFixupsTests.cs | 1 + .../KotlinFixups.cs | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs b/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs index 8ee054a2f..c920a83c9 100644 --- a/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs +++ b/tests/generator-Tests/Unit-Tests/KotlinFixupsTests.cs @@ -62,6 +62,7 @@ public void CollidingHashSiblings_AreDeduplicated () Assert.AreEqual (1, klass.Methods.Count, "Duplicate hash-mangled sibling should have been removed."); Assert.AreEqual ("Add", klass.Methods [0].Name); + Assert.AreEqual ("add-AAAAAAA", klass.Methods [0].JavaName, "The first hash-mangled sibling in source order should survive."); Assert.IsTrue (warnings.Messages.Any (m => m.Contains ("BG8C02")), "Expected BG8C02 warning, got: " + string.Join (Environment.NewLine, warnings.Messages)); } diff --git a/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs b/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs index f17924a72..d7ba1a76c 100644 --- a/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs +++ b/tools/generator/Java.Interop.Tools.Generator.Transformation/KotlinFixups.cs @@ -55,7 +55,7 @@ private static void FixupInterface (InterfaceGen gen) // other AND with pre-existing non-mangled overloads. Both cases produce // CS0111 in the generated code. Until step 2 of dotnet/java-interop#1431 // projects inline-class params as strongly-typed wrappers, drop the - // mangled duplicates deterministically and warn so the user can + // later mangled duplicate deterministically and warn so the user can // override via Metadata.xml if desired. Non-mangled methods are always // kept; only mangled methods are ever removed. private static void RemoveCollidingSiblings (GenBase gen, List renamed) @@ -64,13 +64,14 @@ private static void RemoveCollidingSiblings (GenBase gen, List renamed) return; foreach (var method in renamed) { - // Collide against every other method on the type, not just other - // mangled siblings, so `tint(MyInlineLong)` collapsing to - // `tint(long)` collides with a pre-existing non-mangled - // `tint(long)` too. - var conflict = gen.Methods.FirstOrDefault (other => other != method && - other.Name == method.Name && other.Matches (method)); - if (conflict == null) + // Only treat `method` as the duplicate if a matching method + // (mangled or non-mangled) appears EARLIER in source order on + // the type. This keeps the first-declared overload and removes + // every later mangled sibling that collapses onto it. + var earlier = gen.Methods + .TakeWhile (m => m != method) + .FirstOrDefault (m => m.Name == method.Name && m.Matches (method)); + if (earlier == null) continue; Report.LogCodedWarning (0, Report.WarningKotlinNameMangledCollision, method, gen.FullName, method.Name, method.JavaName);