From 96ad92c8e0931a8756d2dececc45ab7813b21e00 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Fri, 14 Aug 2026 14:04:15 -0600 Subject: [PATCH 1/3] Escape variable declarations named after C# keywords (#133) Usages of a variable were escaped via NameExpressionVisitor, but the declarations themselves were not, producing uncompilable output such as `InputStream in = ...` alongside a correctly escaped `Init(@in)`. Apply TypeHelper.EscapeIdentifier at the four declarator sites: local variables, fields, try-with-resources resources, and foreach variables. The foreach visitor previously round-tripped the name through a VariableDeclarator and read back Identifier.ValueText, which strips the `@` prefix; it now builds the identifier token directly. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/EscapeIdentifierTests.cs | 74 +++++++++++++++++++ .../Declarations/FieldDeclarationVisitor.cs | 4 +- .../Statements/ExpressionStatementVisitor.cs | 4 +- .../Statements/ForEachStatementVisitor.cs | 6 +- .../Statements/TryStatementVisitor.cs | 2 +- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/JavaToCSharp.Tests/EscapeIdentifierTests.cs b/JavaToCSharp.Tests/EscapeIdentifierTests.cs index 60a80c4..957ad8d 100644 --- a/JavaToCSharp.Tests/EscapeIdentifierTests.cs +++ b/JavaToCSharp.Tests/EscapeIdentifierTests.cs @@ -141,6 +141,80 @@ public void bar(List items) { Assert.Contains("(@object) => Print(@object)", parsed); } + /// + /// Regression test for #133: the declaration of a local variable named after a C# keyword + /// was left unescaped even though its usages were escaped. + /// + [Fact] + public void ConvertText_GivenLocalVariableNamedAfterKeyword_ShouldEscapeDeclaration() + { + const string javaCode = """ + public class Foo { + public void bar() { + InputStream in = openStream(); + init(in); + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("InputStream @in = OpenStream();", parsed); + Assert.Contains("Init(@in);", parsed); + } + + [Fact] + public void ConvertText_GivenTryWithResourcesNamedAfterKeyword_ShouldEscapeDeclaration() + { + const string javaCode = """ + public class Foo { + public void bar(File dictionaryFile) { + try (InputStream in = new FileInputStream(dictionaryFile)) { + init(in); + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("using (InputStream @in = new FileInputStream(dictionaryFile))", parsed); + Assert.Contains("Init(@in);", parsed); + } + + [Fact] + public void ConvertText_GivenFieldNamedAfterKeyword_ShouldEscapeDeclaration() + { + const string javaCode = """ + public class Foo { + private int base = 1; + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("@base", parsed); + } + + [Fact] + public void ConvertText_GivenForEachVariableNamedAfterKeyword_ShouldEscapeDeclaration() + { + const string javaCode = """ + public class Foo { + public void bar(List items) { + for (String object : items) { + print(object); + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("foreach (string @object in items)", parsed); + Assert.Contains("Print(@object);", parsed); + } + /// /// Escaped identifiers must survive a round trip through the C# parser without producing /// diagnostics, which is what the original crash was really about. diff --git a/JavaToCSharp/Declarations/FieldDeclarationVisitor.cs b/JavaToCSharp/Declarations/FieldDeclarationVisitor.cs index d37cc9e..875cdf0 100644 --- a/JavaToCSharp/Declarations/FieldDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/FieldDeclarationVisitor.cs @@ -53,12 +53,12 @@ public override MemberDeclarationSyntax VisitForClass( if (initSyntax is not null) { - var varDeclarationSyntax = SyntaxFactory.VariableDeclarator(name).WithInitializer(SyntaxFactory.EqualsValueClause(initSyntax)); + var varDeclarationSyntax = SyntaxFactory.VariableDeclarator(TypeHelper.EscapeIdentifier(name)).WithInitializer(SyntaxFactory.EqualsValueClause(initSyntax)); variables.Add(varDeclarationSyntax); } } else - variables.Add(SyntaxFactory.VariableDeclarator(name)); + variables.Add(SyntaxFactory.VariableDeclarator(TypeHelper.EscapeIdentifier(name))); } var typeSyntax = TypeHelper.ConvertTypeSyntax(commonType, arrayRank ?? 0); diff --git a/JavaToCSharp/Statements/ExpressionStatementVisitor.cs b/JavaToCSharp/Statements/ExpressionStatementVisitor.cs index 262cdbc..12635f6 100644 --- a/JavaToCSharp/Statements/ExpressionStatementVisitor.cs +++ b/JavaToCSharp/Statements/ExpressionStatementVisitor.cs @@ -62,13 +62,13 @@ private static StatementSyntax VisitVariableDeclarationStatement(ConversionConte var initSyntax = ExpressionVisitor.VisitExpression(context, initExpr); if (initSyntax is not null) { - var varDeclarationSyntax = SyntaxFactory.VariableDeclarator(name).WithInitializer(SyntaxFactory.EqualsValueClause(initSyntax)); + var varDeclarationSyntax = SyntaxFactory.VariableDeclarator(TypeHelper.EscapeIdentifier(name)).WithInitializer(SyntaxFactory.EqualsValueClause(initSyntax)); variables.Add(varDeclarationSyntax); } } else { - variables.Add(SyntaxFactory.VariableDeclarator(name)); + variables.Add(SyntaxFactory.VariableDeclarator(TypeHelper.EscapeIdentifier(name))); } } diff --git a/JavaToCSharp/Statements/ForEachStatementVisitor.cs b/JavaToCSharp/Statements/ForEachStatementVisitor.cs index 19a43be..fa6f16d 100644 --- a/JavaToCSharp/Statements/ForEachStatementVisitor.cs +++ b/JavaToCSharp/Statements/ForEachStatementVisitor.cs @@ -22,13 +22,11 @@ public class ForEachStatementVisitor : StatementVisitor var type = TypeHelper.ConvertType(varType); var variableDeclarators = varExpr.getVariables()?.ToList()?? []; - var vars = variableDeclarators - .Select(i => SyntaxFactory.VariableDeclarator(i.toString())) - .ToArray(); + var identifier = SyntaxFactory.ParseToken(TypeHelper.EscapeIdentifier(variableDeclarators[0].getNameAsString())); var body = foreachStmt.getBody(); var bodySyntax = VisitStatement(context, body); - return bodySyntax is null ? null : SyntaxFactory.ForEachStatement(SyntaxFactory.ParseTypeName(type), vars[0].Identifier.ValueText, iterableSyntax, bodySyntax); + return bodySyntax is null ? null : SyntaxFactory.ForEachStatement(SyntaxFactory.ParseTypeName(type), identifier, iterableSyntax, bodySyntax); } } diff --git a/JavaToCSharp/Statements/TryStatementVisitor.cs b/JavaToCSharp/Statements/TryStatementVisitor.cs index e842134..7827127 100644 --- a/JavaToCSharp/Statements/TryStatementVisitor.cs +++ b/JavaToCSharp/Statements/TryStatementVisitor.cs @@ -63,7 +63,7 @@ public override StatementSyntax Visit(ConversionContext context, TryStmt tryStmt SyntaxFactory.VariableDeclaration( SyntaxFactory.ParseTypeName(TypeHelper.ConvertType(variable.getType())), SyntaxFactory.SeparatedList([ - SyntaxFactory.VariableDeclarator(variable.getNameAsString()) + SyntaxFactory.VariableDeclarator(TypeHelper.EscapeIdentifier(variable.getNameAsString())) .WithInitializer(SyntaxFactory.EqualsValueClause(initSyntax)) ]) )); From 3b6e9a5f01f1d7c6d0f0f61fc083d0f18205f60e Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Fri, 14 Aug 2026 14:36:22 -0600 Subject: [PATCH 2/3] Escape for-loop and catch-clause identifiers named after C# keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous commit, covering the two remaining declaration sites that left keyword identifiers unescaped. The for-loop initializer passed VariableDeclarator.toString() — the name *and* its initializer, e.g. "i = 0" — into SyntaxFactory.VariableDeclarator, which treats the whole string as an identifier. Escaping that blindly corrupts the output, so build the declarator from its parts instead: the escaped name plus the initializer visited as an expression. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/EscapeIdentifierTests.cs | 57 +++++++++++++++++++ .../Statements/ForStatementVisitor.cs | 17 +++++- .../Statements/TryStatementVisitor.cs | 2 +- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/JavaToCSharp.Tests/EscapeIdentifierTests.cs b/JavaToCSharp.Tests/EscapeIdentifierTests.cs index 957ad8d..e52a8df 100644 --- a/JavaToCSharp.Tests/EscapeIdentifierTests.cs +++ b/JavaToCSharp.Tests/EscapeIdentifierTests.cs @@ -215,6 +215,63 @@ public void bar(List items) { Assert.Contains("Print(@object);", parsed); } + [Fact] + public void ConvertText_GivenForLoopVariableNamedAfterKeyword_ShouldEscapeDeclaration() + { + const string javaCode = """ + public class Foo { + public void bar() { + for (int base = 0; base < 10; base++) { + print(base); + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("for (int @base = 0; @base < 10; @base++)", parsed); + Assert.Contains("Print(@base);", parsed); + } + + [Fact] + public void ConvertText_GivenForLoopWithMultipleVariables_ShouldEscapeOnlyKeywords() + { + const string javaCode = """ + public class Foo { + public void bar() { + for (int base = 0, i = 1; base < 10; base++) { + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("int @base = 0, i = 1;", parsed); + } + + [Fact] + public void ConvertText_GivenCatchParameterNamedAfterKeyword_ShouldEscapeParameter() + { + const string javaCode = """ + public class Foo { + public void bar() { + try { + baz(); + } catch (Exception event) { + handle(event); + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("catch (Exception @event)", parsed); + Assert.Contains("Handle(@event);", parsed); + } + /// /// Escaped identifiers must survive a round trip through the C# parser without producing /// diagnostics, which is what the original crash was really about. diff --git a/JavaToCSharp/Statements/ForStatementVisitor.cs b/JavaToCSharp/Statements/ForStatementVisitor.cs index 1f20c53..cac4ae3 100644 --- a/JavaToCSharp/Statements/ForStatementVisitor.cs +++ b/JavaToCSharp/Statements/ForStatementVisitor.cs @@ -26,7 +26,22 @@ public class ForStatementVisitor : StatementVisitor var variableDeclarators = varExpr.getVariables()?.ToList() ?? []; var vars = variableDeclarators - .Select(i => SyntaxFactory.VariableDeclarator(i.toString())) + .Select(i => + { + var declarator = SyntaxFactory.VariableDeclarator(TypeHelper.EscapeIdentifier(i.getNameAsString())); + var varInit = i.getInitializer().FromOptional(); + + if (varInit is null) + { + return declarator; + } + + var varInitSyntax = ExpressionVisitor.VisitExpression(context, varInit); + + return varInitSyntax is null + ? declarator + : declarator.WithInitializer(SyntaxFactory.EqualsValueClause(varInitSyntax)); + }) .ToArray(); varSyntax = SyntaxFactory.VariableDeclaration(SyntaxFactory.ParseTypeName(type), SyntaxFactory.SeparatedList(vars, Enumerable.Repeat(SyntaxFactory.Token(SyntaxKind.CommaToken), vars.Length - 1))); diff --git a/JavaToCSharp/Statements/TryStatementVisitor.cs b/JavaToCSharp/Statements/TryStatementVisitor.cs index 7827127..50cd7c2 100644 --- a/JavaToCSharp/Statements/TryStatementVisitor.cs +++ b/JavaToCSharp/Statements/TryStatementVisitor.cs @@ -143,7 +143,7 @@ private static TryStatementSyntax AddCatches(ConversionContext context, CatchCla SyntaxFactory.CatchClause( SyntaxFactory.CatchDeclaration( SyntaxFactory.ParseTypeName(type), - SyntaxFactory.ParseToken(ctch.getParameter().getNameAsString()) + SyntaxFactory.ParseToken(TypeHelper.EscapeIdentifier(ctch.getParameter().getNameAsString())) ), filter: null, block: catchBlockSyntax From 3ed5cd10cb9725e88c31b7c0593a90446a568c0f Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Fri, 14 Aug 2026 14:37:33 -0600 Subject: [PATCH 3/3] Escape try-with-resources name-expression resources The Java 9+ form `try (existingVar)` references an already-declared effectively-final variable rather than declaring one, and was the last identifier site left unescaped. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/EscapeIdentifierTests.cs | 23 +++++++++++++++++++ .../Statements/TryStatementVisitor.cs | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/JavaToCSharp.Tests/EscapeIdentifierTests.cs b/JavaToCSharp.Tests/EscapeIdentifierTests.cs index e52a8df..324ea27 100644 --- a/JavaToCSharp.Tests/EscapeIdentifierTests.cs +++ b/JavaToCSharp.Tests/EscapeIdentifierTests.cs @@ -272,6 +272,29 @@ public void bar() { Assert.Contains("Handle(@event);", parsed); } + /// + /// Java 9+ allows an already-declared effectively-final variable as a try-with-resources + /// resource. That is a reference, not a declaration, but it still needs escaping. + /// + [Fact] + public void ConvertText_GivenTryWithResourcesNameExpressionNamedAfterKeyword_ShouldEscapeReference() + { + const string javaCode = """ + public class Foo { + public void bar() throws Exception { + InputStream in = openStream(); + try (in) { + init(in); + } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("using (@in)", parsed); + } + /// /// Escaped identifiers must survive a round trip through the C# parser without producing /// diagnostics, which is what the original crash was really about. diff --git a/JavaToCSharp/Statements/TryStatementVisitor.cs b/JavaToCSharp/Statements/TryStatementVisitor.cs index 50cd7c2..02bc352 100644 --- a/JavaToCSharp/Statements/TryStatementVisitor.cs +++ b/JavaToCSharp/Statements/TryStatementVisitor.cs @@ -40,7 +40,7 @@ public override StatementSyntax Visit(ConversionContext context, TryStmt tryStmt { result = SyntaxFactory.UsingStatement(result) .WithExpression( - SyntaxFactory.IdentifierName(resource.asNameExpr().getNameAsString())); + SyntaxFactory.IdentifierName(TypeHelper.EscapeIdentifier(resource.asNameExpr().getNameAsString()))); } else if (resource.isVariableDeclarationExpr()) {