()", parsed);
+
+ // No method reference may become an invocation.
+ Assert.DoesNotContain("string.Length()", parsed);
+ Assert.DoesNotContain("Java8MethodReferences.Add()", parsed);
+ }
+
+ private static string Convert(string javaCode, bool allowWarnings = false)
+ {
+ var options = new JavaConversionOptions { IncludeComments = false };
+
+ options.WarningEncountered += (_, eventArgs) =>
+ {
+ if (!allowWarnings)
+ {
+ throw new InvalidOperationException($"Encountered a warning in conversion: {eventArgs.Message}");
+ }
+ };
+
+ return JavaToCSharpConverter.ConvertText(javaCode, options) ?? "";
+ }
+}
diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs
index e890a70..58dfd83 100644
--- a/JavaToCSharp.Tests/IntegrationTests.cs
+++ b/JavaToCSharp.Tests/IntegrationTests.cs
@@ -22,6 +22,8 @@ public class IntegrationTests(ITestOutputHelper testOutputHelper)
[InlineData("Resources/Java11LambdaInference.java")]
[InlineData("Resources/MultidimensionalArrays.java", true)]
[InlineData("Resources/Java17SealedClasses.java", true)]
+ // Conversion-only: java.util.function has no BCL delegate mapping, so the output cannot be run.
+ [InlineData("Resources/Java8MethodReferences.java")]
public void GeneralSuccessfulConversionTest(string filePath, bool allowWarnings = false)
{
var options = new JavaConversionOptions
@@ -82,6 +84,9 @@ public void GeneralUnsuccessfulConversionTest(string filePath)
[InlineData("Resources/BooleanArrays.java")]
[InlineData("Resources/BinaryLiterals.java")]
[InlineData("Resources/NestedEnumStaticUsing.java")]
+ [InlineData("Resources/Java16LocalRecords.java")]
+ [InlineData("Resources/InstanceInitializers.java")]
+ [InlineData("Resources/StaticImports.java")]
public void FullIntegrationTests(string filePath, bool allowWarnings = false)
{
var options = new JavaConversionOptions
diff --git a/JavaToCSharp.Tests/Resources/InstanceInitializers.java b/JavaToCSharp.Tests/Resources/InstanceInitializers.java
new file mode 100644
index 0000000..289346b
--- /dev/null
+++ b/JavaToCSharp.Tests/Resources/InstanceInitializers.java
@@ -0,0 +1,75 @@
+/// Expect:
+/// - output: "a: count=1 tag=init\nb: count=2 tag=x\nc: count=2 tag=x-y\nd: count=1 tag=init\nstatic=7\n"
+package example;
+
+// Instance initializer blocks run at the start of every constructor that does not chain to another
+// constructor of the same class. A constructor that chains via this(...) must not re-run them.
+public class Program {
+ static int staticValue;
+
+ static {
+ staticValue = 7;
+ }
+
+ public static class Counter {
+ public String tag;
+ public int count;
+
+ // Two separate initializer blocks, to verify both run and in declaration order.
+ {
+ tag = "init";
+ }
+
+ {
+ count = 1;
+ }
+
+ public Counter() {
+ }
+
+ public Counter(String first) {
+ tag = first;
+ count = 2;
+ }
+
+ // Chains to Counter(String), so the initializers already ran there and must not run again.
+ public Counter(String first, String second) {
+ this(first);
+ tag = tag + "-" + second;
+ }
+ }
+
+ // A class with an initializer but no declared constructor needs one synthesized, or the
+ // initializer would be dropped entirely.
+ public static class Implicit {
+ public String tag;
+ public int count;
+
+ {
+ tag = "init";
+ count = 1;
+ }
+ }
+
+ public static void describe(String label, int count, String tag) {
+ System.out.println(label + ": count=" + count + " tag=" + tag);
+ }
+
+ public static void main(String[] args) {
+ Counter a = new Counter();
+ describe("a", a.count, a.tag);
+
+ Counter b = new Counter("x");
+ describe("b", b.count, b.tag);
+
+ // count stays 2 rather than resetting to 1, because the chained constructor did not re-run
+ // the initializer.
+ Counter c = new Counter("x", "y");
+ describe("c", c.count, c.tag);
+
+ Implicit d = new Implicit();
+ describe("d", d.count, d.tag);
+
+ System.out.println("static=" + staticValue);
+ }
+}
diff --git a/JavaToCSharp.Tests/Resources/Java16LocalRecords.java b/JavaToCSharp.Tests/Resources/Java16LocalRecords.java
new file mode 100644
index 0000000..0473a53
--- /dev/null
+++ b/JavaToCSharp.Tests/Resources/Java16LocalRecords.java
@@ -0,0 +1,36 @@
+/// Expect:
+/// - output: "area=6\npair=1,2\nsame=True\nnested=9\n"
+package example;
+
+// Java 16 local records (JEP 395) may be declared in a method body. C# has no local record
+// declaration, so they are hoisted to the enclosing type.
+public class Program {
+ public static int scale(int value) {
+ // A local record in a second method, to verify each is hoisted independently.
+ record Factor(int amount) {
+ }
+
+ Factor f = new Factor(3);
+ return value * f.amount;
+ }
+
+ public static void main(String[] args) {
+ record Rect(int width, int height) {
+ }
+
+ Rect r = new Rect(2, 3);
+ System.out.println("area=" + (r.width * r.height));
+
+ // A second local record in the same method, to verify both are hoisted.
+ record Pair(int left, int right) {
+ }
+
+ Pair p = new Pair(1, 2);
+ System.out.println("pair=" + p.left + "," + p.right);
+
+ // Records have value equality in both languages.
+ System.out.println("same=" + p.equals(new Pair(1, 2)));
+
+ System.out.println("nested=" + scale(3));
+ }
+}
diff --git a/JavaToCSharp.Tests/Resources/Java8MethodReferences.java b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java
new file mode 100644
index 0000000..30c5744
--- /dev/null
+++ b/JavaToCSharp.Tests/Resources/Java8MethodReferences.java
@@ -0,0 +1,51 @@
+package example;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Java 8 method references (JEP 126).
+ *
+ * These convert to C# method groups rather than invocations. This file is a conversion-only
+ * test: java.util.function has no mapping to BCL delegate types, so the generated C# cannot be
+ * compiled and run by the integration harness. See ConvertJava21GapTests for the assertions on
+ * the generated syntax.
+ */
+public class Java8MethodReferences {
+ public static int add(int a, int b) {
+ return a + b;
+ }
+
+ public String value;
+
+ public String upper() {
+ return value.toUpperCase();
+ }
+
+ // Reference to an instance method of a type, applied to a supplied receiver.
+ public Function lengthOf() {
+ return String::length;
+ }
+
+ // Constructor reference, which has no C# equivalent and becomes a lambda.
+ public Supplier> newList() {
+ return ArrayList::new;
+ }
+
+ // Reference to a static method.
+ public BiFunction adder() {
+ return Java8MethodReferences::add;
+ }
+
+ // Reference to an instance method of a particular object.
+ public Supplier upperOf() {
+ return this::upper;
+ }
+
+ public void printAll(List items) {
+ items.forEach(System.out::println);
+ }
+}
diff --git a/JavaToCSharp.Tests/Resources/StaticImports.java b/JavaToCSharp.Tests/Resources/StaticImports.java
new file mode 100644
index 0000000..00c8c4c
--- /dev/null
+++ b/JavaToCSharp.Tests/Resources/StaticImports.java
@@ -0,0 +1,47 @@
+/// Expect:
+/// - output: "clamped=5\nmax=9\ndoubled=8\nsingle=3\n"
+package example;
+
+import static example.MathHelpers.doubled;
+import static example.MathHelpers.max;
+import static example.MathHelpers.min;
+import static example.Constants.*;
+
+// A static import names the declaring type, so it must become `using static`. Treating it as a
+// namespace import stripped the type off and left the imported members unresolvable.
+//
+// java.lang.Math is deliberately not used here: it maps to Java.Lang.Math, which does not exist in
+// the BCL, so the generated code could not be compiled and run by this harness.
+class MathHelpers {
+ public static int max(int a, int b) {
+ return a > b ? a : b;
+ }
+
+ public static int min(int a, int b) {
+ return a < b ? a : b;
+ }
+
+ public static int doubled(int a) {
+ return a * 2;
+ }
+}
+
+class Constants {
+ public static final int SINGLE = 3;
+}
+
+public class Program {
+ public static int clamp(int value, int low, int high) {
+ return min(max(value, low), high);
+ }
+
+ public static void main(String[] args) {
+ System.out.println("clamped=" + clamp(12, 1, 5));
+ System.out.println("max=" + max(9, 3));
+ System.out.println("doubled=" + doubled(4));
+
+ // An on-demand static import (`import static example.Constants.*`) already names the type,
+ // so nothing is stripped from it.
+ System.out.println("single=" + SINGLE);
+ }
+}
diff --git a/JavaToCSharp/ConversionContext.cs b/JavaToCSharp/ConversionContext.cs
index fbc4fca..6b2d5ac 100644
--- a/JavaToCSharp/ConversionContext.cs
+++ b/JavaToCSharp/ConversionContext.cs
@@ -4,7 +4,11 @@ namespace JavaToCSharp;
public class ConversionContext(JavaConversionOptions options)
{
- public Queue PendingAnonymousTypes { get; } = new();
+ ///
+ /// Types that must be hoisted to the enclosing type, because C# has no local equivalent of the
+ /// Java construct that declared them: anonymous class bodies and local records.
+ ///
+ public Queue PendingAnonymousTypes { get; } = new();
public ISet UsedAnonymousTypeNames { get; } = new HashSet();
@@ -25,6 +29,13 @@ public class ConversionContext(JavaConversionOptions options)
///
internal List PendingStatements { get; } = [];
+ ///
+ /// Instance initializer blocks collected while visiting the members of the class currently being
+ /// converted. Java runs these at the start of every constructor, so they are prepended to each
+ /// constructor body by .
+ ///
+ internal List PendingInstanceInitializers { get; } = [];
+
///
/// The identifier that a Java yield statement should assign to, when a multi-statement
/// switch expression has been lowered into a switch statement. Null when not inside such a lowering.
diff --git a/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs b/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs
index 0bf5595..c91ee90 100644
--- a/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs
+++ b/JavaToCSharp/Declarations/ClassOrInterfaceDeclarationVisitor.cs
@@ -155,6 +155,51 @@ public static InterfaceDeclarationSyntax VisitInterfaceDeclaration(ConversionCon
return WithSealedComment(classSyntax.WithJavaComments(context, interfaceDecl), sealedComment);
}
+ ///
+ /// Prepends any collected instance initializer blocks to each constructor body, mirroring Java's
+ /// rule that they run at the start of every constructor. Constructors that chain to another
+ /// constructor of the same class via this(...) are skipped, since the initializers already
+ /// ran in the constructor being chained to. When the class declares no constructor at all, a
+ /// default one is synthesized to hold them.
+ ///
+ private static ClassDeclarationSyntax ApplyInstanceInitializers(ConversionContext context, ClassDeclarationSyntax classSyntax)
+ {
+ if (context.PendingInstanceInitializers.Count == 0)
+ {
+ return classSyntax;
+ }
+
+ var initializerStatements = context.PendingInstanceInitializers
+ .SelectMany(block => block.Statements)
+ .ToArray();
+
+ var constructors = classSyntax.Members.OfType()
+ .Where(ctor => !ctor.Modifiers.Any(SyntaxKind.StaticKeyword))
+ .ToList();
+
+ if (constructors.Count == 0)
+ {
+ return classSyntax.AddMembers(
+ SyntaxFactory.ConstructorDeclaration(classSyntax.Identifier.ValueText)
+ .WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.PublicKeyword)))
+ .WithBody(SyntaxFactory.Block(initializerStatements)));
+ }
+
+ return classSyntax.ReplaceNodes(
+ constructors,
+ (original, _) =>
+ {
+ if (original.Initializer?.ThisOrBaseKeyword.IsKind(SyntaxKind.ThisKeyword) == true)
+ {
+ return original;
+ }
+
+ var body = original.Body ?? SyntaxFactory.Block();
+
+ return original.WithBody(body.WithStatements(body.Statements.InsertRange(0, initializerStatements)));
+ });
+ }
+
public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext context,
ClassOrInterfaceDeclaration classDecl, bool isNested = false)
{
@@ -230,6 +275,12 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con
var members = classDecl.getMembers()?.ToList();
+ // Instance initializers are collected per-class. Nested types are visited inline below, so
+ // the outer class's pending initializers are set aside to keep them from leaking into a
+ // nested type's constructors (and vice versa).
+ var outerInstanceInitializers = context.PendingInstanceInitializers.ToList();
+ context.PendingInstanceInitializers.Clear();
+
if (members is not null)
{
foreach (var member in members)
@@ -268,6 +319,11 @@ public static ClassDeclarationSyntax VisitClassDeclaration(ConversionContext con
}
}
+ classSyntax = ApplyInstanceInitializers(context, classSyntax);
+
+ context.PendingInstanceInitializers.Clear();
+ context.PendingInstanceInitializers.AddRange(outerInstanceInitializers);
+
var annotations = classDecl.getAnnotations().ToList();
if (annotations is { Count: > 0 })
diff --git a/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs b/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs
index 1cbb03f..e1e0977 100644
--- a/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs
+++ b/JavaToCSharp/Declarations/InitializerDeclarationVisitor.cs
@@ -1,5 +1,4 @@
-using com.github.javaparser;
-using com.github.javaparser.ast.body;
+using com.github.javaparser.ast.body;
using com.github.javaparser.ast.type;
using JavaToCSharp.Statements;
using Microsoft.CodeAnalysis.CSharp;
@@ -9,24 +8,27 @@ namespace JavaToCSharp.Declarations;
public class InitializerDeclarationVisitor : BodyDeclarationVisitor
{
- public override MemberDeclarationSyntax VisitForClass(
+ public override MemberDeclarationSyntax? VisitForClass(
ConversionContext context,
ClassDeclarationSyntax classSyntax,
InitializerDeclaration declaration,
IReadOnlyList extends,
IReadOnlyList implements)
{
- if (!declaration.isStatic())
- {
- //throw new NotImplementedException("Support for non-static initializers is not understood or implemented");
- context.Options.Warning("Support for non-static initializers is not understood or implemented",
- declaration.getBegin().FromRequiredOptional().line);
- }
-
var block = declaration.getBody();
var blockSyntax = (BlockSyntax)new BlockStatementVisitor().Visit(context, block);
+ // Java runs an instance initializer block at the start of every constructor, so it cannot be
+ // emitted as a member on its own. Stash it for the class visitor to prepend to each
+ // constructor body; emitting it as a static constructor (as this once did) would both run at
+ // the wrong time and collide with any real static initializer.
+ if (!declaration.isStatic())
+ {
+ context.PendingInstanceInitializers.Add(blockSyntax);
+ return null;
+ }
+
return SyntaxFactory.ConstructorDeclaration(classSyntax.Identifier.ValueText)
.WithModifiers(SyntaxFactory.TokenList(SyntaxFactory.Token(SyntaxKind.StaticKeyword)))
.WithBody(blockSyntax);
diff --git a/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs b/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs
index 27af830..41963a8 100644
--- a/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs
+++ b/JavaToCSharp/Expressions/MethodReferenceExpressionVisitor.cs
@@ -17,27 +17,43 @@ protected override ExpressionSyntax Visit(ConversionContext context, MethodRefer
scopeSyntax = VisitExpression(context, scope);
}
- var methodName = TypeHelper.Capitalize(expr.getIdentifier());
- methodName = TypeHelper.ReplaceCommonMethodNames(methodName);
-
- ExpressionSyntax methodExpression;
-
- if (scopeSyntax is null)
+ // A constructor reference (`Foo::new`) has no C# method-group equivalent, so it becomes a
+ // lambda that news up the type instead. The scope is a type name here rather than a value,
+ // so it is converted as a type to keep any generic arguments (`ArrayList::new`).
+ if (expr.getIdentifier() == "new")
{
- methodExpression = SyntaxFactory.IdentifierName(methodName);
- }
- else
- {
- methodExpression = SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, scopeSyntax, SyntaxFactory.IdentifierName(methodName));
+ var typeName = scope is TypeExpr typeExpr
+ ? TypeHelper.ConvertType(typeExpr.getType().toString())
+ : scopeSyntax?.ToString() ?? "object";
+
+ return SyntaxFactory.ParenthesizedLambdaExpression(
+ SyntaxFactory.ObjectCreationExpression(
+ SyntaxFactory.ParseTypeName(typeName),
+ SyntaxFactory.ArgumentList(),
+ initializer: null));
}
+ var methodName = TypeHelper.Capitalize(expr.getIdentifier());
+ methodName = TypeHelper.ReplaceCommonMethodNames(methodName);
+
var args = expr.getTypeArguments().FromOptional();
- if (args is null || args.size() == 0)
+ SimpleNameSyntax nameSyntax = args is null || args.size() == 0
+ ? SyntaxFactory.IdentifierName(methodName)
+ : SyntaxFactory.GenericName(SyntaxFactory.Identifier(methodName))
+ .WithTypeArgumentList(
+ SyntaxFactory.TypeArgumentList(
+ SyntaxFactory.SeparatedList(
+ (args.ToList() ?? [])
+ .Select(x => SyntaxFactory.ParseTypeName(TypeHelper.ConvertType(x.toString()))))));
+
+ // A method reference is a method group, not an invocation: `String::length` is `Length`,
+ // not `Length()`. Wrapping it in an InvocationExpression would call the method here.
+ if (scopeSyntax is null)
{
- return SyntaxFactory.InvocationExpression(methodExpression);
+ return nameSyntax;
}
- return SyntaxFactory.InvocationExpression(methodExpression, TypeHelper.GetSyntaxFromArguments(context, args));
+ return SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, scopeSyntax, nameSyntax);
}
}
diff --git a/JavaToCSharp/Statements/StatementVisitor.cs b/JavaToCSharp/Statements/StatementVisitor.cs
index 36e3737..87b003e 100644
--- a/JavaToCSharp/Statements/StatementVisitor.cs
+++ b/JavaToCSharp/Statements/StatementVisitor.cs
@@ -40,7 +40,8 @@ static StatementVisitor()
{ typeof(WhileStmt), new WhileStatementVisitor() },
{ typeof(YieldStmt), new YieldStatementVisitor() },
{ typeof(EmptyStmt), new EmptyStatementVisitor() },
- { typeof(LocalClassDeclarationStmt), new TypeDeclarationStatementVisitor() }
+ { typeof(LocalClassDeclarationStmt), new TypeDeclarationStatementVisitor() },
+ { typeof(LocalRecordDeclarationStmt), new LocalRecordDeclarationStatementVisitor() }
};
}
diff --git a/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs b/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs
index 666460a..20440c7 100644
--- a/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs
+++ b/JavaToCSharp/Statements/TypeDeclarationStatementVisitor.cs
@@ -22,3 +22,25 @@ public class TypeDeclarationStatementVisitor : StatementVisitor
+/// Handles Java 16 local record declarations (void m() { record R(int a) {} }).
+///
+///
+/// C# has no local record declaration: the compiler parses record R(int a); in a method body
+/// as a local function, so the record cannot stay where Java declared it. It is instead hoisted to
+/// the enclosing type, reusing the same queue that lifts anonymous class bodies. This widens the
+/// record's scope, which is harmless unless the enclosing type already declares a member of the same
+/// name.
+///
+public class LocalRecordDeclarationStatementVisitor : StatementVisitor
+{
+ public override StatementSyntax? Visit(ConversionContext context, LocalRecordDeclarationStmt statement)
+ {
+ var recordSyntax = RecordDeclarationVisitor.VisitRecordDeclaration(context, statement.getRecordDeclaration(), true);
+
+ context.PendingAnonymousTypes.Enqueue(recordSyntax);
+
+ return null;
+ }
+}
diff --git a/JavaToCSharp/UsingsHelper.cs b/JavaToCSharp/UsingsHelper.cs
index f358633..575c8a2 100644
--- a/JavaToCSharp/UsingsHelper.cs
+++ b/JavaToCSharp/UsingsHelper.cs
@@ -22,7 +22,14 @@ public static IEnumerable GetUsings(ConversionContext cont
var importNameWithoutClassName = lastPartStartIndex == -1 ?
importName :
importName[..lastPartStartIndex];
- var nameSpace = TypeHelper.Capitalize(importNameWithoutClassName);
+
+ // A member-specific static import (`import static java.util.Arrays.asList`) ends in the
+ // member name, so stripping the last segment leaves the declaring type that `using
+ // static` needs. An on-demand static import (`import static java.util.Arrays.*`) already
+ // ends in the type, so it is used as-is.
+ var isStatic = import.isStatic();
+ var nameSpace = TypeHelper.Capitalize(
+ isStatic && import.isAsterisk() ? importName : importNameWithoutClassName);
// Override namespace if a non empty mapping is found (mapping to empty string removes the import)
if (options.SyntaxMappings.ImportMappings.TryGetValue(importName, out var mappedNamespace))
@@ -36,6 +43,11 @@ public static IEnumerable GetUsings(ConversionContext cont
var usingSyntax = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(nameSpace));
+ if (isStatic)
+ {
+ usingSyntax = usingSyntax.WithStaticKeyword(SyntaxFactory.Token(SyntaxKind.StaticKeyword));
+ }
+
if (context.Options.IncludeComments)
{
usingSyntax = CommentsHelper.AddUsingComments(usingSyntax, import);