From 9be8e823e3af267f1a5211a7afec817c5d241c96 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Fri, 14 Aug 2026 13:39:25 -0600 Subject: [PATCH 1/2] Fix static usings leaking across files (#148) Static usings discovered while visiting an enum were accumulated on JavaConversionOptions, which callers reuse across every file in a conversion run. The list was never cleared, so each successive file emitted the static usings of every enum seen before it. The leak was also cross-namespace: UsingsHelper prefixes each name with the *current* file's namespace, so an enum from com.foo would surface in a com.bar file as "using static Com.Bar.Color;" - a reference to a type that does not exist there. Move the accumulator to ConversionContext, which is already constructed once per ConvertText call and already holds the other per-file state (PendingAnonymousTypes, UsedAnonymousTypeNames). This scopes the state correctly by construction rather than relying on cleanup, and is safe when one options instance is shared across concurrent conversions. Use a HashSet since the names are a set; the existing Distinct in GetUsings was previously masking duplicate accumulation. Removes the public JavaConversionOptions.StaticUsingEnumNames property. It was only ever written to internally and was not usable as an input, so this is a breaking change in name only. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/ConvertEnumTests.cs | 91 +++++++++++++++++++ JavaToCSharp/ConversionContext.cs | 2 + .../Declarations/EnumDeclarationVisitor.cs | 2 +- JavaToCSharp/JavaConversionOptions.cs | 2 - JavaToCSharp/UsingsHelper.cs | 2 +- 5 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 JavaToCSharp.Tests/ConvertEnumTests.cs diff --git a/JavaToCSharp.Tests/ConvertEnumTests.cs b/JavaToCSharp.Tests/ConvertEnumTests.cs new file mode 100644 index 0000000..7a1fb2b --- /dev/null +++ b/JavaToCSharp.Tests/ConvertEnumTests.cs @@ -0,0 +1,91 @@ +namespace JavaToCSharp.Tests; + +public class ConvertEnumTests +{ + [Fact] + public void Enum_In_Same_File_Emits_Static_Using() + { + const string javaCode = """ + package com.example; + public enum Color { RED, GREEN } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("using static Com.Example.Color;", parsed); + } + + [Fact] + public void Static_Usings_Do_Not_Leak_Into_Later_Files_Sharing_Options() + { + const string enumCode = """ + package com.example; + public enum Color { RED, GREEN } + """; + const string classCode = """ + package com.example; + public class Foo { + } + """; + + // The same options instance is reused across files, as the CLI and GUI do. + var options = NewOptions(); + + var enumResult = Convert(enumCode, options); + var classResult = Convert(classCode, options); + + Assert.Contains("using static Com.Example.Color;", enumResult); + Assert.DoesNotContain("using static", classResult); + } + + [Fact] + public void Static_Usings_Do_Not_Leak_Across_Namespaces() + { + const string enumCode = """ + package com.foo; + public enum Color { RED, GREEN } + """; + const string classCode = """ + package com.bar; + public class Foo { + } + """; + + var options = NewOptions(); + + Convert(enumCode, options); + var classResult = Convert(classCode, options); + + // Leaking here previously produced "using static Com.Bar.Color;", a + // reference to a type that does not exist in that namespace. + Assert.DoesNotContain("Color", classResult); + } + + [Fact] + public void Repeated_Enum_Name_Emits_Single_Static_Using() + { + const string javaCode = """ + package com.example; + public enum Color { RED } + public class Holder { + public enum Color { GREEN } + } + """; + + var parsed = Convert(javaCode); + + var occurrences = parsed.Split("using static Com.Example.Color;").Length - 1; + Assert.Equal(1, occurrences); + } + + private static JavaConversionOptions NewOptions() + { + var options = new JavaConversionOptions { IncludeComments = false }; + options.WarningEncountered += (_, eventArgs) + => Console.WriteLine("Line {0}: {1}", eventArgs.JavaLineNumber, eventArgs.Message); + return options; + } + + private static string Convert(string javaCode, JavaConversionOptions? options = null) + => JavaToCSharpConverter.ConvertText(javaCode, options ?? NewOptions()) ?? ""; +} diff --git a/JavaToCSharp/ConversionContext.cs b/JavaToCSharp/ConversionContext.cs index 2f565f2..b14417f 100644 --- a/JavaToCSharp/ConversionContext.cs +++ b/JavaToCSharp/ConversionContext.cs @@ -8,6 +8,8 @@ public class ConversionContext(JavaConversionOptions options) public ISet UsedAnonymousTypeNames { get; } = new HashSet(); + public ISet StaticUsingEnumNames { get; } = new HashSet(); + public JavaConversionOptions Options { get; } = options; public string? RootTypeName { get; set; } diff --git a/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs b/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs index 97a700c..7722699 100644 --- a/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs @@ -97,7 +97,7 @@ public static EnumDeclarationSyntax VisitEnumDeclaration(ConversionContext conte if (mods.Contains(Modifier.Keyword.PUBLIC)) enumSyntax = enumSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); - context.Options.StaticUsingEnumNames.Add(name); + context.StaticUsingEnumNames.Add(name); return enumSyntax.WithJavaComments(context, enumDecl); diff --git a/JavaToCSharp/JavaConversionOptions.cs b/JavaToCSharp/JavaConversionOptions.cs index 85795cd..1281a8a 100644 --- a/JavaToCSharp/JavaConversionOptions.cs +++ b/JavaToCSharp/JavaConversionOptions.cs @@ -12,8 +12,6 @@ public class JavaConversionOptions public IList Usings { get; } = new List(); - public IList StaticUsingEnumNames { get; } = new List(); - public bool IncludeSubdirectories { get; set; } = true; public bool IncludeUsings { get; set; } = true; diff --git a/JavaToCSharp/UsingsHelper.cs b/JavaToCSharp/UsingsHelper.cs index a4630ff..d9b3295 100644 --- a/JavaToCSharp/UsingsHelper.cs +++ b/JavaToCSharp/UsingsHelper.cs @@ -53,7 +53,7 @@ public static IEnumerable GetUsings(ConversionContext cont if (namespaceNameSyntax is not null) { - foreach (var staticUsing in options?.StaticUsingEnumNames ?? []) + foreach (var staticUsing in context.StaticUsingEnumNames) { var usingSyntax = SyntaxFactory .UsingDirective(SyntaxFactory.ParseName($"{namespaceNameSyntax}.{staticUsing}")) From 1ad13dd882495eadb79425fd9116db8e0c456d68 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Fri, 14 Aug 2026 13:48:28 -0600 Subject: [PATCH 2/2] Qualify static usings by declaring type; scope run state to context Nested enums produced a static using that named only the enum, omitting its declaring type: an enum "Inner" inside class "Holder" emitted "using static Com.Example.Inner;" when the C# type is Com.Example.Holder.Inner. That using does not resolve, so any file with a nested enum - a common Java shape - generated C# that failed to compile. Build the name by walking the AST's enclosing type declarations, so arbitrary nesting depth and enums in interfaces are handled too. Also move ConversionState off JavaConversionOptions and onto ConversionContext. It was per-run state written on a caller-owned config object, the same defect as StaticUsingEnumNames. State changes now go through the context, which records the value and raises the existing StateChanged event, so event ordering is unchanged. The public property was written but never read anywhere in the repo; the GUI tracks state via the event. Drop GetUsings' redundant JavaConversionOptions parameter. Its only caller passed the same instance already reachable via context.Options, so the two could not disagree; reading it from the context also lets the internal null-guards go away. Adds an executable integration case (NestedEnumStaticUsing.java) that compiles and runs the generated C#, which fails before this change. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/ConvertEnumTests.cs | 66 ++++++++++++++++++- JavaToCSharp.Tests/IntegrationTests.cs | 1 + .../Resources/NestedEnumStaticUsing.java | 11 ++++ JavaToCSharp/ConversionContext.cs | 12 ++++ .../Declarations/EnumDeclarationVisitor.cs | 24 ++++++- JavaToCSharp/JavaConversionOptions.cs | 8 +-- JavaToCSharp/JavaToCSharpConverter.cs | 12 ++-- JavaToCSharp/UsingsHelper.cs | 6 +- 8 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 JavaToCSharp.Tests/Resources/NestedEnumStaticUsing.java diff --git a/JavaToCSharp.Tests/ConvertEnumTests.cs b/JavaToCSharp.Tests/ConvertEnumTests.cs index 7a1fb2b..7e6a2f5 100644 --- a/JavaToCSharp.Tests/ConvertEnumTests.cs +++ b/JavaToCSharp.Tests/ConvertEnumTests.cs @@ -62,7 +62,56 @@ public class Foo { } [Fact] - public void Repeated_Enum_Name_Emits_Single_Static_Using() + public void Nested_Enum_Static_Using_Includes_Declaring_Type() + { + const string javaCode = """ + package com.example; + public class Holder { + public enum Inner { A, B } + } + """; + + var parsed = Convert(javaCode); + + // The C# type is Com.Example.Holder.Inner; a using naming only the enum + // would not resolve. + Assert.Contains("using static Com.Example.Holder.Inner;", parsed); + } + + [Fact] + public void Deeply_Nested_Enum_Static_Using_Includes_Full_Path() + { + const string javaCode = """ + package com.example; + public class Outer { + public static class Middle { + public enum Deep { A } + } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("using static Com.Example.Outer.Middle.Deep;", parsed); + } + + [Fact] + public void Enum_Nested_In_Interface_Static_Using_Includes_Declaring_Type() + { + const string javaCode = """ + package com.example; + public interface Contract { + enum Kind { A, B } + } + """; + + var parsed = Convert(javaCode); + + Assert.Contains("using static Com.Example.Contract.Kind;", parsed); + } + + [Fact] + public void Same_Enum_Name_At_Different_Scopes_Emits_Distinct_Static_Usings() { const string javaCode = """ package com.example; @@ -74,6 +123,21 @@ public enum Color { GREEN } var parsed = Convert(javaCode); + // These are two different C# types and each needs its own static using. + Assert.Contains("using static Com.Example.Color;", parsed); + Assert.Contains("using static Com.Example.Holder.Color;", parsed); + } + + [Fact] + public void Static_Using_Is_Not_Duplicated_For_A_Single_Enum() + { + const string javaCode = """ + package com.example; + public enum Color { RED, GREEN } + """; + + var parsed = Convert(javaCode); + var occurrences = parsed.Split("using static Com.Example.Color;").Length - 1; Assert.Equal(1, occurrences); } diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index f12723d..ec753c8 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -72,6 +72,7 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/DeprecatedAnnotation.java")] [InlineData("Resources/BooleanArrays.java")] [InlineData("Resources/BinaryLiterals.java")] + [InlineData("Resources/NestedEnumStaticUsing.java")] public void FullIntegrationTests(string filePath) { var options = new JavaConversionOptions diff --git a/JavaToCSharp.Tests/Resources/NestedEnumStaticUsing.java b/JavaToCSharp.Tests/Resources/NestedEnumStaticUsing.java new file mode 100644 index 0000000..909bd73 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/NestedEnumStaticUsing.java @@ -0,0 +1,11 @@ +/// Expect: +/// - output: "RED\n" +package example; + +public class Program { + public enum Color { RED, GREEN } + + public static void main(String[] args) { + System.out.println(Color.RED); + } +} diff --git a/JavaToCSharp/ConversionContext.cs b/JavaToCSharp/ConversionContext.cs index b14417f..1360bba 100644 --- a/JavaToCSharp/ConversionContext.cs +++ b/JavaToCSharp/ConversionContext.cs @@ -12,7 +12,19 @@ public class ConversionContext(JavaConversionOptions options) public JavaConversionOptions Options { get; } = options; + public ConversionState ConversionState { get; private set; } + public string? RootTypeName { get; set; } public string? LastTypeName { get; set; } + + /// + /// Records the new conversion state and raises . + /// + internal void ConversionStateChanged(ConversionState newState) + { + ConversionState = newState; + + Options.ConversionStateChanged(newState); + } } diff --git a/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs b/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs index 7722699..2286902 100644 --- a/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs +++ b/JavaToCSharp/Declarations/EnumDeclarationVisitor.cs @@ -97,7 +97,7 @@ public static EnumDeclarationSyntax VisitEnumDeclaration(ConversionContext conte if (mods.Contains(Modifier.Keyword.PUBLIC)) enumSyntax = enumSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword)); - context.StaticUsingEnumNames.Add(name); + context.StaticUsingEnumNames.Add(GetTypeNameWithinPackage(enumDecl, name)); return enumSyntax.WithJavaComments(context, enumDecl); @@ -118,4 +118,26 @@ EnumMemberDeclarationSyntax MembersToCommentTrivia(EnumMemberDeclarationSyntax l return lastMemberDecl; } } + + /// + /// Builds the enum's name qualified by any types it is nested within, excluding the package + /// (e.g. "Holder.Inner" for an enum "Inner" declared inside class "Holder"). A static using + /// must name the enum's declaring type, not just the enum itself, to resolve. + /// + private static string GetTypeNameWithinPackage(EnumDeclaration enumDecl, string name) + { + var parts = new List { name }; + + for (var parent = enumDecl.getParentNode().FromOptional(); + parent is not null; + parent = parent.getParentNode().FromOptional()) + { + if (parent is TypeDeclaration typeDecl) + { + parts.Insert(0, typeDecl.getNameAsString()); + } + } + + return string.Join(".", parts); + } } diff --git a/JavaToCSharp/JavaConversionOptions.cs b/JavaToCSharp/JavaConversionOptions.cs index 1281a8a..4d78abc 100644 --- a/JavaToCSharp/JavaConversionOptions.cs +++ b/JavaToCSharp/JavaConversionOptions.cs @@ -35,8 +35,6 @@ public class JavaConversionOptions public SyntaxMapping SyntaxMappings { get; set; } = new SyntaxMapping(); - public ConversionState ConversionState { get; set; } - public JavaConversionOptions AddPackageReplacement(string pattern, string replacement, RegexOptions options = RegexOptions.None) { PackageReplacements.Add(new Replacement(pattern, replacement, options)); @@ -73,9 +71,5 @@ public JavaConversionOptions SetUsings(IEnumerable usings) internal void Warning(string message, int javaLineNumber) => WarningEncountered?.Invoke(this, new ConversionWarningEventArgs(message, javaLineNumber)); internal void ConversionStateChanged(ConversionState newState) - { - ConversionState = newState; - - StateChanged?.Invoke(this, new ConversionStateChangedEventArgs(newState)); - } + => StateChanged?.Invoke(this, new ConversionStateChangedEventArgs(newState)); } diff --git a/JavaToCSharp/JavaToCSharpConverter.cs b/JavaToCSharp/JavaToCSharpConverter.cs index 676dbac..eba282d 100644 --- a/JavaToCSharp/JavaToCSharpConverter.cs +++ b/JavaToCSharp/JavaToCSharpConverter.cs @@ -16,16 +16,16 @@ public static class JavaToCSharpConverter { options ??= new JavaConversionOptions(); - options.ConversionStateChanged(ConversionState.Starting); - var context = new ConversionContext(options); + context.ConversionStateChanged(ConversionState.Starting); + var textBytes = Encoding.UTF8.GetBytes(javaText ?? string.Empty); using var memoryStream = new MemoryStream(textBytes); using var wrapper = new InputStreamWrapper(memoryStream); - options.ConversionStateChanged(ConversionState.ParsingJavaAst); + context.ConversionStateChanged(ConversionState.ParsingJavaAst); var parser = new JavaParser(); parser.getParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_17); @@ -47,7 +47,7 @@ public static class JavaToCSharpConverter var result = parsed.getResult().FromRequiredOptional(); - options.ConversionStateChanged(ConversionState.BuildingCSharpAst); + context.ConversionStateChanged(ConversionState.BuildingCSharpAst); var types = result.getTypes().ToList() ?? []; var imports = result.getImports()?.ToList() ?? []; @@ -127,7 +127,7 @@ public static class JavaToCSharpConverter var root = SyntaxFactory.CompilationUnit( externs: [], - usings: SyntaxFactory.List(UsingsHelper.GetUsings(context, imports, options, namespaceNameSyntax)), + usings: SyntaxFactory.List(UsingsHelper.GetUsings(context, imports, namespaceNameSyntax)), attributeLists: [], members: SyntaxFactory.List(rootMembers) ); @@ -144,7 +144,7 @@ public static class JavaToCSharpConverter var tree = SyntaxFactory.SyntaxTree(sanitizedRoot); - options.ConversionStateChanged(ConversionState.Done); + context.ConversionStateChanged(ConversionState.Done); return tree.GetText().ToString(); } diff --git a/JavaToCSharp/UsingsHelper.cs b/JavaToCSharp/UsingsHelper.cs index d9b3295..f358633 100644 --- a/JavaToCSharp/UsingsHelper.cs +++ b/JavaToCSharp/UsingsHelper.cs @@ -9,9 +9,9 @@ public static class UsingsHelper { public static IEnumerable GetUsings(ConversionContext context, IEnumerable imports, - JavaConversionOptions? options, NameSyntax? namespaceNameSyntax) { + var options = context.Options; var usings = new List(); foreach (var import in imports) @@ -25,7 +25,7 @@ public static IEnumerable GetUsings(ConversionContext cont var nameSpace = TypeHelper.Capitalize(importNameWithoutClassName); // Override namespace if a non empty mapping is found (mapping to empty string removes the import) - if (options is not null && options.SyntaxMappings.ImportMappings.TryGetValue(importName, out var mappedNamespace)) + if (options.SyntaxMappings.ImportMappings.TryGetValue(importName, out var mappedNamespace)) { if (string.IsNullOrEmpty(mappedNamespace)) { @@ -44,7 +44,7 @@ public static IEnumerable GetUsings(ConversionContext cont usings.Add(usingSyntax.NormalizeWhitespace().WithTrailingNewLines()); } - if (options?.IncludeUsings == true) + if (options.IncludeUsings) { usings.AddRange(options.Usings .Where(x => !string.IsNullOrWhiteSpace(x))