From 727934747bcacd5cbc5e3d93f55f7d3f669b97b2 Mon Sep 17 00:00:00 2001 From: martinfrancois Date: Wed, 12 Aug 2026 19:53:33 +0200 Subject: [PATCH 1/3] Preserve escaped backslash-newline text in UsePortableNewlines --- .../staticanalysis/UsePortableNewlines.java | 96 ++++++++++- .../UsePortableNewlinesTest.java | 161 ++++++++++++++++++ 2 files changed, 252 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java b/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java index 37c4388b7..b02b5edbe 100644 --- a/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java +++ b/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java @@ -29,6 +29,8 @@ import org.openrewrite.java.tree.J; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Set; import static java.util.Collections.singleton; @@ -93,11 +95,95 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { if (literal.getValue() instanceof String && literal.getValueSource() != null) { String source = literal.getValueSource(); String value = (String) literal.getValue(); - // Check if the source contains the escape sequence \n - if (source.contains("\\n")) { - return literal - .withValue(value.replace("\n", "%n")) - .withValueSource(source.replace("\\n", "%n")); + StringBuilder translatedSource = new StringBuilder(source.length()); + List rawStarts = new ArrayList<>(); + List rawEnds = new ArrayList<>(); + int translatedBackslashes = 0; + for (int i = 0; i < source.length();) { + int rawStart = i; + char translated = source.charAt(i++); + if (translated == '\\') { + int unicode = i; + while (unicode < source.length() && source.charAt(unicode) == 'u') { + unicode++; + } + if (translatedBackslashes % 2 == 0 && unicode > i && unicode + 4 <= source.length()) { + try { + translated = (char) Integer.parseInt(source.substring(unicode, unicode + 4), 16); + i = unicode + 4; + } catch (NumberFormatException ignored) { + // Keep the raw backslash; an invalid Unicode escape is not valid Java source. + } + } + } + translatedSource.append(translated); + translatedBackslashes = translated == '\\' ? translatedBackslashes + 1 : 0; + rawStarts.add(rawStart); + rawEnds.add(i); + } + + List replacements = new ArrayList<>(); + List replacedNewlines = new ArrayList<>(); + boolean textBlock = translatedSource.toString().startsWith("\"\"\""); + boolean openingTextBlockLine = textBlock; + int newlineIndex = 0; + int consecutiveBackslashes = 0; + for (int i = textBlock ? 3 : 1; i < translatedSource.length(); i++) { + char current = translatedSource.charAt(i); + if (current == '\\') { + consecutiveBackslashes++; + continue; + } + if (current == 'n' && consecutiveBackslashes % 2 == 1) { + replacements.add(new int[]{rawStarts.get(i - 1), rawEnds.get(i)}); + replacedNewlines.add(newlineIndex++); + } else if (consecutiveBackslashes % 2 == 1 && current >= '0' && current <= '7') { + int octal = current - '0'; + int maxDigits = current <= '3' ? 3 : 2; + int digits = 1; + while (digits < maxDigits && i + 1 < translatedSource.length()) { + char next = translatedSource.charAt(i + 1); + if (next < '0' || next > '7') { + break; + } + octal = octal * 8 + next - '0'; + digits++; + i++; + } + if (octal == '\n') { + newlineIndex++; + } + } else if (textBlock && (current == '\n' || current == '\r')) { + boolean crlf = current == '\r' && i + 1 < translatedSource.length() && + translatedSource.charAt(i + 1) == '\n'; + if (openingTextBlockLine) { + openingTextBlockLine = false; + } else if (consecutiveBackslashes % 2 == 0) { + newlineIndex++; + } + if (crlf) { + i++; + } + } + consecutiveBackslashes = 0; + } + if (!replacedNewlines.isEmpty()) { + StringBuilder transformedSource = new StringBuilder(source); + for (int i = replacements.size() - 1; i >= 0; i--) { + int[] replacement = replacements.get(i); + transformedSource.replace(replacement[0], replacement[1], "%n"); + } + StringBuilder transformedValue = new StringBuilder(value.length()); + newlineIndex = 0; + for (int i = 0; i < value.length(); i++) { + char current = value.charAt(i); + if (current == '\n' && replacedNewlines.contains(newlineIndex++)) { + transformedValue.append("%n"); + } else { + transformedValue.append(current); + } + } + return literal.withValue(transformedValue.toString()).withValueSource(transformedSource.toString()); } } } diff --git a/src/test/java/org/openrewrite/staticanalysis/UsePortableNewlinesTest.java b/src/test/java/org/openrewrite/staticanalysis/UsePortableNewlinesTest.java index dd6ef17b0..8d77baf72 100644 --- a/src/test/java/org/openrewrite/staticanalysis/UsePortableNewlinesTest.java +++ b/src/test/java/org/openrewrite/staticanalysis/UsePortableNewlinesTest.java @@ -291,4 +291,165 @@ void test(String name) { ) ); } + + @Test + void doesNotCorruptEscapedBackslashNewlineInFormattedTextBlock() { + rewriteRun( + //language=java + java( + """ + class Test { + String script(String payload) { + return \""" + printf '%%s\\\\n' '%s' + \""".formatted(payload); + } + } + """ + ) + ); + } + + @Test + void replaceNewlineWithoutChangingEscapedBackslashNewline() { + rewriteRun( + //language=java + java( + """ + class Test { + String message(String value) { + return String.format("line=%s\\nscript=printf '%%s\\\\n'", value); + } + } + """, + """ + class Test { + String message(String value) { + return String.format("line=%s%nscript=printf '%%s\\\\n'", value); + } + } + """ + ) + ); + } + + @Test + void accountForUnicodeEscapedBackslash() { + rewriteRun( + //language=java + java( + """ + class Test { + String message() { + return String.format("\\u005c\\\\n"); + } + } + """, + """ + class Test { + String message() { + return String.format("\\u005c\\%n"); + } + } + """ + ) + ); + } + + @Test + void accountForCrLfTextBlockContinuation() { + rewriteRun( + //language=java + java( + """ + class Test { + String message() { + return \""" + foo\\ + bar\\n + \""".formatted(); + } + } + """.replace("\n", "\r\n"), + """ + class Test { + String message() { + return \""" + foo\\ + bar%n + \""".formatted(); + } + } + """.replace("\n", "\r\n") + ) + ); + } + + @Test + void accountForOctalLineFeedBeforeNewlineEscape() { + rewriteRun( + //language=java + java( + """ + class Test { + String message() { + return String.format("\\12\\n"); + } + } + """, + """ + class Test { + String message() { + return String.format("\\12%n"); + } + } + """ + ) + ); + } + + @Test + void detectUnicodeEscapedTextBlockDelimiter() { + rewriteRun( + //language=java + java( + """ + class Test { + String message() { + return String.format(\\u0022\\u0022\\u0022 + first line + second line\\n + \\u0022\\u0022\\u0022); + } + } + """, + """ + class Test { + String message() { + return String.format(\\u0022\\u0022\\u0022 + first line + second line%n + \\u0022\\u0022\\u0022); + } + } + """ + ) + ); + } + + @Test + void accountForUnicodeEligibilityAfterTranslatedBackslashes() { + rewriteRun( + //language=java + java( + """ + class Test { + String message() { + return String.format("§u005c§§§u006e"); + } + } + """.replace("§", "\\") + ) + ); + } } From 8d8528552511e1279b1f6882a1d298d1a0c650a5 Mon Sep 17 00:00:00 2001 From: martinfrancois Date: Sun, 16 Aug 2026 22:58:20 +0200 Subject: [PATCH 2/3] Name escape-parsing constants and helpers for readability --- .../staticanalysis/UsePortableNewlines.java | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java b/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java index b02b5edbe..ea45a9d74 100644 --- a/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java +++ b/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java @@ -39,6 +39,13 @@ @EqualsAndHashCode(callSuper = false) public class UsePortableNewlines extends Recipe { + private static final String PORTABLE_NEWLINE = "%n"; + private static final String TEXT_BLOCK_DELIMITER = "\"\"\""; + private static final String STRING_DELIMITER = "\""; + private static final int UNICODE_ESCAPE_HEX_DIGITS = 4; + private static final int HEX_RADIX = 16; + private static final int OCTAL_RADIX = 8; + private static final MethodMatcher STRING_FORMATTED = new MethodMatcher("java.lang.String formatted(..)"); private static final MethodMatcher STRING_FORMAT = new MethodMatcher("java.lang.String format(java.lang.String, ..)"); @@ -103,14 +110,15 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { int rawStart = i; char translated = source.charAt(i++); if (translated == '\\') { - int unicode = i; - while (unicode < source.length() && source.charAt(unicode) == 'u') { - unicode++; + int hexStart = i; + while (hexStart < source.length() && source.charAt(hexStart) == 'u') { + hexStart++; } - if (translatedBackslashes % 2 == 0 && unicode > i && unicode + 4 <= source.length()) { + // JLS 3.3: a backslash starts a Unicode escape only when preceded by an even number of backslashes + if (translatedBackslashes % 2 == 0 && hexStart > i && hexStart + UNICODE_ESCAPE_HEX_DIGITS <= source.length()) { try { - translated = (char) Integer.parseInt(source.substring(unicode, unicode + 4), 16); - i = unicode + 4; + translated = (char) Integer.parseInt(source.substring(hexStart, hexStart + UNICODE_ESCAPE_HEX_DIGITS), HEX_RADIX); + i = hexStart + UNICODE_ESCAPE_HEX_DIGITS; } catch (NumberFormatException ignored) { // Keep the raw backslash; an invalid Unicode escape is not valid Java source. } @@ -124,29 +132,31 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { List replacements = new ArrayList<>(); List replacedNewlines = new ArrayList<>(); - boolean textBlock = translatedSource.toString().startsWith("\"\"\""); + boolean textBlock = translatedSource.toString().startsWith(TEXT_BLOCK_DELIMITER); boolean openingTextBlockLine = textBlock; int newlineIndex = 0; int consecutiveBackslashes = 0; - for (int i = textBlock ? 3 : 1; i < translatedSource.length(); i++) { + for (int i = (textBlock ? TEXT_BLOCK_DELIMITER : STRING_DELIMITER).length(); i < translatedSource.length(); i++) { char current = translatedSource.charAt(i); if (current == '\\') { consecutiveBackslashes++; continue; } - if (current == 'n' && consecutiveBackslashes % 2 == 1) { + // An odd run of preceding backslashes means the current character is escaped + boolean escaped = consecutiveBackslashes % 2 == 1; + if (current == 'n' && escaped) { replacements.add(new int[]{rawStarts.get(i - 1), rawEnds.get(i)}); replacedNewlines.add(newlineIndex++); - } else if (consecutiveBackslashes % 2 == 1 && current >= '0' && current <= '7') { + } else if (escaped && isOctalDigit(current)) { int octal = current - '0'; - int maxDigits = current <= '3' ? 3 : 2; + int maxDigits = maxOctalEscapeDigits(current); int digits = 1; while (digits < maxDigits && i + 1 < translatedSource.length()) { char next = translatedSource.charAt(i + 1); - if (next < '0' || next > '7') { + if (!isOctalDigit(next)) { break; } - octal = octal * 8 + next - '0'; + octal = octal * OCTAL_RADIX + next - '0'; digits++; i++; } @@ -158,7 +168,8 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { translatedSource.charAt(i + 1) == '\n'; if (openingTextBlockLine) { openingTextBlockLine = false; - } else if (consecutiveBackslashes % 2 == 0) { + } else if (!escaped) { + // An escaped line terminator is a text-block continuation, which produces no newline newlineIndex++; } if (crlf) { @@ -171,14 +182,14 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { StringBuilder transformedSource = new StringBuilder(source); for (int i = replacements.size() - 1; i >= 0; i--) { int[] replacement = replacements.get(i); - transformedSource.replace(replacement[0], replacement[1], "%n"); + transformedSource.replace(replacement[0], replacement[1], PORTABLE_NEWLINE); } StringBuilder transformedValue = new StringBuilder(value.length()); newlineIndex = 0; for (int i = 0; i < value.length(); i++) { char current = value.charAt(i); if (current == '\n' && replacedNewlines.contains(newlineIndex++)) { - transformedValue.append("%n"); + transformedValue.append(PORTABLE_NEWLINE); } else { transformedValue.append(current); } @@ -189,4 +200,13 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { } return maybeLiteral; } + + private static boolean isOctalDigit(char c) { + return '0' <= c && c <= '7'; + } + + // JLS 3.10.6: octal escapes are at most \377, so a third digit is only allowed after a leading 0-3 + private static int maxOctalEscapeDigits(char firstOctalDigit) { + return firstOctalDigit <= '3' ? 3 : 2; + } } From d7a0937e8781d0c495ced430bcd290b5f2a677c1 Mon Sep 17 00:00:00 2001 From: martinfrancois Date: Sun, 16 Aug 2026 23:41:18 +0200 Subject: [PATCH 3/3] Name the escape characters driving the escape parser --- .../staticanalysis/UsePortableNewlines.java | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java b/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java index ea45a9d74..2b15091e0 100644 --- a/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java +++ b/src/main/java/org/openrewrite/staticanalysis/UsePortableNewlines.java @@ -45,6 +45,11 @@ public class UsePortableNewlines extends Recipe { private static final int UNICODE_ESCAPE_HEX_DIGITS = 4; private static final int HEX_RADIX = 16; private static final int OCTAL_RADIX = 8; + private static final char BACKSLASH = '\\'; + private static final char UNICODE_ESCAPE_MARKER = 'u'; + private static final char NEWLINE_ESCAPE_LETTER = 'n'; + private static final char LINE_FEED = '\n'; + private static final char CARRIAGE_RETURN = '\r'; private static final MethodMatcher STRING_FORMATTED = new MethodMatcher("java.lang.String formatted(..)"); @@ -109,9 +114,9 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { for (int i = 0; i < source.length();) { int rawStart = i; char translated = source.charAt(i++); - if (translated == '\\') { + if (translated == BACKSLASH) { int hexStart = i; - while (hexStart < source.length() && source.charAt(hexStart) == 'u') { + while (hexStart < source.length() && source.charAt(hexStart) == UNICODE_ESCAPE_MARKER) { hexStart++; } // JLS 3.3: a backslash starts a Unicode escape only when preceded by an even number of backslashes @@ -125,7 +130,7 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { } } translatedSource.append(translated); - translatedBackslashes = translated == '\\' ? translatedBackslashes + 1 : 0; + translatedBackslashes = translated == BACKSLASH ? translatedBackslashes + 1 : 0; rawStarts.add(rawStart); rawEnds.add(i); } @@ -138,13 +143,13 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { int consecutiveBackslashes = 0; for (int i = (textBlock ? TEXT_BLOCK_DELIMITER : STRING_DELIMITER).length(); i < translatedSource.length(); i++) { char current = translatedSource.charAt(i); - if (current == '\\') { + if (current == BACKSLASH) { consecutiveBackslashes++; continue; } // An odd run of preceding backslashes means the current character is escaped boolean escaped = consecutiveBackslashes % 2 == 1; - if (current == 'n' && escaped) { + if (current == NEWLINE_ESCAPE_LETTER && escaped) { replacements.add(new int[]{rawStarts.get(i - 1), rawEnds.get(i)}); replacedNewlines.add(newlineIndex++); } else if (escaped && isOctalDigit(current)) { @@ -160,12 +165,12 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { digits++; i++; } - if (octal == '\n') { + if (octal == LINE_FEED) { newlineIndex++; } - } else if (textBlock && (current == '\n' || current == '\r')) { - boolean crlf = current == '\r' && i + 1 < translatedSource.length() && - translatedSource.charAt(i + 1) == '\n'; + } else if (textBlock && (current == LINE_FEED || current == CARRIAGE_RETURN)) { + boolean crlf = current == CARRIAGE_RETURN && i + 1 < translatedSource.length() && + translatedSource.charAt(i + 1) == LINE_FEED; if (openingTextBlockLine) { openingTextBlockLine = false; } else if (!escaped) { @@ -188,7 +193,7 @@ private static Expression replaceNewlineInLiteral(Expression maybeLiteral) { newlineIndex = 0; for (int i = 0; i < value.length(); i++) { char current = value.charAt(i); - if (current == '\n' && replacedNewlines.contains(newlineIndex++)) { + if (current == LINE_FEED && replacedNewlines.contains(newlineIndex++)) { transformedValue.append(PORTABLE_NEWLINE); } else { transformedValue.append(current);