LineDelimiterPreserver.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.sandbox.jdt.cleanup.multifile.api;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Locale;
import java.util.Objects;
/** Restores a rewritten text file's original line delimiter without changing its encoding. */
public final class LineDelimiterPreserver {
private static final byte[] UTF_8_BOM= { (byte) 0xef, (byte) 0xbb, (byte) 0xbf };
private static final byte[] UTF_16_BE_BOM= { (byte) 0xfe, (byte) 0xff };
private static final byte[] UTF_16_LE_BOM= { (byte) 0xff, (byte) 0xfe };
private static final byte[] UTF_32_BE_BOM= { 0, 0, (byte) 0xfe, (byte) 0xff };
private static final byte[] UTF_32_LE_BOM= { (byte) 0xff, (byte) 0xfe, 0, 0 };
private enum LineDelimiter {
LF("\n"), //$NON-NLS-1$
CRLF("\r\n"), //$NON-NLS-1$
CR("\r"); //$NON-NLS-1$
private final String text;
LineDelimiter(String text) {
this.text= text;
}
private static LineDelimiter detect(String content) {
boolean foundLf= false;
boolean foundCrLf= false;
boolean foundCr= false;
for (int index= 0; index < content.length(); index++) {
char current= content.charAt(index);
if (current == '\r') {
if (index + 1 < content.length() && content.charAt(index + 1) == '\n') {
foundCrLf= true;
index++;
} else {
foundCr= true;
}
} else if (current == '\n') {
foundLf= true;
}
}
int styleCount= (foundLf ? 1 : 0) + (foundCrLf ? 1 : 0) + (foundCr ? 1 : 0);
if (styleCount != 1) {
return null;
}
if (foundCrLf) {
return CRLF;
}
return foundLf ? LF : CR;
}
}
private record Encoding(Charset charset, byte[] byteOrderMark) {
private Encoding {
byteOrderMark= byteOrderMark.clone();
}
@Override
public byte[] byteOrderMark() {
return byteOrderMark.clone();
}
}
private LineDelimiterPreserver() {
}
/**
* Returns rewritten bytes with the original file's single line-delimiter style,
* declared character set and byte-order mark. Files without one unambiguous
* original delimiter are returned unchanged.
*
* @param original original file bytes
* @param rewritten bytes produced by the rewrite
* @param declaredCharset Eclipse file character set
* @return rewritten bytes with original text representation preserved
* @throws IOException if either byte sequence cannot be decoded or encoded safely
*/
public static byte[] preserve(byte[] original, byte[] rewritten, String declaredCharset) throws IOException {
Objects.requireNonNull(original, "original"); //$NON-NLS-1$
Objects.requireNonNull(rewritten, "rewritten"); //$NON-NLS-1$
Objects.requireNonNull(declaredCharset, "declaredCharset"); //$NON-NLS-1$
Charset declared;
try {
declared= Charset.forName(declaredCharset);
} catch (IllegalArgumentException e) {
throw new IOException("Unsupported source character set: " + declaredCharset, e); //$NON-NLS-1$
}
Encoding encoding= encodingOf(original, declared);
String originalText= decode(original, encoding, "original"); //$NON-NLS-1$
LineDelimiter delimiter= LineDelimiter.detect(originalText);
if (delimiter == null) {
return rewritten;
}
String rewrittenText= decode(rewritten, encoding, "rewritten"); //$NON-NLS-1$
String normalized= normalize(rewrittenText, delimiter.text);
byte[] encoded= encode(normalized, encoding);
return Arrays.equals(encoded, rewritten) ? rewritten : encoded;
}
private static Encoding encodingOf(byte[] content, Charset declared) throws IOException {
Bom bom= Bom.detect(content);
if (bom != null) {
if (!compatible(declared, bom.charset())) {
throw new IOException("Source byte-order mark " + bom.charset().name() //$NON-NLS-1$
+ " conflicts with declared character set " + declared.name()); //$NON-NLS-1$
}
return new Encoding(bom.charset(), bom.bytes());
}
String name= declared.name().toUpperCase(Locale.ROOT);
if ("UTF-16".equals(name)) { //$NON-NLS-1$
return new Encoding(StandardCharsets.UTF_16BE, new byte[0]);
}
if ("UTF-32".equals(name)) { //$NON-NLS-1$
return new Encoding(Charset.forName("UTF-32BE"), new byte[0]); //$NON-NLS-1$
}
return new Encoding(declared, new byte[0]);
}
private static boolean compatible(Charset declared, Charset encoded) {
String declaredName= declared.name().toUpperCase(Locale.ROOT);
String encodedName= encoded.name().toUpperCase(Locale.ROOT);
return declaredName.equals(encodedName)
|| "UTF-16".equals(declaredName) && encodedName.startsWith("UTF-16") //$NON-NLS-1$ //$NON-NLS-2$
|| "UTF-32".equals(declaredName) && encodedName.startsWith("UTF-32"); //$NON-NLS-1$ //$NON-NLS-2$
}
private static String decode(byte[] content, Encoding encoding, String label) throws IOException {
int offset= bomOffset(content, encoding);
try {
return encoding.charset().newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(content, offset, content.length - offset))
.toString();
} catch (CharacterCodingException e) {
throw new IOException("Cannot decode " + label + " source as " + encoding.charset().name(), e); //$NON-NLS-1$ //$NON-NLS-2$
}
}
private static int bomOffset(byte[] content, Encoding encoding) throws IOException {
Bom actual= Bom.detect(content);
if (actual == null) {
return 0;
}
if (!actual.charset().equals(encoding.charset())) {
throw new IOException("Rewritten source changed byte-order mark from " //$NON-NLS-1$
+ encoding.charset().name() + " to " + actual.charset().name()); //$NON-NLS-1$
}
return actual.bytes().length;
}
private static byte[] encode(String content, Encoding encoding) throws IOException {
try {
ByteBuffer bytes= encoding.charset().newEncoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.encode(CharBuffer.wrap(content));
byte[] bom= encoding.byteOrderMark();
int encodedLength= bytes.remaining();
byte[] result= new byte[bom.length + encodedLength];
System.arraycopy(bom, 0, result, 0, bom.length);
bytes.get(result, bom.length, encodedLength);
return result;
} catch (CharacterCodingException e) {
throw new IOException("Cannot encode rewritten source as " + encoding.charset().name(), e); //$NON-NLS-1$
}
}
private static String normalize(String content, String delimiter) {
StringBuilder normalized= new StringBuilder(content.length() + 64);
for (int index= 0; index < content.length(); index++) {
char current= content.charAt(index);
if (current == '\r') {
if (index + 1 < content.length() && content.charAt(index + 1) == '\n') {
index++;
}
normalized.append(delimiter);
} else if (current == '\n') {
normalized.append(delimiter);
} else {
normalized.append(current);
}
}
return normalized.toString();
}
private record Bom(Charset charset, byte[] bytes) {
private Bom {
bytes= bytes.clone();
}
@Override
public byte[] bytes() {
return bytes.clone();
}
private static Bom detect(byte[] content) {
if (startsWith(content, UTF_32_BE_BOM)) {
return new Bom(Charset.forName("UTF-32BE"), UTF_32_BE_BOM); //$NON-NLS-1$
}
if (startsWith(content, UTF_32_LE_BOM)) {
return new Bom(Charset.forName("UTF-32LE"), UTF_32_LE_BOM); //$NON-NLS-1$
}
if (startsWith(content, UTF_8_BOM)) {
return new Bom(StandardCharsets.UTF_8, UTF_8_BOM);
}
if (startsWith(content, UTF_16_BE_BOM)) {
return new Bom(StandardCharsets.UTF_16BE, UTF_16_BE_BOM);
}
if (startsWith(content, UTF_16_LE_BOM)) {
return new Bom(StandardCharsets.UTF_16LE, UTF_16_LE_BOM);
}
return null;
}
}
private static boolean startsWith(byte[] content, byte[] prefix) {
if (content.length < prefix.length) {
return false;
}
for (int index= 0; index < prefix.length; index++) {
if (content[index] != prefix[index]) {
return false;
}
}
return true;
}
}