diff --git a/org.eclipse.jdt.core.manipulation/META-INF/MANIFEST.MF b/org.eclipse.jdt.core.manipulation/META-INF/MANIFEST.MF index 309d85c77d8..706f5223e79 100644 --- a/org.eclipse.jdt.core.manipulation/META-INF/MANIFEST.MF +++ b/org.eclipse.jdt.core.manipulation/META-INF/MANIFEST.MF @@ -10,7 +10,7 @@ Bundle-Localization: plugin Require-Bundle: org.eclipse.core.runtime;bundle-version="[3.31.0,4.0.0)", org.eclipse.core.resources;bundle-version="[3.20.0,4.0.0)", org.eclipse.ltk.core.refactoring;bundle-version="[3.14.0,4.0.0)", - org.eclipse.jdt.core;bundle-version="[3.40.0,4.0.0)", + org.eclipse.jdt.core;bundle-version="[3.46.0,4.0.0)", org.eclipse.core.expressions;bundle-version="[3.9.0,4.0.0)", org.eclipse.text;bundle-version="[3.14.0,4.0.0)", org.eclipse.jdt.launching;bundle-version="3.23.0", diff --git a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallHierarchyCore.java b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallHierarchyCore.java index c18151cc809..5d2427527fd 100644 --- a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallHierarchyCore.java +++ b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallHierarchyCore.java @@ -23,20 +23,30 @@ import java.util.List; import java.util.StringTokenizer; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.core.IJavaElement; import org.eclipse.jdt.core.IMember; import org.eclipse.jdt.core.IMethod; import org.eclipse.jdt.core.IModuleDescription; import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.ITypeHierarchy; import org.eclipse.jdt.core.ITypeRoot; +import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.Signature; import org.eclipse.jdt.core.dom.ASTParser; import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.manipulation.JavaManipulation; +import org.eclipse.jdt.core.search.IJavaSearchConstants; import org.eclipse.jdt.core.search.IJavaSearchScope; import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.core.search.SearchMatch; +import org.eclipse.jdt.core.search.SearchPattern; +import org.eclipse.jdt.core.search.SearchRequestor; import org.eclipse.jdt.internal.core.manipulation.JavaManipulationPlugin; import org.eclipse.jdt.internal.corext.dom.IASTSharedValues; @@ -297,7 +307,8 @@ private static StringMatcher[] parseList(String listString) { static CompilationUnit getCompilationUnitNode(IMember member, boolean resolveBindings) { ITypeRoot typeRoot= member.getTypeRoot(); try { - if (typeRoot.exists() && typeRoot.getBuffer() != null) { + if (typeRoot != null && typeRoot.exists() && typeRoot.getBuffer() != null + && JavaCore.isJavaLikeFileName(typeRoot.getElementName())) { ASTParser parser= ASTParser.newParser(IASTSharedValues.SHARED_AST_LEVEL); parser.setSource(typeRoot); parser.setResolveBindings(resolveBindings); @@ -305,10 +316,217 @@ static CompilationUnit getCompilationUnitNode(IMember member, boolean resolveBin } } catch (JavaModelException e) { JavaManipulationPlugin.log(e); + } catch (ClassCastException e) { + // Non-standard ITypeRoot (e.g. from a contributed search participant) + // that does not implement the internal compiler interfaces required + // by ASTParser — fall through and return null + JavaManipulationPlugin.log(e); } return null; } + /** + * Searches for the first {@link IMember} declaration matching the given + * name, element type, and optional call-site constraints. + * + *

Filters are applied in order of cheapness: argument count first + * (O(1)), then declaring type (string compare or hierarchy lookup), + * then argument types (per-parameter type compatibility check). + * + * @param elementName the simple name to search for + * @param searchFor one of {@link IJavaSearchConstants#METHOD}, + * {@link IJavaSearchConstants#FIELD}, or + * {@link IJavaSearchConstants#TYPE} + * @param scope the search scope + * @param monitor progress monitor, may be {@code null} + * @param expectedArgCount expected argument count, or {@code -1} to skip + * @param receiverTypeFQN receiver type FQN to match declaring type + * against, or {@code null} to skip + * @param declaringTypeCandidates FQN candidates for the declaring type, + * or {@code null} to skip + * @param expectedArgTypes argument type FQNs from the call site (may + * contain {@code "UNKNOWN"} entries), or {@code null} to skip + * @return the first matching member, or {@code null} if none found + */ + static IMember findFirstDeclaration(String elementName, int searchFor, + IJavaSearchScope scope, IProgressMonitor monitor, + int expectedArgCount, String receiverTypeFQN, + List declaringTypeCandidates, + String[] expectedArgTypes) { + SearchPattern pattern= SearchPattern.createPattern( + elementName, searchFor, + IJavaSearchConstants.DECLARATIONS, + SearchPattern.R_EXACT_MATCH | SearchPattern.R_CASE_SENSITIVE); + if (pattern == null) + return null; + final IMember[] result= { null }; + try { + new SearchEngine().search(pattern, + SearchEngine.getSearchParticipants(), scope, + new SearchRequestor() { + @Override + public void acceptSearchMatch(SearchMatch match) { + if (result[0] != null) { + return; + } + if (!(match.getElement() instanceof IMember m)) { + return; + } + if (m instanceof IMethod method + && !matchesMethod(method, + expectedArgCount, + expectedArgTypes)) { + return; + } + if (m.getDeclaringType() != null) { + String declFQN= m.getDeclaringType() + .getFullyQualifiedName(); + if (receiverTypeFQN != null + && !isTypeOrSupertype(declFQN, + receiverTypeFQN, m)) { + return; + } + if (declaringTypeCandidates != null + && !declaringTypeCandidates.isEmpty() + && !declaringTypeCandidates + .contains(declFQN)) { + return; + } + } + result[0]= m; + throw new OperationCanceledException(); + } + }, monitor); + } catch (OperationCanceledException e) { + // short-circuit: first match found + } catch (CoreException e) { + JavaManipulationPlugin.log(e); + } + return result[0]; + } + + /** + * Checks if a candidate method matches the expected argument count + * and types from the call site. + */ + private static boolean matchesMethod(IMethod method, + int expectedArgCount, String[] expectedArgTypes) { + int paramCount= method.getNumberOfParameters(); + // Argument count filter + if (expectedArgCount >= 0 + && paramCount != expectedArgCount) { + String[] sigs= method.getParameterTypes(); + if (paramCount == 0 + || Signature.getArrayCount( + sigs[paramCount - 1]) == 0 + || expectedArgCount < paramCount - 1) { + return false; + } + } + // Argument type filter + if (expectedArgTypes != null + && expectedArgTypes.length > 0 + && expectedArgTypes.length == paramCount) { + String[] paramSigs= method.getParameterTypes(); + IType declType= method.getDeclaringType(); + for (int i= 0; i < paramCount; i++) { + String callSiteType= expectedArgTypes[i]; + if ("UNKNOWN".equals(callSiteType)) { //$NON-NLS-1$ + continue; + } + String paramFQN= resolveParameterType( + paramSigs[i], declType); + if (paramFQN == null) { + continue; + } + if (!isTypeCompatible(callSiteType, + paramFQN, method)) { + return false; + } + } + } + return true; + } + + /** + * Resolves a JDT parameter type signature to a fully qualified + * name using the declaring type's context. + */ + private static String resolveParameterType(String paramSig, + IType declaringType) { + try { + String erasure= Signature.getTypeErasure(paramSig); + String simpleName= Signature.toString(erasure); + if (simpleName.contains(".")) { //$NON-NLS-1$ + return simpleName; + } + if (declaringType == null) { + return null; + } + String[][] resolved= declaringType.resolveType(simpleName); + if (resolved != null && resolved.length > 0) { + String pkg= resolved[0][0]; + String name= resolved[0][1]; + return pkg.isEmpty() ? name : pkg + "." + name; //$NON-NLS-1$ + } + } catch (JavaModelException e) { + // can't resolve + } + return null; + } + + /** + * Checks if the call-site argument type is compatible with the + * parameter type. The argument type must be the same as or a + * subtype of the parameter type. + */ + private static boolean isTypeCompatible(String argTypeFQN, + String paramTypeFQN, IMember context) { + if (argTypeFQN.equals(paramTypeFQN)) { + return true; + } + // The parameter type is a supertype of the argument type + // (e.g., param is Object, arg is String) — check if + // argType is-a paramType + return isTypeOrSupertype(paramTypeFQN, argTypeFQN, context); + } + + /** + * Checks if {@code candidateFQN} is the same as or a supertype of + * {@code receiverFQN}. Uses the JDT type hierarchy when available. + */ + private static boolean isTypeOrSupertype(String candidateFQN, + String receiverFQN, IMember context) { + if (candidateFQN.equals(receiverFQN)) { + return true; + } + // Check common base types without hierarchy lookup + if ("java.lang.Object".equals(candidateFQN)) { //$NON-NLS-1$ + return true; + } + // Try type hierarchy for precise check + try { + if (context.getJavaProject() != null) { + IType receiverType= context.getJavaProject() + .findType(receiverFQN); + if (receiverType != null) { + ITypeHierarchy hierarchy= + receiverType.newSupertypeHierarchy(null); + for (IType superType + : hierarchy.getAllSupertypes(receiverType)) { + if (candidateFQN.equals( + superType.getFullyQualifiedName())) { + return true; + } + } + } + } + } catch (JavaModelException e) { + JavaManipulationPlugin.log(e); + } + return false; + } + public static boolean isPossibleInputElement(Object element){ if (! (element instanceof IMember)) return false; diff --git a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeAnalyzerVisitor.java b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeAnalyzerVisitor.java index f460d1e62d1..3269df462e9 100644 --- a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeAnalyzerVisitor.java +++ b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeAnalyzerVisitor.java @@ -30,6 +30,7 @@ import org.eclipse.jdt.core.IMethod; import org.eclipse.jdt.core.ISourceRange; import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.core.JavaModelException; import org.eclipse.jdt.core.dom.ASTNode; import org.eclipse.jdt.core.dom.AbstractTypeDeclaration; @@ -38,6 +39,7 @@ import org.eclipse.jdt.core.dom.ClassInstanceCreation; import org.eclipse.jdt.core.dom.CompilationUnit; 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; @@ -47,6 +49,7 @@ import org.eclipse.jdt.core.dom.SuperConstructorInvocation; import org.eclipse.jdt.core.dom.SuperMethodInvocation; import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.search.IJavaSearchConstants; import org.eclipse.jdt.core.search.IJavaSearchScope; import org.eclipse.jdt.internal.core.manipulation.JavaManipulationPlugin; @@ -321,12 +324,58 @@ protected void addMethodCall(IMethodBinding calledMethodBinding, ASTNode node) { referencedMembers.forEach(m -> { fSearchResults.addMember(member, m, position, position + length, number < 1 ? 1 : number, potential); }); + } else if (node instanceof MethodInvocation mi + && mi.getExpression() != null + && mi.getExpression().resolveTypeBinding() == null + && fMember.getCompilationUnit() != null + && JavaCore.isJavaLikeFileName( + fMember.getCompilationUnit() + .getElementName())) { + // Binding is null AND the receiver type is + // unresolvable in a Java source file — likely a + // contributed type (e.g., Kotlin class not compiled + // by ECJ). Search for the method declaration via + // contributed search participants. + List args= mi.arguments(); + resolveViaSearch(mi.getName().getIdentifier(), + args, node); } } catch (JavaModelException jme) { JavaManipulationPlugin.log(jme); } } + private void resolveViaSearch(String methodName, + List arguments, ASTNode node) { + String[] argTypes= null; + if (arguments != null && !arguments.isEmpty()) { + argTypes= new String[arguments.size()]; + for (int i= 0; i < arguments.size(); i++) { + ITypeBinding binding= + arguments.get(i).resolveTypeBinding(); + if (binding != null) { + argTypes[i]= binding.getErasure() + .getQualifiedName(); + } else { + argTypes[i]= "UNKNOWN"; //$NON-NLS-1$ + } + } + } + IMember found= CallHierarchyCore.findFirstDeclaration( + methodName, IJavaSearchConstants.METHOD, + getSearchScope(), fProgressMonitor, + arguments != null ? arguments.size() : -1, + null, null, argTypes); + if (found != null) { + fSearchResults.addMember(fMember, found, + node.getStartPosition(), + node.getStartPosition() + node.getLength(), + fCompilationUnit.getLineNumber( + node.getStartPosition()), + false); + } + } + private static IMethod findIncludingSupertypes(IMethodBinding method, IType type, IProgressMonitor pm) throws JavaModelException { IMethod inThisType= Bindings.findMethod(method, type); if (inThisType != null) diff --git a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeMethodWrapper.java b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeMethodWrapper.java index 4f895549332..74d7baa640f 100644 --- a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeMethodWrapper.java +++ b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CalleeMethodWrapper.java @@ -20,10 +20,21 @@ import java.util.HashMap; import java.util.Map; +import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jdt.core.IJavaElement; import org.eclipse.jdt.core.IMember; +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.IType; import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.search.IJavaSearchConstants; +import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.core.search.SearchMatch; +import org.eclipse.jdt.core.search.DerivedSourceSearchParticipant; +import org.eclipse.jdt.core.search.SearchParticipant; + +import org.eclipse.jdt.internal.core.manipulation.JavaManipulationPlugin; class CalleeMethodWrapper extends MethodWrapper { private Comparator fMethodWrapperComparator = new MethodWrapperComparator(); @@ -100,8 +111,82 @@ protected Map findChildren(IProgressMonitor progressMonitor) cu.accept(visitor); return visitor.getCallees(); + } else { + return findCalleesFromParticipants(member, progressMonitor); } } return new HashMap<>(0); } + + private Map findCalleesFromParticipants(IMember member, IProgressMonitor monitor) { + try { + String path= null; + if (member.getResource() != null) { + path= member.getResource().getFullPath().toString(); + } else if (member.getPath() != null) { + path= member.getPath().toString(); + } + if (path == null) + return new HashMap<>(0); + + CallSearchResultCollector collector= new CallSearchResultCollector(); + SearchParticipant[] participants= SearchEngine.getSearchParticipants(); + + for (SearchParticipant participant : participants) { + if (participant instanceof DerivedSourceSearchParticipant dsp) { + SearchMatch[] calleeMatches= dsp.locateCallees( + member, dsp.getDocument(path), monitor); + + for (SearchMatch match : calleeMatches) { + if (match.getElement() instanceof IMember callee) { + IMember resolved= resolveCallee(callee, monitor); + if (resolved != null) { + collector.addMember(member, resolved, + match.getOffset(), + match.getOffset() + match.getLength()); + } + } + } + } + } + return collector.getCallers(); + } catch (CoreException e) { + JavaManipulationPlugin.log(e); + return new HashMap<>(0); + } + } + + private IMember resolveCallee(IMember callee, IProgressMonitor monitor) { + if (callee.exists()) { + return callee; + } + int searchFor; + switch (callee.getElementType()) { + case IJavaElement.METHOD: + searchFor= IJavaSearchConstants.METHOD; + break; + case IJavaElement.FIELD: + searchFor= IJavaSearchConstants.FIELD; + break; + default: + searchFor= IJavaSearchConstants.TYPE; + break; + } + // Extract call site context from standard JDT interfaces + int argCount= -1; + String receiverTypeFQN= null; + String[] argTypes= null; + if (callee instanceof IMethod method) { + argCount= method.getNumberOfParameters(); + argTypes= method.getParameterTypes(); + IType declType= method.getDeclaringType(); + if (declType != null) { + receiverTypeFQN= declType.getFullyQualifiedName(); + } + } + return CallHierarchyCore.findFirstDeclaration( + callee.getElementName(), searchFor, + CallHierarchyCore.getDefault().getSearchScope(), monitor, + argCount, receiverTypeFQN, null, argTypes); + } } diff --git a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallerMethodWrapper.java b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallerMethodWrapper.java index 09a4300266b..6e312564cfc 100644 --- a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallerMethodWrapper.java +++ b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/CallerMethodWrapper.java @@ -26,17 +26,18 @@ import org.eclipse.jdt.core.Flags; import org.eclipse.jdt.core.IField; +import org.eclipse.jdt.core.ICompilationUnit; import org.eclipse.jdt.core.IInitializer; import org.eclipse.jdt.core.IJavaElement; import org.eclipse.jdt.core.IMember; import org.eclipse.jdt.core.IMethod; import org.eclipse.jdt.core.ISourceRange; import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.JavaCore; import org.eclipse.jdt.core.JavaModelException; import org.eclipse.jdt.core.search.IJavaSearchConstants; import org.eclipse.jdt.core.search.IJavaSearchScope; import org.eclipse.jdt.core.search.SearchEngine; -import org.eclipse.jdt.core.search.SearchParticipant; import org.eclipse.jdt.core.search.SearchPattern; import org.eclipse.jdt.internal.core.manipulation.JavaManipulationPlugin; @@ -148,11 +149,22 @@ protected Map findChildren(IProgressMonitor progressMonitor) } SearchEngine searchEngine= new SearchEngine(); - MethodReferencesSearchRequestor searchRequestor= new MethodReferencesSearchRequestor(); + // When the search target is a non-Java element (e.g., from + // a contributed SearchParticipant like Kotlin), the Java + // MatchLocator cannot fully resolve the declaring type + // binding and reports matches as A_INACCURATE. These + // matches are still valid — the method name and parameter + // count match — so accept them. + ICompilationUnit cu= member.getCompilationUnit(); + boolean isContributedElement= cu != null + && !JavaCore.isJavaLikeFileName(cu.getElementName()); + MethodReferencesSearchRequestor searchRequestor= isContributedElement + ? new MethodReferencesSearchRequestor(false) + : new MethodReferencesSearchRequestor(); IJavaSearchScope defaultSearchScope= getSearchScope(); boolean isWorkspaceScope= SearchEngine.createWorkspaceScope().equals(defaultSearchScope); IJavaSearchScope searchScope= isWorkspaceScope ? getAccurateSearchScope(defaultSearchScope, member) : defaultSearchScope; - searchEngine.search(pattern, new SearchParticipant[] { SearchEngine.getDefaultSearchParticipant() }, searchScope, searchRequestor, + searchEngine.search(pattern, SearchEngine.getSearchParticipants(), searchScope, searchRequestor, monitor); return searchRequestor.getCallers(); diff --git a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/MethodReferencesSearchRequestor.java b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/MethodReferencesSearchRequestor.java index ef86d8ec697..cf116b6d48d 100644 --- a/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/MethodReferencesSearchRequestor.java +++ b/org.eclipse.jdt.core.manipulation/core extension/org/eclipse/jdt/internal/corext/callhierarchy/MethodReferencesSearchRequestor.java @@ -29,6 +29,11 @@ class MethodReferencesSearchRequestor extends SearchRequestor { fSearchResults = new CallSearchResultCollector(); } + MethodReferencesSearchRequestor(boolean requireExactMatch) { + this(); + fRequireExactMatch = requireExactMatch; + } + public Map getCallers() { return fSearchResults.getCallers(); } diff --git a/org.eclipse.jdt.ui.tests/plugin.xml b/org.eclipse.jdt.ui.tests/plugin.xml index d12ed4d9273..cc885f1e80d 100644 --- a/org.eclipse.jdt.ui.tests/plugin.xml +++ b/org.eclipse.jdt.ui.tests/plugin.xml @@ -302,5 +302,14 @@ schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"/> - + + + + + + diff --git a/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/callhierarchy/TestCallHierarchyParticipant.java b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/callhierarchy/TestCallHierarchyParticipant.java new file mode 100644 index 00000000000..5f43223700e --- /dev/null +++ b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/callhierarchy/TestCallHierarchyParticipant.java @@ -0,0 +1,91 @@ +/******************************************************************************* + * Copyright (c) 2026 Eclipse Foundation 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 + * + * Contributors: + * Arcadiy Ivanov - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ui.tests.callhierarchy; + +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; + +import org.eclipse.jdt.core.IMember; +import org.eclipse.jdt.core.search.IJavaSearchScope; +import org.eclipse.jdt.core.search.SearchDocument; +import org.eclipse.jdt.core.search.SearchMatch; +import org.eclipse.jdt.core.search.DerivedSourceSearchParticipant; +import org.eclipse.jdt.core.search.SearchPattern; +import org.eclipse.jdt.core.search.SearchRequestor; + +/** + * Test search participant for call hierarchy tests. Registered via plugin.xml + * for the {@code .testlang} file extension. + * + *

Tests control its behavior via static fields set before invoking the + * call hierarchy API. + */ +public class TestCallHierarchyParticipant extends DerivedSourceSearchParticipant { + + /** Callees to return from {@link #locateCallees}. Set by tests before use. */ + public static SearchMatch[] calleesToReturn = new SearchMatch[0]; + + /** Number of times {@link #locateCallees} was called. */ + public static int locateCalleesCallCount = 0; + + public static void reset() { + calleesToReturn = new SearchMatch[0]; + locateCalleesCallCount = 0; + } + + @Override + public SearchDocument getDocument(String documentPath) { + return new SearchDocument(documentPath, this) { + @Override + public byte[] getByteContents() { + return new byte[0]; + } + + @Override + public char[] getCharContents() { + return new char[0]; + } + + @Override + public String getEncoding() { + return "UTF-8"; //$NON-NLS-1$ + } + }; + } + + @Override + public void indexDocument(SearchDocument document, IPath indexLocation) { + // no-op + } + + @Override + public void locateMatches(SearchDocument[] documents, SearchPattern pattern, + IJavaSearchScope scope, SearchRequestor requestor, + IProgressMonitor monitor) throws CoreException { + // no-op + } + + @Override + public IPath[] selectIndexes(SearchPattern query, IJavaSearchScope scope) { + return new IPath[0]; + } + + @Override + public SearchMatch[] locateCallees(IMember caller, SearchDocument document, + IProgressMonitor monitor) throws CoreException { + locateCalleesCallCount++; + return calleesToReturn; + } +} diff --git a/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/core/CallHierarchyParticipantTest.java b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/core/CallHierarchyParticipantTest.java new file mode 100644 index 00000000000..9cabb27ba96 --- /dev/null +++ b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/core/CallHierarchyParticipantTest.java @@ -0,0 +1,401 @@ +/******************************************************************************* + * Copyright (c) 2026 Eclipse Foundation 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 + * + * Contributors: + * Arcadiy Ivanov - initial API and implementation + *******************************************************************************/ +package org.eclipse.jdt.ui.tests.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Proxy; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.NullProgressMonitor; + +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IncrementalProjectBuilder; + +import org.eclipse.jdt.core.IClassFile; +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IMember; +import org.eclipse.jdt.core.IMethod; +import org.eclipse.jdt.core.IPackageFragment; +import org.eclipse.jdt.core.IPackageFragmentRoot; +import org.eclipse.jdt.core.IType; +import org.eclipse.jdt.core.ITypeRoot; +import org.eclipse.jdt.core.search.SearchEngine; +import org.eclipse.jdt.core.search.SearchMatch; +import org.eclipse.jdt.core.search.SearchParticipant; + +import org.eclipse.jdt.internal.corext.callhierarchy.CallHierarchyCore; +import org.eclipse.jdt.internal.corext.callhierarchy.MethodWrapper; + +import org.eclipse.jdt.ui.tests.callhierarchy.TestCallHierarchyParticipant; + +import org.eclipse.jdt.testplugin.JavaProjectHelper; + +/** + * Tests for call hierarchy integration with contributed search participants. + * + *

Verifies that {@code CallerMethodWrapper} uses all search participants + * (not just the default) for incoming calls, and that {@code CalleeMethodWrapper} + * falls back to {@code DerivedSourceSearchParticipant.locateCallees()} for outgoing calls + * when no Java AST is available. + */ +public class CallHierarchyParticipantTest { + + private IJavaProject fSourceProject; + private IJavaProject fBinaryProject; + + @Before + public void setUp() throws Exception { + fSourceProject = JavaProjectHelper.createJavaProject("SourceProject", "bin"); + fBinaryProject = JavaProjectHelper.createJavaProject("BinaryProject", "bin"); + TestCallHierarchyParticipant.reset(); + } + + @After + public void tearDown() throws Exception { + TestCallHierarchyParticipant.reset(); + JavaProjectHelper.delete(fBinaryProject); + JavaProjectHelper.delete(fSourceProject); + } + + /** + * Verifies that {@code SearchEngine.getSearchParticipants()} includes both the + * default participant and the contributed test participant. + */ + @Test + public void searchParticipantsIncludesContributed() throws Exception { + SearchParticipant[] participants = SearchEngine.getSearchParticipants(); + assertTrue("Should have at least 2 participants (default + contributed)", + participants.length >= 2); + + boolean foundTest = false; + for (SearchParticipant p : participants) { + if (p instanceof TestCallHierarchyParticipant) { + foundTest = true; + } + } + assertTrue("Test search participant should be present", foundTest); + assertNotNull("First participant should be non-null", participants[0]); + } + + /** + * Verifies that the existing Java AST-based callee analysis path is unchanged + * when a Java AST is available. + */ + @Test + public void outgoingCallsJavaASTPathUnchanged() throws Exception { + JavaProjectHelper.addRTJar9(fSourceProject); + IPackageFragmentRoot src = JavaProjectHelper.addSourceContainer(fSourceProject, "src"); + IPackageFragment pkg = src.createPackageFragment("testpkg", true, null); + ICompilationUnit cu = pkg.createCompilationUnit("Source.java", + """ + package testpkg; + public class Source { + public void targetMethod() {} + public void callerMethod() { targetMethod(); } + } + """, + true, null); + fSourceProject.getProject().build(IncrementalProjectBuilder.FULL_BUILD, null); + + IType type = cu.getType("Source"); + IMethod callerMethod = type.getMethod("callerMethod", new String[0]); + assertNotNull(callerMethod); + assertTrue(callerMethod.exists()); + + TestCallHierarchyParticipant.reset(); + + MethodWrapper[] roots = CallHierarchyCore.getDefault().getCalleeRoots(new IMember[] { callerMethod }); + assertEquals(1, roots.length); + MethodWrapper[] callees = roots[0].getCalls(new NullProgressMonitor()); + + assertEquals("Should find exactly one callee via Java AST", 1, callees.length); + assertEquals("targetMethod", callees[0].getMember().getElementName()); + } + + /** + * Verifies that {@code CalleeMethodWrapper} falls back to + * {@code DerivedSourceSearchParticipant.locateCallees()} when the member has no Java AST + * (binary member without source attachment). + */ + @Test + public void outgoingCallsFallBackToParticipants() throws Exception { + JavaProjectHelper.addRTJar9(fSourceProject); + IPackageFragmentRoot src = JavaProjectHelper.addSourceContainer(fSourceProject, "src"); + IPackageFragment pkg = src.createPackageFragment("testpkg", true, null); + ICompilationUnit cu = pkg.createCompilationUnit("Lib.java", + """ + package testpkg; + public class Lib { + public void targetMethod() {} + public void anotherTarget() {} + public void callerMethod() { targetMethod(); anotherTarget(); } + } + """, + true, null); + fSourceProject.getProject().build(IncrementalProjectBuilder.FULL_BUILD, null); + + IType sourceType = cu.getType("Lib"); + IMethod sourceTargetMethod = sourceType.getMethod("targetMethod", new String[0]); + IMethod sourceAnotherTarget = sourceType.getMethod("anotherTarget", new String[0]); + assertTrue("sourceTargetMethod should exist", sourceTargetMethod.exists()); + assertTrue("sourceAnotherTarget should exist", sourceAnotherTarget.exists()); + + // Add source project's output as a class folder to binary project (no source attachment) + JavaProjectHelper.addRTJar9(fBinaryProject); + IPath outputPath = fSourceProject.getOutputLocation(); + IFolder outputFolder = fSourceProject.getProject() + .getFolder(outputPath.removeFirstSegments(1)); + JavaProjectHelper.addLibrary(fBinaryProject, outputFolder.getFullPath(), null, null); + + IType binaryType = fBinaryProject.findType("testpkg.Lib"); + assertNotNull("Binary type should be found", binaryType); + IMethod binaryCallerMethod = binaryType.getMethod("callerMethod", new String[0]); + assertTrue("Binary callerMethod should exist", binaryCallerMethod.exists()); + + // Verify this is truly a binary member with no source + ITypeRoot typeRoot = binaryCallerMethod.getTypeRoot(); + assertTrue("Should be a class file, not source", typeRoot instanceof IClassFile); + assertNull("Binary class file without source should have null buffer", typeRoot.getBuffer()); + + // Set up participant to return callees + TestCallHierarchyParticipant.reset(); + TestCallHierarchyParticipant.calleesToReturn = new SearchMatch[] { + new SearchMatch(sourceTargetMethod, SearchMatch.A_ACCURATE, 0, 14, + SearchEngine.getDefaultSearchParticipant(), + binaryCallerMethod.getResource()), + new SearchMatch(sourceAnotherTarget, SearchMatch.A_ACCURATE, 20, 13, + SearchEngine.getDefaultSearchParticipant(), + binaryCallerMethod.getResource()), + }; + + // Invoke callee hierarchy on the binary method + MethodWrapper[] roots = CallHierarchyCore.getDefault().getCalleeRoots( + new IMember[] { binaryCallerMethod }); + assertEquals(1, roots.length); + MethodWrapper[] callees = roots[0].getCalls(new NullProgressMonitor()); + + // Verify participant was called + assertTrue("locateCallees should have been called", + TestCallHierarchyParticipant.locateCalleesCallCount > 0); + + // Verify callees from participant appeared + assertEquals("Should find 2 callees from participant", 2, callees.length); + + boolean foundTarget = false; + boolean foundAnother = false; + for (MethodWrapper callee : callees) { + String name = callee.getMember().getElementName(); + if ("targetMethod".equals(name)) foundTarget = true; + if ("anotherTarget".equals(name)) foundAnother = true; + } + assertTrue("Should find targetMethod as callee", foundTarget); + assertTrue("Should find anotherTarget as callee", foundAnother); + } + + /** + * Verifies that {@code CalleeMethodWrapper.resolveCallee()} resolves + * existing callees directly without additional search. + */ + @Test + public void outgoingCallsResolvesExistingCallees() throws Exception { + JavaProjectHelper.addRTJar9(fSourceProject); + IPackageFragmentRoot src = JavaProjectHelper.addSourceContainer(fSourceProject, "src"); + IPackageFragment pkg = src.createPackageFragment("testpkg", true, null); + ICompilationUnit cu = pkg.createCompilationUnit("Lib.java", + """ + package testpkg; + public class Lib { + public void targetMethod() {} + public void anotherTarget() {} + public void callerMethod() {} + } + """, + true, null); + fSourceProject.getProject().build(IncrementalProjectBuilder.FULL_BUILD, null); + + // Add source project's output as a class folder to binary project (no source) + JavaProjectHelper.addRTJar9(fBinaryProject); + IPath outputPath = fSourceProject.getOutputLocation(); + IFolder outputFolder = fSourceProject.getProject() + .getFolder(outputPath.removeFirstSegments(1)); + JavaProjectHelper.addLibrary(fBinaryProject, outputFolder.getFullPath(), null, null); + + IType binaryType = fBinaryProject.findType("testpkg.Lib"); + assertNotNull("Binary type should be found", binaryType); + IMethod binaryCallerMethod = binaryType.getMethod("callerMethod", new String[0]); + assertTrue("Binary callerMethod should exist", binaryCallerMethod.exists()); + + // Return source methods (exists=true) - resolveCallee short-circuits + IType sourceType = cu.getType("Lib"); + IMethod sourceTargetMethod = sourceType.getMethod("targetMethod", new String[0]); + IMethod sourceAnotherTarget = sourceType.getMethod("anotherTarget", new String[0]); + assertTrue("Source targetMethod should exist for resolution", sourceTargetMethod.exists()); + assertTrue("Source anotherTarget should exist for resolution", sourceAnotherTarget.exists()); + + TestCallHierarchyParticipant.reset(); + TestCallHierarchyParticipant.calleesToReturn = new SearchMatch[] { + new SearchMatch(sourceTargetMethod, SearchMatch.A_ACCURATE, 0, 14, + SearchEngine.getDefaultSearchParticipant(), + binaryCallerMethod.getResource()), + new SearchMatch(sourceAnotherTarget, SearchMatch.A_ACCURATE, 20, 13, + SearchEngine.getDefaultSearchParticipant(), + binaryCallerMethod.getResource()), + }; + + MethodWrapper[] roots = CallHierarchyCore.getDefault().getCalleeRoots( + new IMember[] { binaryCallerMethod }); + assertEquals(1, roots.length); + MethodWrapper[] callees = roots[0].getCalls(new NullProgressMonitor()); + + assertTrue("locateCallees should have been called", + TestCallHierarchyParticipant.locateCalleesCallCount > 0); + assertEquals("Should find 2 callees", 2, callees.length); + } + + /** + * Verifies that {@code CalleeMethodWrapper} falls back to participants when + * the member's type root is a non-standard {@link ICompilationUnit} that does + * not implement the internal compiler interface (causing a + * {@code ClassCastException} in {@code ASTParser.createAST()}). + * + *

This simulates the scenario where a contributed search participant provides + * {@code IMember} instances backed by a custom {@code ICompilationUnit} + * (e.g. KotlinCompilationUnit) that implements the public API but not the + * internal compiler interface. + */ + @Test + public void outgoingCallsFallBackOnClassCastException() throws Exception { + JavaProjectHelper.addRTJar9(fSourceProject); + IPackageFragmentRoot src = JavaProjectHelper.addSourceContainer(fSourceProject, "src"); + IPackageFragment pkg = src.createPackageFragment("testpkg", true, null); + ICompilationUnit cu = pkg.createCompilationUnit("Source.java", + """ + package testpkg; + public class Source { + public void targetMethod() {} + public void anotherTarget() {} + public void callerMethod() { targetMethod(); anotherTarget(); } + } + """, + true, null); + fSourceProject.getProject().build(IncrementalProjectBuilder.FULL_BUILD, null); + + IType sourceType = cu.getType("Source"); + IMethod realCallerMethod = sourceType.getMethod("callerMethod", new String[0]); + IMethod sourceTargetMethod = sourceType.getMethod("targetMethod", new String[0]); + IMethod sourceAnotherTarget = sourceType.getMethod("anotherTarget", new String[0]); + assertTrue(realCallerMethod.exists()); + + // Create a proxy ICompilationUnit that implements only the public API + // (not org.eclipse.jdt.internal.compiler.env.ICompilationUnit), + // causing ASTParser.createAST() to throw ClassCastException + ICompilationUnit fakeCU = (ICompilationUnit) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] { ICompilationUnit.class }, + (proxy, method, args) -> method.invoke(cu, args)); + + // Create a proxy IMethod that delegates to realCallerMethod but returns + // the non-standard ICompilationUnit from getTypeRoot() + IMethod proxyMethod = (IMethod) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[] { IMethod.class }, + (proxy, method, args) -> { + if ("getTypeRoot".equals(method.getName())) { + return fakeCU; + } + return method.invoke(realCallerMethod, args); + }); + + assertTrue("Proxy method should delegate exists()", proxyMethod.exists()); + assertNotNull("Proxy method should have a type root", proxyMethod.getTypeRoot()); + assertNotNull("Proxy type root should have a buffer", proxyMethod.getTypeRoot().getBuffer()); + + // Set up participant to return callees + TestCallHierarchyParticipant.reset(); + TestCallHierarchyParticipant.calleesToReturn = new SearchMatch[] { + new SearchMatch(sourceTargetMethod, SearchMatch.A_ACCURATE, 0, 14, + SearchEngine.getDefaultSearchParticipant(), + realCallerMethod.getResource()), + new SearchMatch(sourceAnotherTarget, SearchMatch.A_ACCURATE, 20, 13, + SearchEngine.getDefaultSearchParticipant(), + realCallerMethod.getResource()), + }; + + // Invoke callee hierarchy on the proxy method — should NOT crash with + // ClassCastException, should fall back to participants + MethodWrapper[] roots = CallHierarchyCore.getDefault().getCalleeRoots( + new IMember[] { proxyMethod }); + assertEquals(1, roots.length); + MethodWrapper[] callees = roots[0].getCalls(new NullProgressMonitor()); + + assertTrue("locateCallees should have been called after ClassCastException fallback", + TestCallHierarchyParticipant.locateCalleesCallCount > 0); + assertEquals("Should find 2 callees from participant", 2, callees.length); + } + + /** + * Verifies that incoming call search uses all search participants + * (not just the default) and still finds Java callers correctly. + * Regression test for switching from {@code getDefaultSearchParticipant()} + * to {@code SearchEngine.getSearchParticipants()}. + */ + @Test + public void incomingCallsUsesAllParticipants() throws Exception { + JavaProjectHelper.addRTJar9(fSourceProject); + IPackageFragmentRoot src = JavaProjectHelper.addSourceContainer(fSourceProject, "src"); + IPackageFragment pkg = src.createPackageFragment("testpkg", true, null); + ICompilationUnit cu = pkg.createCompilationUnit("Caller.java", + """ + package testpkg; + public class Caller { + public void targetMethod() {} + public void firstCaller() { targetMethod(); } + public void secondCaller() { targetMethod(); } + } + """, + true, null); + fSourceProject.getProject().build(IncrementalProjectBuilder.FULL_BUILD, null); + + IType type = cu.getType("Caller"); + IMethod targetMethod = type.getMethod("targetMethod", new String[0]); + assertNotNull(targetMethod); + assertTrue(targetMethod.exists()); + + MethodWrapper[] roots = CallHierarchyCore.getDefault().getCallerRoots( + new IMember[] { targetMethod }); + assertEquals(1, roots.length); + MethodWrapper[] callers = roots[0].getCalls(new NullProgressMonitor()); + + assertEquals("Should find 2 callers", 2, callers.length); + + boolean foundFirst = false; + boolean foundSecond = false; + for (MethodWrapper caller : callers) { + String name = caller.getMember().getElementName(); + if ("firstCaller".equals(name)) foundFirst = true; + if ("secondCaller".equals(name)) foundSecond = true; + } + assertTrue("Should find firstCaller", foundFirst); + assertTrue("Should find secondCaller", foundSecond); + } +} diff --git a/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/core/CoreTestSuite.java b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/core/CoreTestSuite.java index 116f41d6676..e8ebfbed856 100644 --- a/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/core/CoreTestSuite.java +++ b/org.eclipse.jdt.ui.tests/ui/org/eclipse/jdt/ui/tests/core/CoreTestSuite.java @@ -30,6 +30,7 @@ BindingsHierarchyTest.class, BindingsNameTest.class, CallHierarchyTest.class, +CallHierarchyParticipantTest.class, ClassPathDetectorTest.class, CodeFormatterUtilTest.class, CodeFormatterTest.class,