package fr.adrienbrault.idea.symfony2plugin.templating; import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler; import com.intellij.openapi.actionSystem.DataContext; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.vfs.VfsUtil; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.patterns.PlatformPatterns; import com.intellij.psi.PsiDirectory; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiManager; import com.intellij.psi.PsiWhiteSpace; import com.intellij.psi.util.PsiTreeUtil; import com.intellij.util.containers.ContainerUtil; import com.jetbrains.php.PhpIndex; import com.jetbrains.php.lang.psi.elements.Field; import com.jetbrains.php.lang.psi.elements.PhpClass; import com.jetbrains.twig.TwigLanguage; import com.jetbrains.twig.TwigTokenTypes; import com.jetbrains.twig.elements.TwigBlockTag; import com.jetbrains.twig.elements.TwigElementTypes; import com.jetbrains.twig.elements.TwigTagWithFileReference; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; import fr.adrienbrault.idea.symfony2plugin.routing.RouteHelper; import fr.adrienbrault.idea.symfony2plugin.templating.dict.TwigExtension; import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigExtensionParser; import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigTypeResolveUtil; import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigUtil; import fr.adrienbrault.idea.symfony2plugin.templating.variable.TwigTypeContainer; import fr.adrienbrault.idea.symfony2plugin.translation.dict.TranslationUtil; import fr.adrienbrault.idea.symfony2plugin.twig.utils.TwigBlockUtil; import fr.adrienbrault.idea.symfony2plugin.twig.variable.collector.ControllerDocVariableCollector; import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil; import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils; import org.apache.commons.lang.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * @author Adrien Brault <[email protected]> * @author Daniel Espendiller <[email protected]> */ public class TwigTemplateGoToDeclarationHandler implements GotoDeclarationHandler { @Nullable @Override public PsiElement[] getGotoDeclarationTargets(PsiElement psiElement, int offset, Editor editor) { if (!Symfony2ProjectComponent.isEnabled(psiElement) || !PlatformPatterns.psiElement().withLanguage(TwigLanguage.INSTANCE).accepts(psiElement)) { return null; } Collection<PsiElement> targets = new ArrayList<>(); if (TwigPattern.getBlockTagPattern().accepts(psiElement)) { targets.addAll(TwigBlockUtil.getBlockOverwriteTargets(psiElement)); } if (TwigPattern.getPathAfterLeafPattern().accepts(psiElement)) { targets.addAll(getRouteParameterGoTo(psiElement)); } if (TwigPattern.getTemplateFileReferenceTagPattern().accepts(psiElement) || TwigPattern.getPrintBlockOrTagFunctionPattern("include", "source").accepts(psiElement)) { // support: {% include() %}, {{ include() }} targets.addAll(getTwigFiles(psiElement, offset)); } else if (PlatformPatterns.psiElement(TwigTokenTypes.STRING_TEXT).withText(PlatformPatterns.string().endsWith(".twig")).accepts(psiElement)) { // provide global twig file resolving // just if we dont match against known file references pattern targets.addAll(getTwigFiles(psiElement, offset)); } if (TwigPattern.getAutocompletableRoutePattern().accepts(psiElement)) { targets.addAll(getRouteGoTo(psiElement)); } // find trans('', {}, '|') // tricky way to get the function string trans(...) if (TwigPattern.getTransDomainPattern().accepts(psiElement)) { PsiElement psiElementTrans = PsiElementUtils.getPrevSiblingOfType(psiElement, PlatformPatterns.psiElement(TwigTokenTypes.IDENTIFIER).withText(PlatformPatterns.string().oneOf("trans", "transchoice"))); if (psiElementTrans != null && TwigUtil.getTwigMethodString(psiElementTrans) != null) { targets.addAll(getTranslationDomainGoto(psiElement)); } } // {% trans from "app" %} // {% transchoice from "app" %} if (TwigPattern.getTranslationTokenTagFromPattern().accepts(psiElement)) { targets.addAll(getTranslationDomainGoto(psiElement)); } if (TwigPattern.getTranslationKeyPattern("trans", "transchoice").accepts(psiElement)) { targets.addAll(getTranslationKeyGoTo(psiElement)); } if (TwigPattern.getPrintBlockOrTagFunctionPattern("controller").accepts(psiElement) || TwigPattern.getStringAfterTagNamePattern("render").accepts(psiElement)) { targets.addAll(getControllerGoTo(psiElement)); } if (TwigPattern.getTransDefaultDomainPattern().accepts(psiElement)) { targets.addAll(TranslationUtil.getDomainPsiFiles(psiElement.getProject(), psiElement.getText())); } if (PlatformPatterns.or(TwigPattern.getFilterPattern(), TwigPattern.getApplyFilterPattern()).accepts(psiElement)) { targets.addAll(getFilterGoTo(psiElement)); } // {% if foo is ... %} // {% if foo is not ... %} if(PlatformPatterns.or(TwigPattern.getAfterIsTokenPattern(), TwigPattern.getAfterIsTokenWithOneIdentifierLeafPattern()).accepts(psiElement)) { targets.addAll(getAfterIsToken(psiElement)); } // {{ goto<caret>_me() }} // {% if goto<caret>_me() %} // {% set foo = foo<caret>_test() %} if (TwigPattern.getPrintBlockFunctionPattern().accepts(psiElement)) { targets.addAll(this.getMacros(psiElement)); targets.addAll(this.getFunctions(psiElement)); } // {% from 'boo.html.twig' import goto_me %} if (TwigPattern.getTemplateImportFileReferenceTagPattern().accepts(psiElement)) { targets.addAll(this.getMacros(psiElement)); } // {% set foo %} // {% set foo = bar %} if (PlatformPatterns .psiElement(TwigTokenTypes.IDENTIFIER) .withParent( PlatformPatterns.psiElement(TwigElementTypes.PRINT_BLOCK) ).withLanguage(TwigLanguage.INSTANCE).accepts(psiElement)) { targets.addAll(getSets(psiElement)); } // {{ foo.fo<caret>o }} if (TwigPattern.getTypeCompletionPattern().accepts(psiElement) || TwigPattern.getPrintBlockFunctionPattern().accepts(psiElement) || TwigPattern.getVariableTypePattern().accepts(psiElement)) { targets.addAll(getTypeGoto(psiElement)); } if (TwigPattern.getTwigDocBlockMatchPattern(ControllerDocVariableCollector.DOC_PATTERN).accepts(psiElement)) { targets.addAll(getControllerNameGoto(psiElement)); } // {{ parent() }} if (TwigPattern.getParentFunctionPattern().accepts(psiElement)) { targets.addAll(getParentGoto(psiElement)); } // constant('Post::PUBLISHED') if (TwigPattern.getPrintBlockOrTagFunctionPattern("constant").accepts(psiElement)) { targets.addAll(getConstantGoto(psiElement)); } // {# @var user \Foo #} if (TwigPattern.getTwigTypeDocBlockPattern().accepts(psiElement)) { targets.addAll(getVarClassGoto(psiElement)); } // {# @see Foo.html.twig #} // {# @see \Class #} if (TwigPattern.getTwigDocSeePattern().accepts(psiElement)) { targets.addAll(getSeeDocTagTargets(psiElement)); } // {% FOO_TOKEN %} if (TwigPattern.getTagTokenBlockPattern().accepts(psiElement)) { targets.addAll(getTokenTargets(psiElement)); } return targets.toArray(new PsiElement[0]); } /** * {% if foo is ... %} */ @NotNull private Collection<PsiElement> getAfterIsToken(@NotNull PsiElement psiElement) { // find text after if statement String text = StringUtils.trim( PhpElementsUtil.getPrevSiblingAsTextUntil(psiElement, TwigPattern.getAfterIsTokenTextPattern(), false) + psiElement.getText() ); if(StringUtils.isBlank(text)) { return Collections.emptyList(); } Set<String> items = new HashSet<>( Collections.singletonList(text) ); // support atleat one identifier after current caret position // "divisi<caret>ble by" PsiElement whitespace = psiElement.getNextSibling(); if(whitespace instanceof PsiWhiteSpace) { PsiElement nextSibling = whitespace.getNextSibling(); if(nextSibling != null && nextSibling.getNode().getElementType() == TwigTokenTypes.IDENTIFIER) { String identifier = nextSibling.getText(); if(StringUtils.isNotBlank(identifier)) { items.add(text + " " + identifier); } } } Collection<PsiElement> psiElements = new ArrayList<>(); for (Map.Entry<String, TwigExtension> entry : TwigExtensionParser.getSimpleTest(psiElement.getProject()).entrySet()) { for (String item : items) { if(entry.getKey().equalsIgnoreCase(item)) { psiElements.addAll(Arrays.asList( PhpElementsUtil.getPsiElementsBySignature(psiElement.getProject(), entry.getValue().getSignature())) ); } } } return psiElements; } @NotNull private Collection<PsiElement> getRouteParameterGoTo(@NotNull PsiElement psiElement) { String routeName = TwigUtil.getMatchingRouteNameOnParameter(psiElement); if(routeName == null) { return Collections.emptyList(); } return Arrays.asList( RouteHelper.getRouteParameterPsiElements(psiElement.getProject(), routeName, psiElement.getText()) ); } @NotNull private Collection<PsiElement> getControllerGoTo(@NotNull PsiElement psiElement) { String text = PsiElementUtils.trimQuote(psiElement.getText()); return Arrays.asList(RouteHelper.getMethodsOnControllerShortcut(psiElement.getProject(), text)); } @NotNull private Collection<PsiElement> getTwigFiles(@NotNull PsiElement psiElement, int offset) { int calulatedOffset = offset - psiElement.getTextRange().getStartOffset(); if (calulatedOffset < 0) { calulatedOffset = 0; } return TwigUtil.getTemplateNavigationOnOffset( psiElement.getProject(), psiElement.getText(), calulatedOffset ); } @NotNull private Collection<PsiElement> getFilterGoTo(@NotNull PsiElement psiElement) { Map<String, TwigExtension> filters = TwigExtensionParser.getFilters(psiElement.getProject()); if(!filters.containsKey(psiElement.getText())) { return Collections.emptyList(); } String signature = filters.get(psiElement.getText()).getSignature(); if(signature == null) { return Collections.emptyList(); } return Arrays.asList(PhpElementsUtil.getPsiElementsBySignature(psiElement.getProject(), signature)); } @NotNull private Collection<PsiElement> getRouteGoTo(@NotNull PsiElement psiElement) { String text = PsiElementUtils.getText(psiElement); if(StringUtils.isBlank(text)) { return Collections.emptyList(); } PsiElement[] methods = RouteHelper.getMethods(psiElement.getProject(), text); if(methods.length > 0) { return Arrays.asList(methods); } return RouteHelper.getRouteDefinitionTargets(psiElement.getProject(), text); } @NotNull private Collection<PsiElement> getTranslationKeyGoTo(@NotNull PsiElement psiElement) { String translationKey = psiElement.getText(); return Arrays.asList( TranslationUtil.getTranslationPsiElements(psiElement.getProject(), translationKey, TwigUtil.getPsiElementTranslationDomain(psiElement)) ); } @NotNull private Collection<PsiElement> getTranslationDomainGoto(@NotNull PsiElement psiElement) { String text = PsiElementUtils.trimQuote(psiElement.getText()); if(StringUtils.isBlank(text)) { return Collections.emptyList(); } return new ArrayList<>(TranslationUtil.getDomainPsiFiles(psiElement.getProject(), text)); } @NotNull private Collection<PsiElement> getConstantGoto(@NotNull PsiElement psiElement) { Collection<PsiElement> targetPsiElements = new ArrayList<>(); String contents = psiElement.getText(); if(StringUtils.isBlank(contents)) { return targetPsiElements; } // global constant if(!contents.contains(":")) { targetPsiElements.addAll(PhpIndex.getInstance(psiElement.getProject()).getConstantsByName(contents)); return targetPsiElements; } // resolve class constants String[] parts = contents.split("::"); if(parts.length != 2) { return targetPsiElements; } PhpClass phpClass = PhpElementsUtil.getClassInterface(psiElement.getProject(), parts[0].replace("\\\\", "\\")); if(phpClass == null) { return targetPsiElements; } Field field = phpClass.findFieldByName(parts[1], true); if(field != null) { targetPsiElements.add(field); } return targetPsiElements; } /** * Extract class from inline variables * * {# @var \AppBundle\Entity\Foo variable #} * {# @var variable \AppBundle\Entity\Foo #} */ @NotNull private Collection<PhpClass> getVarClassGoto(@NotNull PsiElement psiElement) { String comment = psiElement.getText(); if(StringUtils.isBlank(comment)) { return Collections.emptyList(); } for(String pattern: TwigTypeResolveUtil.DOC_TYPE_PATTERN_SINGLE) { Matcher matcher = Pattern.compile(pattern).matcher(comment); if (matcher.find()) { String className = matcher.group("class"); if(StringUtils.isNotBlank(className)) { return PhpElementsUtil.getClassesInterface(psiElement.getProject(), className); } } } return Collections.emptyList(); } @NotNull private Collection<PsiElement> getSeeDocTagTargets(@NotNull PsiElement psiElement) { String comment = psiElement.getText(); if(StringUtils.isBlank(comment)) { return Collections.emptyList(); } Collection<PsiElement> psiElements = new ArrayList<>(); for(String pattern: new String[] {TwigPattern.DOC_SEE_REGEX, TwigUtil.DOC_SEE_REGEX_WITHOUT_SEE}) { Matcher matcher = Pattern.compile(pattern).matcher(comment); if (!matcher.find()) { continue; } String content = matcher.group(1); if(content.toLowerCase().endsWith(".twig")) { psiElements.addAll(TwigUtil.getTemplatePsiElements(psiElement.getProject(), content)); } psiElements.addAll(PhpElementsUtil.getClassesInterface(psiElement.getProject(), content)); ContainerUtil.addAll(psiElements, RouteHelper.getMethodsOnControllerShortcut(psiElement.getProject(), content)); PsiDirectory parent = psiElement.getContainingFile().getParent(); if(parent != null) { VirtualFile relativeFile = VfsUtil.findRelativeFile(parent.getVirtualFile(), content.replace("\\", "/").split("/")); if(relativeFile != null) { ContainerUtil.addIfNotNull(psiElements, PsiManager.getInstance(psiElement.getProject()).findFile(relativeFile)); } } Matcher methodMatcher = Pattern.compile("([\\w\\\\-]+):+([\\w_\\-]+)").matcher(content); if (methodMatcher.find()) { for (PhpClass phpClass : PhpIndex.getInstance(psiElement.getProject()).getAnyByFQN(methodMatcher.group(1))) { ContainerUtil.addIfNotNull(psiElements, phpClass.findMethodByName(methodMatcher.group(2))); } } } return psiElements; } @NotNull private Collection<PsiElement> getTypeGoto(@NotNull PsiElement psiElement) { Collection<PsiElement> targetPsiElements = new ArrayList<>(); // class, class.method, class.method.method // click on first item is our class name Collection<String> beforeLeaf = TwigTypeResolveUtil.formatPsiTypeName(psiElement); if(beforeLeaf.size() == 0) { Collection<TwigTypeContainer> twigTypeContainers = TwigTypeResolveUtil.resolveTwigMethodName(psiElement, TwigTypeResolveUtil.formatPsiTypeNameWithCurrent(psiElement)); for(TwigTypeContainer twigTypeContainer: twigTypeContainers) { if(twigTypeContainer.getPhpNamedElement() != null) { targetPsiElements.add(twigTypeContainer.getPhpNamedElement()); } } } else { Collection<TwigTypeContainer> types = TwigTypeResolveUtil.resolveTwigMethodName(psiElement, beforeLeaf); String text = psiElement.getText(); if(StringUtils.isNotBlank(text)) { // provide method / field goto for(TwigTypeContainer twigTypeContainer: types) { if(twigTypeContainer.getPhpNamedElement() != null) { targetPsiElements.addAll(TwigTypeResolveUtil.getTwigPhpNameTargets(twigTypeContainer.getPhpNamedElement(), text)); } } } } return targetPsiElements; } @NotNull private Collection<PsiElement> getFunctions(@NotNull PsiElement psiElement) { Map<String, TwigExtension> functions = TwigExtensionParser.getFunctions(psiElement.getProject()); String funcName = psiElement.getText(); if(!functions.containsKey(funcName)) { return Collections.emptyList(); } return Arrays.asList(PhpElementsUtil.getPsiElementsBySignature(psiElement.getProject(), functions.get(funcName).getSignature())); } @NotNull private Collection<PsiElement> getSets(@NotNull PsiElement psiElement) { String funcName = psiElement.getText(); for(String twigSet: TwigUtil.getSetDeclaration(psiElement.getContainingFile())) { if(twigSet.equals(funcName)) { // @TODO: drop regex return Arrays.asList(PsiTreeUtil.collectElements(psiElement.getContainingFile(), psiElement1 -> PlatformPatterns.psiElement(TwigTagWithFileReference.class) .accepts(psiElement1) && psiElement1.getText() .matches("\\{%\\s?set\\s?" + Pattern.quote(funcName) + "\\s?.*")) ); } } return Collections.emptyList(); } @NotNull private Collection<PsiElement> getMacros(@NotNull PsiElement psiElement) { String funcName = psiElement.getText(); // check for complete file as namespace import {% import "file" as foo %} // {% import _self as foobar %} // {{ foobar.bar }} PsiElement prevSibling = psiElement.getPrevSibling(); if(prevSibling != null && prevSibling.getNode().getElementType() == TwigTokenTypes.DOT) { PsiElement identifier = prevSibling.getPrevSibling(); if(identifier == null || identifier.getNode().getElementType() != TwigTokenTypes.IDENTIFIER) { return Collections.emptyList(); } return TwigUtil.getImportedMacrosNamespaces( psiElement.getContainingFile(), identifier.getText() + "." + funcName ); } // {% from _self import foobar as input, foobar %} return TwigUtil.getImportedMacros(psiElement.getContainingFile(), funcName); } @NotNull private Collection<PsiElement> getControllerNameGoto(@NotNull PsiElement psiElement) { Pattern pattern = Pattern.compile(ControllerDocVariableCollector.DOC_PATTERN); Matcher matcher = pattern.matcher(psiElement.getText()); if (!matcher.find()) { return Collections.emptyList(); } String controllerName = matcher.group(1); return Arrays.asList(RouteHelper.getMethodsOnControllerShortcut(psiElement.getProject(), controllerName)); } @NotNull private Collection<PsiElement> getParentGoto(@NotNull PsiElement psiElement) { // find printblock PsiElement printBlock = psiElement.getParent(); if(printBlock == null || !PlatformPatterns.psiElement(TwigElementTypes.PRINT_BLOCK).accepts(printBlock)) { return Collections.emptyList(); } // printblock need to be child block statement PsiElement blockStatement = printBlock.getParent(); if(blockStatement == null || !PlatformPatterns.psiElement(TwigElementTypes.BLOCK_STATEMENT).accepts(blockStatement)) { return Collections.emptyList(); } // BlockTag is first child of block statement PsiElement blockTag = blockStatement.getFirstChild(); if(!(blockTag instanceof TwigBlockTag)) { return Collections.emptyList(); } String blockName = ((TwigBlockTag) blockTag).getName(); if(blockName == null) { return Collections.emptyList(); } return TwigBlockUtil.getBlockOverwriteTargets(psiElement.getContainingFile(), blockName, false); } @NotNull private Collection<? extends PsiElement> getTokenTargets(@NotNull PsiElement psiElement) { String tagName = psiElement.getText(); if(StringUtils.isBlank(tagName)) { return Collections.emptyList(); } Collection<PsiElement> targets = new ArrayList<>(); TwigUtil.visitTokenParsers(psiElement.getProject(), pair -> { // support direct tag name or ending tag // {% tag_name %} // {% endtag_name %} String currentTagName = pair.getFirst(); if(tagName.equalsIgnoreCase(currentTagName) || (tagName.toLowerCase().startsWith("end") && currentTagName.equalsIgnoreCase(tagName.substring(3)))) { targets.add(pair.getSecond()); } }); return targets; } @Nullable @Override public String getActionText(@NotNull DataContext dataContext) { return null; } }