CharsetConstructorMigration.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer and others.
 *
 * 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.triggerpattern.cleanup;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTMatcher;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.ParenthesizedExpression;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.jdt.internal.corext.refactoring.structure.CompilationUnitRewrite;
import org.eclipse.text.edits.TextEditGroup;

/** Shared Charset expression rewrites and constructor exception planning. */
public final class CharsetConstructorMigration {
    private static final String INSTALLED= CharsetConstructorMigration.class.getName();
    private static final Set<String> CHARSETS= Set.of("UTF-8", "UTF-16", "UTF-16BE", "UTF-16LE", "US-ASCII", "ISO-8859-1"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$
    private CharsetConstructorMigration() { }

    /** Collect only constructors whose actual Charset overload adds IOException. */
    public static CheckedExceptionMigration.Plan plan(Collection<CompilationUnit> roots) throws CoreException {
        return plan(roots, new NullProgressMonitor());
    }

    public static CheckedExceptionMigration.Plan plan(Collection<CompilationUnit> roots, IProgressMonitor monitor) throws CoreException {
        List<ClassInstanceCreation> changes= new ArrayList<>();
        ITypeBinding[] added= { null };
        for (CompilationUnit root : roots) {
            if (root.getJavaElement() == null || !org.eclipse.jdt.internal.corext.util.JavaModelUtil.is10OrHigher(root.getJavaElement().getJavaProject())) continue;
            root.accept(new ASTVisitor() {
            @Override public boolean visit(ClassInstanceCreation node) {
                IMethodBinding target= target(node);
                if (target != null && convertible(node)) {
                    for (ITypeBinding exception : target.getExceptionTypes()) {
                        if ("java.io.IOException".equals(exception.getQualifiedName())) { //$NON-NLS-1$
                            added[0]= exception;
                            changes.add(node);
                        }
                    }
                }
                return true;
            }
            });
        }
        return changes.isEmpty() ? null : CheckedExceptionMigration.plan(roots, changes, added[0], "java.io.FileNotFoundException", monitor); //$NON-NLS-1$
    }

    public static void install(CompilationUnitRewrite rewrite, CheckedExceptionMigration.Plan plan) {
        rewrite.getASTRewrite().setProperty(INSTALLED, plan);
    }

    /** Used by the standalone DSL path as well as the encoding cleanup. */
    public static void adapt(ASTNode original, ASTNode replacement, String replacementText, CompilationUnitRewrite rewrite, TextEditGroup group) throws CoreException {
        if (rewrite.getASTRewrite().getProperty(INSTALLED) != null) return;
        if (!(original instanceof ClassInstanceCreation creation) || !(replacement instanceof ClassInstanceCreation)) return;
        IMethodBinding target= target(creation);
        if (target == null || !usesTargetOverload(creation, target, replacementText)) return;
        for (ITypeBinding exception : target.getExceptionTypes()) {
            if ("java.io.IOException".equals(exception.getQualifiedName())) { //$NON-NLS-1$
                CheckedExceptionMigration.plan(List.of(rewrite.getRoot()), List.of(original), exception,
                        "java.io.FileNotFoundException").apply(rewrite, group); //$NON-NLS-1$
            }
        }
    }

    /** Use AST edits and the shared NLS finisher, never a textual enclosing try. */
    static boolean rewriteExpression(ASTNode original, ASTNode replacement, String text,
            CompilationUnitRewrite rewrite, TextEditGroup group) {
        if (rewriteCharsetLookup(original, replacement, text, rewrite, group)) return true;
        if (!(original instanceof ClassInstanceCreation creation) || !(replacement instanceof ClassInstanceCreation copy)) return false;
        if (rewriteStringConstructor(creation, copy, rewrite, group)) return true;
        IMethodBinding target= target(creation);
        if (target == null || !usesTargetOverload(creation, target, text)) return false;
        if (creation.arguments().size() > 1 && creation.arguments().get(1) instanceof StringLiteral literal)
            EncodingSourceRewrite.record(rewrite, literal);
        copy.arguments().set(0, rewrite.getASTRewrite().createMoveTarget((ASTNode) creation.arguments().get(0)));
        rewrite.getASTRewrite().replace(original, copy, group);
        return true;
    }

    /** Leaf replacements compose; replacing the enclosing statement would lose sibling lookups. */
    private static boolean rewriteCharsetLookup(ASTNode original, ASTNode replacement, String text,
            CompilationUnitRewrite rewrite, TextEditGroup group) {
        if (!(original instanceof MethodInvocation call) || !(replacement instanceof QualifiedName)
                || call.arguments().size() != 1 || !(call.arguments().get(0) instanceof StringLiteral literal)) return false;
        IMethodBinding binding= call.resolveMethodBinding();
        if (binding == null || binding.isRecovered() || !"forName".equals(binding.getName()) //$NON-NLS-1$
                || !"java.nio.charset.Charset".equals(binding.getDeclaringClass().getQualifiedName()) //$NON-NLS-1$
                || binding.getParameterTypes().length != 1
                || !"java.lang.String".equals(binding.getParameterTypes()[0].getQualifiedName())) return false; //$NON-NLS-1$
        String charset= literal.getLiteralValue().toUpperCase(Locale.ROOT);
        if (!CHARSETS.contains(charset)
                || !("java.nio.charset.StandardCharsets." + charset.replace('-', '_')).equals(text.strip())) return false; //$NON-NLS-1$
        EncodingSourceRewrite.record(rewrite, literal);
        rewrite.getASTRewrite().replace(original, replacement, group);
        return true;
    }

    /** Preserve sibling and nested edits instead of replacing their entire statement for NLS. */
    private static boolean rewriteStringConstructor(ClassInstanceCreation original, ClassInstanceCreation copy,
            CompilationUnitRewrite rewrite, TextEditGroup group) {
        IMethodBinding binding= original.resolveConstructorBinding();
        if (binding == null || binding.isRecovered()
                || !"java.lang.String".equals(binding.getDeclaringClass().getQualifiedName())) return false; //$NON-NLS-1$
        ITypeBinding[] parameters= binding.getParameterTypes();
        int count= parameters.length;
        if ((count != 2 && count != 4) || copy.arguments().size() != count
                || !"byte[]".equals(parameters[0].getQualifiedName()) //$NON-NLS-1$
                || !"java.lang.String".equals(parameters[count - 1].getQualifiedName()) //$NON-NLS-1$
                || TypeChangeDetector.detectCharsetTypeChange(original, copy) == null) return false;
        String type= copy.getType().toString();
        if (!type.equals(original.getType().toString()) && !"java.lang.String".equals(type)) return false; //$NON-NLS-1$
        for (int index= 0; index < count - 1; index++) {
            if (!((ASTNode) original.arguments().get(index)).subtreeMatch(new ASTMatcher(), copy.arguments().get(index))) return false;
        }
        EncodingSourceRewrite.record(rewrite, (ASTNode) original.arguments().get(count - 1));
        for (int index= 0; index < count - 1; index++) {
            copy.arguments().set(index, rewrite.getASTRewrite().createMoveTarget((ASTNode) original.arguments().get(index)));
        }
        rewrite.getASTRewrite().replace(original, copy, group);
        return true;
    }

    private static boolean usesTargetOverload(ClassInstanceCreation original, IMethodBinding target, String text) {
        ASTParser parser= ASTParser.newParser(AST.getJLSLatest());
        parser.setKind(ASTParser.K_EXPRESSION);
        parser.setSource(text.toCharArray());
        ASTNode parsed= parser.createAST(null);
        if (!(parsed instanceof ClassInstanceCreation call) || (parsed.getFlags() & (ASTNode.MALFORMED | ASTNode.RECOVERED)) != 0
                || call.arguments().size() != target.getParameterTypes().length) return false;
        String name= call.getType().toString();
        if (!name.equals(target.getDeclaringClass().getQualifiedName()) && !name.equals(original.getType().toString())) return false;
        if (!((ASTNode) original.arguments().get(0)).subtreeMatch(new ASTMatcher(), call.arguments().get(0))) return false;
        Expression argument= (Expression) call.arguments().get(1);
        while (argument instanceof ParenthesizedExpression parentheses) argument= parentheses.getExpression();
        // Inspect the unshortened recipe, before ImportRewrite introduces simple names.
        // A .name() call is deliberately not a typed Charset argument.
        if (argument instanceof QualifiedName field) return "java.nio.charset.StandardCharsets".equals(field.getQualifier().getFullyQualifiedName()) //$NON-NLS-1$
                && CHARSETS.contains(field.getName().getIdentifier().replace('_', '-'));
        return argument instanceof MethodInvocation method && method.arguments().isEmpty()
                && "defaultCharset".equals(method.getName().getIdentifier()) //$NON-NLS-1$
                && method.getExpression() instanceof Name owner && "java.nio.charset.Charset".equals(owner.getFullyQualifiedName()); //$NON-NLS-1$
    }

    private static boolean convertible(ClassInstanceCreation node) {
        return node.arguments().size() == 1 || node.arguments().get(1) instanceof StringLiteral literal
                && CHARSETS.contains(literal.getLiteralValue().toUpperCase(Locale.ROOT));
    }

    private static IMethodBinding target(ClassInstanceCreation node) {
        IMethodBinding old= node.resolveConstructorBinding();
        if (old == null || old.isRecovered() || node.getAnonymousClassDeclaration() != null) return null;
        ITypeBinding[] parameters= old.getParameterTypes();
        if (parameters.length == 0 || parameters.length > 3) return null;
        String owner= old.getDeclaringClass().getQualifiedName();
        String source= parameters[0].getQualifiedName();
        if (!("java.util.Scanner".equals(owner) && "java.io.File".equals(source)) //$NON-NLS-1$ //$NON-NLS-2$
                && !("java.util.Formatter".equals(owner) && ("java.io.File".equals(source) || "java.lang.String".equals(source)))) return null; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
        if (parameters.length > 1 && !"java.lang.String".equals(parameters[1].getQualifiedName())) return null; //$NON-NLS-1$
        for (IMethodBinding method : old.getDeclaringClass().getDeclaredMethods()) {
            ITypeBinding[] types= method.getParameterTypes();
            if (method.isConstructor() && types.length == ("java.util.Scanner".equals(owner) ? 2 : 3) //$NON-NLS-1$
                    && source.equals(types[0].getQualifiedName()) && "java.nio.charset.Charset".equals(types[1].getQualifiedName())) return method; //$NON-NLS-1$
        }
        return null;
    }
}