Skip to content

Commit f033e63

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

File tree

13 files changed

+368
-88
lines changed

13 files changed

+368
-88
lines changed

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,24 @@ private static String getCallableSignature(PsiElement psiElement, Method method)
322322
return null;
323323
}
324324

325+
/**
326+
* Resolves `node_class` options like `['node_class' => RenderBlockNode::class]` to the node `compile()` method signature.
327+
*/
328+
@Nullable
329+
private static String getNodeClassCompileSignature(PsiElement[] psiElement) {
330+
if (psiElement.length <= 2 || !(psiElement[2] instanceof ArrayCreationExpression arrayCreationExpression)) {
331+
return null;
332+
}
333+
334+
PhpPsiElement nodeClassValue = PhpElementsUtil.getArrayValue(arrayCreationExpression, "node_class");
335+
String nodeClass = nodeClassValue != null ? PhpElementsUtil.getStringValue(nodeClassValue) : null;
336+
if (StringUtils.isBlank(nodeClass)) {
337+
return null;
338+
}
339+
340+
return String.format("#M#C\\%s.%s", StringUtils.stripStart(nodeClass, "\\"), "compile");
341+
}
342+
325343
private static void parseOperators(@NotNull Method method, @NotNull Map<String, TwigExtension> filters) {
326344
final PhpClass containingClass = method.getContainingClass();
327345
if (containingClass == null) {
@@ -433,6 +451,11 @@ private static void visitNewExpression(@NotNull NewExpression element, @NotNull
433451
signature = getCallableSignature(psiElement[1], method);
434452
}
435453

454+
// `['node_class' => RenderBlockNode::class]`
455+
if (signature == null) {
456+
signature = getNodeClassCompileSignature(psiElement);
457+
}
458+
436459
// creation options like: needs_environment
437460
Map<String, String> options;
438461
if (psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression) {
@@ -555,6 +578,11 @@ private static void visitNewExpression(@NotNull NewExpression element, @NotNull
555578
signature = getCallableSignature(psiElement[1], method);
556579
}
557580

581+
// `['node_class' => RenderBlockNode::class]`
582+
if (signature == null) {
583+
signature = getNodeClassCompileSignature(psiElement);
584+
}
585+
558586
if (signature == null && psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression arrayCreationExpression) {
559587
PhpPsiElement phpPsiElement = PhpElementsUtil.getArrayValue(arrayCreationExpression, "parser_callable");
560588
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
}

0 commit comments

Comments
 (0)