Skip to content

Commit f535131

Browse files
authored
Merge pull request #1576 from Haehnchen/feature/routes-php8-attributes
#1567 support routes definition inside PHP8 attributes
2 parents d7ccdc6 + aea8776 commit f535131

File tree

5 files changed

+262
-11
lines changed

5 files changed

+262
-11
lines changed

src/main/java/fr/adrienbrault/idea/symfony2plugin/stubs/indexes/visitor/AnnotationRouteElementWalkingVisitor.java

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@
99
import com.jetbrains.php.lang.documentation.phpdoc.parser.PhpDocElementTypes;
1010
import com.jetbrains.php.lang.documentation.phpdoc.psi.PhpDocComment;
1111
import com.jetbrains.php.lang.documentation.phpdoc.psi.tags.PhpDocTag;
12-
import com.jetbrains.php.lang.psi.elements.Method;
13-
import com.jetbrains.php.lang.psi.elements.PhpClass;
14-
import com.jetbrains.php.lang.psi.elements.PhpPsiElement;
15-
import com.jetbrains.php.lang.psi.elements.StringLiteralExpression;
12+
import com.jetbrains.php.lang.psi.elements.*;
13+
import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionArgument;
1614
import de.espend.idea.php.annotation.util.AnnotationUtil;
1715
import fr.adrienbrault.idea.symfony2plugin.routing.RouteHelper;
1816
import fr.adrienbrault.idea.symfony2plugin.stubs.dict.StubIndexedRoute;
1917
import fr.adrienbrault.idea.symfony2plugin.util.AnnotationBackportUtil;
18+
import fr.adrienbrault.idea.symfony2plugin.util.PhpPsiAttributesUtil;
2019
import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils;
2120
import org.apache.commons.lang.StringUtils;
2221
import org.jetbrains.annotations.NotNull;
2322
import org.jetbrains.annotations.Nullable;
2423

24+
import java.util.ArrayList;
2525
import java.util.Collection;
2626
import java.util.Map;
2727
import java.util.regex.Matcher;
@@ -49,6 +49,10 @@ public void visitElement(PsiElement element) {
4949
visitPhpDocTag((PhpDocTag) element);
5050
}
5151

52+
if ((element instanceof PhpAttributesList)) {
53+
visitPhpAttributesList((PhpAttributesList) element);
54+
}
55+
5256
super.visitElement(element);
5357
}
5458

@@ -122,6 +126,83 @@ private void visitPhpDocTag(@NotNull PhpDocTag phpDocTag) {
122126
}
123127
}
124128

129+
private void visitPhpAttributesList(@NotNull PhpAttributesList phpAttributesList) {
130+
PsiElement parent = phpAttributesList.getParent();
131+
132+
// prefix on class scope
133+
String routeNamePrefix = "";
134+
if (parent instanceof Method) {
135+
PhpClass containingClass = ((Method) parent).getContainingClass();
136+
if (containingClass != null) {
137+
for (PhpAttribute attribute : containingClass.getAttributes()) {
138+
String fqn = attribute.getFQN();
139+
if(fqn == null || !RouteHelper.isRouteClassAnnotation(fqn)) {
140+
continue;
141+
}
142+
143+
String nameAttribute = PhpPsiAttributesUtil.getAttributeValueByNameAsString(attribute, "name");
144+
if (nameAttribute != null) {
145+
routeNamePrefix = nameAttribute;
146+
}
147+
}
148+
}
149+
}
150+
151+
for (PhpAttribute attribute : phpAttributesList.getAttributes()) {
152+
String fqn = attribute.getFQN();
153+
if(fqn == null || !RouteHelper.isRouteClassAnnotation(fqn)) {
154+
continue;
155+
}
156+
157+
String nameAttribute = PhpPsiAttributesUtil.getAttributeValueByNameAsString(attribute, "name");
158+
159+
String routeName = null;
160+
if (nameAttribute != null) {
161+
routeName = nameAttribute;
162+
} else {
163+
if (parent instanceof Method) {
164+
routeName = AnnotationBackportUtil.getRouteByMethod((Method) parent);
165+
}
166+
}
167+
168+
if (routeName == null) {
169+
continue;
170+
}
171+
172+
StubIndexedRoute route = new StubIndexedRoute(routeNamePrefix + routeName);
173+
174+
if (parent instanceof Method) {
175+
route.setController(getController((Method) parent));
176+
}
177+
178+
// find path "#[Route('/attributesWithoutName')]" or "#[Route(path: '/attributesWithoutName')]"
179+
String pathAttribute = PhpPsiAttributesUtil.getAttributeValueByNameAsString(attribute, "path");
180+
if (pathAttribute != null) {
181+
route.setPath(pathAttribute);
182+
} else {
183+
// find default "#[Route('/attributesWithoutName')]"
184+
for (PhpExpectedFunctionArgument argument : attribute.getArguments()) {
185+
if (argument.getArgumentIndex() == 0) {
186+
String value = PsiElementUtils.trimQuote(argument.getValue());
187+
if (StringUtils.isNotBlank(value)) {
188+
route.setPath(value);
189+
}
190+
191+
break;
192+
}
193+
}
194+
}
195+
196+
Collection<String> methods = PhpPsiAttributesUtil.getAttributeValueByNameAsArray(attribute, "methods");
197+
if (!methods.isEmpty()) {
198+
// array: needed for serialize
199+
route.setMethods(new ArrayList<>(methods));
200+
}
201+
202+
map.put(route.getName(), route);
203+
}
204+
}
205+
125206
/**
126207
* Extract route name of parent class "@Route(name="foo_")"
127208
*/
@@ -202,12 +283,21 @@ private String getController(@NotNull PhpDocTag phpDocTag) {
202283
return null;
203284
}
204285

286+
return getController(method);
287+
}
288+
289+
/**
290+
* FooController::fooAction
291+
*/
292+
@Nullable
293+
private String getController(@NotNull Method method) {
205294
PhpClass containingClass = method.getContainingClass();
206295
if(containingClass == null) {
207296
return null;
208297
}
209298

210-
return String.format("%s::%s",
299+
return String.format(
300+
"%s::%s",
211301
StringUtils.stripStart(containingClass.getFQN(), "\\"),
212302
method.getName()
213303
);

src/main/java/fr/adrienbrault/idea/symfony2plugin/util/AnnotationBackportUtil.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,22 +172,23 @@ public static String getQualifiedName(@NotNull PsiElement psiElement, @NotNull S
172172
* "Foo\ParkResortBundle\Controller\SubController\BundleController\FooController::nestedFooAction" => foo_parkresort_sub_bundle_foo_nestedfoo"
173173
*/
174174
public static String getRouteByMethod(@NotNull PhpDocTag phpDocTag) {
175-
PhpPsiElement method = getMethodScope(phpDocTag);
175+
Method method = getMethodScope(phpDocTag);
176176
if (method == null) {
177177
return null;
178178
}
179179

180+
return getRouteByMethod(method);
181+
}
182+
183+
public static String getRouteByMethod(@NotNull Method method) {
180184
String name = method.getName();
181-
if(name == null) {
182-
return null;
183-
}
184185

185186
// strip action
186187
if(name.endsWith("Action")) {
187188
name = name.substring(0, name.length() - "Action".length());
188189
}
189190

190-
PhpClass containingClass = ((Method) method).getContainingClass();
191+
PhpClass containingClass = method.getContainingClass();
191192
if(containingClass == null) {
192193
return null;
193194
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package fr.adrienbrault.idea.symfony2plugin.util;
2+
3+
import com.intellij.patterns.PlatformPatterns;
4+
import com.intellij.patterns.PsiElementPattern;
5+
import com.intellij.psi.PsiElement;
6+
import com.intellij.psi.PsiWhiteSpace;
7+
import com.intellij.psi.util.PsiTreeUtil;
8+
import com.jetbrains.php.lang.lexer.PhpTokenTypes;
9+
import com.jetbrains.php.lang.psi.PhpPsiUtil;
10+
import com.jetbrains.php.lang.psi.elements.ArrayCreationExpression;
11+
import com.jetbrains.php.lang.psi.elements.ParameterList;
12+
import com.jetbrains.php.lang.psi.elements.PhpAttribute;
13+
import com.jetbrains.php.lang.psi.elements.StringLiteralExpression;
14+
import org.apache.commons.lang.StringUtils;
15+
import org.jetbrains.annotations.NotNull;
16+
import org.jetbrains.annotations.Nullable;
17+
18+
import java.util.Collection;
19+
import java.util.Collections;
20+
21+
/**
22+
* Helpers for PHP 8 Attributes psi access
23+
*
24+
* @author Daniel Espendiller <daniel@espendiller.net>
25+
*/
26+
public class PhpPsiAttributesUtil {
27+
@Nullable
28+
public static String getAttributeValueByNameAsString(@NotNull PhpAttribute attribute, @NotNull String attributeName) {
29+
PsiElement nextSibling = findAttributeByName(attribute, attributeName);
30+
31+
if (nextSibling instanceof StringLiteralExpression) {
32+
String contents = ((StringLiteralExpression) nextSibling).getContents();
33+
if (StringUtils.isNotBlank(contents)) {
34+
return contents;
35+
}
36+
}
37+
38+
return null;
39+
}
40+
41+
@NotNull
42+
public static Collection<String> getAttributeValueByNameAsArray(@NotNull PhpAttribute attribute, @NotNull String attributeName) {
43+
PsiElement nextSibling = findAttributeByName(attribute, attributeName);
44+
45+
if (nextSibling instanceof ArrayCreationExpression) {
46+
return PhpElementsUtil.getArrayValuesAsString((ArrayCreationExpression) nextSibling);
47+
}
48+
49+
return Collections.emptyList();
50+
}
51+
52+
/**
53+
* Workaround to find given attribute: "#[Route('/attributesWithoutName', name: "")]" as attribute iteration given the index as "int" but not the key as name
54+
*/
55+
@Nullable
56+
private static PsiElement findAttributeByName(@NotNull PhpAttribute attribute, @NotNull String attributeName) {
57+
ParameterList parameterList = PsiTreeUtil.findChildOfType(attribute, ParameterList.class);
58+
if (parameterList == null) {
59+
return null;
60+
}
61+
62+
Collection<PsiElement> childrenOfTypeAsList = PsiElementUtils.getChildrenOfTypeAsList(parameterList, getAttributeColonPattern(attributeName));
63+
64+
if (childrenOfTypeAsList.isEmpty()) {
65+
return null;
66+
}
67+
68+
PsiElement colon = childrenOfTypeAsList.iterator().next();
69+
70+
return PhpPsiUtil.getNextSibling(colon, psiElement -> psiElement instanceof PsiWhiteSpace);
71+
}
72+
73+
/**
74+
* "#[Route('/path', name: 'attributes_action')]"
75+
*/
76+
@NotNull
77+
private static PsiElementPattern.Capture<PsiElement> getAttributeColonPattern(String name) {
78+
return PlatformPatterns.psiElement().withElementType(
79+
PhpTokenTypes.opCOLON
80+
).afterLeaf(PlatformPatterns.psiElement().withElementType(PhpTokenTypes.IDENTIFIER).withText(name));
81+
}
82+
}

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/stubs/indexes/RoutesStubIndexTest.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public String getTestDataPath() {
3131
public void testRouteIdIndex() {
3232
assertIndexContains(RoutesStubIndex.KEY,
3333
"foo_yaml_pattern", "foo_yaml_path", "foo_yaml_path_only",
34-
"foo_xml_pattern", "foo_xml_path", "foo_xml_id_only"
34+
"foo_xml_pattern", "foo_xml_path", "foo_xml_id_only", "attributes_action", "app_my_default_attributeswithoutname"
3535
);
3636

3737
assertIndexNotContains(RoutesStubIndex.KEY,
@@ -154,6 +154,29 @@ public void testAnnotationThatRouteWithPrefixIsInIndex() {
154154
assertIndexContains(RoutesStubIndex.KEY, "foo_prefix_home");
155155
}
156156

157+
public void testThatPhp8AttributesMethodsAreInIndex() {
158+
RouteInterface route = getFirstValue("attributes_action");
159+
assertEquals("attributes_action", route.getName());
160+
assertEquals("AppBundle\\My\\Controller\\DefaultController::attributesAction", route.getController());
161+
assertEquals("/attributes-action", route.getPath());
162+
163+
RouteInterface route2 = getFirstValue("app_my_default_attributeswithoutname");
164+
assertEquals("app_my_default_attributeswithoutname", route2.getName());
165+
assertEquals("AppBundle\\My\\Controller\\DefaultController::attributesWithoutName", route2.getController());
166+
assertEquals("/attributesWithoutName", route2.getPath());
167+
assertContainsElements(route2.getMethods(), "POST", "GET");
168+
169+
RouteInterface route3 = getFirstValue("attributes-names");
170+
assertEquals("attributes-names", route3.getName());
171+
assertEquals("AppBundle\\My\\Controller\\DefaultController::attributesPath", route3.getController());
172+
assertEquals("/attributes-path", route3.getPath());
173+
174+
RouteInterface route4 = getFirstValue("foo-attributes_prefix_home");
175+
assertEquals("MyAttributesPrefix\\PrefixController::editAction", route4.getController());
176+
assertEquals("foo-attributes_prefix_home", route4.getName());
177+
assertEquals("/edit/{id}", route4.getPath());
178+
}
179+
157180
@NotNull
158181
private RouteInterface getFirstValue(@NotNull String key) {
159182
return FileBasedIndex.getInstance().getValues(RoutesStubIndex.KEY, key, GlobalSearchScope.allScope(getProject())).get(0);

src/test/java/fr/adrienbrault/idea/symfony2plugin/tests/stubs/indexes/fixtures/RoutesStubIndex.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,5 +169,60 @@ class DefaultController
169169
public function fooAction()
170170
{
171171
}
172+
173+
#[Route('/attributes-action', name: 'attributes_action')]
174+
public function attributesAction()
175+
{
176+
}
177+
178+
#[Route('/attributesWithoutName', methods: ['GET', 'POST'])]
179+
public function attributesWithoutName()
180+
{
181+
}
182+
183+
#[Route(path: '/attributes-path', name: 'attributes-names')]
184+
public function attributesPath()
185+
{
186+
}
187+
}
188+
}
189+
190+
191+
namespace MyAttributesPrefix
192+
{
193+
use Symfony\Component\Routing\Annotation\Route;
194+
195+
#[Route(path: '/foo-attributes', name: 'foo-attributes_')]
196+
class PrefixController
197+
{
198+
#[Route(path: '/edit/{id}', name: 'prefix_home')]
199+
public function editAction()
200+
{
201+
}
202+
}
203+
}
204+
205+
namespace Symfony\Component\Routing\Annotation
206+
{
207+
class Route
208+
{
209+
public function __construct(
210+
$data = [],
211+
$path = null,
212+
string $name = null,
213+
array $requirements = [],
214+
array $options = [],
215+
array $defaults = [],
216+
string $host = null,
217+
array $methods = [],
218+
array $schemes = [],
219+
string $condition = null,
220+
int $priority = null,
221+
string $locale = null,
222+
string $format = null,
223+
bool $utf8 = null,
224+
bool $stateless = null
225+
) {
226+
}
172227
}
173228
}

0 commit comments

Comments
 (0)