Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, TwigExtension> filters) {
final PhpClass containingClass = method.getContainingClass();
if (containingClass == null) {
Expand Down Expand Up @@ -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<String, String> options;
if (psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Method> {
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<Method> {
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)
}

/**
Expand All @@ -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<String, TwigExtension>,
): List<Method> {
val methods = linkedSetOf<Method>()
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)
}

/**
Expand All @@ -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, TwigExtension>,
): 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.
*/
Expand All @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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 <daniel@espendiller.net>
*/
class TwigFindUsagesHandler(
twigElement: PsiElement,
private val phpTargets: List<PsiElement>,
) : 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<PsiElement> = phpTargets.toTypedArray()
override fun getPrimaryElements(): Array<PsiElement> = 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<in UsageInfo>,
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
}

/**
Expand All @@ -48,21 +57,107 @@ class TwigFindUsagesHandler(
processor: Processor<in UsageInfo>,
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<PsiElement> {
private fun getEffectivePhpTargets(element: PsiElement, phpTargets: List<PsiElement>): List<PsiElement> {
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<in UsageInfo>,
options: FindUsagesOptions,
): Boolean {
return ApplicationManager.getApplication().runReadAction<Boolean> {
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<PsiElement>
}

/**
* Twig usage target that delegates the search to one or more resolved PHP elements.
*/
data class TwigPhpFindUsagesTarget(
override val primaryElement: PsiElement,
val phpTargets: List<PsiElement>,
) : TwigFindUsagesTarget {
override val primaryElements: List<PsiElement> = 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<PsiElement> = 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)
}
}
Loading
Loading