CheckedExceptionAnalysis.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.Collection;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Set;

import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.CatchClause;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.ConstructorInvocation;
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.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.RecordDeclaration;
import org.eclipse.jdt.core.dom.SuperConstructorInvocation;
import org.eclipse.jdt.core.dom.SuperMethodInvocation;
import org.eclipse.jdt.core.dom.ThrowStatement;
import org.eclipse.jdt.core.dom.TryStatement;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.UnionType;
import org.eclipse.jdt.core.dom.VariableDeclarationExpression;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.internal.corext.dom.Bindings;
import org.eclipse.jdt.internal.corext.refactoring.util.AbstractExceptionAnalyzer;

/** Remaining exception flow, excluding only the converted invocation's own effect. */
final class CheckedExceptionAnalysis extends AbstractExceptionAnalyzer {
    private final Set<ASTNode> converted;
    private final ITypeBinding target;
    private final ASTRewrite rewrite;
    private boolean uncertain;

    private CheckedExceptionAnalysis(Set<ASTNode> converted, ITypeBinding target, ASTRewrite rewrite) {
        this.converted= converted;
        this.target= target;
        this.rewrite= rewrite;
    }

    @SuppressWarnings("unchecked")
    static boolean canRemove(ASTNode scope, ASTNode visited, String exceptionFQN, ASTRewrite rewrite) {
        String key= CheckedExceptionAnalysis.class.getName() + ':' + exceptionFQN;
        Set<ASTNode> converted= (Set<ASTNode>) rewrite.getProperty(key);
        if (converted == null) {
            converted= Collections.newSetFromMap(new IdentityHashMap<>());
            rewrite.setProperty(key, converted);
        }
        ASTNode invocation= visited;
        while (invocation != null && !(invocation instanceof MethodInvocation)
                && !(invocation instanceof ClassInstanceCreation) && !(invocation instanceof SuperMethodInvocation)
                && !(invocation instanceof ConstructorInvocation) && !(invocation instanceof SuperConstructorInvocation)) {
            invocation= invocation.getParent();
        }
        if (invocation == null) return false;
        converted.add(invocation);
        List<Type> types= scope instanceof MethodDeclaration method ? method.thrownExceptionTypes()
                : ((List<CatchClause>) ((TryStatement) scope).catchClauses()).stream()
                        .flatMap(c -> c.getException().getType() instanceof UnionType union
                                ? ((List<Type>) union.types()).stream() : java.util.stream.Stream.of(c.getException().getType()))
                        .toList();
        ITypeBinding target= types.stream().map(Type::resolveBinding)
                .filter(t -> t != null && !t.isRecovered() && exceptionFQN.equals(t.getErasure().getQualifiedName()))
                .findFirst().orElse(null);
        if (target == null) return false;
        CheckedExceptionAnalysis analysis= new CheckedExceptionAnalysis(converted, target, rewrite);
        if (scope instanceof TryStatement statement) {
            statement.getBody().accept(analysis);
            for (Object resource : statement.resources()) ((ASTNode) resource).accept(analysis);
        } else if (((MethodDeclaration) scope).getBody() != null) {
            ((MethodDeclaration) scope).getBody().accept(analysis);
        }
        return !analysis.uncertain && analysis.getCurrentExceptions().stream()
                .noneMatch(t -> t.isAssignmentCompatible(target) || target.isAssignmentCompatible(t));
    }

    /** A wider handler must not intercept effects from unrelated surviving expressions. */
    static boolean canWidenCatch(TryStatement statement, CatchClause clause,
            Collection<? extends ASTNode> changedCalls, ITypeBinding added) {
        Set<ASTNode> converted= Collections.newSetFromMap(new IdentityHashMap<>());
        converted.addAll(changedCalls);
        CheckedExceptionAnalysis analysis= new CheckedExceptionAnalysis(converted, null,
                ASTRewrite.create(statement.getAST()));
        statement.getBody().accept(analysis);
        for (Object resource : statement.resources()) ((ASTNode) resource).accept(analysis);
        Type caught= clause.getException().getType();
        List<?> alternatives= caught instanceof UnionType union ? union.types() : List.of(caught);
        return !analysis.uncertain && analysis.getCurrentExceptions().stream().noneMatch(exception ->
                (exception.isAssignmentCompatible(added) || added.isAssignmentCompatible(exception))
                && alternatives.stream().map(Type.class::cast).map(Type::resolveBinding)
                        .noneMatch(type -> type != null && exception.isAssignmentCompatible(type)));
    }

    @Override
    public boolean preVisit2(ASTNode node) {
        if (node instanceof CatchClause clause && !rewrite.getListRewrite(clause.getParent(),
                TryStatement.CATCH_CLAUSES_PROPERTY).getRewrittenList().contains(clause)) return false;
        if (node instanceof Expression resource && node.getParent() instanceof TryStatement statement
                && statement.resources().contains(resource)) {
            ITypeBinding type= resource.resolveTypeBinding();
            check(null, type == null || type.isRecovered() ? null
                    : Bindings.findMethodInHierarchy(type, "close", new ITypeBinding[0])); //$NON-NLS-1$
        }
        return true;
    }

    // The resource's implicit close is checked for both declarations and Java 9 resource references above.
    @Override
    public boolean visit(VariableDeclarationExpression node) { return true; }

    @Override
    public boolean visit(RecordDeclaration node) { return false; }

    @Override
    public boolean visit(MethodInvocation node) { check(node, node.resolveMethodBinding()); return true; }

    @Override
    public boolean visit(SuperMethodInvocation node) { check(node, node.resolveMethodBinding()); return true; }

    @Override
    public boolean visit(ClassInstanceCreation node) { check(node, node.resolveConstructorBinding()); return true; }

    @Override
    public boolean visit(ConstructorInvocation node) { check(node, node.resolveConstructorBinding()); return true; }

    @Override
    public boolean visit(SuperConstructorInvocation node) { check(node, node.resolveConstructorBinding()); return true; }

    @Override
    public boolean visit(ThrowStatement node) {
        ITypeBinding type= node.getExpression().resolveTypeBinding();
        if (type == null || type.isRecovered()) uncertain= true;
        else addException(type, node.getAST());
        return true;
    }

    private void check(ASTNode node, IMethodBinding binding) {
        if (binding == null || binding.isRecovered()) {
            uncertain= true;
            return;
        }
        for (ITypeBinding exception : binding.getExceptionTypes()) {
            if (exception.isRecovered()) uncertain= true;
            // Receiver and argument expressions are still visited even for a converted call.
            else if (node == null || !converted.contains(node) || target != null && !exception.isEqualTo(target))
                addException(exception, rewrite.getAST());
        }
    }
}