Skip to content

Commit 6979f38

Browse files
paulirwinclaude
andauthored
Review and correct the Java-to-C# type mappings (#134) (#181)
Fixes #134. ## Interfaces were bound to concrete types The issue's example — `Map<K, V>` mapping to `Dictionary<K, V>` instead of `IDictionary<K, V>` — turned out to be one of several. Converting a probe file showed variables declared against a Java collection interface were losing the abstraction the Java source chose, even though `List` → `IList` was already correct. `Map` now maps to `IDictionary` and `Set` to `ISet`, with `Collection`, `Comparable`, `Comparator`, `Iterable`, `SortedMap`/`SortedSet` and the `Navigable` variants mapped alongside them. ## ...which required mapping the concrete types too Fixing only the interfaces would have made output *worse*, since interfaces are exactly what you cannot instantiate. `new HashMap<>()` was previously emitting `new HashMap()` — a type that does not exist in .NET — because `HashMap`, `LinkedHashMap`, `LinkedHashSet`, `TreeMap` and `TreeSet` had no entries at all. Those are added. `Map.put` is also lowered to an index assignment, the way `List.set` already was. This is not strictly a type mapping, but the integration test would not compile without it: a converted `IDictionary` had no usable way to store into itself. It reuses the existing `List.set` lowering, which already discards the same return value. ## Other commonly-used types filled in Boxed primitives (`Character`, `Double`, `Short`, and `Byte` → `sbyte`, as Java's `byte` is signed), `BigDecimal`, `StringBuffer`, `Closeable`, and the common exceptions: `Throwable`, `ClassCastException`, `NumberFormatException`, `IndexOutOfBoundsException`, `ArrayIndexOutOfBoundsException`, `NoSuchElementException`, `OutOfMemoryError`, `StackOverflowError`, `InterruptedException`, `CloneNotSupportedException`. ## Deliberately left unmapped: `CharSequence`, `Runnable`, `Void` All three were added and then removed. `CharSequence` → `string` broke an existing test: `class Foo implements CharSequence` became `: string`, which does not compile because `string` is sealed. `Runnable` → `Action` and `Void` → `void` (illegal in `Future<Void>`) fail the same way. The mapping table is position-blind — it cannot tell a variable declaration from a base-type list — so each of these would fix some conversions while breaking others. Handling them needs position-aware conversion rather than a table entry. ## Testing All 415 tests pass. New unit tests cover the collection, simple-type and exception mappings; a new `CollectionTypeMappings.java` integration resource compiles and *runs* the generated C# rather than only asserting on the conversion text. To support that, the integration harness now mirrors the CLI's default usings and references `System.Collections.dll` (where `SortedDictionary`/`SortedSet` live). ## Pre-existing bugs found but not fixed Both are out of scope here; happy to file them separately. - **Diamond operator drops type arguments**: `new HashMap<>()` converts to `new Dictionary()`, losing `<string, int>`. The new test resource uses explicit type arguments to work around this. - **Qualified type names pass through unconverted**: `TypeNameParser` handles simple identifiers only, so `Map.Entry<K, V>` is left as-is. Relatedly, `java.*` imports emit unusable usings such as `using Java;`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 64fbb2a commit 6979f38

5 files changed

Lines changed: 157 additions & 19 deletions

File tree

JavaToCSharp.Tests/ConcurrencyTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ public void ConvertType_IsThreadSafe()
2323
("String", "string"),
2424
("Integer", "int"),
2525
("List<String>", "IList<string>"),
26-
("Map<String, Integer>", "Dictionary<string, int>"),
26+
("Map<String, Integer>", "IDictionary<string, int>"),
2727
("int[]", "int[]"),
28-
("List<Map<String, Object>>", "IList<Dictionary<string, object>>"),
28+
("List<Map<String, Object>>", "IList<IDictionary<string, object>>"),
2929
];
3030

3131
var failures = new System.Collections.Concurrent.ConcurrentBag<string>();

JavaToCSharp.Tests/ConvertTypeTests.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,69 @@ public interface Lemmatizer {
5858
Assert.Contains("string[] Lemmatize(string[] toks, string[] tags);", parsed);
5959
}
6060

61+
[Theory]
62+
// Collection interfaces keep their abstraction rather than binding to a concrete type (#134).
63+
[InlineData("Map<String, Integer>", "IDictionary<string, int>")]
64+
[InlineData("Set<String>", "ISet<string>")]
65+
[InlineData("Collection<String>", "ICollection<string>")]
66+
[InlineData("Iterable<String>", "IEnumerable<string>")]
67+
[InlineData("SortedMap<String, Integer>", "IDictionary<string, int>")]
68+
// ...while the concrete java implementations map to instantiable .NET types.
69+
[InlineData("HashMap<String, Integer>", "Dictionary<string, int>")]
70+
[InlineData("LinkedHashMap<String, Integer>", "Dictionary<string, int>")]
71+
[InlineData("TreeMap<String, Integer>", "SortedDictionary<string, int>")]
72+
[InlineData("TreeSet<String>", "SortedSet<string>")]
73+
[InlineData("LinkedHashSet<String>", "HashSet<string>")]
74+
public void ConvertType_Collections(string javaType, string expected)
75+
{
76+
Assert.Equal(expected, TypeHelper.ConvertType(javaType));
77+
}
78+
79+
[Theory]
80+
[InlineData("Character", "char")]
81+
[InlineData("Double", "double")]
82+
[InlineData("Short", "short")]
83+
[InlineData("Byte", "sbyte")] // java's byte is signed
84+
[InlineData("BigDecimal", "decimal")]
85+
[InlineData("StringBuffer", "StringBuilder")]
86+
public void ConvertType_SimpleTypes(string javaType, string expected)
87+
{
88+
Assert.Equal(expected, TypeHelper.ConvertType(javaType));
89+
}
90+
91+
[Theory]
92+
[InlineData("Throwable", "Exception")]
93+
[InlineData("ClassCastException", "InvalidCastException")]
94+
[InlineData("NumberFormatException", "FormatException")]
95+
[InlineData("IndexOutOfBoundsException", "IndexOutOfRangeException")]
96+
[InlineData("ArrayIndexOutOfBoundsException", "IndexOutOfRangeException")]
97+
[InlineData("NoSuchElementException", "InvalidOperationException")]
98+
[InlineData("OutOfMemoryError", "OutOfMemoryException")]
99+
public void ConvertType_Exceptions(string javaType, string expected)
100+
{
101+
Assert.Equal(expected, TypeHelper.ConvertType(javaType));
102+
}
103+
104+
[Fact]
105+
public void ConvertType_MapDeclaration_AssignedFromHashMap()
106+
{
107+
const string javaCode = """
108+
import java.util.*;
109+
110+
public class Holder {
111+
private Map<String, Integer> counts = new HashMap<String, Integer>();
112+
}
113+
""";
114+
var options = new JavaConversionOptions
115+
{
116+
IncludeUsings = false,
117+
IncludeNamespace = false,
118+
};
119+
var parsed = JavaToCSharpConverter.ConvertText(javaCode, options) ?? "";
120+
121+
Assert.Contains("private IDictionary<string, int> counts = new Dictionary<string, int>();", parsed);
122+
}
123+
61124
[Fact]
62125
public void ConvertType_GenericSingleParameter()
63126
{

JavaToCSharp.Tests/IntegrationTests.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ public void GeneralUnsuccessfulConversionTest(string filePath)
9494
[InlineData("Resources/ExceptionGetMessage.java")]
9595
[InlineData("Resources/LongLiterals.java")]
9696
[InlineData("Resources/MixedArrayRankDeclarations.java")]
97+
[InlineData("Resources/CollectionTypeMappings.java")]
9798
public void FullIntegrationTests(string filePath, bool allowWarnings = false)
9899
=> RunFullIntegrationTest(filePath, allowWarnings);
99100

@@ -115,7 +116,11 @@ private void RunFullIntegrationTest(string filePath, bool allowWarnings, bool us
115116
UseLabeledBreakAndContinue = useLabeledBreakAndContinue,
116117
};
117118

119+
// Mirror the CLI's default usings so the compiled sample sees what a real conversion would.
118120
options.AddUsing("System");
121+
options.AddUsing("System.Collections.Generic");
122+
options.AddUsing("System.Linq");
123+
options.AddUsing("System.Text");
119124

120125
options.WarningEncountered += (_, eventArgs) =>
121126
{
@@ -251,6 +256,7 @@ private static IEnumerable<MetadataReference> GetMetadataReferencesForBcl()
251256
{
252257
yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Private.CoreLib.dll"));
253258
yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Console.dll"));
259+
yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Collections.dll"));
254260
yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Linq.dll"));
255261
yield return MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll"));
256262
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/// Expect:
2+
/// - output: "1\n2\nTrue\nTrue\na\n"
3+
package example;
4+
5+
public class Program {
6+
public static void main(String[] args) {
7+
// A variable declared against the java interface must convert to the .NET interface,
8+
// while the concrete implementation it is assigned from must stay instantiable.
9+
Map<String, Integer> counts = new HashMap<String, Integer>();
10+
counts.put("a", 1);
11+
System.out.println(counts.get("a"));
12+
13+
Map<String, Integer> sorted = new TreeMap<String, Integer>();
14+
sorted.put("b", 2);
15+
System.out.println(sorted.get("b"));
16+
17+
Set<String> set = new HashSet<String>();
18+
set.add("x");
19+
System.out.println(set.contains("x"));
20+
21+
Set<String> sortedSet = new TreeSet<String>();
22+
sortedSet.add("y");
23+
System.out.println(sortedSet.contains("y"));
24+
25+
List<String> list = new ArrayList<String>();
26+
list.add("a");
27+
System.out.println(list.get(0));
28+
}
29+
}

JavaToCSharp/TypeHelper.cs

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,37 +17,74 @@ public static class TypeHelper
1717
// so this must be a concurrent collection to keep parallel conversions safe.
1818
private static readonly ConcurrentDictionary<string, string> _typeNameConversions = new()
1919
{
20-
// Simple types
20+
// Primitives and their boxed counterparts. Java's boxed types are nullable references while
21+
// the C# equivalents are value types, so a null-valued Java variable will need manual review.
2122
["boolean"] = "bool",
2223
["Boolean"] = "bool",
23-
["ICloseable"] = "IDisposable",
24+
["Byte"] = "sbyte", // java's byte is signed, unlike C#'s
25+
["Character"] = "char",
26+
["Double"] = "double",
27+
["Float"] = "float",
2428
["Integer"] = "int",
2529
["Long"] = "long",
26-
["Float"] = "float",
27-
["String"] = "string",
28-
["Object"] = "object",
29-
["AutoCloseable"] = "IDisposable",
30+
["Short"] = "short",
3031

31-
// Generic types
32-
["ArrayList"] = "List",
33-
["List"] = "IList",
34-
["Map"] = "Dictionary",
35-
["Set"] = "HashSet",
32+
// Other simple types
33+
["AutoCloseable"] = "IDisposable",
34+
["BigDecimal"] = "decimal",
35+
["Closeable"] = "IDisposable",
36+
["ICloseable"] = "IDisposable",
37+
["Object"] = "object",
38+
["String"] = "string",
39+
["StringBuffer"] = "StringBuilder",
40+
41+
// Collection interfaces map to the .NET interfaces so that variables declared against an
42+
// abstraction stay abstract; the concrete java implementations below supply the `new` types.
43+
["Collection"] = "ICollection",
44+
["Comparable"] = "IComparable",
45+
["Comparator"] = "IComparer",
46+
["Iterable"] = "IEnumerable",
3647
["Iterator"] = "IEnumerator",
48+
["List"] = "IList",
49+
["Map"] = "IDictionary",
50+
["NavigableMap"] = "IDictionary",
51+
["NavigableSet"] = "ISet",
52+
["Set"] = "ISet",
53+
["SortedMap"] = "IDictionary",
54+
["SortedSet"] = "ISet",
55+
56+
// Concrete collection implementations. These are what `new Foo<>()` expressions resolve to,
57+
// so they must name instantiable .NET types rather than interfaces.
58+
["ArrayList"] = "List",
59+
["HashMap"] = "Dictionary",
60+
["LinkedHashMap"] = "Dictionary",
61+
["LinkedHashSet"] = "HashSet",
62+
["TreeMap"] = "SortedDictionary",
63+
["TreeSet"] = "SortedSet",
3764

3865
// Exceptions
66+
["AccessDeniedException"] = "UnauthorizedAccessException",
3967
["AlreadyClosedException"] = "ObjectDisposedException",
68+
["ArrayIndexOutOfBoundsException"] = "IndexOutOfRangeException",
69+
["AssertionError"] = "InvalidOperationException",
70+
["ClassCastException"] = "InvalidCastException",
71+
["CloneNotSupportedException"] = "NotSupportedException",
72+
["EOFException"] = "EndOfStreamException",
4073
["Error"] = "Exception",
4174
["IllegalArgumentException"] = "ArgumentException",
4275
["IllegalStateException"] = "InvalidOperationException",
43-
["UnsupportedOperationException"] = "NotSupportedException",
44-
["RuntimeException"] = "Exception",
45-
["AccessDeniedException"] = "UnauthorizedAccessException",
46-
["AssertionError"] = "InvalidOperationException",
76+
["IndexOutOfBoundsException"] = "IndexOutOfRangeException",
77+
["InterruptedException"] = "OperationCanceledException",
78+
["NoSuchElementException"] = "InvalidOperationException",
79+
["NoSuchFileException"] = "FileNotFoundException",
4780
["NullPointerException"] = "NullReferenceException",
81+
["NumberFormatException"] = "FormatException",
82+
["OutOfMemoryError"] = "OutOfMemoryException",
83+
["RuntimeException"] = "Exception",
84+
["StackOverflowError"] = "StackOverflowException",
85+
["Throwable"] = "Exception",
4886
["UncheckedIOException"] = "IOException",
49-
["EOFException"] = "EndOfStreamException",
50-
["NoSuchFileException"] = "FileNotFoundException",
87+
["UnsupportedOperationException"] = "NotSupportedException",
5188
};
5289

5390
public static void AddOrUpdateTypeNameConversions(string key, string value)
@@ -293,6 +330,9 @@ public static bool TryTransformMethodCall(ConversionContext context, MethodCallE
293330
return true;
294331
}
295332

333+
// Java's put returns the previous value, which an index assignment discards. That
334+
// matches the existing handling of List.set, whose return value is dropped too.
335+
case "put" when args.size() == 2:
296336
case "set" when args.size() == 2:
297337
{
298338
var scopeSyntaxSet = ExpressionVisitor.VisitExpression(context, scope);

0 commit comments

Comments
 (0)