diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigExtensionParser.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigExtensionParser.java index 1ba295b2b..bacc79715 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigExtensionParser.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigExtensionParser.java @@ -322,6 +322,24 @@ private static String getCallableSignature(PsiElement psiElement, Method method) return null; } + /** + * Resolves `node_class` options like `['node_class' => RenderBlockNode::class]` to the node `compile()` method signature. + */ + @Nullable + private static String getNodeClassCompileSignature(PsiElement[] psiElement) { + if (psiElement.length <= 2 || !(psiElement[2] instanceof ArrayCreationExpression arrayCreationExpression)) { + return null; + } + + PhpPsiElement nodeClassValue = PhpElementsUtil.getArrayValue(arrayCreationExpression, "node_class"); + String nodeClass = nodeClassValue != null ? PhpElementsUtil.getStringValue(nodeClassValue) : null; + if (StringUtils.isBlank(nodeClass)) { + return null; + } + + return String.format("#M#C\\%s.%s", StringUtils.stripStart(nodeClass, "\\"), "compile"); + } + private static void parseOperators(@NotNull Method method, @NotNull Map filters) { final PhpClass containingClass = method.getContainingClass(); if (containingClass == null) { @@ -433,6 +451,11 @@ private static void visitNewExpression(@NotNull NewExpression element, @NotNull signature = getCallableSignature(psiElement[1], method); } + // `['node_class' => RenderBlockNode::class]` + if (signature == null) { + signature = getNodeClassCompileSignature(psiElement); + } + // creation options like: needs_environment Map options; if (psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression) { @@ -555,6 +578,11 @@ private static void visitNewExpression(@NotNull NewExpression element, @NotNull signature = getCallableSignature(psiElement[1], method); } + // `['node_class' => RenderBlockNode::class]` + if (signature == null) { + signature = getNodeClassCompileSignature(psiElement); + } + if (signature == null && psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression arrayCreationExpression) { PhpPsiElement phpPsiElement = PhpElementsUtil.getArrayValue(arrayCreationExpression, "parser_callable"); if (phpPsiElement != null) { diff --git a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigExtensionUsageUtil.kt b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigExtensionUsageUtil.kt index e302f652d..7c8ce7c9e 100644 --- a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigExtensionUsageUtil.kt +++ b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigExtensionUsageUtil.kt @@ -11,25 +11,10 @@ import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigExtensionParser */ object TwigExtensionUsageUtil { /** - * Resolves a Twig function usage leaf like `importmap` in `{{ importmap() }}` to PHP method targets. + * Resolves the current Twig caret leaf to one logical Twig extension symbol. */ - fun getFunctionTargets(element: PsiElement): List { - if (!TwigPattern.getPrintBlockFunctionPattern().accepts(element)) { - return emptyList() - } - - return getMethodTargetsForName(element, TwigExtensionParser.getFunctions(element.project)) - } - - /** - * Resolves a Twig filter usage leaf like `foo` in `{{ value|foo }}` or `{% apply foo %}` to PHP method targets. - */ - fun getFilterTargets(element: PsiElement): List { - if (!TwigPattern.getFilterPattern().accepts(element) && !TwigPattern.getApplyFilterPattern().accepts(element)) { - return emptyList() - } - - return getMethodTargetsForName(element, TwigExtensionParser.getFilters(element.project)) + fun getTwigExtensionSymbol(element: PsiElement): TwigExtensionUsageSymbol? { + return getFunctionSymbol(element) ?: getFilterSymbol(element) } /** @@ -54,28 +39,27 @@ object TwigExtensionUsageUtil { } /** - * Resolves one concrete Twig extension name to all matching PHP methods. Multiple matches are preserved. + * Resolves a Twig function usage leaf to its canonical function symbol name. */ - private fun getMethodTargetsForName( - element: PsiElement, - extensions: Map, - ): List { - val methods = linkedSetOf() - val twigName = element.text - if (twigName.isBlank()) { - return emptyList() + fun getFunctionSymbol(element: PsiElement): TwigExtensionUsageSymbol? { + if (!TwigPattern.getPrintBlockFunctionPattern().accepts(element)) { + return null } - for ((extensionName, twigExtension) in extensions) { - if (!extensionName.equals(twigName, ignoreCase = true)) { - continue - } + val name = getRegisteredExtensionName(element.text, TwigExtensionParser.getFunctions(element.project)) ?: return null + return TwigExtensionUsageSymbol(TwigExtensionUsageKind.FUNCTION, name) + } - val target = TwigExtensionParser.getExtensionTarget(element.project, twigExtension) as? Method ?: continue - methods.add(target) + /** + * Resolves a Twig filter usage leaf to its canonical filter symbol name. + */ + fun getFilterSymbol(element: PsiElement): TwigExtensionUsageSymbol? { + if (!TwigPattern.getFilterPattern().accepts(element) && !TwigPattern.getApplyFilterPattern().accepts(element)) { + return null } - return methods.toList() + val name = getRegisteredExtensionName(element.text, TwigExtensionParser.getFilters(element.project)) ?: return null + return TwigExtensionUsageSymbol(TwigExtensionUsageKind.FILTER, name) } /** @@ -97,6 +81,20 @@ object TwigExtensionUsageUtil { return names } + /** + * Finds the canonical registered Twig extension name for one symbol leaf. + */ + private fun getRegisteredExtensionName( + twigName: String, + extensions: Map, + ): String? { + if (twigName.isBlank()) { + return null + } + + return extensions.keys.firstOrNull { it.equals(twigName, ignoreCase = true) } + } + /** * Compares PHP methods by PSI identity first and then by method name plus containing class. */ @@ -115,3 +113,25 @@ object TwigExtensionUsageUtil { return candidateClass.isEquivalentTo(targetClass) || candidateClass.fqn == targetClass.fqn } } + +/** + * Twig extension symbol kind like `form_start` in `{{ form_start() }}` or `trans` in `{{ value|trans }}`. + */ +enum class TwigExtensionUsageKind { + /** Twig function like `form_start` in `{{ form_start() }}`. */ + FUNCTION, + + /** Twig filter like `trans` in `{{ value|trans }}`. */ + FILTER, +} + +/** + * One logical Twig extension symbol like `form_start` in `{{ form_start() }}`. + */ +data class TwigExtensionUsageSymbol( + /** Symbol kind such as function or filter. */ + val kind: TwigExtensionUsageKind, + + /** Canonical registered Twig name like `form_start` or `trans`. */ + val name: String, +) diff --git a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandler.kt b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandler.kt index 252e4fcb3..d59806178 100644 --- a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandler.kt +++ b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandler.kt @@ -2,42 +2,51 @@ package fr.adrienbrault.idea.symfony2plugin.templating.usages import com.intellij.find.findUsages.FindUsagesHandler import com.intellij.find.findUsages.FindUsagesOptions +import com.intellij.openapi.application.ApplicationManager import com.intellij.psi.PsiElement +import com.intellij.psi.PsiRecursiveElementWalkingVisitor import com.intellij.psi.search.GlobalSearchScope import com.intellij.usageView.UsageInfo import com.intellij.util.Processor +import com.jetbrains.twig.TwigFileType +import fr.adrienbrault.idea.symfony2plugin.templating.TwigPattern /** - * Delegates Twig-started Find Usages to the resolved PHP targets instead of searching the raw Twig PSI locally. + * Handles Find Usages started from Twig, either by delegating to resolved PHP targets or by + * searching exact Twig extension symbol names when the Twig symbol itself is the usage identity. * * @author Daniel Espendiller */ class TwigFindUsagesHandler( - twigElement: PsiElement, - private val phpTargets: List, -) : FindUsagesHandler(twigElement) { + private val target: TwigFindUsagesTarget, +) : FindUsagesHandler(target.primaryElement) { /** - * Exposes the resolved PHP targets as the primary search targets for the Usage View. + * Exposes the effective primary search targets for the Usage View. */ - override fun getPrimaryElements(): Array = phpTargets.toTypedArray() + override fun getPrimaryElements(): Array = target.primaryElements.toTypedArray() /** - * Delegates the actual search work to the PHP targets so existing PHP and Twig reference search logic is reused. + * Delegates member/class usages to PHP targets and searches Twig extension symbols directly by name. */ override fun processElementUsages( element: PsiElement, processor: Processor, options: FindUsagesOptions, ): Boolean { - val targets = getEffectiveTargets(element) + return when (target) { + is TwigPhpFindUsagesTarget -> { + val targets = getEffectivePhpTargets(element, target.phpTargets) - for (target in targets) { - if (!super.processElementUsages(target, processor, options)) { - return false + for (phpTarget in targets) { + if (!super.processElementUsages(phpTarget, processor, options)) { + return false + } + } + + true } + is TwigExtensionSymbolFindUsagesTarget -> processTwigExtensionUsages(target.symbol, processor, options) } - - return true } /** @@ -48,21 +57,107 @@ class TwigFindUsagesHandler( processor: Processor, searchScope: GlobalSearchScope, ): Boolean { - val targets = getEffectiveTargets(element) + return when (target) { + is TwigPhpFindUsagesTarget -> { + val targets = getEffectivePhpTargets(element, target.phpTargets) + + for (phpTarget in targets) { + if (!super.processUsagesInText(phpTarget, processor, searchScope)) { + return false + } + } - for (target in targets) { - if (!super.processUsagesInText(target, processor, searchScope)) { - return false + true } + is TwigExtensionSymbolFindUsagesTarget -> true } - - return true } /** * Supports both direct calls with the original Twig PSI and platform calls with one of the primary PHP targets. */ - private fun getEffectiveTargets(element: PsiElement): List { + private fun getEffectivePhpTargets(element: PsiElement, phpTargets: List): List { return phpTargets.firstOrNull { it.isEquivalentTo(element) }?.let { listOf(it) } ?: phpTargets } + + /** + * Searches Twig files directly for the exact extension symbol name under Find Usages, for example `form_start` in `{{ form_start() }}`. + */ + private fun processTwigExtensionUsages( + symbol: TwigExtensionUsageSymbol, + processor: Processor, + options: FindUsagesOptions, + ): Boolean { + return ApplicationManager.getApplication().runReadAction { + val project = target.primaryElement.project + val searchScope = options.searchScope as? GlobalSearchScope ?: GlobalSearchScope.projectScope(project) + val twigScope = GlobalSearchScope.getScopeRestrictedByFileTypes(searchScope, TwigFileType.INSTANCE) + + for (twigFile in TwigMethodReferencesSearchExecutor().collectTwigFiles(project, twigScope, setOf(symbol.name))) { + twigFile.accept(object : PsiRecursiveElementWalkingVisitor() { + override fun visitElement(element: PsiElement) { + if (matchesTwigExtensionSymbol(element, symbol)) { + processor.process(UsageInfo(element)) + } + + super.visitElement(element) + } + }) + } + + true + } + } +} + +/** + * Describes the effective identity of a Twig Find Usages session. + */ +sealed interface TwigFindUsagesTarget { + /** + * Anchor PSI element used to create the handler and open the Usage View. + */ + val primaryElement: PsiElement + + /** + * Effective search targets exposed to the Usage View, either PHP delegates or the Twig symbol itself. + */ + val primaryElements: List +} + +/** + * Twig usage target that delegates the search to one or more resolved PHP elements. + */ +data class TwigPhpFindUsagesTarget( + override val primaryElement: PsiElement, + val phpTargets: List, +) : TwigFindUsagesTarget { + override val primaryElements: List = phpTargets +} + +/** + * Twig extension usage target that keeps the Twig symbol itself as the usage identity, for example `form_start` or `trans`. + */ +data class TwigExtensionSymbolFindUsagesTarget( + override val primaryElement: PsiElement, + val symbol: TwigExtensionUsageSymbol, +) : TwigFindUsagesTarget { + override val primaryElements: List = listOf(primaryElement) +} + +/** + * Matches only the exact Twig extension symbol leaf, such as `form_start` in `{{ form_start() }}` or `trans` in `{% apply trans %}`. + */ +private fun matchesTwigExtensionSymbol( + element: PsiElement, + symbol: TwigExtensionUsageSymbol, +): Boolean { + if (!element.text.equals(symbol.name, ignoreCase = true)) { + return false + } + + return when (symbol.kind) { + TwigExtensionUsageKind.FUNCTION -> TwigPattern.getPrintBlockFunctionPattern().accepts(element) + TwigExtensionUsageKind.FILTER -> TwigPattern.getFilterPattern().accepts(element) || TwigPattern.getApplyFilterPattern().accepts(element) + } } diff --git a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandlerFactory.kt b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandlerFactory.kt index 6b63ec674..2dfc3be1c 100644 --- a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandlerFactory.kt +++ b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigFindUsagesHandlerFactory.kt @@ -28,10 +28,12 @@ import com.jetbrains.twig.TwigFileType */ class TwigFindUsagesHandlerFactory : FindUsagesHandlerFactory(), UsageTargetProvider { /** - * Allows Find Usages to start directly on Twig usage PSI by first exposing the resolved PHP targets. + * Allows Find Usages to start directly on Twig usage PSI by exposing either Twig symbol targets + * or delegated PHP targets depending on the resolved usage identity. */ override fun getTargets(element: PsiElement): Array { - val usageTargets: Array = getTwigFindUsagesPhpTargets(element) + val target = getTwigFindUsagesTarget(element) ?: return UsageTarget.EMPTY_ARRAY + val usageTargets: Array = target.primaryElements .map { PsiElement2UsageTargetAdapter(it, true) } .toTypedArray() @@ -47,33 +49,35 @@ class TwigFindUsagesHandlerFactory : FindUsagesHandlerFactory(), UsageTargetProv } /** - * Activates the custom handler only when the Twig caret resolves to PHP class/member targets. + * Activates the custom handler only when the Twig caret resolves to a supported Twig/PHP usage identity. */ - override fun canFindUsages(element: PsiElement): Boolean = getTwigFindUsagesPhpTargets(element).isNotEmpty() + override fun canFindUsages(element: PsiElement): Boolean = getTwigFindUsagesTarget(element) != null /** - * Stores the PHP delegation targets that should be searched instead of the raw Twig PSI. + * Stores the effective Twig or PHP search targets for the Find Usages session. */ override fun createFindUsagesHandler(element: PsiElement, forHighlightUsages: Boolean): FindUsagesHandler? { - val targets = getTwigFindUsagesPhpTargets(element) - - return targets.takeIf { it.isNotEmpty() }?.let { TwigFindUsagesHandler(element, it) } + return getTwigFindUsagesTarget(element)?.let { TwigFindUsagesHandler(it) } } } /** - * Reuses Twig goto resolution and keeps only PHP Find Usages targets. - * Twig member lookups can arrive on a composite field reference, so nearby leaf candidates are probed as well. + * Resolves the Find Usages identity for one Twig caret position. + * Extension symbols stay Twig-symbol based, while all other cases keep delegating to PHP. */ -private fun getTwigFindUsagesPhpTargets(element: PsiElement): List { +private fun getTwigFindUsagesTarget(element: PsiElement): TwigFindUsagesTarget? { if (element.containingFile?.fileType != TwigFileType.INSTANCE) { - return emptyList() + return null } val resolvedCandidates = mutableListOf>>() val candidateElements = getTwigFindUsagesCaretElement(element)?.let(::listOf) ?: listOf(element) for (candidate in candidateElements) { + TwigExtensionUsageUtil.getTwigExtensionSymbol(candidate)?.let { symbol -> + return TwigExtensionSymbolFindUsagesTarget(candidate, symbol) + } + val resolvedTargets = TwigUsageTargetUtil.getTwigFindUsagesTargets(candidate) .filter { it is PhpClass || it is Method || it is Field || it is PhpEnumCase } @@ -82,15 +86,12 @@ private fun getTwigFindUsagesPhpTargets(element: PsiElement): List { } } - val preferredTargets = resolvedCandidates + return resolvedCandidates .maxWithOrNull(compareBy>>( { it.first.textOffset }, { if (it.second.any { target -> target !is PhpClass }) 1 else 0 }, )) - ?.second - ?: emptyList() - - return preferredTargets + ?.let { TwigPhpFindUsagesTarget(it.first, it.second) } } /** diff --git a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigMethodReferencesSearchExecutor.kt b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigMethodReferencesSearchExecutor.kt index 291ba7789..26e3af8ea 100644 --- a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigMethodReferencesSearchExecutor.kt +++ b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigMethodReferencesSearchExecutor.kt @@ -346,8 +346,7 @@ class TwigMethodReferencesSearchExecutor : QueryExecutor): Boolean { continue } + // Symbol-based Twig extension usage targets like `form_start` in `{{ form_start() }}` should stay in the Twig bucket. + if (target.element.containingFile is TwigFile && TwigExtensionUsageUtil.getTwigExtensionSymbol(target.element) != null) { + return true + } + // Limit Twig usage grouping to the concrete PHP symbol kinds supported by the Twig search executor. if (target.element is Method || target.element is Field || target.element is PhpEnumCase || target.element is PhpClass) { return true diff --git a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigUsageTargetUtil.kt b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigUsageTargetUtil.kt index 884a99ff5..bc6dac195 100644 --- a/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigUsageTargetUtil.kt +++ b/src/main/kotlin/fr/adrienbrault/idea/symfony2plugin/templating/usages/TwigUsageTargetUtil.kt @@ -22,8 +22,6 @@ object TwigUsageTargetUtil { fun getTwigFindUsagesTargets(element: PsiElement): List { val targets = linkedSetOf() - targets.addAll(TwigExtensionUsageUtil.getFunctionTargets(element)) - targets.addAll(TwigExtensionUsageUtil.getFilterTargets(element)) targets.addAll(TwigTemplateGoToDeclarationHandler.getTypeGoto(element)) targets.addAll(getConstantTargets(element)) targets.addAll(getEnumTargets(element)) diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/TwigExtensionParserTest.java b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/TwigExtensionParserTest.java index 9e0b1633e..7e770bbca 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/TwigExtensionParserTest.java +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/TwigExtensionParserTest.java @@ -100,6 +100,16 @@ public void testExtensionAreCollected() { "#M#C\\Twig\\Extensions.parseAttributeFunction", functions.get("attribute_parser_callable").getSignature() ); + + assertEquals( + "#M#C\\Symfony\\Bridge\\Twig\\Node\\RenderBlockNode.compile", + functions.get("form_start").getSignature() + ); + + assertEquals( + "#M#C\\My_Filter_Node.compile", + filters.get("node_class_filter").getSignature() + ); } public void testExtensionAreCollectedForDeprecated() { diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/fixtures/twig_extensions.php b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/fixtures/twig_extensions.php index 9ce52f8b0..f1b645642 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/fixtures/twig_extensions.php +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/fixtures/twig_extensions.php @@ -4,6 +4,9 @@ { function foo_test() {} class My_Node_Test {} + class My_Filter_Node { + public function compile() {} + } class ClassInstance { public function getFoobar() {} @@ -91,6 +94,7 @@ public function getFilters() new \Twig_Filter('trans_2', [$this, 'foobar']), new TwigFilter('trans_3', [$this, 'foobar']), new TwigFilter('default', [self::class, 'default'], ['node_class' => DefaultFilter::class]), + new TwigFilter('node_class_filter', null, ['node_class' => \My_Filter_Node::class]), new TwigFilter('spaceless_deprecation_info', [self::class, 'spaceless'], ['is_safe' => ['html'], 'deprecation_info' => new \Twig\DeprecatedCallableInfo('twig/twig', '3.12')]), new TwigFilter('spaceless_deprecation_deprecated', [self::class, 'spaceless'], ['is_safe' => ['html'], 'deprecated' => 12.12]), ]; @@ -122,6 +126,9 @@ public function getFunctions() new TwigFunction('class_php_callable_method_foobar', $this->getFoobar(...)), new TwigFunction('class_php_callable_function_foobar', max(...)), new TwigFunction('attribute_parser_callable', null, ['parser_callable' => [self::class, 'parseAttributeFunction']]), + new TwigFunction('form', null, ['node_class' => \Symfony\Bridge\Twig\Node\RenderBlockNode::class]), + new TwigFunction('form_start', null, ['node_class' => \Symfony\Bridge\Twig\Node\RenderBlockNode::class]), + new TwigFunction('form_end', null, ['node_class' => \Symfony\Bridge\Twig\Node\RenderBlockNode::class]), new TwigFunction('deprecated_function_info', [self::class, 'deprecatedFunctionInfo'], ['deprecation_info' => new \Twig\DeprecatedCallableInfo('twig/twig', '3.12')]), new TwigFunction('deprecated_function', [self::class, 'deprecatedFunction'], ['deprecated' => 12.12]), ]; @@ -153,6 +160,15 @@ public function foobar() } } +namespace Symfony\Bridge\Twig\Node { + class RenderBlockNode + { + public function compile() + { + } + } +} + namespace Twig\Extension { interface ExtensionInterface {} class AbstractExtension implements ExtensionInterface {} @@ -180,4 +196,4 @@ public function formatProductNumberTest(string $number): string { } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerFactoryTest.kt b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerFactoryTest.kt index ef6d58f0c..dfef07f89 100644 --- a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerFactoryTest.kt +++ b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerFactoryTest.kt @@ -202,8 +202,7 @@ class TwigFindUsagesHandlerFactoryTest : SymfonyLightCodeInsightFixtureTestCase( val handler = TwigFindUsagesHandlerFactory().createFindUsagesHandler(twigElement!!, false) assertInstanceOf(handler, TwigFindUsagesHandler::class.java) assertEquals(1, handler!!.primaryElements.size) - assertInstanceOf(handler.primaryElements[0], Method::class.java) - assertEquals("formatProductNumberFunction", (handler.primaryElements[0] as Method).name) + assertEquals("product_number_function", handler.primaryElements[0].text) } fun testCreateFindUsagesHandlerOnTwigFilterUsageReturnsPhpMethodPrimaryElement() { @@ -214,8 +213,18 @@ class TwigFindUsagesHandlerFactoryTest : SymfonyLightCodeInsightFixtureTestCase( val handler = TwigFindUsagesHandlerFactory().createFindUsagesHandler(twigElement!!, false) assertInstanceOf(handler, TwigFindUsagesHandler::class.java) assertEquals(1, handler!!.primaryElements.size) - assertInstanceOf(handler.primaryElements[0], Method::class.java) - assertEquals("formatProductNumberFilter", (handler.primaryElements[0] as Method).name) + assertEquals("product_number_filter", handler.primaryElements[0].text) + } + + fun testCreateFindUsagesHandlerOnNodeClassTwigFunctionUsageReturnsTwigSymbolPrimaryElement() { + val psiFile = configureByProjectPath("templates/index.html.twig", "{{ form_start() }}") + val twigElement = psiFile.findElementAt(myFixture.caretOffset) + assertNotNull(twigElement) + + val handler = TwigFindUsagesHandlerFactory().createFindUsagesHandler(twigElement!!, false) + assertInstanceOf(handler, TwigFindUsagesHandler::class.java) + assertEquals(1, handler!!.primaryElements.size) + assertEquals("form_start", handler.primaryElements[0].text) } fun testGetTargetsOnTwigConstantEnumCaseReturnsPhpEnumCaseUsageTarget() { diff --git a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerIntegrationTest.kt b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerIntegrationTest.kt index 4689c7b4d..b282f7a11 100644 --- a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerIntegrationTest.kt +++ b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerIntegrationTest.kt @@ -344,8 +344,7 @@ class TwigFindUsagesHandlerIntegrationTest : SymfonyLightCodeInsightFixtureTestC val primaryElements = handler!!.primaryElements assertEquals(1, primaryElements.size) - assertInstanceOf(primaryElements[0], Method::class.java) - assertEquals("formatProductNumberFunction", (primaryElements[0] as Method).name) + assertEquals("product_number_function", primaryElements[0].text) val usages = findUsagesFromPlatform(twigElement) @@ -365,8 +364,7 @@ class TwigFindUsagesHandlerIntegrationTest : SymfonyLightCodeInsightFixtureTestC val primaryElements = handler!!.primaryElements assertEquals(1, primaryElements.size) - assertInstanceOf(primaryElements[0], Method::class.java) - assertEquals("formatProductNumberFilter", (primaryElements[0] as Method).name) + assertEquals("product_number_filter", primaryElements[0].text) val usages = findUsagesFromPlatform(twigElement) @@ -374,6 +372,28 @@ class TwigFindUsagesHandlerIntegrationTest : SymfonyLightCodeInsightFixtureTestC assertContainsUsageFile(usages, "templates/secondary.html.twig") } + fun testPlatformFindUsagesTriggeredFromNodeClassTwigFunctionSearchesExactSymbolUsages() { + myFixture.addFileToProject("templates/secondary.html.twig", "{{ form_start() }}") + myFixture.addFileToProject("templates/other.html.twig", "{{ form() }} {{ form_end() }}") + + val psiFile = configureByProjectPath("templates/index.html.twig", "{{ form_start() }}") + val twigElement = psiFile.findElementAt(myFixture.caretOffset) + assertNotNull(twigElement) + + val handler = getPlatformFindUsagesHandler(twigElement!!) + assertInstanceOf(handler, TwigFindUsagesHandler::class.java) + + val primaryElements = handler!!.primaryElements + assertEquals(1, primaryElements.size) + assertEquals("form_start", primaryElements[0].text) + + val usages = findUsagesFromPlatform(twigElement) + + assertContainsUsageFile(usages, "templates/index.html.twig") + assertContainsUsageFile(usages, "templates/secondary.html.twig") + assertNotContainsUsageFile(usages, "templates/other.html.twig") + } + private fun findUsagesFromPlatform(targetElement: PsiElement): List { val handler = getPlatformFindUsagesHandler(targetElement) assertNotNull(handler) diff --git a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerTest.kt b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerTest.kt index e39fe412d..15b84491b 100644 --- a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerTest.kt +++ b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigFindUsagesHandlerTest.kt @@ -7,10 +7,18 @@ import com.intellij.psi.util.PsiTreeUtil import com.intellij.usageView.UsageInfo import com.jetbrains.php.lang.psi.elements.Method import com.jetbrains.twig.elements.TwigFieldReference +import fr.adrienbrault.idea.symfony2plugin.templating.usages.TwigExtensionSymbolFindUsagesTarget +import fr.adrienbrault.idea.symfony2plugin.templating.usages.TwigExtensionUsageKind +import fr.adrienbrault.idea.symfony2plugin.templating.usages.TwigExtensionUsageSymbol +import fr.adrienbrault.idea.symfony2plugin.templating.usages.TwigPhpFindUsagesTarget import fr.adrienbrault.idea.symfony2plugin.templating.usages.TwigFindUsagesHandler import fr.adrienbrault.idea.symfony2plugin.tests.SymfonyLightCodeInsightFixtureTestCase class TwigFindUsagesHandlerTest : SymfonyLightCodeInsightFixtureTestCase() { + override fun getTestDataPath(): String { + return "src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/fixtures" + } + fun testPrimaryElementsExposeResolvedPhpTargets() { val phpFile = configureByProjectPath( "src/Bar.php", @@ -30,7 +38,7 @@ class TwigFindUsagesHandlerTest : SymfonyLightCodeInsightFixtureTestCase() { val twigElement = PsiTreeUtil.getParentOfType(twigFile.findElementAt(myFixture.caretOffset), TwigFieldReference::class.java, false) assertNotNull(twigElement) - val handler = TwigFindUsagesHandler(twigElement!!, listOf(method!!)) + val handler = TwigFindUsagesHandler(TwigPhpFindUsagesTarget(twigElement!!, listOf(method!!))) assertEquals(1, handler.primaryElements.size) assertSame(method, handler.primaryElements[0]) @@ -57,7 +65,36 @@ class TwigFindUsagesHandlerTest : SymfonyLightCodeInsightFixtureTestCase() { val twigElement = PsiTreeUtil.getParentOfType(twigFile.findElementAt(myFixture.caretOffset), TwigFieldReference::class.java, false) assertNotNull(twigElement) - val handler = TwigFindUsagesHandler(twigElement!!, listOf(method!!)) + val handler = TwigFindUsagesHandler(TwigPhpFindUsagesTarget(twigElement!!, listOf(method!!))) + val options: FindUsagesOptions = handler.findUsagesOptions + options.searchScope = GlobalSearchScope.projectScope(project) + options.isSearchForTextOccurrences = false + options.isUsages = true + + val usages = mutableListOf() + val completed = handler.processElementUsages(twigElement, { usages.add(it) }, options) + assertTrue("Find usages processing was interrupted", completed) + + assertContainsUsageFile(usages, "templates/index.html.twig") + assertContainsUsageFile(usages, "templates/secondary.html.twig") + } + + fun testProcessElementUsagesSearchesTwigExtensionSymbolsByExactName() { + myFixture.copyFileToProject("twig_extensions.php") + + val twigFile = configureByProjectPath("templates/index.html.twig", "{{ form_start() }}") + myFixture.addFileToProject("templates/secondary.html.twig", "{{ form_start() }}") + myFixture.addFileToProject("templates/other.html.twig", "{{ form() }} {{ form_end() }}") + + val twigElement = twigFile.findElementAt(myFixture.caretOffset) + assertNotNull(twigElement) + + val handler = TwigFindUsagesHandler( + TwigExtensionSymbolFindUsagesTarget( + primaryElement = twigElement!!, + symbol = TwigExtensionUsageSymbol(TwigExtensionUsageKind.FUNCTION, "form_start"), + ) + ) val options: FindUsagesOptions = handler.findUsagesOptions options.searchScope = GlobalSearchScope.projectScope(project) options.isSearchForTextOccurrences = false @@ -69,6 +106,7 @@ class TwigFindUsagesHandlerTest : SymfonyLightCodeInsightFixtureTestCase() { assertContainsUsageFile(usages, "templates/index.html.twig") assertContainsUsageFile(usages, "templates/secondary.html.twig") + assertNotContainsUsageFile(usages, "templates/other.html.twig") } private fun configureByProjectPath(filePath: String, content: String): PsiFile { @@ -90,4 +128,10 @@ class TwigFindUsagesHandlerTest : SymfonyLightCodeInsightFixtureTestCase() { fail("Expected usage in file: $relativePath; actual: $actualUsages") } + + private fun assertNotContainsUsageFile(usages: List, relativePath: String) { + if (usages.any { it.virtualFile?.path?.endsWith(relativePath) == true }) { + fail("Did not expect usage in file: $relativePath") + } + } } diff --git a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigMethodUsageTypeProviderTest.kt b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigMethodUsageTypeProviderTest.kt index 3ddb633b8..93af0fa36 100644 --- a/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigMethodUsageTypeProviderTest.kt +++ b/src/test/kotlin/fr/adrienbrault/idea/symfony2plugin/tests/templating/usages/TwigMethodUsageTypeProviderTest.kt @@ -13,6 +13,15 @@ import fr.adrienbrault.idea.symfony2plugin.templating.usages.TwigMethodUsageType import fr.adrienbrault.idea.symfony2plugin.tests.SymfonyLightCodeInsightFixtureTestCase class TwigMethodUsageTypeProviderTest : SymfonyLightCodeInsightFixtureTestCase() { + override fun setUp() { + super.setUp() + myFixture.copyFileToProject("twig_extensions.php") + } + + override fun getTestDataPath(): String { + return "src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/util/fixtures" + } + fun testClassifiesTwigMethodUsages() { val method = getMethodUnderCaret( """ @@ -57,6 +66,34 @@ class TwigMethodUsageTypeProviderTest : SymfonyLightCodeInsightFixtureTestCase() assertEquals("Twig", usageType.toString()) } + fun testClassifiesTwigFunctionSymbolUsages() { + val twigFile = configureByProjectPath("templates/index.html.twig", "{{ form_start() }}") + val element = twigFile.findElementAt(myFixture.caretOffset) + assertNotNull(element) + + val usageType: UsageType? = TwigMethodUsageTypeProvider().getUsageType( + element!!, + arrayOf(PsiElement2UsageTargetAdapter(element, true)) + ) + + assertNotNull(usageType) + assertEquals("Twig", usageType.toString()) + } + + fun testClassifiesTwigFilterSymbolUsages() { + val twigFile = configureByProjectPath("templates/index.html.twig", "{{ value|product_number_filter }}") + val element = twigFile.findElementAt(myFixture.caretOffset) + assertNotNull(element) + + val usageType: UsageType? = TwigMethodUsageTypeProvider().getUsageType( + element!!, + arrayOf(PsiElement2UsageTargetAdapter(element, true)) + ) + + assertNotNull(usageType) + assertEquals("Twig", usageType.toString()) + } + private fun getMethodUnderCaret(content: String): Method { val psiFile = myFixture.configureByText("Bar.php", content) val element = psiFile.findElementAt(myFixture.caretOffset) @@ -72,4 +109,15 @@ class TwigMethodUsageTypeProviderTest : SymfonyLightCodeInsightFixtureTestCase() assertNotNull(field) return field!! } + + private fun configureByProjectPath(filePath: String, content: String): com.intellij.psi.PsiFile { + val caretOffset = content.indexOf("") + assertTrue("Missing marker", caretOffset >= 0) + + myFixture.addFileToProject(filePath, content.replace("", "")) + myFixture.configureFromExistingVirtualFile(myFixture.findFileInTempDir(filePath)) + myFixture.editor.caretModel.moveToOffset(caretOffset) + + return myFixture.file + } }