Skip to content

Commit

Permalink
Merge pull request #1576 from Haehnchen/feature/routes-php8-attributes
Browse files Browse the repository at this point in the history
#1567 support routes definition inside PHP8 attributes
  • Loading branch information
Haehnchen committed Jan 23, 2021
2 parents d7ccdc6 + aea8776 commit f535131
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@
import com.jetbrains.php.lang.documentation.phpdoc.parser.PhpDocElementTypes;
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.Method;
import com.jetbrains.php.lang.psi.elements.PhpClass;
import com.jetbrains.php.lang.psi.elements.PhpPsiElement;
import com.jetbrains.php.lang.psi.elements.StringLiteralExpression;
import com.jetbrains.php.lang.psi.elements.*;
import com.jetbrains.php.lang.psi.stubs.indexes.expectedArguments.PhpExpectedFunctionArgument;
import de.espend.idea.php.annotation.util.AnnotationUtil;
import fr.adrienbrault.idea.symfony2plugin.routing.RouteHelper;
import fr.adrienbrault.idea.symfony2plugin.stubs.dict.StubIndexedRoute;
import fr.adrienbrault.idea.symfony2plugin.util.AnnotationBackportUtil;
import fr.adrienbrault.idea.symfony2plugin.util.PhpPsiAttributesUtil;
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.ArrayList;
import java.util.Collection;
import java.util.Map;
import java.util.regex.Matcher;
Expand Down Expand Up @@ -49,6 +49,10 @@ public void visitElement(PsiElement element) {
visitPhpDocTag((PhpDocTag) element);
}

if ((element instanceof PhpAttributesList)) {
visitPhpAttributesList((PhpAttributesList) element);
}

super.visitElement(element);
}

Expand Down Expand Up @@ -122,6 +126,83 @@ private void visitPhpDocTag(@NotNull PhpDocTag phpDocTag) {
}
}

private void visitPhpAttributesList(@NotNull PhpAttributesList phpAttributesList) {
PsiElement parent = phpAttributesList.getParent();

// prefix on class scope
String routeNamePrefix = "";
if (parent instanceof Method) {
PhpClass containingClass = ((Method) parent).getContainingClass();
if (containingClass != null) {
for (PhpAttribute attribute : containingClass.getAttributes()) {
String fqn = attribute.getFQN();
if(fqn == null || !RouteHelper.isRouteClassAnnotation(fqn)) {
continue;
}

String nameAttribute = PhpPsiAttributesUtil.getAttributeValueByNameAsString(attribute, "name");
if (nameAttribute != null) {
routeNamePrefix = nameAttribute;
}
}
}
}

for (PhpAttribute attribute : phpAttributesList.getAttributes()) {
String fqn = attribute.getFQN();
if(fqn == null || !RouteHelper.isRouteClassAnnotation(fqn)) {
continue;
}

String nameAttribute = PhpPsiAttributesUtil.getAttributeValueByNameAsString(attribute, "name");

String routeName = null;
if (nameAttribute != null) {
routeName = nameAttribute;
} else {
if (parent instanceof Method) {
routeName = AnnotationBackportUtil.getRouteByMethod((Method) parent);
}
}

if (routeName == null) {
continue;
}

StubIndexedRoute route = new StubIndexedRoute(routeNamePrefix + routeName);

if (parent instanceof Method) {
route.setController(getController((Method) parent));
}

// find path "#[Route('/attributesWithoutName')]" or "#[Route(path: '/attributesWithoutName')]"
String pathAttribute = PhpPsiAttributesUtil.getAttributeValueByNameAsString(attribute, "path");
if (pathAttribute != null) {
route.setPath(pathAttribute);
} else {
// find default "#[Route('/attributesWithoutName')]"
for (PhpExpectedFunctionArgument argument : attribute.getArguments()) {
if (argument.getArgumentIndex() == 0) {
String value = PsiElementUtils.trimQuote(argument.getValue());
if (StringUtils.isNotBlank(value)) {
route.setPath(value);
}

break;
}
}
}

Collection<String> methods = PhpPsiAttributesUtil.getAttributeValueByNameAsArray(attribute, "methods");
if (!methods.isEmpty()) {
// array: needed for serialize
route.setMethods(new ArrayList<>(methods));
}

map.put(route.getName(), route);
}
}

/**
* Extract route name of parent class "@Route(name="foo_")"
*/
Expand Down Expand Up @@ -202,12 +283,21 @@ private String getController(@NotNull PhpDocTag phpDocTag) {
return null;
}

return getController(method);
}

/**
* FooController::fooAction
*/
@Nullable
private String getController(@NotNull Method method) {
PhpClass containingClass = method.getContainingClass();
if(containingClass == null) {
return null;
}

return String.format("%s::%s",
return String.format(
"%s::%s",
StringUtils.stripStart(containingClass.getFQN(), "\\"),
method.getName()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,22 +172,23 @@ public static String getQualifiedName(@NotNull PsiElement psiElement, @NotNull S
* "Foo\ParkResortBundle\Controller\SubController\BundleController\FooController::nestedFooAction" => foo_parkresort_sub_bundle_foo_nestedfoo"
*/
public static String getRouteByMethod(@NotNull PhpDocTag phpDocTag) {
PhpPsiElement method = getMethodScope(phpDocTag);
Method method = getMethodScope(phpDocTag);
if (method == null) {
return null;
}

return getRouteByMethod(method);
}

public static String getRouteByMethod(@NotNull Method method) {
String name = method.getName();
if(name == null) {
return null;
}

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

PhpClass containingClass = ((Method) method).getContainingClass();
PhpClass containingClass = method.getContainingClass();
if(containingClass == null) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package fr.adrienbrault.idea.symfony2plugin.util;

import com.intellij.patterns.PlatformPatterns;
import com.intellij.patterns.PsiElementPattern;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiWhiteSpace;
import com.intellij.psi.util.PsiTreeUtil;
import com.jetbrains.php.lang.lexer.PhpTokenTypes;
import com.jetbrains.php.lang.psi.PhpPsiUtil;
import com.jetbrains.php.lang.psi.elements.ArrayCreationExpression;
import com.jetbrains.php.lang.psi.elements.ParameterList;
import com.jetbrains.php.lang.psi.elements.PhpAttribute;
import com.jetbrains.php.lang.psi.elements.StringLiteralExpression;
import org.apache.commons.lang.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.Collection;
import java.util.Collections;

/**
* Helpers for PHP 8 Attributes psi access
*
* @author Daniel Espendiller <daniel@espendiller.net>
*/
public class PhpPsiAttributesUtil {
@Nullable
public static String getAttributeValueByNameAsString(@NotNull PhpAttribute attribute, @NotNull String attributeName) {
PsiElement nextSibling = findAttributeByName(attribute, attributeName);

if (nextSibling instanceof StringLiteralExpression) {
String contents = ((StringLiteralExpression) nextSibling).getContents();
if (StringUtils.isNotBlank(contents)) {
return contents;
}
}

return null;
}

@NotNull
public static Collection<String> getAttributeValueByNameAsArray(@NotNull PhpAttribute attribute, @NotNull String attributeName) {
PsiElement nextSibling = findAttributeByName(attribute, attributeName);

if (nextSibling instanceof ArrayCreationExpression) {
return PhpElementsUtil.getArrayValuesAsString((ArrayCreationExpression) nextSibling);
}

return Collections.emptyList();
}

/**
* Workaround to find given attribute: "#[Route('/attributesWithoutName', name: "")]" as attribute iteration given the index as "int" but not the key as name
*/
@Nullable
private static PsiElement findAttributeByName(@NotNull PhpAttribute attribute, @NotNull String attributeName) {
ParameterList parameterList = PsiTreeUtil.findChildOfType(attribute, ParameterList.class);
if (parameterList == null) {
return null;
}

Collection<PsiElement> childrenOfTypeAsList = PsiElementUtils.getChildrenOfTypeAsList(parameterList, getAttributeColonPattern(attributeName));

if (childrenOfTypeAsList.isEmpty()) {
return null;
}

PsiElement colon = childrenOfTypeAsList.iterator().next();

return PhpPsiUtil.getNextSibling(colon, psiElement -> psiElement instanceof PsiWhiteSpace);
}

/**
* "#[Route('/path', name: 'attributes_action')]"
*/
@NotNull
private static PsiElementPattern.Capture<PsiElement> getAttributeColonPattern(String name) {
return PlatformPatterns.psiElement().withElementType(
PhpTokenTypes.opCOLON
).afterLeaf(PlatformPatterns.psiElement().withElementType(PhpTokenTypes.IDENTIFIER).withText(name));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public String getTestDataPath() {
public void testRouteIdIndex() {
assertIndexContains(RoutesStubIndex.KEY,
"foo_yaml_pattern", "foo_yaml_path", "foo_yaml_path_only",
"foo_xml_pattern", "foo_xml_path", "foo_xml_id_only"
"foo_xml_pattern", "foo_xml_path", "foo_xml_id_only", "attributes_action", "app_my_default_attributeswithoutname"
);

assertIndexNotContains(RoutesStubIndex.KEY,
Expand Down Expand Up @@ -154,6 +154,29 @@ public void testAnnotationThatRouteWithPrefixIsInIndex() {
assertIndexContains(RoutesStubIndex.KEY, "foo_prefix_home");
}

public void testThatPhp8AttributesMethodsAreInIndex() {
RouteInterface route = getFirstValue("attributes_action");
assertEquals("attributes_action", route.getName());
assertEquals("AppBundle\\My\\Controller\\DefaultController::attributesAction", route.getController());
assertEquals("/attributes-action", route.getPath());

RouteInterface route2 = getFirstValue("app_my_default_attributeswithoutname");
assertEquals("app_my_default_attributeswithoutname", route2.getName());
assertEquals("AppBundle\\My\\Controller\\DefaultController::attributesWithoutName", route2.getController());
assertEquals("/attributesWithoutName", route2.getPath());
assertContainsElements(route2.getMethods(), "POST", "GET");

RouteInterface route3 = getFirstValue("attributes-names");
assertEquals("attributes-names", route3.getName());
assertEquals("AppBundle\\My\\Controller\\DefaultController::attributesPath", route3.getController());
assertEquals("/attributes-path", route3.getPath());

RouteInterface route4 = getFirstValue("foo-attributes_prefix_home");
assertEquals("MyAttributesPrefix\\PrefixController::editAction", route4.getController());
assertEquals("foo-attributes_prefix_home", route4.getName());
assertEquals("/edit/{id}", route4.getPath());
}

@NotNull
private RouteInterface getFirstValue(@NotNull String key) {
return FileBasedIndex.getInstance().getValues(RoutesStubIndex.KEY, key, GlobalSearchScope.allScope(getProject())).get(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,5 +169,60 @@ class DefaultController
public function fooAction()
{
}

#[Route('/attributes-action', name: 'attributes_action')]
public function attributesAction()
{
}

#[Route('/attributesWithoutName', methods: ['GET', 'POST'])]
public function attributesWithoutName()
{
}

#[Route(path: '/attributes-path', name: 'attributes-names')]
public function attributesPath()
{
}
}
}


namespace MyAttributesPrefix
{
use Symfony\Component\Routing\Annotation\Route;

#[Route(path: '/foo-attributes', name: 'foo-attributes_')]
class PrefixController
{
#[Route(path: '/edit/{id}', name: 'prefix_home')]
public function editAction()
{
}
}
}

namespace Symfony\Component\Routing\Annotation
{
class Route
{
public function __construct(
$data = [],
$path = null,
string $name = null,
array $requirements = [],
array $options = [],
array $defaults = [],
string $host = null,
array $methods = [],
array $schemes = [],
string $condition = null,
int $priority = null,
string $locale = null,
string $format = null,
bool $utf8 = null,
bool $stateless = null
) {
}
}
}

0 comments on commit f535131

Please sign in to comment.