diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/stubs/indexes/UxTemplateStubIndex.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/stubs/indexes/UxTemplateStubIndex.java new file mode 100644 index 000000000..ca6a310c5 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/stubs/indexes/UxTemplateStubIndex.java @@ -0,0 +1,63 @@ +package fr.adrienbrault.idea.symfony2plugin.stubs.indexes; + +import com.intellij.util.indexing.*; +import com.intellij.util.io.DataExternalizer; +import com.intellij.util.io.EnumeratorStringDescriptor; +import com.intellij.util.io.KeyDescriptor; +import com.jetbrains.php.lang.psi.PhpFile; +import com.jetbrains.php.lang.psi.stubs.indexes.PhpConstantNameIndex; +import fr.adrienbrault.idea.symfony2plugin.util.UxUtil; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Daniel Espendiller + */ +public class UxTemplateStubIndex extends FileBasedIndexExtension { + public static final ID KEY = ID.create("fr.adrienbrault.idea.symfony2plugin.ux_template_index"); + + private final KeyDescriptor myKeyDescriptor = new EnumeratorStringDescriptor(); + @Override + public @NotNull ID getName() { + return KEY; + } + + @Override + public @NotNull DataIndexer getIndexer() { + return inputData -> { + Map map = new HashMap<>(); + + if(inputData.getPsiFile() instanceof PhpFile phpFile) { + UxUtil.visitAsTwigComponent(phpFile, pair -> map.put(pair.getFirst(), pair.getSecond().getFQN())); + } + + return map; + }; + } + + @Override + public @NotNull KeyDescriptor getKeyDescriptor() { + return this.myKeyDescriptor; + } + + @Override + public @NotNull DataExternalizer getValueExternalizer() { + return EnumeratorStringDescriptor.INSTANCE; + } + + @Override + public int getVersion() { + return 1; + } + + public FileBasedIndex.@NotNull InputFilter getInputFilter() { + return PhpConstantNameIndex.PHP_INPUT_FILTER; + } + + @Override + public boolean dependsOnFileContent() { + return true; + } +} diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/HtmlTemplateGoToDeclarationHandler.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/HtmlTemplateGoToDeclarationHandler.java new file mode 100644 index 000000000..75382ba0f --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/HtmlTemplateGoToDeclarationHandler.java @@ -0,0 +1,78 @@ +package fr.adrienbrault.idea.symfony2plugin.templating; + +import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler; +import com.intellij.openapi.editor.Editor; +import com.intellij.psi.PsiElement; +import com.intellij.psi.html.HtmlTag; +import com.intellij.psi.xml.XmlElementType; +import com.intellij.psi.xml.XmlToken; +import com.intellij.psi.xml.XmlTokenType; +import com.jetbrains.php.lang.psi.elements.Field; +import com.jetbrains.php.lang.psi.elements.PhpClass; +import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; +import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigHtmlCompletionUtil; +import fr.adrienbrault.idea.symfony2plugin.util.UxUtil; +import org.apache.commons.lang.StringUtils; + +import java.util.ArrayList; +import java.util.Collection; + +/** + * @author Daniel Espendiller + */ +public class HtmlTemplateGoToDeclarationHandler implements GotoDeclarationHandler { + public PsiElement[] getGotoDeclarationTargets(PsiElement psiElement, int offset, Editor editor) { + if (!Symfony2ProjectComponent.isEnabled(psiElement)) { + return null; + } + + Collection targets = new ArrayList<>(); + + // 5) { + if (TwigHtmlCompletionUtil.getTwigNamespacePattern().accepts(psiElement)) { + + String componentName = StringUtils.stripStart(text, "twig:"); + if (!componentName.isBlank()) { + targets.addAll(UxUtil.getTwigComponentNameTargets(psiElement.getProject(), componentName)); + } + } + } else { + // + if (psiElement instanceof XmlToken) { + PsiElement parent = psiElement.getParent(); + if (parent.getNode().getElementType() == XmlElementType.XML_ATTRIBUTE) { + if (parent.getParent() instanceof HtmlTag htmlTag && htmlTag.getName().startsWith("twig:")) { + String text = psiElement.getText(); + + for (PhpClass phpClass : UxUtil.getTwigComponentNameTargets(psiElement.getProject(), htmlTag.getName().substring(5))) { + Field fieldByName = phpClass.findFieldByName(StringUtils.stripStart(text, ":"), false); + if (fieldByName != null) { + targets.add(fieldByName); + } + } + }; + } + } + + return targets.toArray(new PsiElement[0]); + } +} diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigComponentHtmlTagExtensions.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigComponentHtmlTagExtensions.java new file mode 100644 index 000000000..84547c4c8 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigComponentHtmlTagExtensions.java @@ -0,0 +1,131 @@ +package fr.adrienbrault.idea.symfony2plugin.templating; + +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.codeInspection.XmlSuppressionProvider; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import com.intellij.psi.PsiFile; +import com.intellij.psi.html.HtmlTag; +import com.intellij.psi.impl.source.xml.SchemaPrefix; +import com.intellij.psi.xml.XmlFile; +import com.intellij.psi.xml.XmlTag; +import com.intellij.psi.xml.XmlToken; +import com.intellij.psi.xml.XmlTokenType; +import com.intellij.xml.XmlExtension; +import com.intellij.xml.XmlTagNameProvider; +import com.jetbrains.twig.TwigFile; +import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; +import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigUtil; +import fr.adrienbrault.idea.symfony2plugin.util.UxUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Daniel Espendiller + */ +public class TwigComponentHtmlTagExtensions { + public static class TwigTemplateTagNameProvider implements XmlTagNameProvider { + @Override + public void addTagNameVariants(List elements, @NotNull XmlTag tag, String prefix) { + PsiElement elementOnTwigViewProvider = TwigUtil.getElementOnTwigViewProvider(tag); + + if (elementOnTwigViewProvider != null && !(elementOnTwigViewProvider.getContainingFile() instanceof TwigFile)) { + return; + } + + for (String twigComponentName : UxUtil.getTwigComponentNames(tag.getProject())) { + elements.add(LookupElementBuilder.create("twig:" + twigComponentName).withIcon(Symfony2Icons.SYMFONY)); + } + } + } + + public static class TwigTemplateXmlExtension extends XmlExtension { + @Override + public boolean isAvailable(PsiFile file) { + return true; + } + + @Override + public @NotNull List getAvailableTagNames(@NotNull XmlFile file, @NotNull XmlTag context) { + List foo = new ArrayList<>(); + + for (String twigComponentName : UxUtil.getTwigComponentNames(file.getProject())) { + foo.add(new TagInfo(twigComponentName, "twig")); + foo.add(new TagInfo("twig:" + twigComponentName, null)); + foo.add(new TagInfo("twig:" + twigComponentName, "twig")); + } + + return foo; + } + + @Override + public @Nullable SchemaPrefix getPrefixDeclaration(XmlTag context, String namespacePrefix) { + if (namespacePrefix.equals("twig") && context instanceof HtmlTag && context.getName().startsWith("twig")) { + return new NullableParentShouldOverwriteSchemaPrefix( + context.getProject(), + context.getContainingFile(), + new TextRange(0, 4), + "twig" + ); + } + + return null; + } + + private static class NullableParentShouldOverwriteSchemaPrefix extends SchemaPrefix { + private final Project project; + private final PsiFile psiFile; + + public NullableParentShouldOverwriteSchemaPrefix(@NotNull Project project, @NotNull PsiFile psiFile, TextRange range, String name) { + super(null, range, name); + this.project = project; + this.psiFile = psiFile; + } + + @Override + public PsiFile getContainingFile() { + return this.psiFile; + } + + @Override + public @NotNull Project getProject() { + return this.project; + } + } + } + + public static class TwigTemplateXmlSuppressionProvider extends XmlSuppressionProvider { + + @Override + public boolean isProviderAvailable(@NotNull PsiFile file) { + return true; + } + + @Override + public boolean isSuppressedFor(@NotNull PsiElement element, @NotNull String inspectionId) { + if (inspectionId.equals("HtmlUnknownTag") && element instanceof XmlToken xmlToken && element.getNode().getElementType() == XmlTokenType.XML_NAME) { + String text = xmlToken.getText(); + + return text.startsWith("twig:") + && UxUtil.getTwigComponentNames(element.getProject()).contains(text.substring(5)); + } + + return false; + } + + @Override + public void suppressForFile(@NotNull PsiElement element, @NotNull String inspectionId) { + + } + + @Override + public void suppressForTag(@NotNull PsiElement element, @NotNull String inspectionId) { + + } + } +} diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigPattern.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigPattern.java index 5e07cde8f..37bee961b 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigPattern.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigPattern.java @@ -430,6 +430,40 @@ public static ElementPattern getStringAfterTagNamePattern(@NotNull S .withLanguage(TwigLanguage.INSTANCE); } + public static ElementPattern getArgumentAfterTagNamePattern(@NotNull String tagName) { + return PlatformPatterns.or( + PlatformPatterns + .psiElement(TwigTokenTypes.IDENTIFIER) + .withParent( + PlatformPatterns.psiElement(TwigElementTypes.TAG) + ) + .afterLeafSkipping( + PlatformPatterns.or( + PlatformPatterns.psiElement(TwigTokenTypes.LBRACE), + PlatformPatterns.psiElement(PsiWhiteSpace.class), + PlatformPatterns.psiElement(TwigTokenTypes.WHITE_SPACE), + PlatformPatterns.psiElement(TwigTokenTypes.SINGLE_QUOTE), + PlatformPatterns.psiElement(TwigTokenTypes.DOUBLE_QUOTE) + ), + PlatformPatterns.psiElement(TwigTokenTypes.TAG_NAME).withText(tagName) + ).withLanguage(TwigLanguage.INSTANCE), + PlatformPatterns.psiElement(TwigTokenTypes.STRING_TEXT) + .withParent( + PlatformPatterns.psiElement(TwigElementTypes.TAG) + ) + .afterLeafSkipping( + PlatformPatterns.or( + PlatformPatterns.psiElement(TwigTokenTypes.LBRACE), + PlatformPatterns.psiElement(PsiWhiteSpace.class), + PlatformPatterns.psiElement(TwigTokenTypes.WHITE_SPACE), + PlatformPatterns.psiElement(TwigTokenTypes.SINGLE_QUOTE), + PlatformPatterns.psiElement(TwigTokenTypes.DOUBLE_QUOTE) + ), + PlatformPatterns.psiElement(TwigTokenTypes.TAG_NAME).withText(tagName) + ).withLanguage(TwigLanguage.INSTANCE) + ); + } + /** * Check for {% if foo is "foo" %} */ @@ -733,38 +767,7 @@ public static ElementPattern getFilterTagPattern() { * {% trans_default_domain %} */ public static ElementPattern getTransDefaultDomainPattern() { - //noinspection unchecked - return PlatformPatterns.or( - PlatformPatterns - .psiElement(TwigTokenTypes.IDENTIFIER) - .withParent( - PlatformPatterns.psiElement(TwigElementTypes.TAG) - ) - .afterLeafSkipping( - PlatformPatterns.or( - PlatformPatterns.psiElement(TwigTokenTypes.LBRACE), - PlatformPatterns.psiElement(PsiWhiteSpace.class), - PlatformPatterns.psiElement(TwigTokenTypes.WHITE_SPACE), - PlatformPatterns.psiElement(TwigTokenTypes.SINGLE_QUOTE), - PlatformPatterns.psiElement(TwigTokenTypes.DOUBLE_QUOTE) - ), - PlatformPatterns.psiElement(TwigTokenTypes.TAG_NAME).withText("trans_default_domain") - ).withLanguage(TwigLanguage.INSTANCE), - PlatformPatterns.psiElement(TwigTokenTypes.STRING_TEXT) - .withParent( - PlatformPatterns.psiElement(TwigElementTypes.TAG) - ) - .afterLeafSkipping( - PlatformPatterns.or( - PlatformPatterns.psiElement(TwigTokenTypes.LBRACE), - PlatformPatterns.psiElement(PsiWhiteSpace.class), - PlatformPatterns.psiElement(TwigTokenTypes.WHITE_SPACE), - PlatformPatterns.psiElement(TwigTokenTypes.SINGLE_QUOTE), - PlatformPatterns.psiElement(TwigTokenTypes.DOUBLE_QUOTE) - ), - PlatformPatterns.psiElement(TwigTokenTypes.TAG_NAME).withText("trans_default_domain") - ).withLanguage(TwigLanguage.INSTANCE) - ); + return getArgumentAfterTagNamePattern("trans_default_domain"); } /** @@ -958,6 +961,24 @@ public static ElementPattern getAutocompletableRoutePattern() { ; } + public static ElementPattern getComponentPattern() { + return PlatformPatterns + .psiElement(TwigTokenTypes.STRING_TEXT) + .afterLeafSkipping( + PlatformPatterns.or( + PlatformPatterns.psiElement(TwigTokenTypes.LBRACE), + PlatformPatterns.psiElement(TwigTokenTypes.WHITE_SPACE), + PlatformPatterns.psiElement(PsiWhiteSpace.class), + PlatformPatterns.psiElement(TwigTokenTypes.SINGLE_QUOTE), + PlatformPatterns.psiElement(TwigTokenTypes.DOUBLE_QUOTE) + ), + PlatformPatterns.or( + PlatformPatterns.psiElement(TwigTokenTypes.IDENTIFIER).withText("component") + ) + ) + .withLanguage(TwigLanguage.INSTANCE); + } + /** * {{ asset('') }} * {{ asset("") }} diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateCompletionContributor.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateCompletionContributor.java index 845fccad2..0e12007db 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateCompletionContributor.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateCompletionContributor.java @@ -45,6 +45,7 @@ import fr.adrienbrault.idea.symfony2plugin.util.ParameterBag; import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil; import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils; +import fr.adrienbrault.idea.symfony2plugin.util.UxUtil; import fr.adrienbrault.idea.symfony2plugin.util.completion.FunctionInsertHandler; import fr.adrienbrault.idea.symfony2plugin.util.completion.PhpClassCompletionProvider; import fr.adrienbrault.idea.symfony2plugin.util.controller.ControllerCompletionProvider; @@ -342,6 +343,20 @@ public void addCompletions(@NotNull CompletionParameters parameters, ProcessingC } ); + extend( + CompletionType.BASIC, + PlatformPatterns.or(TwigPattern.getComponentPattern(), TwigPattern.getArgumentAfterTagNamePattern("component")), + new CompletionProvider<>() { + public void addCompletions(@NotNull CompletionParameters parameters, @NotNull ProcessingContext context, @NotNull CompletionResultSet resultSet) { + if (!Symfony2ProjectComponent.isEnabled(parameters.getPosition())) { + return; + } + + resultSet.addAllElements(UxUtil.getComponentLookupElements(parameters.getPosition().getProject())); + } + } + ); + // routing parameter completion extend( CompletionType.BASIC, @@ -466,6 +481,20 @@ public void addCompletions(@NotNull CompletionParameters parameters, ProcessingC new IncompleteBlockCompletionProvider() ); + // {% com => {% com '...' + extend( + CompletionType.BASIC, + PlatformPatterns.psiElement(TwigTokenTypes.TAG_NAME), + new IncompleteComponentCompletionProvider() + ); + + // {{ com => {{ com('...') + extend( + CompletionType.BASIC, + TwigPattern.getCompletablePattern(), + new IncompleteComponentPrintBlockCompletionProvider() + ); + // {{ in => {{ include('...') extend( CompletionType.BASIC, @@ -999,6 +1028,62 @@ public boolean accepts(@NotNull String s, ProcessingContext processingContext) { } } + /** + * {% com => {% com '...' + */ + private class IncompleteComponentCompletionProvider extends CompletionProvider { + @Override + protected void addCompletions(@NotNull CompletionParameters completionParameters, @NotNull ProcessingContext processingContext, @NotNull CompletionResultSet resultSet) { + PsiElement position = completionParameters.getOriginalPosition(); + if(!Symfony2ProjectComponent.isEnabled(position)) { + return; + } + + resultSet.restartCompletionOnPrefixChange(StandardPatterns.string().longerThan(1).with(new PatternCondition<>("component startsWith") { + @Override + public boolean accepts(@NotNull String s, ProcessingContext processingContext) { + return "component".startsWith(s); + } + })); + + if (!isCompletionStartingMatch("component", completionParameters, 2)) { + return; + } + + for (LookupElement blockLookupElement : UxUtil.getComponentLookupElements(position.getProject())) { + resultSet.addElement(LookupElementBuilder.create("component " + blockLookupElement.getLookupString()).withIcon(Symfony2Icons.SYMFONY)); + } + } + } + + + /** + * {{ com => {{ component('...') + */ + private class IncompleteComponentPrintBlockCompletionProvider extends CompletionProvider { + @Override + protected void addCompletions(@NotNull CompletionParameters completionParameters, @NotNull ProcessingContext processingContext, @NotNull CompletionResultSet resultSet) { + if(!Symfony2ProjectComponent.isEnabled(completionParameters.getPosition())) { + return; + } + + resultSet.restartCompletionOnPrefixChange(StandardPatterns.string().longerThan(2).with(new PatternCondition<>("component startsWith") { + @Override + public boolean accepts(@NotNull String s, ProcessingContext processingContext) { + return "component".startsWith(s); + } + })); + + if (!isCompletionStartingMatch("component", completionParameters, 2)) { + return; + } + + for (LookupElement element : UxUtil.getComponentLookupElements(completionParameters.getPosition().getProject())) { + resultSet.addElement(LookupElementBuilder.create(LookupElementBuilder.create(String.format("component('%s')", element.getLookupString()))).withIcon(Symfony2Icons.SYMFONY)); + } + } + } + private static class PhpProxyForTwigTypCompletionProvider extends CompletionProvider { @Override protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull ProcessingContext context, @NotNull CompletionResultSet result) { diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateGoToDeclarationHandler.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateGoToDeclarationHandler.java index 259bd41ef..4feb0de29 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateGoToDeclarationHandler.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/TwigTemplateGoToDeclarationHandler.java @@ -34,6 +34,7 @@ import fr.adrienbrault.idea.symfony2plugin.twig.variable.collector.ControllerDocVariableCollector; import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil; import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils; +import fr.adrienbrault.idea.symfony2plugin.util.UxUtil; import org.apache.commons.lang.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -78,6 +79,10 @@ public PsiElement[] getGotoDeclarationTargets(PsiElement psiElement, int offset, targets.addAll(getRouteGoTo(psiElement)); } + if (TwigPattern.getComponentPattern().accepts(psiElement) || TwigPattern.getArgumentAfterTagNamePattern("component").accepts(psiElement)) { + targets.addAll(getComponentGoTo(psiElement)); + } + // find trans('', {}, '|') // tricky way to get the function string trans(...) if (TwigPattern.getTransDomainPattern().accepts(psiElement)) { @@ -290,6 +295,16 @@ private Collection getRouteGoTo(@NotNull PsiElement psiElement) { return RouteHelper.getRouteDefinitionTargets(psiElement.getProject(), text); } + private Collection getComponentGoTo(@NotNull PsiElement psiElement) { + String text = PsiElementUtils.getText(psiElement); + + if(StringUtils.isBlank(text)) { + return Collections.emptyList(); + } + + return UxUtil.getTwigComponentNameTargets(psiElement.getProject(), text); + } + @NotNull private Collection getTranslationKeyGoTo(@NotNull PsiElement psiElement) { String translationKey = psiElement.getText(); diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/completion/TwigHtmlCompletionContributor.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/completion/TwigHtmlCompletionContributor.java index e0b36951f..c57c7a54a 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/completion/TwigHtmlCompletionContributor.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/completion/TwigHtmlCompletionContributor.java @@ -2,22 +2,31 @@ import com.intellij.codeInsight.completion.*; import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; import com.intellij.patterns.ElementPattern; +import com.intellij.patterns.PatternCondition; import com.intellij.patterns.PlatformPatterns; import com.intellij.psi.PsiElement; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.psi.xml.XmlAttribute; +import com.intellij.psi.xml.XmlTag; import com.intellij.util.ProcessingContext; +import com.jetbrains.php.lang.psi.elements.PhpClass; +import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; -import fr.adrienbrault.idea.symfony2plugin.asset.AssetLookupElement; import fr.adrienbrault.idea.symfony2plugin.asset.AssetDirectoryReader; import fr.adrienbrault.idea.symfony2plugin.asset.AssetFile; +import fr.adrienbrault.idea.symfony2plugin.asset.AssetLookupElement; import fr.adrienbrault.idea.symfony2plugin.routing.RouteHelper; import fr.adrienbrault.idea.symfony2plugin.routing.RouteLookupElement; import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigHtmlCompletionUtil; import fr.adrienbrault.idea.symfony2plugin.templating.util.TwigUtil; import fr.adrienbrault.idea.symfony2plugin.translation.TranslatorLookupElement; import fr.adrienbrault.idea.symfony2plugin.translation.dict.TranslationUtil; +import fr.adrienbrault.idea.symfony2plugin.util.UxUtil; import org.jetbrains.annotations.NotNull; +import java.util.Arrays; import java.util.List; import java.util.function.Function; import java.util.stream.Collectors; @@ -134,5 +143,64 @@ protected void addCompletions(@NotNull CompletionParameters parameters, Processi resultSet.addAllElements(collect); } }); + + // + extend( + CompletionType.BASIC, + TwigHtmlCompletionUtil.getTwigNamespacePattern(), + new CompletionProvider<>() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull ProcessingContext processingContext, @NotNull CompletionResultSet resultSet) { + PsiElement position = parameters.getOriginalPosition(); + if (!Symfony2ProjectComponent.isEnabled(position)) { + return; + } + + resultSet.addAllElements(UxUtil.getComponentLookupElements(position.getProject())); + } + } + ); + + extend( + CompletionType.BASIC, + PlatformPatterns.psiElement().withParent(PlatformPatterns.psiElement(XmlAttribute.class).withParent(PlatformPatterns.psiElement(XmlTag.class).with(new PatternCondition<>("starting with 'twig:'") { + @Override + public boolean accepts(@NotNull XmlTag xmlTag, ProcessingContext context) { + return xmlTag.getName().startsWith("twig:"); + } + }))), + new CompletionProvider<>() { + @Override + protected void addCompletions(@NotNull CompletionParameters parameters, @NotNull ProcessingContext processingContext, @NotNull CompletionResultSet resultSet) { + PsiElement position = parameters.getOriginalPosition(); + if (!Symfony2ProjectComponent.isEnabled(position)) { + return; + } + + XmlTag parentOfType = PsiTreeUtil.getParentOfType(position, XmlTag.class); + if (parentOfType == null) { + return; + } + + for (PhpClass phpClass : UxUtil.getTwigComponentNameTargets(position.getProject(), parentOfType.getName().substring(5))) { + Arrays.stream(phpClass.getOwnFields()).filter(field -> field.getModifier().isPublic()).forEach(field -> { + LookupElementBuilder element = LookupElementBuilder + .create(field.getName()) + .withIcon(Symfony2Icons.SYMFONY) + .withTypeText(field.getType().toString(), true); + + resultSet.addElement(element); + + LookupElementBuilder element2 = LookupElementBuilder + .create(":" + field.getName()) + .withIcon(Symfony2Icons.SYMFONY) + .withTypeText(field.getType().toString(), true); + + resultSet.addElement(element2); + }); + } + } + } + ); } } diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigHtmlCompletionUtil.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigHtmlCompletionUtil.java index 8c9413d22..40bbe12f0 100644 --- a/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigHtmlCompletionUtil.java +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/templating/util/TwigHtmlCompletionUtil.java @@ -4,12 +4,21 @@ import com.intellij.psi.PsiElement; import com.intellij.psi.html.HtmlTag; import com.intellij.psi.xml.XmlText; +import com.intellij.psi.xml.XmlTokenType; +import com.intellij.util.ProcessingContext; import org.jetbrains.annotations.NotNull; /** * @author Daniel Espendiller */ public class TwigHtmlCompletionUtil { + public static final PatternCondition HTML_TAG_TWIG_COMPONENT_PREFIX = new PatternCondition<>("twig prefix") { + @Override + public boolean accepts(@NotNull PsiElement psiElement, ProcessingContext context) { + String text = psiElement.getText(); + return text.startsWith("twig:"); + } + }; // html inside twig: href="" public static PsiElementPattern.Capture getHrefAttributePattern() { @@ -128,4 +137,10 @@ public static PsiElementPattern.Capture getAssetImageAttributePatter } + /** + * "" + */ + public static PsiElementPattern.Capture getTwigNamespacePattern() { + return PlatformPatterns.psiElement().withElementType(XmlTokenType.XML_NAME).with(HTML_TAG_TWIG_COMPONENT_PREFIX); + } } diff --git a/src/main/java/fr/adrienbrault/idea/symfony2plugin/util/UxUtil.java b/src/main/java/fr/adrienbrault/idea/symfony2plugin/util/UxUtil.java new file mode 100644 index 000000000..fa10e6dd9 --- /dev/null +++ b/src/main/java/fr/adrienbrault/idea/symfony2plugin/util/UxUtil.java @@ -0,0 +1,99 @@ +package fr.adrienbrault.idea.symfony2plugin.util; + +import com.intellij.codeInsight.lookup.LookupElement; +import com.intellij.codeInsight.lookup.LookupElementBuilder; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.Key; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.psi.util.CachedValue; +import com.intellij.util.indexing.FileBasedIndex; +import com.jetbrains.php.lang.psi.PhpFile; +import com.jetbrains.php.lang.psi.elements.PhpAttribute; +import com.jetbrains.php.lang.psi.elements.PhpClass; +import com.jetbrains.php.lang.psi.elements.PhpNamedElement; +import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; +import fr.adrienbrault.idea.symfony2plugin.stubs.cache.FileIndexCaches; +import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.UxTemplateStubIndex; +import kotlin.Pair; +import org.apache.commons.lang.StringUtils; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * @author Daniel Espendiller + */ +public class UxUtil { + private static String AS_TWIG_COMPONENT = "\\Symfony\\UX\\TwigComponent\\Attribute\\AsTwigComponent"; + + private static final Key>> TWIG_COMPONENTS = new Key<>("SYMFONY_TWIG_COMPONENTS"); + + public static void visitAsTwigComponent(@NotNull PhpFile phpFile, @NotNull Consumer> consumer) { + for (PhpNamedElement topLevelElement : phpFile.getTopLevelDefs().values()) { + if (topLevelElement instanceof PhpClass clazz) { + for (PhpAttribute attribute : clazz.getAttributes(AS_TWIG_COMPONENT)) { + String name = PhpPsiAttributesUtil.getAttributeValueByNameAsStringWithDefaultParameterFallback(attribute, "name"); + if (name == null) { + name = clazz.getName(); + } + + consumer.accept(new Pair<>(name, clazz)); + } + } + } + } + + public static Set getTwigComponentNames(@NotNull Project project) { + return FileIndexCaches.getIndexKeysCache(project, TWIG_COMPONENTS, UxTemplateStubIndex.KEY); + } + + public static Set getTwigComponentNameTargets(@NotNull Project project, @NotNull String name) { + Set phpClasses = new HashSet<>(); + + for (String fqn : FileBasedIndex.getInstance().getValues(UxTemplateStubIndex.KEY, name, GlobalSearchScope.allScope(project))) { + PhpClass classInterface = PhpElementsUtil.getClassInterface(project, fqn); + if (classInterface != null) { + phpClasses.add(classInterface); + } + } + + return phpClasses; + } + + public static Set getTwigComponentAllTargets(@NotNull Project project) { + Set phpClasses = new HashSet<>(); + + for (String twigComponentName : getTwigComponentNames(project)) { + for (String fqn : FileBasedIndex.getInstance().getValues(UxTemplateStubIndex.KEY, twigComponentName, GlobalSearchScope.allScope(project))) { + PhpClass classInterface = PhpElementsUtil.getClassInterface(project, fqn); + if (classInterface != null) { + phpClasses.add(classInterface); + } + } + } + + return phpClasses; + } + + + public static Collection getComponentLookupElements(@NotNull Project project) { + Map components = new HashMap<>(); + + for (String twigComponentName : getTwigComponentNames(project)) { + for (String fqn : FileBasedIndex.getInstance().getValues(UxTemplateStubIndex.KEY, twigComponentName, GlobalSearchScope.allScope(project))) { + components.put(twigComponentName, fqn); + } + } + + return components.entrySet() + .stream() + .map(entry -> + LookupElementBuilder.create(entry.getKey()) + .withIcon(Symfony2Icons.SYMFONY) + .withTypeText(StringUtils.stripStart(entry.getValue(), "\\")) + ) + .collect(Collectors.toList()); + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 65f498db6..b547d89f4 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -183,7 +183,13 @@ + + + + + + @@ -240,6 +246,7 @@ + diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateCompletionContributorTest.java b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateCompletionContributorTest.java index 4f6ba1161..38e56340e 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateCompletionContributorTest.java +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateCompletionContributorTest.java @@ -86,6 +86,10 @@ public void testCompletionForRoutingParameter() { assertNavigationMatch(TwigFileType.INSTANCE, "{{ path('xml_route', {'slug'}) }}", PlatformPatterns.psiElement()); } + public void testCompletionForTwigComponent() { + assertCompletionContains(TwigFileType.INSTANCE, "{{ component(''}) }}", "Alert"); + } + public void testInsertHandlerForTwigFunctionWithStringParameter() { assertCompletionResultEquals(TwigFileType.INSTANCE, "{{ a_test }}", "{{ a_test('') }}"); assertCompletionResultEquals(TwigFileType.INSTANCE, "{{ b_test }}", "{{ b_test('') }}"); diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateGoToDeclarationHandlerTest.java b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateGoToDeclarationHandlerTest.java index b2065cb7c..59f0d20b1 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateGoToDeclarationHandlerTest.java +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/TwigTemplateGoToDeclarationHandlerTest.java @@ -284,4 +284,20 @@ public void testSelfMacroImport() { "{{ _self.foobar('password') }}" ); } + + public void testComponentNameNavigation() { + assertNavigationMatch( + TwigFileType.INSTANCE, + "{{ component('Alert') }}", + PlatformPatterns.psiElement(PhpClass.class) + ); + } + + public void testComponentNameTagNavigation() { + assertNavigationMatch( + TwigFileType.INSTANCE, + "{% component Alert %}", + PlatformPatterns.psiElement(PhpClass.class) + ); + } } diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateCompletionContributorTest.php b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateCompletionContributorTest.php index 2841b768b..dccd9cc53 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateCompletionContributorTest.php +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateCompletionContributorTest.php @@ -103,3 +103,14 @@ public function getFunctions(): array } } } + +namespace App\Components +{ + use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + + #[AsTwigComponent] + class Alert + { + } +} + diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateGoToLocalDeclarationHandler.php b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateGoToLocalDeclarationHandler.php index 81657c28d..66b41093a 100644 --- a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateGoToLocalDeclarationHandler.php +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/templating/fixtures/TwigTemplateGoToLocalDeclarationHandler.php @@ -38,4 +38,14 @@ interface TokenParserInterface { public function getTag(); } +} + +namespace App\Components +{ + use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; + + #[AsTwigComponent] + class Alert + { + } } \ No newline at end of file diff --git a/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/util/UxUtilTest.java b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/util/UxUtilTest.java new file mode 100644 index 000000000..67c01c950 --- /dev/null +++ b/src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/util/UxUtilTest.java @@ -0,0 +1,70 @@ +package fr.adrienbrault.idea.symfony2plugin.tests.util; + +import com.jetbrains.php.lang.PhpFileType; +import com.jetbrains.php.lang.psi.PhpFile; +import com.jetbrains.php.lang.psi.PhpPsiElementFactory; +import com.jetbrains.php.lang.psi.elements.PhpClass; +import fr.adrienbrault.idea.symfony2plugin.tests.SymfonyLightCodeInsightFixtureTestCase; +import fr.adrienbrault.idea.symfony2plugin.util.UxUtil; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Daniel Espendiller + */ +public class UxUtilTest extends SymfonyLightCodeInsightFixtureTestCase { + + public void testVisitAsTwigComponent() { + PhpFile phpFile = (PhpFile) PhpPsiElementFactory.createPsiFileFromText(getProject(), " components = new HashMap<>(); + UxUtil.visitAsTwigComponent(phpFile, pair -> components.put(pair.getFirst(), pair.getSecond())); + + assertEquals("\\App\\Components\\Alert", components.get("Alert").getFQN()); + assertEquals("\\App\\Components\\Alert2", components.get("Alert2Foobar").getFQN()); + assertEquals("\\App\\Components\\Alert3", components.get("Alert3Foobar").getFQN()); + } + + public void testGetTwigComponentNames() { + myFixture.configureByText(PhpFileType.INSTANCE, "