Skip to content

Commit 35c22c9

Browse files
committed
Add support for direct Twig extension symbol usage detection in "Find Usages" and improve handler delegations
1 parent 307ccb6 commit 35c22c9

File tree

13 files changed

+356
-88
lines changed

13 files changed

+356
-88
lines changed

src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigExtensionParser.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,14 @@ private static void visitNewExpression(@NotNull NewExpression element, @NotNull
433433
signature = getCallableSignature(psiElement[1], method);
434434
}
435435

436+
if (signature == null && psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression arrayCreationExpression) {
437+
PhpPsiElement nodeClassValue = PhpElementsUtil.getArrayValue(arrayCreationExpression, "node_class");
438+
String nodeClass = nodeClassValue != null ? PhpElementsUtil.getStringValue(nodeClassValue) : null;
439+
if (StringUtils.isNotBlank(nodeClass)) {
440+
signature = String.format("#M#C\\%s.%s", StringUtils.stripStart(nodeClass, "\\"), "compile");
441+
}
442+
}
443+
436444
// creation options like: needs_environment
437445
Map<String, String> options;
438446
if (psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression) {
@@ -555,6 +563,14 @@ private static void visitNewExpression(@NotNull NewExpression element, @NotNull
555563
signature = getCallableSignature(psiElement[1], method);
556564
}
557565

566+
if (signature == null && psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression arrayCreationExpression) {
567+
PhpPsiElement nodeClassValue = PhpElementsUtil.getArrayValue(arrayCreationExpression, "node_class");
568+
String nodeClass = nodeClassValue != null ? PhpElementsUtil.getStringValue(nodeClassValue) : null;
569+
if (StringUtils.isNotBlank(nodeClass)) {
570+
signature = String.format("#M#C\\%s.%s", StringUtils.stripStart(nodeClass, "\\"), "compile");
571+
}
572+
}
573+
558574
if (signature == null && psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression arrayCreationExpression) {
559575
PhpPsiElement phpPsiElement = PhpElementsUtil.getArrayValue(arrayCreationExpression, "parser_callable");
560576
if (phpPsiElement != null) {

src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigExtensionUsageUtil.kt

Lines changed: 42 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,10 @@ import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigExtensionParser
1111
*/
1212
object TwigExtensionUsageUtil {
1313
/**
14-
* Resolves a Twig function usage leaf like `importmap` in `{{ importmap() }}` to PHP method targets.
14+
* Resolves the current Twig caret leaf to one logical Twig extension symbol.
1515
*/
16-
fun getFunctionTargets(element: PsiElement): List<Method> {
17-
if (!TwigPattern.getPrintBlockFunctionPattern().accepts(element)) {
18-
return emptyList()
19-
}
20-
21-
return getMethodTargetsForName(element, TwigExtensionParser.getFunctions(element.project))
22-
}
23-
24-
/**
25-
* Resolves a Twig filter usage leaf like `foo` in `{{ value|foo }}` or `{% apply foo %}` to PHP method targets.
26-
*/
27-
fun getFilterTargets(element: PsiElement): List<Method> {
28-
if (!TwigPattern.getFilterPattern().accepts(element) && !TwigPattern.getApplyFilterPattern().accepts(element)) {
29-
return emptyList()
30-
}
31-
32-
return getMethodTargetsForName(element, TwigExtensionParser.getFilters(element.project))
16+
fun getTwigExtensionSymbol(element: PsiElement): TwigExtensionUsageSymbol? {
17+
return getFunctionSymbol(element) ?: getFilterSymbol(element)
3318
}
3419

3520
/**
@@ -54,28 +39,27 @@ object TwigExtensionUsageUtil {
5439
}
5540

5641
/**
57-
* Resolves one concrete Twig extension name to all matching PHP methods. Multiple matches are preserved.
42+
* Resolves a Twig function usage leaf to its canonical function symbol name.
5843
*/
59-
private fun getMethodTargetsForName(
60-
element: PsiElement,
61-
extensions: Map<String, TwigExtension>,
62-
): List<Method> {
63-
val methods = linkedSetOf<Method>()
64-
val twigName = element.text
65-
if (twigName.isBlank()) {
66-
return emptyList()
44+
fun getFunctionSymbol(element: PsiElement): TwigExtensionUsageSymbol? {
45+
if (!TwigPattern.getPrintBlockFunctionPattern().accepts(element)) {
46+
return null
6747
}
6848

69-
for ((extensionName, twigExtension) in extensions) {
70-
if (!extensionName.equals(twigName, ignoreCase = true)) {
71-
continue
72-
}
49+
val name = getRegisteredExtensionName(element.text, TwigExtensionParser.getFunctions(element.project)) ?: return null
50+
return TwigExtensionUsageSymbol(TwigExtensionUsageKind.FUNCTION, name)
51+
}
7352

74-
val target = TwigExtensionParser.getExtensionTarget(element.project, twigExtension) as? Method ?: continue
75-
methods.add(target)
53+
/**
54+
* Resolves a Twig filter usage leaf to its canonical filter symbol name.
55+
*/
56+
fun getFilterSymbol(element: PsiElement): TwigExtensionUsageSymbol? {
57+
if (!TwigPattern.getFilterPattern().accepts(element) && !TwigPattern.getApplyFilterPattern().accepts(element)) {
58+
return null
7659
}
7760

78-
return methods.toList()
61+
val name = getRegisteredExtensionName(element.text, TwigExtensionParser.getFilters(element.project)) ?: return null
62+
return TwigExtensionUsageSymbol(TwigExtensionUsageKind.FILTER, name)
7963
}
8064

8165
/**
@@ -97,6 +81,20 @@ object TwigExtensionUsageUtil {
9781
return names
9882
}
9983

84+
/**
85+
* Finds the canonical registered Twig extension name for one symbol leaf.
86+
*/
87+
private fun getRegisteredExtensionName(
88+
twigName: String,
89+
extensions: Map<String, TwigExtension>,
90+
): String? {
91+
if (twigName.isBlank()) {
92+
return null
93+
}
94+
95+
return extensions.keys.firstOrNull { it.equals(twigName, ignoreCase = true) }
96+
}
97+
10098
/**
10199
* Compares PHP methods by PSI identity first and then by method name plus containing class.
102100
*/
@@ -115,3 +113,13 @@ object TwigExtensionUsageUtil {
115113
return candidateClass.isEquivalentTo(targetClass) || candidateClass.fqn == targetClass.fqn
116114
}
117115
}
116+
117+
enum class TwigExtensionUsageKind {
118+
FUNCTION,
119+
FILTER,
120+
}
121+
122+
data class TwigExtensionUsageSymbol(
123+
val kind: TwigExtensionUsageKind,
124+
val name: String,
125+
)

src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandler.kt

Lines changed: 115 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,51 @@ package fr.adrienbrault.idea.symfony2plugin.templating.usages
22

33
import com.intellij.find.findUsages.FindUsagesHandler
44
import com.intellij.find.findUsages.FindUsagesOptions
5+
import com.intellij.openapi.application.ApplicationManager
56
import com.intellij.psi.PsiElement
7+
import com.intellij.psi.PsiRecursiveElementWalkingVisitor
68
import com.intellij.psi.search.GlobalSearchScope
79
import com.intellij.usageView.UsageInfo
810
import com.intellij.util.Processor
11+
import com.jetbrains.twig.TwigFileType
12+
import fr.adrienbrault.idea.symfony2plugin.templating.TwigPattern
913

1014
/**
11-
* Delegates Twig-started Find Usages to the resolved PHP targets instead of searching the raw Twig PSI locally.
15+
* Handles Find Usages started from Twig, either by delegating to resolved PHP targets or by
16+
* searching exact Twig extension symbol names when the Twig symbol itself is the usage identity.
1217
*
1318
* @author Daniel Espendiller <daniel@espendiller.net>
1419
*/
1520
class TwigFindUsagesHandler(
16-
twigElement: PsiElement,
17-
private val phpTargets: List<PsiElement>,
18-
) : FindUsagesHandler(twigElement) {
21+
private val target: TwigFindUsagesTarget,
22+
) : FindUsagesHandler(target.primaryElement) {
1923
/**
20-
* Exposes the resolved PHP targets as the primary search targets for the Usage View.
24+
* Exposes the effective primary search targets for the Usage View.
2125
*/
22-
override fun getPrimaryElements(): Array<PsiElement> = phpTargets.toTypedArray()
26+
override fun getPrimaryElements(): Array<PsiElement> = target.primaryElements.toTypedArray()
2327

2428
/**
25-
* Delegates the actual search work to the PHP targets so existing PHP and Twig reference search logic is reused.
29+
* Delegates member/class usages to PHP targets and searches Twig extension symbols directly by name.
2630
*/
2731
override fun processElementUsages(
2832
element: PsiElement,
2933
processor: Processor<in UsageInfo>,
3034
options: FindUsagesOptions,
3135
): Boolean {
32-
val targets = getEffectiveTargets(element)
36+
return when (target) {
37+
is TwigPhpFindUsagesTarget -> {
38+
val targets = getEffectivePhpTargets(element, target.phpTargets)
3339

34-
for (target in targets) {
35-
if (!super.processElementUsages(target, processor, options)) {
36-
return false
40+
for (phpTarget in targets) {
41+
if (!super.processElementUsages(phpTarget, processor, options)) {
42+
return false
43+
}
44+
}
45+
46+
true
3747
}
48+
is TwigExtensionSymbolFindUsagesTarget -> processTwigExtensionUsages(target.symbol, processor, options)
3849
}
39-
40-
return true
4150
}
4251

4352
/**
@@ -48,21 +57,107 @@ class TwigFindUsagesHandler(
4857
processor: Processor<in UsageInfo>,
4958
searchScope: GlobalSearchScope,
5059
): Boolean {
51-
val targets = getEffectiveTargets(element)
60+
return when (target) {
61+
is TwigPhpFindUsagesTarget -> {
62+
val targets = getEffectivePhpTargets(element, target.phpTargets)
63+
64+
for (phpTarget in targets) {
65+
if (!super.processUsagesInText(phpTarget, processor, searchScope)) {
66+
return false
67+
}
68+
}
5269

53-
for (target in targets) {
54-
if (!super.processUsagesInText(target, processor, searchScope)) {
55-
return false
70+
true
5671
}
72+
is TwigExtensionSymbolFindUsagesTarget -> true
5773
}
58-
59-
return true
6074
}
6175

6276
/**
6377
* Supports both direct calls with the original Twig PSI and platform calls with one of the primary PHP targets.
6478
*/
65-
private fun getEffectiveTargets(element: PsiElement): List<PsiElement> {
79+
private fun getEffectivePhpTargets(element: PsiElement, phpTargets: List<PsiElement>): List<PsiElement> {
6680
return phpTargets.firstOrNull { it.isEquivalentTo(element) }?.let { listOf(it) } ?: phpTargets
6781
}
82+
83+
/**
84+
* Searches Twig templates directly for the exact extension symbol name under Find Usages.
85+
*/
86+
private fun processTwigExtensionUsages(
87+
symbol: TwigExtensionUsageSymbol,
88+
processor: Processor<in UsageInfo>,
89+
options: FindUsagesOptions,
90+
): Boolean {
91+
return ApplicationManager.getApplication().runReadAction<Boolean> {
92+
val project = target.primaryElement.project
93+
val searchScope = options.searchScope as? GlobalSearchScope ?: GlobalSearchScope.projectScope(project)
94+
val twigScope = GlobalSearchScope.getScopeRestrictedByFileTypes(searchScope, TwigFileType.INSTANCE)
95+
96+
for (twigFile in TwigMethodReferencesSearchExecutor().collectTwigFiles(project, twigScope, setOf(symbol.name))) {
97+
twigFile.accept(object : PsiRecursiveElementWalkingVisitor() {
98+
override fun visitElement(element: PsiElement) {
99+
if (matchesTwigExtensionSymbol(element, symbol)) {
100+
processor.process(UsageInfo(element))
101+
}
102+
103+
super.visitElement(element)
104+
}
105+
})
106+
}
107+
108+
true
109+
}
110+
}
111+
}
112+
113+
/**
114+
* Describes the effective identity of a Twig Find Usages session.
115+
*/
116+
sealed interface TwigFindUsagesTarget {
117+
/**
118+
* Anchor PSI element used to create the handler and open the Usage View.
119+
*/
120+
val primaryElement: PsiElement
121+
122+
/**
123+
* Effective search targets exposed to the Usage View, either PHP delegates or the Twig symbol itself.
124+
*/
125+
val primaryElements: List<PsiElement>
126+
}
127+
128+
/**
129+
* Twig usage target that delegates the search to one or more resolved PHP elements.
130+
*/
131+
data class TwigPhpFindUsagesTarget(
132+
override val primaryElement: PsiElement,
133+
val phpTargets: List<PsiElement>,
134+
) : TwigFindUsagesTarget {
135+
override val primaryElements: List<PsiElement> = phpTargets
136+
}
137+
138+
/**
139+
* Twig extension usage target that keeps the Twig symbol itself as the usage identity, for example `form_start` or `trans`.
140+
*/
141+
data class TwigExtensionSymbolFindUsagesTarget(
142+
override val primaryElement: PsiElement,
143+
val symbol: TwigExtensionUsageSymbol,
144+
) : TwigFindUsagesTarget {
145+
override val primaryElements: List<PsiElement> = listOf(primaryElement)
146+
}
147+
148+
/**
149+
* Matches only the exact Twig extension symbol leaf, such as `form_start` in `{{ form_start() }}` or `trans` in `{% apply trans %}`.
150+
*/
151+
private fun matchesTwigExtensionSymbol(
152+
element: PsiElement,
153+
symbol: TwigExtensionUsageSymbol,
154+
): Boolean {
155+
if (!element.text.equals(symbol.name, ignoreCase = true)) {
156+
return false
157+
}
158+
159+
return when (symbol.kind) {
160+
TwigExtensionUsageKind.FUNCTION -> TwigPattern.getPrintBlockFunctionPattern().accepts(element)
161+
TwigExtensionUsageKind.FILTER -> TwigPattern.getFilterPattern().accepts(element) || TwigPattern.getApplyFilterPattern().accepts(element)
162+
}
68163
}

src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandlerFactory.kt

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ import com.jetbrains.twig.TwigFileType
2828
*/
2929
class TwigFindUsagesHandlerFactory : FindUsagesHandlerFactory(), UsageTargetProvider {
3030
/**
31-
* Allows Find Usages to start directly on Twig usage PSI by first exposing the resolved PHP targets.
31+
* Allows Find Usages to start directly on Twig usage PSI by exposing either Twig symbol targets
32+
* or delegated PHP targets depending on the resolved usage identity.
3233
*/
3334
override fun getTargets(element: PsiElement): Array<UsageTarget> {
34-
val usageTargets: Array<UsageTarget> = getTwigFindUsagesPhpTargets(element)
35+
val target = getTwigFindUsagesTarget(element) ?: return UsageTarget.EMPTY_ARRAY
36+
val usageTargets: Array<UsageTarget> = target.primaryElements
3537
.map { PsiElement2UsageTargetAdapter(it, true) }
3638
.toTypedArray()
3739

@@ -47,33 +49,35 @@ class TwigFindUsagesHandlerFactory : FindUsagesHandlerFactory(), UsageTargetProv
4749
}
4850

4951
/**
50-
* Activates the custom handler only when the Twig caret resolves to PHP class/member targets.
52+
* Activates the custom handler only when the Twig caret resolves to a supported Twig/PHP usage identity.
5153
*/
52-
override fun canFindUsages(element: PsiElement): Boolean = getTwigFindUsagesPhpTargets(element).isNotEmpty()
54+
override fun canFindUsages(element: PsiElement): Boolean = getTwigFindUsagesTarget(element) != null
5355

5456
/**
55-
* Stores the PHP delegation targets that should be searched instead of the raw Twig PSI.
57+
* Stores the effective Twig or PHP search targets for the Find Usages session.
5658
*/
5759
override fun createFindUsagesHandler(element: PsiElement, forHighlightUsages: Boolean): FindUsagesHandler? {
58-
val targets = getTwigFindUsagesPhpTargets(element)
59-
60-
return targets.takeIf { it.isNotEmpty() }?.let { TwigFindUsagesHandler(element, it) }
60+
return getTwigFindUsagesTarget(element)?.let { TwigFindUsagesHandler(it) }
6161
}
6262
}
6363

6464
/**
65-
* Reuses Twig goto resolution and keeps only PHP Find Usages targets.
66-
* Twig member lookups can arrive on a composite field reference, so nearby leaf candidates are probed as well.
65+
* Resolves the Find Usages identity for one Twig caret position.
66+
* Extension symbols stay Twig-symbol based, while all other cases keep delegating to PHP.
6767
*/
68-
private fun getTwigFindUsagesPhpTargets(element: PsiElement): List<PsiElement> {
68+
private fun getTwigFindUsagesTarget(element: PsiElement): TwigFindUsagesTarget? {
6969
if (element.containingFile?.fileType != TwigFileType.INSTANCE) {
70-
return emptyList()
70+
return null
7171
}
7272

7373
val resolvedCandidates = mutableListOf<Pair<PsiElement, List<PsiElement>>>()
7474
val candidateElements = getTwigFindUsagesCaretElement(element)?.let(::listOf) ?: listOf(element)
7575

7676
for (candidate in candidateElements) {
77+
TwigExtensionUsageUtil.getTwigExtensionSymbol(candidate)?.let { symbol ->
78+
return TwigExtensionSymbolFindUsagesTarget(candidate, symbol)
79+
}
80+
7781
val resolvedTargets = TwigUsageTargetUtil.getTwigFindUsagesTargets(candidate)
7882
.filter { it is PhpClass || it is Method || it is Field || it is PhpEnumCase }
7983

@@ -82,15 +86,12 @@ private fun getTwigFindUsagesPhpTargets(element: PsiElement): List<PsiElement> {
8286
}
8387
}
8488

85-
val preferredTargets = resolvedCandidates
89+
return resolvedCandidates
8690
.maxWithOrNull(compareBy<Pair<PsiElement, List<PsiElement>>>(
8791
{ it.first.textOffset },
8892
{ if (it.second.any { target -> target !is PhpClass }) 1 else 0 },
8993
))
90-
?.second
91-
?: emptyList()
92-
93-
return preferredTargets
94+
?.let { TwigPhpFindUsagesTarget(it.first, it.second) }
9495
}
9596

9697
/**

0 commit comments

Comments
 (0)