From bd516dd46710d976f111fa057b6a29f1103ac9f4 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Tue, 18 Aug 2026 08:49:11 -0600 Subject: [PATCH] Review and correct the Java-to-C# type mappings (#134) Variables declared against a Java collection interface were being bound to a concrete .NET type: Map became Dictionary and Set became HashSet, losing the abstraction the Java source chose. Map now maps to IDictionary and Set to ISet, matching how List already mapped to IList, and Collection, Comparable, Comparator, Iterable, SortedMap/SortedSet and their Navigable variants are mapped alongside them. That change only works if the concrete implementations are mapped too, since those are what `new Foo<>()` resolves to, so HashMap, LinkedHashMap, LinkedHashSet, TreeMap and TreeSet are added. Java's Map.put is also lowered to an index assignment, the way List.set already was; without it a converted map declaration has no usable way to store into itself. Also fills in the boxed primitives (Character, Double, Short, Byte -- to sbyte, as Java's byte is signed), BigDecimal, StringBuffer, Closeable, and the common exceptions: Throwable, ClassCastException, NumberFormatException, IndexOutOfBoundsException and friends. CharSequence, Runnable and Void are deliberately left unmapped. The mapping table cannot see what position a type appears in, and each of those is valid as a base type in Java but not as its natural C# counterpart -- string is sealed, and `Future` does not compile. The integration harness now mirrors the CLI's default usings and references System.Collections.dll, so the new sample compiles and runs the generated C# rather than only asserting on the conversion. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/ConcurrencyTests.cs | 4 +- JavaToCSharp.Tests/ConvertTypeTests.cs | 63 ++++++++++++++++ JavaToCSharp.Tests/IntegrationTests.cs | 6 ++ .../Resources/CollectionTypeMappings.java | 29 ++++++++ JavaToCSharp/TypeHelper.cs | 74 ++++++++++++++----- 5 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 JavaToCSharp.Tests/Resources/CollectionTypeMappings.java diff --git a/JavaToCSharp.Tests/ConcurrencyTests.cs b/JavaToCSharp.Tests/ConcurrencyTests.cs index bc2beef..d904012 100644 --- a/JavaToCSharp.Tests/ConcurrencyTests.cs +++ b/JavaToCSharp.Tests/ConcurrencyTests.cs @@ -23,9 +23,9 @@ public void ConvertType_IsThreadSafe() ("String", "string"), ("Integer", "int"), ("List", "IList"), - ("Map", "Dictionary"), + ("Map", "IDictionary"), ("int[]", "int[]"), - ("List>", "IList>"), + ("List>", "IList>"), ]; var failures = new System.Collections.Concurrent.ConcurrentBag(); diff --git a/JavaToCSharp.Tests/ConvertTypeTests.cs b/JavaToCSharp.Tests/ConvertTypeTests.cs index c9f56ce..4e52e83 100644 --- a/JavaToCSharp.Tests/ConvertTypeTests.cs +++ b/JavaToCSharp.Tests/ConvertTypeTests.cs @@ -58,6 +58,69 @@ public interface Lemmatizer { Assert.Contains("string[] Lemmatize(string[] toks, string[] tags);", parsed); } + [Theory] + // Collection interfaces keep their abstraction rather than binding to a concrete type (#134). + [InlineData("Map", "IDictionary")] + [InlineData("Set", "ISet")] + [InlineData("Collection", "ICollection")] + [InlineData("Iterable", "IEnumerable")] + [InlineData("SortedMap", "IDictionary")] + // ...while the concrete java implementations map to instantiable .NET types. + [InlineData("HashMap", "Dictionary")] + [InlineData("LinkedHashMap", "Dictionary")] + [InlineData("TreeMap", "SortedDictionary")] + [InlineData("TreeSet", "SortedSet")] + [InlineData("LinkedHashSet", "HashSet")] + public void ConvertType_Collections(string javaType, string expected) + { + Assert.Equal(expected, TypeHelper.ConvertType(javaType)); + } + + [Theory] + [InlineData("Character", "char")] + [InlineData("Double", "double")] + [InlineData("Short", "short")] + [InlineData("Byte", "sbyte")] // java's byte is signed + [InlineData("BigDecimal", "decimal")] + [InlineData("StringBuffer", "StringBuilder")] + public void ConvertType_SimpleTypes(string javaType, string expected) + { + Assert.Equal(expected, TypeHelper.ConvertType(javaType)); + } + + [Theory] + [InlineData("Throwable", "Exception")] + [InlineData("ClassCastException", "InvalidCastException")] + [InlineData("NumberFormatException", "FormatException")] + [InlineData("IndexOutOfBoundsException", "IndexOutOfRangeException")] + [InlineData("ArrayIndexOutOfBoundsException", "IndexOutOfRangeException")] + [InlineData("NoSuchElementException", "InvalidOperationException")] + [InlineData("OutOfMemoryError", "OutOfMemoryException")] + public void ConvertType_Exceptions(string javaType, string expected) + { + Assert.Equal(expected, TypeHelper.ConvertType(javaType)); + } + + [Fact] + public void ConvertType_MapDeclaration_AssignedFromHashMap() + { + const string javaCode = """ + import java.util.*; + + public class Holder { + private Map counts = new HashMap(); + } + """; + var options = new JavaConversionOptions + { + IncludeUsings = false, + IncludeNamespace = false, + }; + var parsed = JavaToCSharpConverter.ConvertText(javaCode, options) ?? ""; + + Assert.Contains("private IDictionary counts = new Dictionary();", parsed); + } + [Fact] public void ConvertType_GenericSingleParameter() { diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index 954079e..b53ea43 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -94,6 +94,7 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/ExceptionGetMessage.java")] [InlineData("Resources/LongLiterals.java")] [InlineData("Resources/MixedArrayRankDeclarations.java")] + [InlineData("Resources/CollectionTypeMappings.java")] public void FullIntegrationTests(string filePath, bool allowWarnings = false) => RunFullIntegrationTest(filePath, allowWarnings); @@ -115,7 +116,11 @@ private void RunFullIntegrationTest(string filePath, bool allowWarnings, bool us UseLabeledBreakAndContinue = useLabeledBreakAndContinue, }; + // Mirror the CLI's default usings so the compiled sample sees what a real conversion would. options.AddUsing("System"); + options.AddUsing("System.Collections.Generic"); + options.AddUsing("System.Linq"); + options.AddUsing("System.Text"); options.WarningEncountered += (_, eventArgs) => { @@ -251,6 +256,7 @@ private static IEnumerable GetMetadataReferencesForBcl() { yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Private.CoreLib.dll")); yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Console.dll")); + yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Collections.dll")); yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Linq.dll")); yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll")); } diff --git a/JavaToCSharp.Tests/Resources/CollectionTypeMappings.java b/JavaToCSharp.Tests/Resources/CollectionTypeMappings.java new file mode 100644 index 0000000..6eb64aa --- /dev/null +++ b/JavaToCSharp.Tests/Resources/CollectionTypeMappings.java @@ -0,0 +1,29 @@ +/// Expect: +/// - output: "1\n2\nTrue\nTrue\na\n" +package example; + +public class Program { + public static void main(String[] args) { + // A variable declared against the java interface must convert to the .NET interface, + // while the concrete implementation it is assigned from must stay instantiable. + Map counts = new HashMap(); + counts.put("a", 1); + System.out.println(counts.get("a")); + + Map sorted = new TreeMap(); + sorted.put("b", 2); + System.out.println(sorted.get("b")); + + Set set = new HashSet(); + set.add("x"); + System.out.println(set.contains("x")); + + Set sortedSet = new TreeSet(); + sortedSet.add("y"); + System.out.println(sortedSet.contains("y")); + + List list = new ArrayList(); + list.add("a"); + System.out.println(list.get(0)); + } +} diff --git a/JavaToCSharp/TypeHelper.cs b/JavaToCSharp/TypeHelper.cs index bcaafcf..34b8908 100644 --- a/JavaToCSharp/TypeHelper.cs +++ b/JavaToCSharp/TypeHelper.cs @@ -17,37 +17,74 @@ public static class TypeHelper // so this must be a concurrent collection to keep parallel conversions safe. private static readonly ConcurrentDictionary _typeNameConversions = new() { - // Simple types + // Primitives and their boxed counterparts. Java's boxed types are nullable references while + // the C# equivalents are value types, so a null-valued Java variable will need manual review. ["boolean"] = "bool", ["Boolean"] = "bool", - ["ICloseable"] = "IDisposable", + ["Byte"] = "sbyte", // java's byte is signed, unlike C#'s + ["Character"] = "char", + ["Double"] = "double", + ["Float"] = "float", ["Integer"] = "int", ["Long"] = "long", - ["Float"] = "float", - ["String"] = "string", - ["Object"] = "object", - ["AutoCloseable"] = "IDisposable", + ["Short"] = "short", - // Generic types - ["ArrayList"] = "List", - ["List"] = "IList", - ["Map"] = "Dictionary", - ["Set"] = "HashSet", + // Other simple types + ["AutoCloseable"] = "IDisposable", + ["BigDecimal"] = "decimal", + ["Closeable"] = "IDisposable", + ["ICloseable"] = "IDisposable", + ["Object"] = "object", + ["String"] = "string", + ["StringBuffer"] = "StringBuilder", + + // Collection interfaces map to the .NET interfaces so that variables declared against an + // abstraction stay abstract; the concrete java implementations below supply the `new` types. + ["Collection"] = "ICollection", + ["Comparable"] = "IComparable", + ["Comparator"] = "IComparer", + ["Iterable"] = "IEnumerable", ["Iterator"] = "IEnumerator", + ["List"] = "IList", + ["Map"] = "IDictionary", + ["NavigableMap"] = "IDictionary", + ["NavigableSet"] = "ISet", + ["Set"] = "ISet", + ["SortedMap"] = "IDictionary", + ["SortedSet"] = "ISet", + + // Concrete collection implementations. These are what `new Foo<>()` expressions resolve to, + // so they must name instantiable .NET types rather than interfaces. + ["ArrayList"] = "List", + ["HashMap"] = "Dictionary", + ["LinkedHashMap"] = "Dictionary", + ["LinkedHashSet"] = "HashSet", + ["TreeMap"] = "SortedDictionary", + ["TreeSet"] = "SortedSet", // Exceptions + ["AccessDeniedException"] = "UnauthorizedAccessException", ["AlreadyClosedException"] = "ObjectDisposedException", + ["ArrayIndexOutOfBoundsException"] = "IndexOutOfRangeException", + ["AssertionError"] = "InvalidOperationException", + ["ClassCastException"] = "InvalidCastException", + ["CloneNotSupportedException"] = "NotSupportedException", + ["EOFException"] = "EndOfStreamException", ["Error"] = "Exception", ["IllegalArgumentException"] = "ArgumentException", ["IllegalStateException"] = "InvalidOperationException", - ["UnsupportedOperationException"] = "NotSupportedException", - ["RuntimeException"] = "Exception", - ["AccessDeniedException"] = "UnauthorizedAccessException", - ["AssertionError"] = "InvalidOperationException", + ["IndexOutOfBoundsException"] = "IndexOutOfRangeException", + ["InterruptedException"] = "OperationCanceledException", + ["NoSuchElementException"] = "InvalidOperationException", + ["NoSuchFileException"] = "FileNotFoundException", ["NullPointerException"] = "NullReferenceException", + ["NumberFormatException"] = "FormatException", + ["OutOfMemoryError"] = "OutOfMemoryException", + ["RuntimeException"] = "Exception", + ["StackOverflowError"] = "StackOverflowException", + ["Throwable"] = "Exception", ["UncheckedIOException"] = "IOException", - ["EOFException"] = "EndOfStreamException", - ["NoSuchFileException"] = "FileNotFoundException", + ["UnsupportedOperationException"] = "NotSupportedException", }; public static void AddOrUpdateTypeNameConversions(string key, string value) @@ -293,6 +330,9 @@ public static bool TryTransformMethodCall(ConversionContext context, MethodCallE return true; } + // Java's put returns the previous value, which an index assignment discards. That + // matches the existing handling of List.set, whose return value is dropped too. + case "put" when args.size() == 2: case "set" when args.size() == 2: { var scopeSyntaxSet = ExpressionVisitor.VisitExpression(context, scope);