Skip to content

Add mappings from imports, methods and annotations to support JUnit test conversions #124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions JavaToCSharp.Tests/SyntaxMappingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using YamlDotNet.Core;

namespace JavaToCSharp.Tests;

public class SyntaxMappingTests
{
[Fact]
public void Deserialize_Mappings()
{
var mappingString = """
ImportMappings:
org.junit.Test : XUnit
java.util.List : ""
""";

var mappings = SyntaxMapping.Deserialize(mappingString);
Assert.NotNull(mappings);
Assert.Equal(2, mappings.ImportMappings.Count);
Assert.Equal("XUnit", mappings.ImportMappings["org.junit.Test"]);
Assert.Equal("", mappings.ImportMappings["java.util.List"]);
Assert.False(mappings.ImportMappings.ContainsKey("other.Clazz"));
Assert.Empty(mappings.VoidMethodMappings);
Assert.Empty(mappings.NonVoidMethodMappings);
Assert.Empty(mappings.AnnotationMappings);
}

[Fact]
public void Conversion_Options_Defaults_To_Empty_Mappings()
{
var options = new JavaConversionOptions();
Assert.Empty(options.SyntaxMappings.ImportMappings);
Assert.Empty(options.SyntaxMappings.VoidMethodMappings);
Assert.Empty(options.SyntaxMappings.NonVoidMethodMappings);
Assert.Empty(options.SyntaxMappings.AnnotationMappings);
}

[Theory]
[InlineData("VoidMethodMappings:\n org.junit.Assert.assertTrue : Assert.True")]
[InlineData("VoidMethodMappings:\n assertTrue : \"\"")]
[InlineData("NonVoidMethodMappings:\n org.junit.Assert.assertTrue : Assert.True")]
[InlineData("NonVoidMethodMappings:\n assertTrue : \"\"")]
public void Validation_Exceptions(string mappingString)
{
Assert.Throws<SemanticErrorException>(() => SyntaxMapping.Deserialize(mappingString));
}

// The use of mappings is tested using a typical JUnit4 test converted to Xunit:
// - Multiple java imports: rewritten and removed (empty value)
// - Multiple java methods: rewritten (void) and not rewritten (non void)
// - Multiple Java method annotations: rewritten and removed (no mapping)
// Not tested:
// - Qualified java method/annotation names (this would require a more elaborated handling of the scope)
// - No qualified CSharp methods (this would require CSharp static imports, that are not implemented)
// - Annotations with parameters
[Fact]
public void Conversion_With_Import_Method_And_Annotation_Mappings()
{
const string javaCode = """
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class MappingsTest {
@Test @CustomJava @NotMapped
public void testAsserts() {
assertEquals("a", "a");
assertTrue(true);
va.assertTrue(true); // non void is not mapped
}
}

""";
var mappingsYaml = """
ImportMappings:
org.junit.Test : Xunit
#to remove static imports
org.junit.Assert.assertEquals : ""
org.junit.Assert.assertTrue : ""
VoidMethodMappings:
assertEquals : Assert.Equal
assertTrue : Assert.True
AnnotationMappings:
Test : Fact
CustomJava : CustomCs

""";
const string expectedCSharpCode = """
using Xunit;

public class MappingsTest
{
[Fact]
[CustomCs]
public virtual void TestAsserts()
{
Assert.Equal("a", "a");
Assert.True(true);
va.AssertTrue(true); // non void is not mapped
}
}

""";

var parsed = GetParsed(javaCode, mappingsYaml);
Assert.Equal(expectedCSharpCode.ReplaceLineEndings(), parsed.ReplaceLineEndings());
}

private static string GetParsed(string javaCode, string mappingsYaml)
{
var mappings = SyntaxMapping.Deserialize(mappingsYaml);
var options = new JavaConversionOptions { IncludeNamespace = false, IncludeUsings = false, SyntaxMappings = mappings };
options.WarningEncountered += (_, eventArgs)
=> Console.WriteLine("Line {0}: {1}", eventArgs.JavaLineNumber, eventArgs.Message);
var parsed = JavaToCSharpConverter.ConvertText(javaCode, options) ?? "";
return parsed;
}
}
8 changes: 8 additions & 0 deletions JavaToCSharp/Declarations/MethodDeclarationVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ private static MemberDeclarationSyntax VisitInternal(
methodSyntax = methodSyntax.AddModifiers(SyntaxFactory.Token(SyntaxKind.OverrideKeyword));
isOverride = true;
}
// add annotation if a mapping is found
else if (context.Options != null && context.Options.SyntaxMappings.AnnotationMappings.TryGetValue(name, out var mappedAnnotation))
{
var attributeList = SyntaxFactory.AttributeList(
SyntaxFactory.SingletonSeparatedList(
SyntaxFactory.Attribute(SyntaxFactory.ParseName(mappedAnnotation))));
methodSyntax = methodSyntax.AddAttributeLists(attributeList);
}
}
}

Expand Down
23 changes: 23 additions & 0 deletions JavaToCSharp/Expressions/MethodCallExpressionVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ public class MethodCallExpressionVisitor : ExpressionVisitor<MethodCallExpr>
}
}

// Override methodName if a mapping is found
if (TryGetMappedMethodName(methodCallExpr.getNameAsString(), scope, context, out var mappedMethodName))
{
methodName = mappedMethodName;
}

ExpressionSyntax methodExpression;

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

return SyntaxFactory.InvocationExpression(methodExpression, TypeHelper.GetSyntaxFromArguments(context, args));
}

private static bool TryGetMappedMethodName(string methodName, Expression? scope, ConversionContext context, out string mappedMethodName)
{
var mappings = context.Options.SyntaxMappings;
if (scope == null && mappings.VoidMethodMappings.TryGetValue(methodName, out var voidMapping))
{
mappedMethodName = voidMapping;
return true;
}
else if (scope != null && mappings.NonVoidMethodMappings.TryGetValue(methodName, out var nonVoidMapping))
{
mappedMethodName = nonVoidMapping;
return true;
}
mappedMethodName = methodName;
return false;
}
}
2 changes: 2 additions & 0 deletions JavaToCSharp/JavaConversionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ public class JavaConversionOptions

public bool UseFileScopedNamespaces { get; set; }

public SyntaxMapping SyntaxMappings { get; set; } = new SyntaxMapping();

public ConversionState ConversionState { get; set; }

public JavaConversionOptions AddPackageReplacement(string pattern, string replacement, RegexOptions options = RegexOptions.None)
Expand Down
1 change: 1 addition & 0 deletions JavaToCSharp/JavaToCSharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<PackageReference Include="IKVM" Version="8.7.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />
<PackageReference Include="YamlDotNet" Version="16.0.0" />
</ItemGroup>
<ItemGroup>
<IkvmReference Include="../Lib/javaparser-core-3.25.4.jar" />
Expand Down
43 changes: 43 additions & 0 deletions JavaToCSharp/SyntaxMapping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace JavaToCSharp;

public class SyntaxMapping
{
public Dictionary<string, string> ImportMappings { get; set; } = new();
public Dictionary<string, string> VoidMethodMappings { get; set; } = new();
public Dictionary<string, string> NonVoidMethodMappings { get; set; } = new();
public Dictionary<string, string> AnnotationMappings { get; set; } = new();

public static SyntaxMapping Deserialize(string yaml)
{
var deserializer = new YamlDotNet.Serialization.Deserializer();
SyntaxMapping mapping = deserializer.Deserialize<SyntaxMapping>(yaml);
mapping.Validate();
return mapping;
}

private void Validate()
{
// Throw exception if any of the requirements are not meet
ValidateMethodMapping(VoidMethodMappings);
ValidateMethodMapping(NonVoidMethodMappings);
}
private static void ValidateMethodMapping(Dictionary<string,string> mapping)
{
// Throw exception if any of the requirements are not meet
foreach (string key in mapping.Keys)
{
if (key.Contains('.'))
{
throw new YamlDotNet.Core.SemanticErrorException("Mappings from fully qualified java methods are not supported");
}
}
foreach (string value in mapping.Values)
{
if (string.IsNullOrEmpty(value))
{
throw new YamlDotNet.Core.SemanticErrorException("Mappings from java methods can not have an empty value");
}
}
}

}
11 changes: 11 additions & 0 deletions JavaToCSharp/UsingsHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ public static IEnumerable<UsingDirectiveSyntax> GetUsings(ConversionContext cont
importName :
importName[..lastPartStartIndex];
var nameSpace = TypeHelper.Capitalize(importNameWithoutClassName);

// Override namespace if a non empty mapping is found (mapping to empty string removes the import)
if (options != null && options.SyntaxMappings.ImportMappings.TryGetValue(importName, out var mappedNamespace))
{
if (string.IsNullOrEmpty(mappedNamespace))
{
continue;
}
nameSpace = mappedNamespace;
}

var usingSyntax = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(nameSpace));

if (context.Options.IncludeComments)
Expand Down
18 changes: 18 additions & 0 deletions JavaToCSharpCli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ public class Program
ArgumentHelpName = "namespace"
};

private static readonly Option<string> _mappingsFileNameOption = new(
name: "--mappings-file",
description: "A yaml file with syntax mappings from imports, methods and annotations");

static Program()
{
_loggerFactory = LoggerFactory.Create(builder =>
Expand Down Expand Up @@ -92,6 +96,7 @@ public static async Task Main(string[] args)
rootCommand.AddGlobalOption(_fileScopedNamespacesOption);
rootCommand.AddGlobalOption(_clearDefaultUsingsOption);
rootCommand.AddGlobalOption(_addUsingsOption);
rootCommand.AddGlobalOption(_mappingsFileNameOption);

await rootCommand.InvokeAsync(args);

Expand Down Expand Up @@ -159,9 +164,22 @@ private static JavaConversionOptions GetJavaConversionOptions(InvocationContext
options.AddUsing(ns);
}

var mappingsFile = context.ParseResult.GetValueForOption(_mappingsFileNameOption);
if (!string.IsNullOrEmpty(mappingsFile))
{
options.SyntaxMappings = ReadMappingsFile(mappingsFile);
}

return options;
}

private static SyntaxMapping ReadMappingsFile(string mappingsFile)
{
// Let fail if cannot be read or deserialized to display the exception message in the CLI
var mappingsStr = File.ReadAllText(mappingsFile);
return SyntaxMapping.Deserialize(mappingsStr);
}

private static Command CreateDirectoryCommand()
{
var inputArgument = new Argument<DirectoryInfo>(
Expand Down
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,63 @@ from the command line.

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

## Syntax Mappings

By default, JavaToCSharp translates some usual Java classes and methods into their C# counterparts
(e.g. Java maps are converted into dictionaries).
You can specify additional mappings to fine tune the translation of the syntactic elements.

The mappings are specified in a yaml file with root keys that represent the kind of mapping,
each having a set of key-value pairs that specify the java to C# mappings:

- `ImportMappings`: Mappings from Java package names to the C# namespaces.
If a key-value pair has an empty value, the package will be removed from the resulting C#.
- `VoidMethodMappings`: Mappings from unqualified Java void methods to C#.
- `NonVoidMethodMappings`: Same as before, but for non-void java methods.
- `AnnotationMappings`: Mappings from Java method annotations to C#.

For example, to convert *JUnit* tests into *xUnit* you can create this mapping file:
```yaml
ImportMappings:
org.junit.Test : Xunit
org.junit.Assert.assertEquals : ""
org.junit.Assert.assertTrue : ""
VoidMethodMappings:
assertEquals : Assert.Equal
assertTrue : Assert.True
AnnotationMappings:
Test : Fact
```

If you specify this file in the `--mappings-file` CLI argument, the conversion of this JUnit test:
```Java
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public class MappingsTest {
@Test
public void testAsserts() {
assertEquals("a", "a");
assertTrue(true);
}
}
```

will produce this xUnit test:
```csharp
using Xunit;

public class MappingsTest
{
[Fact]
public virtual void TestAsserts()
{
Assert.Equal("a", "a");
Assert.True(true);
}
}
```

## .NET Support

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