Skip to content

Commit adae786

Browse files
authored
Add mappings from imports, methods and annotations to support JUnit test conversions (#124)
* Add CLI option to read syntax mappings (yaml file) * Apply mappings to imports, methods and annotations * Document mappins in README
1 parent b84f9de commit adae786

File tree

9 files changed

+279
-0
lines changed

9 files changed

+279
-0
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using YamlDotNet.Core;
2+
3+
namespace JavaToCSharp.Tests;
4+
5+
public class SyntaxMappingTests
6+
{
7+
[Fact]
8+
public void Deserialize_Mappings()
9+
{
10+
var mappingString = """
11+
ImportMappings:
12+
org.junit.Test : XUnit
13+
java.util.List : ""
14+
""";
15+
16+
var mappings = SyntaxMapping.Deserialize(mappingString);
17+
Assert.NotNull(mappings);
18+
Assert.Equal(2, mappings.ImportMappings.Count);
19+
Assert.Equal("XUnit", mappings.ImportMappings["org.junit.Test"]);
20+
Assert.Equal("", mappings.ImportMappings["java.util.List"]);
21+
Assert.False(mappings.ImportMappings.ContainsKey("other.Clazz"));
22+
Assert.Empty(mappings.VoidMethodMappings);
23+
Assert.Empty(mappings.NonVoidMethodMappings);
24+
Assert.Empty(mappings.AnnotationMappings);
25+
}
26+
27+
[Fact]
28+
public void Conversion_Options_Defaults_To_Empty_Mappings()
29+
{
30+
var options = new JavaConversionOptions();
31+
Assert.Empty(options.SyntaxMappings.ImportMappings);
32+
Assert.Empty(options.SyntaxMappings.VoidMethodMappings);
33+
Assert.Empty(options.SyntaxMappings.NonVoidMethodMappings);
34+
Assert.Empty(options.SyntaxMappings.AnnotationMappings);
35+
}
36+
37+
[Theory]
38+
[InlineData("VoidMethodMappings:\n org.junit.Assert.assertTrue : Assert.True")]
39+
[InlineData("VoidMethodMappings:\n assertTrue : \"\"")]
40+
[InlineData("NonVoidMethodMappings:\n org.junit.Assert.assertTrue : Assert.True")]
41+
[InlineData("NonVoidMethodMappings:\n assertTrue : \"\"")]
42+
public void Validation_Exceptions(string mappingString)
43+
{
44+
Assert.Throws<SemanticErrorException>(() => SyntaxMapping.Deserialize(mappingString));
45+
}
46+
47+
// The use of mappings is tested using a typical JUnit4 test converted to Xunit:
48+
// - Multiple java imports: rewritten and removed (empty value)
49+
// - Multiple java methods: rewritten (void) and not rewritten (non void)
50+
// - Multiple Java method annotations: rewritten and removed (no mapping)
51+
// Not tested:
52+
// - Qualified java method/annotation names (this would require a more elaborated handling of the scope)
53+
// - No qualified CSharp methods (this would require CSharp static imports, that are not implemented)
54+
// - Annotations with parameters
55+
[Fact]
56+
public void Conversion_With_Import_Method_And_Annotation_Mappings()
57+
{
58+
const string javaCode = """
59+
import static org.junit.Assert.assertEquals;
60+
import static org.junit.Assert.assertTrue;
61+
import org.junit.Test;
62+
public class MappingsTest {
63+
@Test @CustomJava @NotMapped
64+
public void testAsserts() {
65+
assertEquals("a", "a");
66+
assertTrue(true);
67+
va.assertTrue(true); // non void is not mapped
68+
}
69+
}
70+
71+
""";
72+
var mappingsYaml = """
73+
ImportMappings:
74+
org.junit.Test : Xunit
75+
#to remove static imports
76+
org.junit.Assert.assertEquals : ""
77+
org.junit.Assert.assertTrue : ""
78+
VoidMethodMappings:
79+
assertEquals : Assert.Equal
80+
assertTrue : Assert.True
81+
AnnotationMappings:
82+
Test : Fact
83+
CustomJava : CustomCs
84+
85+
""";
86+
const string expectedCSharpCode = """
87+
using Xunit;
88+
89+
public class MappingsTest
90+
{
91+
[Fact]
92+
[CustomCs]
93+
public virtual void TestAsserts()
94+
{
95+
Assert.Equal("a", "a");
96+
Assert.True(true);
97+
va.AssertTrue(true); // non void is not mapped
98+
}
99+
}
100+
101+
""";
102+
103+
var parsed = GetParsed(javaCode, mappingsYaml);
104+
Assert.Equal(expectedCSharpCode.ReplaceLineEndings(), parsed.ReplaceLineEndings());
105+
}
106+
107+
private static string GetParsed(string javaCode, string mappingsYaml)
108+
{
109+
var mappings = SyntaxMapping.Deserialize(mappingsYaml);
110+
var options = new JavaConversionOptions { IncludeNamespace = false, IncludeUsings = false, SyntaxMappings = mappings };
111+
options.WarningEncountered += (_, eventArgs)
112+
=> Console.WriteLine("Line {0}: {1}", eventArgs.JavaLineNumber, eventArgs.Message);
113+
var parsed = JavaToCSharpConverter.ConvertText(javaCode, options) ?? "";
114+
return parsed;
115+
}
116+
}

JavaToCSharp/Declarations/MethodDeclarationVisitor.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ private static MemberDeclarationSyntax VisitInternal(
123123
methodSyntax = methodSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.OverrideKeyword));
124124
isOverride = true;
125125
}
126+
// add annotation if a mapping is found
127+
else if (context.Options != null && context.Options.SyntaxMappings.AnnotationMappings.TryGetValue(name, out var mappedAnnotation))
128+
{
129+
var attributeList = SyntaxFactory.AttributeList(
130+
SyntaxFactory.SingletonSeparatedList(
131+
SyntaxFactory.Attribute(SyntaxFactory.ParseName(mappedAnnotation))));
132+
methodSyntax = methodSyntax.AddAttributeLists(attributeList);
133+
}
126134
}
127135
}
128136

JavaToCSharp/Expressions/MethodCallExpressionVisitor.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ public class MethodCallExpressionVisitor : ExpressionVisitor<MethodCallExpr>
4141
}
4242
}
4343

44+
// Override methodName if a mapping is found
45+
if (TryGetMappedMethodName(methodCallExpr.getNameAsString(), scope, context, out var mappedMethodName))
46+
{
47+
methodName = mappedMethodName;
48+
}
49+
4450
ExpressionSyntax methodExpression;
4551

4652
if (scopeSyntax == null)
@@ -61,4 +67,21 @@ public class MethodCallExpressionVisitor : ExpressionVisitor<MethodCallExpr>
6167

6268
return SyntaxFactory.InvocationExpression(methodExpression, TypeHelper.GetSyntaxFromArguments(context, args));
6369
}
70+
71+
private static bool TryGetMappedMethodName(string methodName, Expression? scope, ConversionContext context, out string mappedMethodName)
72+
{
73+
var mappings = context.Options.SyntaxMappings;
74+
if (scope == null && mappings.VoidMethodMappings.TryGetValue(methodName, out var voidMapping))
75+
{
76+
mappedMethodName = voidMapping;
77+
return true;
78+
}
79+
else if (scope != null && mappings.NonVoidMethodMappings.TryGetValue(methodName, out var nonVoidMapping))
80+
{
81+
mappedMethodName = nonVoidMapping;
82+
return true;
83+
}
84+
mappedMethodName = methodName;
85+
return false;
86+
}
6487
}

JavaToCSharp/JavaConversionOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public class JavaConversionOptions
3535

3636
public bool UseFileScopedNamespaces { get; set; }
3737

38+
public SyntaxMapping SyntaxMappings { get; set; } = new SyntaxMapping();
39+
3840
public ConversionState ConversionState { get; set; }
3941

4042
public JavaConversionOptions AddPackageReplacement(string pattern, string replacement, RegexOptions options = RegexOptions.None)

JavaToCSharp/JavaToCSharp.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<PackageReference Include="IKVM" Version="8.7.1" />
2020
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
2121
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />
22+
<PackageReference Include="YamlDotNet" Version="16.0.0" />
2223
</ItemGroup>
2324
<ItemGroup>
2425
<IkvmReference Include="../Lib/javaparser-core-3.25.4.jar" />

JavaToCSharp/SyntaxMapping.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
namespace JavaToCSharp;
2+
3+
public class SyntaxMapping
4+
{
5+
public Dictionary<string, string> ImportMappings { get; set; } = new();
6+
public Dictionary<string, string> VoidMethodMappings { get; set; } = new();
7+
public Dictionary<string, string> NonVoidMethodMappings { get; set; } = new();
8+
public Dictionary<string, string> AnnotationMappings { get; set; } = new();
9+
10+
public static SyntaxMapping Deserialize(string yaml)
11+
{
12+
var deserializer = new YamlDotNet.Serialization.Deserializer();
13+
SyntaxMapping mapping = deserializer.Deserialize<SyntaxMapping>(yaml);
14+
mapping.Validate();
15+
return mapping;
16+
}
17+
18+
private void Validate()
19+
{
20+
// Throw exception if any of the requirements are not meet
21+
ValidateMethodMapping(VoidMethodMappings);
22+
ValidateMethodMapping(NonVoidMethodMappings);
23+
}
24+
private static void ValidateMethodMapping(Dictionary<string,string> mapping)
25+
{
26+
// Throw exception if any of the requirements are not meet
27+
foreach (string key in mapping.Keys)
28+
{
29+
if (key.Contains('.'))
30+
{
31+
throw new YamlDotNet.Core.SemanticErrorException("Mappings from fully qualified java methods are not supported");
32+
}
33+
}
34+
foreach (string value in mapping.Values)
35+
{
36+
if (string.IsNullOrEmpty(value))
37+
{
38+
throw new YamlDotNet.Core.SemanticErrorException("Mappings from java methods can not have an empty value");
39+
}
40+
}
41+
}
42+
43+
}

JavaToCSharp/UsingsHelper.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ public static IEnumerable<UsingDirectiveSyntax> GetUsings(ConversionContext cont
2323
importName :
2424
importName[..lastPartStartIndex];
2525
var nameSpace = TypeHelper.Capitalize(importNameWithoutClassName);
26+
27+
// Override namespace if a non empty mapping is found (mapping to empty string removes the import)
28+
if (options != null && options.SyntaxMappings.ImportMappings.TryGetValue(importName, out var mappedNamespace))
29+
{
30+
if (string.IsNullOrEmpty(mappedNamespace))
31+
{
32+
continue;
33+
}
34+
nameSpace = mappedNamespace;
35+
}
36+
2637
var usingSyntax = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(nameSpace));
2738

2839
if (context.Options.IncludeComments)

JavaToCSharpCli/Program.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ public class Program
7070
ArgumentHelpName = "namespace"
7171
};
7272

73+
private static readonly Option<string> _mappingsFileNameOption = new(
74+
name: "--mappings-file",
75+
description: "A yaml file with syntax mappings from imports, methods and annotations");
76+
7377
static Program()
7478
{
7579
_loggerFactory = LoggerFactory.Create(builder =>
@@ -98,6 +102,7 @@ public static async Task Main(string[] args)
98102
rootCommand.AddGlobalOption(_fileScopedNamespacesOption);
99103
rootCommand.AddGlobalOption(_clearDefaultUsingsOption);
100104
rootCommand.AddGlobalOption(_addUsingsOption);
105+
rootCommand.AddGlobalOption(_mappingsFileNameOption);
101106

102107
await rootCommand.InvokeAsync(args);
103108

@@ -166,9 +171,22 @@ private static JavaConversionOptions GetJavaConversionOptions(InvocationContext
166171
options.AddUsing(ns);
167172
}
168173

174+
var mappingsFile = context.ParseResult.GetValueForOption(_mappingsFileNameOption);
175+
if (!string.IsNullOrEmpty(mappingsFile))
176+
{
177+
options.SyntaxMappings = ReadMappingsFile(mappingsFile);
178+
}
179+
169180
return options;
170181
}
171182

183+
private static SyntaxMapping ReadMappingsFile(string mappingsFile)
184+
{
185+
// Let fail if cannot be read or deserialized to display the exception message in the CLI
186+
var mappingsStr = File.ReadAllText(mappingsFile);
187+
return SyntaxMapping.Deserialize(mappingsStr);
188+
}
189+
172190
private static Command CreateDirectoryCommand()
173191
{
174192
var inputArgument = new Argument<DirectoryInfo>(

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,63 @@ from the command line.
2222

2323
The core library is installable via NuGet at https://www.nuget.org/packages/JavaToCSharp/
2424

25+
## Syntax Mappings
26+
27+
By default, JavaToCSharp translates some usual Java classes and methods into their C# counterparts
28+
(e.g. Java maps are converted into dictionaries).
29+
You can specify additional mappings to fine tune the translation of the syntactic elements.
30+
31+
The mappings are specified in a yaml file with root keys that represent the kind of mapping,
32+
each having a set of key-value pairs that specify the java to C# mappings:
33+
34+
- `ImportMappings`: Mappings from Java package names to the C# namespaces.
35+
If a key-value pair has an empty value, the package will be removed from the resulting C#.
36+
- `VoidMethodMappings`: Mappings from unqualified Java void methods to C#.
37+
- `NonVoidMethodMappings`: Same as before, but for non-void java methods.
38+
- `AnnotationMappings`: Mappings from Java method annotations to C#.
39+
40+
For example, to convert *JUnit* tests into *xUnit* you can create this mapping file:
41+
```yaml
42+
ImportMappings:
43+
org.junit.Test : Xunit
44+
org.junit.Assert.assertEquals : ""
45+
org.junit.Assert.assertTrue : ""
46+
VoidMethodMappings:
47+
assertEquals : Assert.Equal
48+
assertTrue : Assert.True
49+
AnnotationMappings:
50+
Test : Fact
51+
```
52+
53+
If you specify this file in the `--mappings-file` CLI argument, the conversion of this JUnit test:
54+
```Java
55+
import static org.junit.Assert.assertEquals;
56+
import static org.junit.Assert.assertTrue;
57+
import org.junit.Test;
58+
public class MappingsTest {
59+
@Test
60+
public void testAsserts() {
61+
assertEquals("a", "a");
62+
assertTrue(true);
63+
}
64+
}
65+
```
66+
67+
will produce this xUnit test:
68+
```csharp
69+
using Xunit;
70+
71+
public class MappingsTest
72+
{
73+
[Fact]
74+
public virtual void TestAsserts()
75+
{
76+
Assert.Equal("a", "a");
77+
Assert.True(true);
78+
}
79+
}
80+
```
81+
2582
## .NET Support
2683

2784
Trunk will generally always target the latest LTS version of .NET for the core library and the CLI/GUI apps.

0 commit comments

Comments
 (0)