From ea55b5a28ce5b44457aa5a7081068d2d8f530daf Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Sat, 15 Aug 2026 20:49:30 -0600 Subject: [PATCH] Support Java 15 text blocks (#64) Convert Java text blocks to C# raw string literals. TextBlockLiteralExpr derives from LiteralStringValueExpr rather than StringLiteralExpr, so the base-type walk in the visitor registry found no match and conversion threw NotImplementedException. Use asString() for the value, which already applies the text block rules: incidental whitespace is stripped and escapes (including \s and trailing backslash line continuations) are resolved. That means the value must not be unescaped again, unlike StringLiteralExpressionVisitor. The generated delimiter is one quote longer than the longest run of quotes in the content, so text containing "" still converts to valid C#. Co-Authored-By: Claude Opus 5 (1M context) --- JavaToCSharp.Tests/IntegrationTests.cs | 1 + .../Resources/Java15TextBlocks.java | 33 +++++++++++ .../VisitLiteralExpressionTests.cs | 17 ++++++ JavaToCSharp/Expressions/ExpressionVisitor.cs | 1 + .../TextBlockLiteralExpressionVisitor.cs | 57 +++++++++++++++++++ 5 files changed, 109 insertions(+) create mode 100644 JavaToCSharp.Tests/Resources/Java15TextBlocks.java create mode 100644 JavaToCSharp/Expressions/TextBlockLiteralExpressionVisitor.cs diff --git a/JavaToCSharp.Tests/IntegrationTests.cs b/JavaToCSharp.Tests/IntegrationTests.cs index ec753c8..8d121e1 100644 --- a/JavaToCSharp.Tests/IntegrationTests.cs +++ b/JavaToCSharp.Tests/IntegrationTests.cs @@ -67,6 +67,7 @@ public void GeneralUnsuccessfulConversionTest(string filePath) [InlineData("Resources/Java9PrivateInterfaceMethods.java")] [InlineData("Resources/Java10TypeInference.java")] [InlineData("Resources/Java14SwitchExpressions.java")] + [InlineData("Resources/Java15TextBlocks.java")] [InlineData("Resources/NewArrayLiteralBug.java")] [InlineData("Resources/OctalLiteralBug.java")] [InlineData("Resources/DeprecatedAnnotation.java")] diff --git a/JavaToCSharp.Tests/Resources/Java15TextBlocks.java b/JavaToCSharp.Tests/Resources/Java15TextBlocks.java new file mode 100644 index 0000000..1273cc4 --- /dev/null +++ b/JavaToCSharp.Tests/Resources/Java15TextBlocks.java @@ -0,0 +1,33 @@ +/// Expect: +/// - output: "\n hi\n\n|he said \"\"quoted\"\" ok\n|a b\tc\n" +package example; + +// https://docs.oracle.com/en/java/javase/15/language/text-blocks.html + +public class Program { + public static void main(String[] args) { + String html = """ + + hi + + """; + + // Two adjacent quotes are legal inside a text block, and require a longer + // delimiter when converted to a C# raw string literal. + String quotes = """ + he said ""quoted"" ok + """; + + // \s keeps a trailing space, a trailing backslash joins lines, and \t is a tab. + String escapes = """ + a\s\ + b\tc + """; + + System.out.print(html); + System.out.print("|"); + System.out.print(quotes); + System.out.print("|"); + System.out.print(escapes); + } +} diff --git a/JavaToCSharp.Tests/VisitLiteralExpressionTests.cs b/JavaToCSharp.Tests/VisitLiteralExpressionTests.cs index cf5aaa6..6785419 100644 --- a/JavaToCSharp.Tests/VisitLiteralExpressionTests.cs +++ b/JavaToCSharp.Tests/VisitLiteralExpressionTests.cs @@ -19,6 +19,23 @@ public void VisitLiteralExpression_String() Assert.Equal("\\r", expr?.GetFirstToken().ValueText); } + [Theory] + // The value is written as it appears in Java source, indented under the opening """. + [InlineData(" a\n b\n ", "a\nb\n")] + [InlineData(" \n hi\n \n ", "\n hi\n\n")] + // Escapes are resolved once: \t stays a tab and \\ collapses to a single backslash. + [InlineData(" tab\there \\\\ backslash\n ", "tab\there \\ backslash\n")] + // Two adjacent quotes need a four-quote delimiter in the generated C#. + [InlineData(" he said \"\"quoted\"\" ok\n ", "he said \"\"quoted\"\" ok\n")] + public void VisitLiteralExpression_TextBlock(string javaValue, string expected) + { + var expr = ExpressionVisitor.VisitExpression( + new ConversionContext(new JavaConversionOptions()), + new TextBlockLiteralExpr(javaValue)); + + Assert.Equal(expected, expr?.GetFirstToken().ValueText); + } + [Theory] [InlineData("0b10", 2)] [InlineData("0b100", 4)] diff --git a/JavaToCSharp/Expressions/ExpressionVisitor.cs b/JavaToCSharp/Expressions/ExpressionVisitor.cs index 0f26be1..9b5ab0e 100644 --- a/JavaToCSharp/Expressions/ExpressionVisitor.cs +++ b/JavaToCSharp/Expressions/ExpressionVisitor.cs @@ -48,6 +48,7 @@ static ExpressionVisitor() { typeof(MethodReferenceExpr), new MethodReferenceExpressionVisitor() }, { typeof(TypeExpr), new TypeExpressionVisitor() }, { typeof(SwitchExpr), new SwitchExpressionVisitor() }, + { typeof(TextBlockLiteralExpr), new TextBlockLiteralExpressionVisitor() }, }; } diff --git a/JavaToCSharp/Expressions/TextBlockLiteralExpressionVisitor.cs b/JavaToCSharp/Expressions/TextBlockLiteralExpressionVisitor.cs new file mode 100644 index 0000000..8e2c0fd --- /dev/null +++ b/JavaToCSharp/Expressions/TextBlockLiteralExpressionVisitor.cs @@ -0,0 +1,57 @@ +using com.github.javaparser.ast.expr; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace JavaToCSharp.Expressions; + +public class TextBlockLiteralExpressionVisitor : ExpressionVisitor +{ + protected override ExpressionSyntax Visit(ConversionContext context, TextBlockLiteralExpr expr) + { + // asString() applies the Java text block rules for us: incidental whitespace is + // stripped and escape sequences (including \s and line continuations) are resolved, + // so the result is the final string value and must not be unescaped again. + var value = expr.asString(); + + return SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + CreateRawStringLiteral(value)); + } + + private static SyntaxToken CreateRawStringLiteral(string value) + { + // C# requires the delimiter to be longer than the longest run of quotes in the + // content, so text containing "" needs at least four quotes to fence it. + var fence = new string('"', Math.Max(3, LongestQuoteRun(value) + 1)); + + // The opening fence is followed by a newline, and the closing fence sits on its own + // line. That final newline belongs to the delimiter rather than the value, so a Java + // text block ending in a newline needs the value written out verbatim before it. + var text = $"{fence}\n{value}\n{fence}"; + + return SyntaxFactory.Token( + SyntaxTriviaList.Empty, + SyntaxKind.MultiLineRawStringLiteralToken, + text, + value, + SyntaxTriviaList.Empty); + } + + private static int LongestQuoteRun(string value) + { + int longest = 0, run = 0; + + foreach (var c in value) + { + run = c == '"' ? run + 1 : 0; + + if (run > longest) + { + longest = run; + } + } + + return longest; + } +}