package fr.adrienbrault.idea.symfony2plugin.templating.util; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Pair; import com.intellij.openapi.vfs.VfsUtil; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.patterns.PlatformPatterns; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import com.intellij.psi.PsiRecursiveElementWalkingVisitor; import com.intellij.psi.search.GlobalSearchScope; import com.intellij.psi.tree.IElementType; import com.intellij.psi.util.PsiTreeUtil; import com.intellij.util.Consumer; import com.intellij.util.Processor; import com.intellij.util.indexing.FileBasedIndex; import com.intellij.util.indexing.FileBasedIndexImpl; import com.jetbrains.php.PhpIndex; import com.jetbrains.php.lang.documentation.phpdoc.psi.PhpDocComment; import com.jetbrains.php.lang.documentation.phpdoc.psi.tags.PhpDocTag; import com.jetbrains.php.lang.psi.elements.Function; import com.jetbrains.php.lang.psi.elements.Method; import com.jetbrains.php.lang.psi.elements.PhpClass; import com.jetbrains.php.lang.psi.elements.PhpPsiElement; import com.jetbrains.twig.TwigFile; import com.jetbrains.twig.TwigFileType; import com.jetbrains.twig.TwigTokenTypes; import com.jetbrains.twig.elements.*; import fr.adrienbrault.idea.symfony2plugin.TwigHelper; import fr.adrienbrault.idea.symfony2plugin.stubs.dict.TemplateUsage; import fr.adrienbrault.idea.symfony2plugin.stubs.dict.TwigMacroTagIndex; import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.PhpTwigTemplateUsageStubIndex; import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.TwigExtendsStubIndex; import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.TwigMacroFunctionStubIndex; import fr.adrienbrault.idea.symfony2plugin.templating.dict.*; import fr.adrienbrault.idea.symfony2plugin.templating.path.TwigPath; import fr.adrienbrault.idea.symfony2plugin.templating.path.TwigPathIndex; import fr.adrienbrault.idea.symfony2plugin.templating.variable.dict.PsiVariable; import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil; import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils; import fr.adrienbrault.idea.symfony2plugin.util.SymfonyBundleUtil; import fr.adrienbrault.idea.symfony2plugin.util.dict.SymfonyBundle; import fr.adrienbrault.idea.symfony2plugin.util.psi.PsiElementAssertUtil; 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 Daniel Espendiller <daniel@espendiller.net> */ public class TwigUtil { @Nullable public static String[] getControllerMethodShortcut(Method method) { // indexAction String methodName = method.getName(); if(!methodName.endsWith("Action")) { return null; } PhpClass phpClass = method.getContainingClass(); if(null == phpClass) { return null; } // defaultController // default/Folder/FolderController String className = phpClass.getName(); if(!className.endsWith("Controller")) { return null; } SymfonyBundleUtil symfonyBundleUtil = new SymfonyBundleUtil(PhpIndex.getInstance(method.getProject())); SymfonyBundle symfonyBundle = symfonyBundleUtil.getContainingBundle(phpClass); if(symfonyBundle == null) { return null; } // find the bundle name of file PhpClass BundleClass = symfonyBundle.getPhpClass(); if(null == BundleClass) { return null; } // check if files is in <Bundle>/Controller/* if(!phpClass.getNamespaceName().startsWith(BundleClass.getNamespaceName() + "Controller\\")) { return null; } // strip the controller folder name String templateFolderName = phpClass.getNamespaceName().substring(BundleClass.getNamespaceName().length() + 11); // HomeBundle:default:indexes // HomeBundle:default/Test:indexes templateFolderName = templateFolderName.replace("\\", "/"); String shortcutName = symfonyBundle.getName() + ":" + templateFolderName + className.substring(0, className.lastIndexOf("Controller")) + ":" + methodName.substring(0, methodName.lastIndexOf("Action")); // @TODO: we should support types later on; but nicer // HomeBundle:default:indexes.html.twig return new String[] { shortcutName + ".html.twig", shortcutName + ".json.twig", shortcutName + ".xml.twig", }; } @NotNull public static Map<String, PsiElement> getTemplateAnnotationFiles(PhpDocTag phpDocTag) { // @TODO: @Template(template="templatename") // Also replace "Matcher" with annotation psi elements; now possible // Wait for "annotation plugin" update; to not implement whole stuff here again? Map<String, PsiElement> templateFiles = new HashMap<>(); // find template name on annotation parameter // @Template("templatename") PhpPsiElement phpDocAttrList = phpDocTag.getFirstPsiChild(); if(phpDocAttrList == null) { return templateFiles; } String tagValue = phpDocAttrList.getText(); Matcher matcher = Pattern.compile("\\(\"(.*)\"").matcher(tagValue); if (matcher.find()) { // @TODO: only one should possible; refactor getTemplatePsiElements PsiElement[] psiElement = TwigHelper.getTemplatePsiElements(phpDocTag.getProject(), matcher.group(1)); if(psiElement.length > 0) { templateFiles.put(matcher.group(1), psiElement[0]); } } return templateFiles; } public static Map<String, PsiElement> getTemplateAnnotationFilesWithSiblingMethod(PhpDocTag phpDocTag) { Map<String, PsiElement> targets = TwigUtil.getTemplateAnnotationFiles(phpDocTag); PhpDocComment phpDocComment = PsiTreeUtil.getParentOfType(phpDocTag, PhpDocComment.class); if(phpDocComment != null) { PsiElement method = phpDocComment.getNextPsiSibling(); if(method instanceof Method) { String[] templateNames = TwigUtil.getControllerMethodShortcut((Method) method); if(templateNames != null) { for (String name : templateNames) { for(PsiElement psiElement: TwigHelper.getTemplatePsiElements(method.getProject(), name)) { targets.put(name, psiElement); } } } } } return targets; } /** * Finds a trans_default_domain definition in twig file * * "{% trans_default_domain "validators" %}" * * @param position current scope to search for: Twig file or embed scope * @return file translation domain */ @Nullable public static String getTransDefaultDomainOnScope(@NotNull PsiElement position) { PsiElement scope = getTransDefaultDomainScope(position); if(scope == null) { return null; } for (PsiElement psiElement : scope.getChildren()) { // filter parent trans_default_domain, it should be in file context if(psiElement instanceof TwigCompositeElement && psiElement.getNode().getElementType() == TwigElementTypes.TAG) { final String[] fileTransDomain = {null}; psiElement.acceptChildren(new PsiRecursiveElementWalkingVisitor() { @Override public void visitElement(PsiElement element) { if(TwigHelper.getTransDefaultDomainPattern().accepts(element)) { String text = PsiElementUtils.trimQuote(element.getText()); if(StringUtils.isNotBlank(text)) { fileTransDomain[0] = text; } } super.visitElement(element); } }); if(fileTransDomain[0] != null) { return fileTransDomain[0]; } } } return null; } /** * Search Twig element to find use trans_default_domain and returns given string parameter */ @Nullable public static String getTransDefaultDomainOnScopeOrInjectedElement(@NotNull PsiElement position, int caretOffset) { if(position.getContainingFile().getContainingFile() == TwigFileType.INSTANCE) { return getTransDefaultDomainOnScope(position); } PsiElement element = getInjectedTwigElement(position.getContainingFile(), caretOffset); if(element != null) { return getTransDefaultDomainOnScope(element); } return null; } /** * Html in Twig is injected trx to find an real Twig element * TODO: there must be some nicer solution * * {% block %}<html/>{% endblock %} */ @Nullable public static PsiElement getInjectedTwigElement(@NotNull PsiFile psiFile, int caretOffset) { PsiElement elementAt; int limit = 20; do { caretOffset = caretOffset - 5; elementAt = psiFile.findElementAt(caretOffset); } while (limit-- > 0 && caretOffset > 0 && elementAt != null && elementAt.getContainingFile().getFileType() != TwigFileType.INSTANCE); return elementAt; } /** * File Scope: * {% trans_default_domain "foo" %} * * Embed: * {embed 'foo.html.twig'}{% trans_default_domain "foo" %}{% endembed %} */ @Nullable public static PsiElement getTransDefaultDomainScope(@NotNull PsiElement psiElement) { return PsiTreeUtil.findFirstParent(psiElement, psiElement1 -> psiElement1 instanceof PsiFile || (psiElement1 instanceof TwigCompositeElement && psiElement1.getNode().getElementType() == TwigElementTypes.EMBED_STATEMENT) ); } /** * need a twig translation print block and search for default domain on parameter or trans_default_domain * * @param psiElement some print block like that 'a'|trans * @return matched domain or "messages" fallback */ @NotNull public static String getPsiElementTranslationDomain(@NotNull PsiElement psiElement) { String domain = getDomainTrans(psiElement); if(domain == null) { domain = getTransDefaultDomainOnScope(psiElement); } return domain == null ? "messages" : domain; } /** * Extract translation domain parameter * trans({}, 'Domain') * transchoice(2, {}, 'Domain') */ @Nullable public static String getDomainTrans(@NotNull PsiElement psiElement) { PsiElement filter = PsiElementUtils.getNextSiblingAndSkip(psiElement, TwigTokenTypes.FILTER, TwigTokenTypes.SINGLE_QUOTE, TwigTokenTypes.DOUBLE_QUOTE); if(!PsiElementAssertUtil.isNotNullAndIsElementType(filter, TwigTokenTypes.FILTER)) { return null; } PsiElement filterName = PsiTreeUtil.nextVisibleLeaf(filter); if(!PsiElementAssertUtil.isNotNullAndIsElementType(filterName, TwigTokenTypes.IDENTIFIER)) { return null; } // Elements that match a simple parameter foo(<caret>,) IElementType[] skipArrayElements = { TwigElementTypes.LITERAL, TwigTokenTypes.LBRACE_SQ, TwigTokenTypes.RBRACE_SQ, TwigTokenTypes.IDENTIFIER }; String filterNameText = filterName.getText(); if("trans".equalsIgnoreCase(filterNameText)) { PsiElement brace = PsiTreeUtil.nextVisibleLeaf(filterName); if (PsiElementAssertUtil.isNotNullAndIsElementType(brace, TwigTokenTypes.LBRACE)) { PsiElement comma = PsiElementUtils.getNextSiblingAndSkip(brace, TwigTokenTypes.COMMA, skipArrayElements); if(comma != null) { String text = extractDomainFromParameter(comma); if (text != null) { return text; } } } } else if ("transchoice".equalsIgnoreCase(filterNameText)) { PsiElement brace = PsiTreeUtil.nextVisibleLeaf(filterName); if (PsiElementAssertUtil.isNotNullAndIsElementType(brace, TwigTokenTypes.LBRACE)) { // skip elements which are possible a parameter variable and are between commas IElementType[] skipElements = { TwigTokenTypes.SINGLE_QUOTE, TwigTokenTypes.DOUBLE_QUOTE, TwigTokenTypes.NUMBER, TwigTokenTypes.STRING_TEXT, TwigTokenTypes.DOT, TwigTokenTypes.IDENTIFIER, TwigTokenTypes.CONCAT, TwigTokenTypes.PLUS, TwigTokenTypes.MINUS, }; PsiElement comma1 = PsiElementUtils.getNextSiblingAndSkip(brace, TwigTokenTypes.COMMA, skipElements); if(comma1 != null) { PsiElement comma2 = PsiElementUtils.getNextSiblingAndSkip(comma1, TwigTokenTypes.COMMA, skipArrayElements); if(comma2 != null) { String text = extractDomainFromParameter(comma2); if (text != null) { return text; } } } } } return null; } /** * ({}, "foobar", ) */ @Nullable private static String extractDomainFromParameter(@NotNull PsiElement comma) { if (PsiElementAssertUtil.isNotNullAndIsElementType(comma, TwigTokenTypes.COMMA)) { PsiElement quote = PsiTreeUtil.nextVisibleLeaf(comma); if (PsiElementAssertUtil.isNotNullAndIsElementType(quote, TwigTokenTypes.SINGLE_QUOTE, TwigTokenTypes.DOUBLE_QUOTE)) { PsiElement text = PsiTreeUtil.nextVisibleLeaf(quote); if (text != null && TwigHelper.getParameterAsStringPattern().accepts(text)) { return text.getText(); } } } return null; } /** * {% import _self as %} * {% import 'foobar.html.twig' as %} * * {% from _self import %} * {% from 'forms.html' import %} */ private static Pair<String, PsiElement> getTemplateNameOnStringAndSelfWithNextPsiElement(@NotNull PsiElement tagPsiElement, @NotNull IElementType elementType) { PsiElement lastElementMatch = null; String template = null; PsiElement selfPsi = PsiElementUtils.getNextSiblingAndSkip(tagPsiElement, TwigTokenTypes.RESERVED_ID); if(selfPsi != null && "_self".equals(selfPsi.getText())) { // {% import _self as foobar %} template = "_self"; lastElementMatch = PsiElementUtils.getNextSiblingAndSkip(selfPsi, elementType); } else { // {% import 'foobar.html.twig' as foobar %} PsiElement templateString = PsiElementUtils.getNextSiblingAndSkip(tagPsiElement, TwigTokenTypes.STRING_TEXT, TwigTokenTypes.SINGLE_QUOTE, TwigTokenTypes.DOUBLE_QUOTE); if(templateString != null) { String templateName = templateString.getText(); if(StringUtils.isNotBlank(templateName)) { template = templateName; lastElementMatch = PsiElementUtils.getNextSiblingAndSkip(templateString, elementType, TwigTokenTypes.SINGLE_QUOTE, TwigTokenTypes.DOUBLE_QUOTE); } } } if(lastElementMatch == null | lastElementMatch == null) { return null; } return Pair.create(template, lastElementMatch); } /** * {% from _self import foobar as input, foobar %} * {% from 'foobar.html.twig' import foobar_twig %} */ @NotNull public static Collection<TwigMacro> getImportedMacros(@NotNull PsiFile psiFile) { PsiElement[] importPsiElements = PsiTreeUtil.collectElements(psiFile, paramPsiElement -> PlatformPatterns.psiElement(TwigElementTypes.IMPORT_TAG).accepts(paramPsiElement) ); if(importPsiElements.length == 0) { return Collections.emptyList(); } Collection<TwigMacro> macros = new ArrayList<>(); for(PsiElement psiImportTag: importPsiElements) { PsiElement firstChild = psiImportTag.getFirstChild(); if(firstChild == null) { continue; } PsiElement tagName = PsiElementUtils.getNextSiblingAndSkip(firstChild, TwigTokenTypes.TAG_NAME); if(tagName == null || !"from".equals(tagName.getText())) { continue; } Pair<String, PsiElement> pair = getTemplateNameOnStringAndSelfWithNextPsiElement(tagName, TwigTokenTypes.IMPORT_KEYWORD); if(pair == null) { continue; } String templateName = pair.getFirst(); // find end block to extract variables PsiElement endBlock = PsiElementUtils.getNextSiblingOfType( pair.getSecond(), PlatformPatterns.psiElement().withElementType(TwigTokenTypes.STATEMENT_BLOCK_END) ); if(endBlock == null) { continue; } String substring = psiFile.getText().substring(pair.getSecond().getTextRange().getEndOffset(), endBlock.getTextOffset()).trim(); for(String macroName : substring.split(",")) { // not nice here search for as "macro as macro_alias" Matcher asMatcher = Pattern.compile("(\\w+)\\s+as\\s+(\\w+)").matcher(macroName.trim()); if(asMatcher.find()) { macros.add(new TwigMacro(asMatcher.group(2), templateName, asMatcher.group(1))); } else { macros.add(new TwigMacro(macroName.trim(), templateName)); } } } return macros; } /** * Get targets for macro imports * * {% from _self import foobar as input, foobar %} * {% from 'foobar.html.twig' import foobar_twig %} */ @NotNull public static Collection<PsiElement> getImportedMacros(@NotNull PsiFile psiFile, @NotNull String funcName) { Collection<PsiElement> psiElements = new ArrayList<>(); for (TwigMacro twigMacro : TwigUtil.getImportedMacros(psiFile)) { if (!twigMacro.getName().equals(funcName)) { continue; } // switch to alias mode String macroName = twigMacro.getOriginalName() == null ? funcName : twigMacro.getOriginalName(); PsiFile[] foreignPsiFile; if ("_self".equals(twigMacro.getTemplate())) { foreignPsiFile = new PsiFile[] {psiFile}; } else { foreignPsiFile = TwigHelper.getTemplatePsiElements(psiFile.getProject(), twigMacro.getTemplate()); } for (PsiFile file : foreignPsiFile) { visitMacros(file, pair -> { if(macroName.equals(pair.getFirst().getName())) { psiElements.add(pair.getSecond()); } }); } } return psiElements; } /** * {% import _self as foobar %} * {% import 'foobar.html.twig' as foobar %} */ public static Collection<TwigMacro> getImportedMacrosNamespaces(@NotNull PsiFile psiFile) { Collection<TwigMacro> macros = new ArrayList<>(); visitImportedMacrosNamespaces(psiFile, pair -> macros.add(pair.getFirst())); return macros; } /** * Find targets for given macros, alias supported * * {% import _self as foobar %} * {{ foobar.bar() }} */ public static Collection<PsiElement> getImportedMacrosNamespaces(@NotNull PsiFile psiFile, @NotNull String macroName) { Collection<PsiElement> macros = new ArrayList<>(); visitImportedMacrosNamespaces(psiFile, pair -> { if(pair.getFirst().getName().equals(macroName)) { macros.add(pair.getSecond()); } }); return macros; } /** * {% import _self as foobar %} * {% import 'foobar.html.twig' as foobar %} */ public static void visitImportedMacrosNamespaces(@NotNull PsiFile psiFile, @NotNull Consumer<Pair<TwigMacro, PsiElement>> consumer) { PsiElement[] importPsiElements = PsiTreeUtil.collectElements(psiFile, psiElement -> psiElement.getNode().getElementType() == TwigElementTypes.IMPORT_TAG ); for (PsiElement importPsiElement : importPsiElements) { PsiElement firstChild = importPsiElement.getFirstChild(); if(firstChild == null) { continue; } PsiElement tagName = PsiElementUtils.getNextSiblingAndSkip(firstChild, TwigTokenTypes.TAG_NAME); if(tagName == null || !"import".equals(tagName.getText())) { continue; } Collection<PsiFile> macroFiles = new HashSet<>(); Pair<String, PsiElement> pair = getTemplateNameOnStringAndSelfWithNextPsiElement(tagName, TwigTokenTypes.AS_KEYWORD); if(pair == null) { continue; } PsiElement asVariable = PsiElementUtils.getNextSiblingAndSkip(pair.getSecond(), TwigTokenTypes.IDENTIFIER); if(asVariable == null) { continue; } String asName = asVariable.getText(); String template = pair.getFirst(); // resolve _self and template name if(template.equals("_self")) { macroFiles.add(psiFile); } else { macroFiles.addAll(Arrays.asList(TwigHelper.getTemplatePsiElements(psiFile.getProject(), template))); } if(macroFiles.size() > 0) { for (PsiFile macroFile : macroFiles) { TwigUtil.visitMacros(macroFile, tagPair -> consumer.consume(Pair.create( new TwigMacro(asName + '.' + tagPair.getFirst().getName(), template).withParameter(tagPair.getFirst().getParameters()), tagPair.getSecond() ))); } } } } /** * {% set foobar = 'foo' %} * {% set foo %}{% endset %} * * TODO: {% set foo, bar = 'foo', 'bar' %} */ @NotNull public static Collection<TwigSet> getSetDeclaration(@NotNull PsiFile psiFile) { Collection<TwigSet> sets = new ArrayList<>(); PsiElement[] psiElements = PsiTreeUtil.collectElements(psiFile, psiElement -> psiElement.getNode().getElementType() == TwigElementTypes.SET_TAG ); for (PsiElement psiElement : psiElements) { PsiElement firstChild = psiElement.getFirstChild(); if(firstChild == null) { continue; } PsiElement tagName = PsiElementUtils.getNextSiblingAndSkip(firstChild, TwigTokenTypes.TAG_NAME); if(tagName == null || !"set".equals(tagName.getText())) { continue; } PsiElement setVariable = PsiElementUtils.getNextSiblingAndSkip(tagName, TwigTokenTypes.IDENTIFIER); if(setVariable == null) { continue; } String text = setVariable.getText(); if(StringUtils.isNotBlank(text)) { sets.add(new TwigSet(text)); } } return sets; } @Nullable public static Method findTwigFileController(TwigFile twigFile) { SymfonyBundle symfonyBundle = new SymfonyBundleUtil(twigFile.getProject()).getContainingBundle(twigFile); if(symfonyBundle == null) { return null; } String relativePath = symfonyBundle.getRelativePath(twigFile.getVirtualFile()); if(relativePath == null || !relativePath.startsWith("Resources/views/")) { return null; } String viewPath = relativePath.substring("Resources/views/".length()); Matcher simpleFilter = Pattern.compile(".*/(\\w+)\\.\\w+\\.twig").matcher(viewPath); if(!simpleFilter.find()) { return null; } String methodName = simpleFilter.group(1) + "Action"; String className = symfonyBundle.getNamespaceName() + "Controller\\" + viewPath.substring(0, viewPath.lastIndexOf("/")).replace("/", "\\") + "Controller"; return PhpElementsUtil.getClassMethod(twigFile.getProject(), className, methodName); } @NotNull public static Set<String> getTemplateName(@NotNull VirtualFile virtualFile, @NotNull TemplateFileMap map) { return map.getNames(virtualFile); } @NotNull public static Set<String> getTemplateName(@NotNull TwigFile twigFile) { return getTemplateName(twigFile.getVirtualFile(), TwigHelper.getTemplateMap(twigFile.getProject(), true, false)); } public static Map<String, PsiVariable> collectControllerTemplateVariables(@NotNull TwigFile twigFile) { Map<String, PsiVariable> vars = new HashMap<>(); Method method = findTwigFileController(twigFile); if(method != null) { vars.putAll(PhpMethodVariableResolveUtil.collectMethodVariables(method)); } for(Function methodIndex : getTwigFileMethodUsageOnIndex(twigFile)) { vars.putAll(PhpMethodVariableResolveUtil.collectMethodVariables(methodIndex)); } return vars; } /** * Collect function variables scopes for given Twig file */ @NotNull public static Set<Function> getTwigFileMethodUsageOnIndex(@NotNull TwigFile psiFile) { return getTwigFileMethodUsageOnIndex(psiFile.getProject(), TwigUtil.getTemplateName(psiFile)); } /** * Collect function scopes to search for Twig variable of given template names: "foo.html.twig" */ @NotNull public static Set<Function> getTwigFileMethodUsageOnIndex(@NotNull Project project, @NotNull Collection<String> keys) { if(keys.size() == 0) { return Collections.emptySet(); } final Set<String> fqn = new HashSet<>(); for(String key: keys) { for (TemplateUsage usage : FileBasedIndex.getInstance().getValues(PhpTwigTemplateUsageStubIndex.KEY, key, GlobalSearchScope.allScope(project))) { fqn.addAll(usage.getScopes()); } } final Set<Function> methods = new HashSet<>(); for (String s : fqn) { // function: "\foo" if(!s.contains(".")) { methods.addAll(PhpIndex.getInstance(project).getFunctionsByFQN("\\" + s)); continue; } // classes: "\foo.action" String[] split = s.split("\\."); if(split.length != 2) { continue; } Method method = PhpElementsUtil.getClassMethod(project, split[0], split[1]); if(method == null) { continue; } methods.add(method); } return methods; } @Nullable public static String getFoldingTemplateNameOrCurrent(@Nullable String templateName) { String foldingName = getFoldingTemplateName(templateName); return foldingName != null ? foldingName : templateName; } @Nullable public static String getFoldingTemplateName(@Nullable String content) { if(content == null || content.length() == 0) return null; String templateShortcutName = null; if(content.endsWith(".html.twig") && content.length() > 10) { templateShortcutName = content.substring(0, content.length() - 10); } else if(content.endsWith(".html.php") && content.length() > 9) { templateShortcutName = content.substring(0, content.length() - 9); } if(templateShortcutName == null || templateShortcutName.length() == 0) { return null; } // template FooBundle:Test:edit.html.twig if(templateShortcutName.length() <= "Bundle:".length()) { return templateShortcutName; } int split = templateShortcutName.indexOf("Bundle:"); if(split > 0) { templateShortcutName = templateShortcutName.substring(0, split) + templateShortcutName.substring("Bundle".length() + split); } return templateShortcutName; } public static String getPresentableTemplateName(Map<String, VirtualFile> files, PsiElement psiElement) { return getPresentableTemplateName(files, psiElement, false); } public static String getPresentableTemplateName(Map<String, VirtualFile> files, PsiElement psiElement, boolean shortMode) { VirtualFile currentFile = psiElement.getContainingFile().getVirtualFile(); List<String> templateNames = new ArrayList<>(); for(Map.Entry<String, VirtualFile> entry: files.entrySet()) { if(entry.getValue().equals(currentFile)) { templateNames.add(entry.getKey()); } } if(templateNames.size() > 0) { // bundle names wins if(templateNames.size() > 1) { templateNames.sort(new TemplateStringComparator()); } String templateName = templateNames.iterator().next(); if(shortMode) { String shortName = getFoldingTemplateName(templateName); if(shortName != null) { return shortName; } } return templateName; } String relativePath = VfsUtil.getRelativePath(currentFile, psiElement.getProject().getBaseDir(), '/'); return relativePath != null ? relativePath : currentFile.getPath(); } private static class TemplateStringComparator implements Comparator<String> { @Override public int compare(String o1, String o2) { if(o1.startsWith("@") && o2.startsWith("@")) { return 0; } if(!o1.startsWith("@") && o2.startsWith("@")) { return -1; } return 1; } } /** * Collections "extends" and "blocks" an path and and sort them on appearance */ public static TwigCreateContainer getOnCreateTemplateElements(@NotNull final Project project, @NotNull VirtualFile startDirectory) { final TwigCreateContainer containerElement = new TwigCreateContainer(); VfsUtil.processFilesRecursively(startDirectory, new Processor<VirtualFile>() { @Override public boolean process(VirtualFile virtualFile) { if(virtualFile.getFileType() != TwigFileType.INSTANCE) { return true; } PsiFile twigFile = PsiManager.getInstance(project).findFile(virtualFile); if(twigFile instanceof TwigFile) { collect((TwigFile) twigFile); } return true; } private void collect(TwigFile twigFile) { for(PsiElement psiElement: twigFile.getChildren()) { if(psiElement instanceof TwigExtendsTag) { for (String s : TwigHelper.getTwigExtendsTagTemplates((TwigExtendsTag) psiElement)) { containerElement.addExtend(s); } } else if(psiElement.getNode().getElementType() == TwigElementTypes.BLOCK_STATEMENT) { PsiElement blockTag = psiElement.getFirstChild(); if(blockTag instanceof TwigBlockTag) { String name = ((TwigBlockTag) blockTag).getName(); if(StringUtils.isNotBlank(name)) { containerElement.addBlock(name); } } } } } }); return containerElement; } /** * Build twig template file content on path with help of TwigCreateContainer */ @Nullable public static String buildStringFromTwigCreateContainer(@NotNull Project project, @Nullable VirtualFile virtualTargetDir) { if(virtualTargetDir == null) { return null; } StringBuilder stringBuilder = new StringBuilder(); TwigCreateContainer container = TwigUtil.getOnCreateTemplateElements(project, virtualTargetDir); String extend = container.getExtend(); if(extend != null) { stringBuilder.append("{% extends '").append(extend).append("' %}").append("\n\n"); } for(String blockName: container.getBlockNames(2)) { stringBuilder.append("{% block ").append(blockName).append(" %}\n\n").append("{% endblock %}").append("\n\n"); } String s = stringBuilder.toString(); return StringUtils.isNotBlank(s) ? s : null; } /** * Gets a template name from "app" or bundle getParent overwrite * * app/Resources/AcmeBlogBundle/views/Blog/index.html.twig * src/Acme/UserBundle/Resources/views/index.html.twig */ @Nullable public static String getTemplateNameByOverwrite(@NotNull Project project, @NotNull VirtualFile virtualFile) { String relativePath = VfsUtil.getRelativePath(virtualFile, project.getBaseDir()); if(relativePath == null) { return null; } // app/Resources/AcmeBlogBundle/views/Blog/index.html.twig Matcher matcher = Pattern.compile("app/Resources/([^/]*Bundle)/views/(.*)$").matcher(relativePath); if (matcher.find()) { return TwigHelper.normalizeTemplateName(matcher.group(1) + ":" + matcher.group(2)); } // src/Acme/UserBundle/Resources/views/index.html.twig SymfonyBundleUtil symfonyBundleUtil = new SymfonyBundleUtil(project); SymfonyBundle containingBundle = symfonyBundleUtil.getContainingBundle(virtualFile); if(containingBundle == null) { return null; } String relative = containingBundle.getRelative(virtualFile); if(relative == null) { return null; } if(!relative.startsWith("Resources/views/")) { return null; } String parentBundleName = containingBundle.getParentBundleName(); if(parentBundleName == null) { return null; } return TwigHelper.normalizeTemplateName(containingBundle.getName() + ":" + relative.substring("Resources/views/".length(), relative.length())); } /** * {% include "foo/#{segment.typeKey}.html.twig" with {'segment': segment} %} * {% include "foo/#{1 + 2}.html.twig" %} * {% include "foo/" ~ segment.typeKey ~ ".html.twig" %} */ public static boolean isValidTemplateString(@NotNull PsiElement element) { String templateName = element.getText(); if(templateName.matches(".*#\\{.*\\}.*")) { return false; } if(PlatformPatterns.psiElement() .afterLeafSkipping( TwigHelper.STRING_WRAP_PATTERN, PlatformPatterns.psiElement(TwigTokenTypes.CONCAT) ).accepts(element) || PlatformPatterns.psiElement().beforeLeafSkipping( TwigHelper.STRING_WRAP_PATTERN, PlatformPatterns.psiElement(TwigTokenTypes.CONCAT) ).accepts(element)) { return false; } return true; } @NotNull public static Collection<String> getCreateAbleTemplatePaths(@NotNull Project project, @NotNull String templateName) { templateName = TwigHelper.normalizeTemplateName(templateName); Collection<String> paths = new HashSet<>(); for (TwigPath twigPath : TwigHelper.getTwigNamespaces(project)) { if(!twigPath.isEnabled()) { continue; } if(templateName.startsWith("@")) { int i = templateName.indexOf("/"); if(i > 0 && templateName.substring(1, i).equals(twigPath.getNamespace())) { paths.add(twigPath.getRelativePath(project) + "/" + templateName.substring(i + 1)); } } else if(twigPath.getNamespaceType() == TwigPathIndex.NamespaceType.BUNDLE && templateName.matches("^\\w+Bundle:.*")) { int i = templateName.indexOf("Bundle:"); String substring = templateName.substring(0, i + 6); if(substring.equals(twigPath.getNamespace())) { paths.add(twigPath.getRelativePath(project) + "/" + templateName.substring(templateName.indexOf(":") + 1).replace(":", "/")); } } else if(twigPath.isGlobalNamespace() && !templateName.contains(":") && !templateName.contains("@")) { paths.add(twigPath.getRelativePath(project) + "/" + StringUtils.stripStart(templateName, "/")); } } return paths; } /** * Collects all files that include, extends, ... a given files */ @NotNull public static Collection<PsiFile> getTemplateFileReferences(@NotNull final PsiFile psiFile, @NotNull TemplateFileMap files) { List<PsiFile> twigChild = new ArrayList<>(); getTemplateFileReferences(files.getTemplates(), psiFile, twigChild, 8); return twigChild; } private static void getTemplateFileReferences(@NotNull Map<String, VirtualFile> files, @NotNull final PsiFile psiFile, @NotNull final List<PsiFile> twigChild, int depth) { if(depth <= 0) { return; } // use set here, we have multiple shortcut on one file, but only one is required final HashSet<VirtualFile> virtualFiles = new LinkedHashSet<>(); for(Map.Entry<String, VirtualFile> entry: files.entrySet()) { // getFilesWithKey dont support keyset with > 1 items (bug?), so we cant merge calls if(entry.getValue().equals(psiFile.getVirtualFile())) { String key = entry.getKey(); FileBasedIndexImpl.getInstance().getFilesWithKey(TwigExtendsStubIndex.KEY, new HashSet<>(Collections.singletonList(key)), virtualFile -> { virtualFiles.add(virtualFile); return true; }, GlobalSearchScope.getScopeRestrictedByFileTypes(GlobalSearchScope.allScope(psiFile.getProject()), TwigFileType.INSTANCE)); } } // finally resolve virtual file to twig files for(VirtualFile virtualFile: virtualFiles) { PsiFile resolvedPsiFile = PsiManager.getInstance(psiFile.getProject()).findFile(virtualFile); if(resolvedPsiFile != null) { twigChild.add(resolvedPsiFile); getTemplateFileReferences(files, resolvedPsiFile, twigChild, --depth); } } } /** * Visit all possible Twig include file pattern */ public static void visitTemplateIncludes(@NotNull TwigFile twigFile, @NotNull Consumer<TemplateInclude> consumer) { visitTemplateIncludes( twigFile, consumer, TemplateInclude.TYPE.EMBED, TemplateInclude.TYPE.INCLUDE, TemplateInclude.TYPE.INCLUDE_FUNCTION, TemplateInclude.TYPE.FROM, TemplateInclude.TYPE.IMPORT, TemplateInclude.TYPE.FORM_THEME ); } public static void visitTemplateIncludes(@NotNull TwigFile twigFile, @NotNull Consumer<TemplateInclude> consumer, @NotNull TemplateInclude.TYPE... types) { if(types.length == 0) { return; } List<TemplateInclude.TYPE> myTypes = Arrays.asList(types); PsiTreeUtil.collectElements(twigFile, psiElement -> { if(psiElement instanceof TwigTagWithFileReference) { // {% include %} if(myTypes.contains(TemplateInclude.TYPE.INCLUDE)) { if(psiElement.getNode().getElementType() == TwigElementTypes.INCLUDE_TAG) { for (String templateName : TwigHelper.getIncludeTagStrings((TwigTagWithFileReference) psiElement)) { if(StringUtils.isNotBlank(templateName)) { consumer.consume(new TemplateInclude(psiElement, templateName, TemplateInclude.TYPE.INCLUDE)); } } } } // {% import "foo.html.twig" if(myTypes.contains(TemplateInclude.TYPE.IMPORT)) { PsiElement embedTag = PsiElementUtils.getChildrenOfType(psiElement, TwigHelper.getTagNameParameterPattern(TwigElementTypes.IMPORT_TAG, "import")); if(embedTag != null) { String templateName = embedTag.getText(); if(StringUtils.isNotBlank(templateName)) { consumer.consume(new TemplateInclude(psiElement, templateName, TemplateInclude.TYPE.IMPORT)); } } } // {% from 'forms.html' import ... %} if(myTypes.contains(TemplateInclude.TYPE.FROM)) { PsiElement embedTag = PsiElementUtils.getChildrenOfType(psiElement, TwigHelper.getTagNameParameterPattern(TwigElementTypes.IMPORT_TAG, "from")); if(embedTag != null) { String templateName = embedTag.getText(); if(StringUtils.isNotBlank(templateName)) { consumer.consume(new TemplateInclude(psiElement, templateName, TemplateInclude.TYPE.IMPORT)); } } } } else if(psiElement instanceof TwigCompositeElement) { // {{ include() }} // {{ source() }} if(myTypes.contains(TemplateInclude.TYPE.INCLUDE_FUNCTION)) { PsiElement includeTag = PsiElementUtils.getChildrenOfType(psiElement, TwigHelper.getPrintBlockFunctionPattern("include", "source")); if(includeTag != null) { String templateName = includeTag.getText(); if(StringUtils.isNotBlank(templateName)) { consumer.consume(new TemplateInclude(psiElement, templateName, TemplateInclude.TYPE.INCLUDE_FUNCTION)); } } } // {% embed "foo.html.twig" if(myTypes.contains(TemplateInclude.TYPE.EMBED)) { PsiElement embedTag = PsiElementUtils.getChildrenOfType(psiElement, TwigHelper.getEmbedPattern()); if(embedTag != null) { String templateName = embedTag.getText(); if(StringUtils.isNotBlank(templateName)) { consumer.consume(new TemplateInclude(psiElement, templateName, TemplateInclude.TYPE.EMBED)); } } } if(myTypes.contains(TemplateInclude.TYPE.FORM_THEME) && psiElement.getNode().getElementType() == TwigElementTypes.TAG) { PsiElement tagElement = PsiElementUtils.getChildrenOfType(psiElement, PlatformPatterns.psiElement().withElementType(TwigTokenTypes.TAG_NAME)); if(tagElement != null) { String text = tagElement.getText(); if("form_theme".equals(text)) { // {% form_theme form.child 'form/fields_child.html.twig' %} PsiElement childrenOfType = PsiElementUtils.getNextSiblingAndSkip(tagElement, TwigTokenTypes.STRING_TEXT, TwigTokenTypes.IDENTIFIER, TwigTokenTypes.SINGLE_QUOTE, TwigTokenTypes.DOUBLE_QUOTE, TwigTokenTypes.DOT ); if(childrenOfType != null) { String templateName = childrenOfType.getText(); if(StringUtils.isNotBlank(templateName)) { consumer.consume(new TemplateInclude(psiElement, templateName, TemplateInclude.TYPE.FORM_THEME)); } } // {% form_theme form.child 'form/fields_child.html.twig' %} PsiElement withElement = PsiElementUtils.getNextSiblingOfType(tagElement, PlatformPatterns.psiElement().withElementType(TwigTokenTypes.IDENTIFIER).withText("with")); if(withElement != null) { PsiElement arrayStart = PsiElementUtils.getNextSiblingAndSkip(tagElement, TwigTokenTypes.LBRACE_SQ, TwigTokenTypes.IDENTIFIER, TwigTokenTypes.SINGLE_QUOTE, TwigTokenTypes.DOUBLE_QUOTE, TwigTokenTypes.DOT ); if(arrayStart != null) { TwigHelper.visitStringInArray(arrayStart, pair -> consumer.consume(new TemplateInclude(psiElement, pair.getFirst(), TemplateInclude.TYPE.FORM_THEME)) ); } } } } } } return false; }); } /** * Get all macros inside file * * {% macro foobar %}{% endmacro %} * {% macro input(name, value, type, size) %}{% endmacro %} */ @NotNull public static Collection<TwigMacroTagInterface> getMacros(@NotNull PsiFile file) { Collection<TwigMacroTagInterface> macros = new ArrayList<>(); Collection<String> keys = new ArrayList<>(); FileBasedIndex fileBasedIndex = FileBasedIndex.getInstance(); fileBasedIndex.processAllKeys(TwigMacroFunctionStubIndex.KEY, s -> { keys.add(s); return true; }, GlobalSearchScope.fileScope(file), null); for (String key : keys) { macros.addAll(fileBasedIndex.getValues(TwigMacroFunctionStubIndex.KEY, key, GlobalSearchScope.fileScope(file))); } return macros; } /** * Get all macros inside file * * {% macro foobar %}{% endmacro %} * {% macro input(name, value, type, size) %}{% endmacro %} */ public static void visitMacros(@NotNull PsiFile file, Consumer<Pair<TwigMacroTag, PsiElement>> consumer) { PsiElement[] psiElements = PsiTreeUtil.collectElements(file, psiElement -> psiElement.getNode().getElementType() == TwigElementTypes.MACRO_TAG ); for (PsiElement psiElement : psiElements) { PsiElement firstChild = psiElement.getFirstChild(); if(firstChild == null) { continue; } PsiElement macroNamePsi = PsiElementUtils.getNextSiblingAndSkip(firstChild, TwigTokenTypes.IDENTIFIER, TwigTokenTypes.TAG_NAME); if(macroNamePsi == null) { continue; } String macroName = macroNamePsi.getText(); String parameter = null; PsiElement nextSiblingAndSkip = PsiElementUtils.getNextSiblingAndSkip(macroNamePsi, TwigTokenTypes.LBRACE); if(nextSiblingAndSkip != null) { PsiElement nextSiblingOfType = PsiElementUtils .getNextSiblingOfType(nextSiblingAndSkip, PlatformPatterns.psiElement() .withElementType(TwigTokenTypes.RBRACE)); if(nextSiblingOfType != null) { parameter = file.getText().substring(nextSiblingAndSkip.getTextOffset(), nextSiblingOfType.getTextOffset() + 1); } } consumer.consume(Pair.create(new TwigMacroTag(macroName, parameter), psiElement)); } } }