package fr.adrienbrault.idea.symfony2plugin.routing; import com.intellij.codeInsight.lookup.LookupElement; import com.intellij.codeInsight.lookup.LookupElementBuilder; import com.intellij.openapi.extensions.ExtensionPointName; import com.intellij.openapi.project.Project; import com.intellij.openapi.util.Key; import com.intellij.openapi.util.io.FileUtil; import com.intellij.openapi.vfs.VfsUtil; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiFile; import com.intellij.psi.PsiManager; import com.intellij.psi.impl.source.xml.XmlDocumentImpl; import com.intellij.psi.search.GlobalSearchScope; import com.intellij.psi.util.*; import com.intellij.psi.xml.XmlAttribute; import com.intellij.psi.xml.XmlFile; import com.intellij.psi.xml.XmlTag; import com.intellij.util.containers.ContainerUtil; import com.intellij.util.indexing.FileBasedIndex; import com.jetbrains.php.lang.documentation.phpdoc.psi.PhpDocComment; import com.jetbrains.php.lang.psi.PhpFile; import com.jetbrains.php.lang.psi.PhpPsiUtil; import com.jetbrains.php.lang.psi.elements.*; import com.jetbrains.php.refactoring.PhpNameUtil; import de.espend.idea.php.annotation.dict.PhpDocCommentAnnotation; import de.espend.idea.php.annotation.dict.PhpDocTagAnnotation; import de.espend.idea.php.annotation.util.AnnotationUtil; import fr.adrienbrault.idea.symfony2plugin.Settings; import fr.adrienbrault.idea.symfony2plugin.Symfony2Icons; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; import fr.adrienbrault.idea.symfony2plugin.extension.RoutingLoader; import fr.adrienbrault.idea.symfony2plugin.extension.RoutingLoaderParameter; import fr.adrienbrault.idea.symfony2plugin.routing.dic.ControllerClassOnShortcutReturn; import fr.adrienbrault.idea.symfony2plugin.routing.dic.ServiceRouteContainer; import fr.adrienbrault.idea.symfony2plugin.routing.dict.RoutesContainer; import fr.adrienbrault.idea.symfony2plugin.routing.dict.RoutingFile; import fr.adrienbrault.idea.symfony2plugin.stubs.SymfonyProcessors; import fr.adrienbrault.idea.symfony2plugin.stubs.dict.StubIndexedRoute; import fr.adrienbrault.idea.symfony2plugin.stubs.indexes.RoutesStubIndex; import fr.adrienbrault.idea.symfony2plugin.util.*; import fr.adrienbrault.idea.symfony2plugin.util.controller.ControllerAction; import fr.adrienbrault.idea.symfony2plugin.util.controller.ControllerIndex; import fr.adrienbrault.idea.symfony2plugin.util.dict.ServiceUtil; import fr.adrienbrault.idea.symfony2plugin.util.dict.SymfonyBundle; import fr.adrienbrault.idea.symfony2plugin.util.yaml.YamlHelper; import org.apache.commons.lang.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.yaml.YAMLUtil; import org.jetbrains.yaml.psi.*; import java.io.File; import java.util.*; import java.util.stream.Collectors; /** * @author Daniel Espendiller <[email protected]> */ public class RouteHelper { private static final Key<CachedValue<Map<String, Route>>> ROUTE_CACHE = new Key<>("SYMFONY:ROUTE_CACHE"); public static Set<String> ROUTE_CLASSES = new HashSet<>(Arrays.asList( "Sensio\\Bundle\\FrameworkExtraBundle\\Configuration\\Route", "Symfony\\Component\\Routing\\Annotation\\Route" )); public static Map<Project, Map<String, RoutesContainer>> COMPILED_CACHE = new HashMap<>(); private static final ExtensionPointName<RoutingLoader> ROUTING_LOADER = new ExtensionPointName<>( "fr.adrienbrault.idea.symfony2plugin.extension.RoutingLoader" ); public static LookupElement[] getRouteParameterLookupElements(@NotNull Project project, @NotNull String routeName) { List<LookupElement> lookupElements = new ArrayList<>(); for (Route route : RouteHelper.getRoute(project, routeName)) { for(String values: route.getVariables()) { lookupElements.add(LookupElementBuilder.create(values).withIcon(Symfony2Icons.ROUTE)); } } if (SymfonyUtil.isVersionGreaterThen(project, "3.2.0")) { lookupElements.add(LookupElementBuilder.create("_fragment").withIcon(Symfony2Icons.ROUTE)); } return lookupElements.toArray(new LookupElement[lookupElements.size()]); } @NotNull public static Collection<Route> getRoute(@NotNull Project project, @NotNull String routeName) { Map<String, Route> compiledRoutes = RouteHelper.getCompiledRoutes(project); if(compiledRoutes.containsKey(routeName)) { return Collections.singletonList(compiledRoutes.get(routeName)); } Collection<Route> routes = new ArrayList<>(); Collection<VirtualFile> routeFiles = FileBasedIndex.getInstance().getContainingFiles(RoutesStubIndex.KEY, routeName, GlobalSearchScope.allScope(project)); for(StubIndexedRoute route: FileBasedIndex.getInstance().getValues(RoutesStubIndex.KEY, routeName, GlobalSearchScope.filesScope(project, routeFiles))) { routes.add(new Route(route)); } return routes; } public static PsiElement[] getRouteParameterPsiElements(Project project, String routeName, String parameterName) { List<PsiElement> results = new ArrayList<>(); for (PsiElement psiElement : RouteHelper.getMethods(project, routeName)) { if(psiElement instanceof Method) { for(Parameter parameter: ((Method) psiElement).getParameters()) { if(parameter.getName().equals(parameterName)) { results.add(parameter); } } } } return results.toArray(new PsiElement[results.size()]); } @NotNull public static PsiElement[] getMethods(@NotNull Project project, @NotNull String routeName) { Collection<PsiElement> targets = new ArrayList<>(); for (Route route : getRoute(project, routeName)) { targets.addAll(Arrays.asList(getMethodsOnControllerShortcut(project, route.getController()))); } return targets.toArray(new PsiElement[targets.size()]); } /** * convert to controller name to method: * * FooBundle\Controller\BarController::fooBarAction * foo_service_bar:fooBar * AcmeDemoBundle:Demo:hello * FooBundle\Controller\BarController (__invoke) * * @param project current project * @param controllerName controller service, raw or compiled * @return targets */ @NotNull public static PsiElement[] getMethodsOnControllerShortcut(@NotNull Project project, @Nullable String controllerName) { if(controllerName == null) { return new PsiElement[0]; } // escaping // "Foobar\\Test" controllerName = controllerName.replace("\\\\", "\\"); if(controllerName.contains("::")) { // FooBundle\Controller\BarController::fooBarAction String className = controllerName.substring(0, controllerName.lastIndexOf("::")); String methodName = controllerName.substring(controllerName.lastIndexOf("::") + 2); Method method = PhpElementsUtil.getClassMethod(project, className, methodName); return method != null ? new PsiElement[] {method} : new PsiElement[0]; } else if(controllerName.contains(":")) { // AcmeDemoBundle:Demo:hello String[] split = controllerName.split(":"); if(split.length == 3) { Collection<Method> controllerMethod = ControllerIndex.getControllerMethod(project, controllerName); if(controllerMethod.size() > 0) { return controllerMethod.toArray(new PsiElement[controllerMethod.size()]); } } // foo_service_bar:fooBar ControllerAction controllerServiceAction = new ControllerIndex(project).getControllerActionOnService(controllerName); if(controllerServiceAction != null) { return new PsiElement[] {controllerServiceAction.getMethod()}; } } else if(PhpNameUtil.isValidNamespaceFullName(controllerName, true)) { // FooBundle\Controller\BarController (__invoke) Method invoke = PhpElementsUtil.getClassMethod(project, controllerName, "__invoke"); if(invoke != null) { return new PsiElement[] {invoke}; } // class fallback Collection<PhpClass> phpClass = PhpElementsUtil.getClassesInterface(project, controllerName); return phpClass.toArray(new PsiElement[phpClass.size()]); } return new PsiElement[0]; } /** * convert to controller class: * * FooBundle\Controller\BarController::fooBarAction * foo_service_bar:fooBar * AcmeDemoBundle:Demo:hello * * @param project current project * @param controllerName controller service, raw or compiled * @return targets */ @Nullable public static ControllerClassOnShortcutReturn getControllerClassOnShortcut(@NotNull Project project,@NotNull String controllerName) { if(controllerName.contains("::")) { // FooBundle\Controller\BarController::fooBarAction PhpClass aClass = PhpElementsUtil.getClass(project, controllerName.substring(0, controllerName.lastIndexOf("::"))); if(aClass != null) { return new ControllerClassOnShortcutReturn(aClass); } return null; } // AcmeDemoBundle:Demo:hello String[] split = controllerName.split(":"); if(split.length == 3) { // try to resolve on bundle path for (SymfonyBundle symfonyBundle : new SymfonyBundleUtil(project).getBundle(split[0])) { PhpClass aClass = PhpElementsUtil.getClass(project, symfonyBundle.getNamespaceName() + "Controller\\" + split[1] + "Controller"); // @TODO: support multiple bundle names if(aClass != null) { return new ControllerClassOnShortcutReturn(aClass); } } } else if(split.length == 2) { // controller as service: // foo_service_bar:fooBar PhpClass phpClass = ServiceUtil.getResolvedClassDefinition(project, split[0]); if(phpClass != null) { return new ControllerClassOnShortcutReturn(phpClass, true); } } return null; } private static String getPath(Project project, String path) { if (!FileUtil.isAbsolute(path)) { // Project relative path path = project.getBasePath() + "/" + path; } return path; } @NotNull public static Map<String, Route> getCompiledRoutes(@NotNull Project project) { Set<String> files = new HashSet<>(); // add custom routing files on settings List<RoutingFile> routingFiles = Settings.getInstance(project).routingFiles; if(routingFiles != null) { for (RoutingFile routingFile : routingFiles) { String path = routingFile.getPath(); if(StringUtils.isNotBlank(path)) { files.add(path); } } } // add defaults; if user never has changed the settings if(routingFiles == null || routingFiles.size() == 0) { Collections.addAll(files, Settings.DEFAULT_ROUTES); } for(String file: files) { File urlGeneratorFile = new File(getPath(project, file)); VirtualFile virtualUrlGeneratorFile = VfsUtil.findFileByIoFile(urlGeneratorFile, false); if (virtualUrlGeneratorFile == null || !urlGeneratorFile.exists()) { // clean file cache if(COMPILED_CACHE.containsKey(project) && COMPILED_CACHE.get(project).containsKey(file)) { COMPILED_CACHE.get(project).remove(file); } } else { if(!COMPILED_CACHE.containsKey(project)) { COMPILED_CACHE.put(project, new HashMap<>()); } Long routesLastModified = urlGeneratorFile.lastModified(); if(!COMPILED_CACHE.get(project).containsKey(file) || !COMPILED_CACHE.get(project).get(file).getLastMod().equals(routesLastModified)) { COMPILED_CACHE.get(project).put(file, new RoutesContainer( routesLastModified, RouteHelper.getRoutesInsideUrlGeneratorFile(project, virtualUrlGeneratorFile) )); Symfony2ProjectComponent.getLogger().info("update routing: " + urlGeneratorFile.toString()); } } } Map<String, Route> routes = new HashMap<>(); if(COMPILED_CACHE.containsKey(project)) { for (RoutesContainer container : COMPILED_CACHE.get(project).values()) { routes.putAll(container.getRoutes()); } } RoutingLoaderParameter parameter = null; for (RoutingLoader routingLoader : ROUTING_LOADER.getExtensions()) { if(parameter == null) { parameter = new RoutingLoaderParameter(project, routes); } routingLoader.invoke(parameter); } return routes; } @NotNull public static Map<String, Route> getRoutesInsideUrlGeneratorFile(@NotNull Project project, @NotNull VirtualFile virtualFile) { PsiFile psiFile = PsiElementUtils.virtualFileToPsiFile(project, virtualFile); if(!(psiFile instanceof PhpFile)) { return Collections.emptyMap(); } return getRoutesInsideUrlGeneratorFile(psiFile); } /** * Temporary or remote files dont support "isInstanceOf", check for string implementation first */ private static boolean isRouteClass(@NotNull PhpClass phpClass) { for (ClassReference classReference : phpClass.getExtendsList().getReferenceElements()) { String fqn = classReference.getFQN(); if(fqn != null && StringUtils.stripStart(fqn, "\\").equalsIgnoreCase("Symfony\\Component\\Routing\\Generator\\UrlGenerator")) { return true; } } for (PhpClass phpInterface : phpClass.getImplementedInterfaces()) { String fqn = phpInterface.getFQN(); if( StringUtils.stripStart(fqn, "\\").equalsIgnoreCase("Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface")) { return true; } } return PhpElementsUtil.isInstanceOf(phpClass, "\\Symfony\\Component\\Routing\\Generator\\UrlGeneratorInterface"); } @NotNull public static Map<String, Route> getRoutesInsideUrlGeneratorFile(@NotNull PsiFile psiFile) { Map<String, Route> routes = new HashMap<>(); // Symfony >= 4 // extract the routes on a return statement // return [['route'] => [...]] for (PhpReturn phpReturn : PsiTreeUtil.findChildrenOfType(psiFile, PhpReturn.class)) { PsiElement argument = phpReturn.getArgument(); if (!(argument instanceof ArrayCreationExpression)) { continue; } // get only the inside arrays // [[..], [..]] => [..], [..] for (Map.Entry<String, PsiElement> routeArray : PhpElementsUtil.getArrayKeyValueMapWithValueAsPsiElement((ArrayCreationExpression) argument).entrySet()) { List<ArrayCreationExpression> routeArrayOptions = new ArrayList<>(); for (PhpPsiElement routeOption : PsiTreeUtil.getChildrenOfTypeAsList(routeArray.getValue(), PhpPsiElement.class)) { routeArrayOptions.add(PsiTreeUtil.getChildOfType(routeOption, ArrayCreationExpression.class)); } routes.put(routeArray.getKey(), convertRouteConfigForReturnArray(routeArray.getKey(), routeArrayOptions)); } } // Symfony < 4 // heavy stuff here, to get nested routing array :) // list($variables, $defaults, $requirements, $tokens, $hostTokens) Collection<PhpClass> phpClasses = PsiTreeUtil.findChildrenOfType(psiFile, PhpClass.class); for(PhpClass phpClass: phpClasses) { if(!isRouteClass(phpClass)) { continue; } // Symfony < 2.8 // static private $declaredRoutes = array(...) // only "getOwnFields" is uncached and dont breaks; find* methods are cached resulting in exceptions Field[] ownFields = phpClass.getOwnFields(); for (Field ownField : ownFields) { if ("declaredRoutes".equals(ownField.getName())) { PsiElement defaultValue = ownField.getDefaultValue(); if(!(defaultValue instanceof ArrayCreationExpression)) { continue; } collectRoutesOnArrayCreation(routes, (ArrayCreationExpression) defaultValue); } } // Symfony >= 2.8 // if (null === self::$declaredRoutes) { // self::$declaredRoutes = array() // } Method constructor = phpClass.getConstructor(); if(constructor == null) { continue; } for (FieldReference fieldReference : PsiTreeUtil.collectElementsOfType(constructor, FieldReference.class)) { String canonicalText = fieldReference.getCanonicalText(); if(!"declaredRoutes".equals(canonicalText)) { continue; } PsiElement assignExpression = fieldReference.getParent(); if(!(assignExpression instanceof AssignmentExpression)) { continue; } PhpPsiElement value = ((AssignmentExpression) assignExpression).getValue(); if(!(value instanceof ArrayCreationExpression)) { continue; } collectRoutesOnArrayCreation(routes, (ArrayCreationExpression) value); } } return routes; } /** * Collects routes in: * * array( * _wdt' => array(..) * } * */ private static void collectRoutesOnArrayCreation(@NotNull Map<String, Route> routes, @NotNull ArrayCreationExpression defaultValue) { for(ArrayHashElement arrayHashElement: defaultValue.getHashElements()) { PsiElement hashKey = arrayHashElement.getKey(); if(!(hashKey instanceof StringLiteralExpression)) { continue; } String routeName = ((StringLiteralExpression) hashKey).getContents(); if(!isProductionRouteName(routeName)) { continue; } routeName = convertLanguageRouteName(routeName); PsiElement hashValue = arrayHashElement.getValue(); if(hashValue instanceof ArrayCreationExpression) { routes.put(routeName, convertRouteConfig(routeName, (ArrayCreationExpression) hashValue)); } } } /** * Used in Symfony > 4 where routes are wrapped into a return array */ @NotNull private static Route convertRouteConfigForReturnArray(@NotNull String routeName, @NotNull List<ArrayCreationExpression> hashElementCollection) { Set<String> variables = new HashSet<>(); if(hashElementCollection.size() >= 1 && hashElementCollection.get(0) != null) { ArrayCreationExpression value = hashElementCollection.get(0); if(value != null) { variables.addAll(PhpElementsUtil.getArrayValuesAsString(value)); } } Map<String, String> defaults = new HashMap<>(); if(hashElementCollection.size() >= 2 && hashElementCollection.get(1) != null) { ArrayCreationExpression value = hashElementCollection.get(1); if(value != null) { defaults = PhpElementsUtil.getArrayKeyValueMap(value); } } Map<String, String>requirements = new HashMap<>(); if(hashElementCollection.size() >= 3 && hashElementCollection.get(2) != null) { ArrayCreationExpression value = hashElementCollection.get(2); if(value != null) { requirements = PhpElementsUtil.getArrayKeyValueMap(value); } } List<Collection<String>> tokens = new ArrayList<>(); if(hashElementCollection.size() >= 4 && hashElementCollection.get(3) != null) { ArrayCreationExpression tokenArray = hashElementCollection.get(3); if(tokenArray != null) { for(ArrayHashElement tokenArrayConfig: tokenArray.getHashElements()) { if(tokenArrayConfig.getValue() instanceof ArrayCreationExpression) { Map<String, String> arrayKeyValueMap = PhpElementsUtil.getArrayKeyValueMap((ArrayCreationExpression) tokenArrayConfig.getValue()); tokens.add(arrayKeyValueMap.values()); } } } } // hostTokens = 4 need them? return new Route(routeName, variables, defaults, requirements, tokens); } /** * Used in Symfony < 4 where routes are wrapped into a class */ @NotNull private static Route convertRouteConfig(@NotNull String routeName, @NotNull ArrayCreationExpression hashValue) { List<ArrayHashElement> hashElementCollection = new ArrayList<>(); hashValue.getHashElements().forEach(hashElementCollection::add); Set<String> variables = new HashSet<>(); if(hashElementCollection.size() >= 1 && hashElementCollection.get(0).getValue() instanceof ArrayCreationExpression) { ArrayCreationExpression value = (ArrayCreationExpression) hashElementCollection.get(0).getValue(); if(value != null) { variables.addAll(PhpElementsUtil.getArrayKeyValueMap(value).values()); } } Map<String, String> defaults = new HashMap<>(); if(hashElementCollection.size() >= 2 && hashElementCollection.get(1).getValue() instanceof ArrayCreationExpression) { ArrayCreationExpression value = (ArrayCreationExpression) hashElementCollection.get(1).getValue(); if(value != null) { defaults = PhpElementsUtil.getArrayKeyValueMap(value); } } Map<String, String>requirements = new HashMap<>(); if(hashElementCollection.size() >= 3 && hashElementCollection.get(2).getValue() instanceof ArrayCreationExpression) { ArrayCreationExpression value = (ArrayCreationExpression) hashElementCollection.get(2).getValue(); if(value != null) { requirements = PhpElementsUtil.getArrayKeyValueMap(value); } } List<Collection<String>> tokens = new ArrayList<>(); if(hashElementCollection.size() >= 4 && hashElementCollection.get(3).getValue() instanceof ArrayCreationExpression) { ArrayCreationExpression tokenArray = (ArrayCreationExpression) hashElementCollection.get(3).getValue(); if(tokenArray != null) { for(ArrayHashElement tokenArrayConfig: tokenArray.getHashElements()) { if(tokenArrayConfig.getValue() instanceof ArrayCreationExpression) { Map<String, String> arrayKeyValueMap = PhpElementsUtil.getArrayKeyValueMap((ArrayCreationExpression) tokenArrayConfig.getValue()); tokens.add(arrayKeyValueMap.values()); } } } } // hostTokens = 4 need them? return new Route(routeName, variables, defaults, requirements, tokens); } private static boolean isProductionRouteName(String routeName) { return !routeName.matches("_assetic_[0-9a-z]+[_\\d+]*"); } /** * support I18nRoutingBundle */ private static String convertLanguageRouteName(String routeName) { if(routeName.matches("^[a-z]{2}__RG__.*$")) { routeName = routeName.replaceAll("^[a-z]{2}+__RG__", ""); } return routeName; } /** * Foo\Bar::methodAction */ @Nullable private static String convertMethodToRouteControllerName(@NotNull Method method) { PhpClass phpClass = method.getContainingClass(); if(phpClass == null) { return null; } return StringUtils.stripStart(phpClass.getFQN(), "\\") + "::" + method.getName(); } /** * FooBundle:Bar::method * FooBundle:Bar\\Foo::method */ @Nullable public static String convertMethodToRouteShortcutControllerName(@NotNull Method method) { PhpClass phpClass = method.getContainingClass(); if(phpClass == null) { return null; } if("__invoke".equals(method.getName())) { return StringUtils.stripStart(phpClass.getFQN(), "\\"); } String className = StringUtils.stripStart(phpClass.getFQN(), "\\"); int bundlePos = className.lastIndexOf("Bundle\\"); if(bundlePos == -1) { return null; } SymfonyBundle symfonyBundle = new SymfonyBundleUtil(method.getProject()).getContainingBundle(phpClass); if(symfonyBundle == null) { return null; } String methodName = method.getName(); // strip method action => FoobarAction if(methodName.endsWith("Action")) { methodName = methodName.substring(0, methodName.length() - "Action".length()); } // try to to find relative class name String controllerClass = className.toLowerCase(); String bundleClass = StringUtils.stripStart(symfonyBundle.getNamespaceName(), "\\").toLowerCase(); if(!controllerClass.startsWith(bundleClass)) { return null; } String relative = StringUtils.stripStart(phpClass.getFQN(), "\\").substring(bundleClass.length()); if(relative.startsWith("Controller\\")) { relative = relative.substring("Controller\\".length()); } if(relative.endsWith("Controller")) { relative = relative.substring(0, relative.length() - "Controller".length()); } return String.format("%s:%s:%s", symfonyBundle.getName(), relative.replace("/", "\\"), methodName); } @NotNull private static Collection<VirtualFile> getRouteDefinitionInsideFile(@NotNull Project project, @NotNull String... routeNames) { Collection<VirtualFile> virtualFiles = new ArrayList<>(); FileBasedIndex.getInstance().getFilesWithKey(RoutesStubIndex.KEY, new HashSet<>(Arrays.asList(routeNames)), virtualFile -> { virtualFiles.add(virtualFile); return true; }, GlobalSearchScope.allScope(project)); return virtualFiles; } @NotNull public static Collection<StubIndexedRoute> getYamlRouteDefinitions(@NotNull YAMLDocument yamlDocument) { Collection<StubIndexedRoute> indexedRoutes = new ArrayList<>(); for(YAMLKeyValue yamlKeyValue : YamlHelper.getTopLevelKeyValues((YAMLFile) yamlDocument.getContainingFile())) { YAMLValue element = yamlKeyValue.getValue(); YAMLKeyValue path = YAMLUtil.findKeyInProbablyMapping(element, "path"); // Symfony bc if(path == null) { path = YAMLUtil.findKeyInProbablyMapping(element, "pattern"); } if(path == null) { continue; } // cleanup: 'foo', "foo" String keyText = StringUtils.strip(StringUtils.strip(yamlKeyValue.getKeyText(), "'"), "\""); if(StringUtils.isBlank(keyText)) { continue; } StubIndexedRoute route = new StubIndexedRoute(keyText); String routePath = path.getValueText(); if(StringUtils.isNotBlank(routePath)) { route.setPath(routePath); } String methods = YamlHelper.getStringValueOfKeyInProbablyMapping(element, "methods"); if(methods != null) { // value: [GET, POST, String[] split = methods.replace("[", "").replace("]", "").replaceAll(" +", "").toLowerCase().split(","); if(split.length > 0) { route.addMethod(split); } } String controller = getYamlController(yamlKeyValue); if(controller != null) { route.setController(normalizeRouteController(controller)); } indexedRoutes.add(route); } return indexedRoutes; } public static Collection<StubIndexedRoute> getXmlRouteDefinitions(XmlFile psiFile) { XmlDocumentImpl document = PsiTreeUtil.getChildOfType(psiFile, XmlDocumentImpl.class); if(document == null) { return Collections.emptyList(); } Collection<StubIndexedRoute> indexedRoutes = new ArrayList<>(); /* * <routes> * <route id="foo" path="/blog/{slug}" methods="GET"> * <default key="_controller">Foo</default> * </route> * * <route id="foo" path="/blog/{slug}" methods="GET" controller="AppBundle:Blog:list"/> * </routes> */ for(XmlTag xmlTag: PsiTreeUtil.getChildrenOfTypeAsList(psiFile.getFirstChild(), XmlTag.class)) { if(xmlTag.getName().equals("routes")) { for(XmlTag servicesTag: xmlTag.getSubTags()) { if(servicesTag.getName().equals("route")) { XmlAttribute xmlAttribute = servicesTag.getAttribute("id"); if(xmlAttribute != null) { String attrValue = xmlAttribute.getValue(); if(attrValue != null && StringUtils.isNotBlank(attrValue)) { StubIndexedRoute route = new StubIndexedRoute(attrValue); String pathAttribute = servicesTag.getAttributeValue("path"); if(pathAttribute == null) { pathAttribute = servicesTag.getAttributeValue("pattern"); } if(pathAttribute != null && StringUtils.isNotBlank(pathAttribute) ) { route.setPath(pathAttribute); } String methods = servicesTag.getAttributeValue("methods"); if(methods != null && StringUtils.isNotBlank(methods)) { String[] split = methods.replaceAll(" +", "").toLowerCase().split("\\|"); if(split.length > 0) { route.addMethod(split); } } // <route><default key="_controller"/></route> // <route controller="AppBundle:Blog:list"/> String controller = getXmlController(servicesTag); if(controller != null) { route.setController(normalizeRouteController(controller)); } indexedRoutes.add(route); } } } } } } return indexedRoutes; } /** * <route controller="Foo"/> * <route> * <default key="_controller">Foo</default> * </route> */ @Nullable public static String getXmlController(@NotNull XmlTag serviceTag) { for(XmlTag subTag :serviceTag.getSubTags()) { if("default".equalsIgnoreCase(subTag.getName())) { String keyValue = subTag.getAttributeValue("key"); if(keyValue != null && "_controller".equals(keyValue)) { String actionName = subTag.getValue().getTrimmedText(); if(StringUtils.isNotBlank(actionName)) { return actionName; } } } } String controller = serviceTag.getAttributeValue("controller"); if(controller != null && StringUtils.isNotBlank(controller)) { return controller; } return null; } /** * Find controller definition in yaml structure * * foo: * defaults: { _controller: "Bundle:Foo:Bar" } * defaults: * _controller: "Bundle:Foo:Bar" * controller: "Bundle:Foo:Bar" */ @Nullable public static String getYamlController(@NotNull YAMLKeyValue psiElement) { YAMLKeyValue yamlKeyValue = YamlHelper.getYamlKeyValue(psiElement, "defaults"); if(yamlKeyValue != null) { final YAMLValue container = yamlKeyValue.getValue(); if(container instanceof YAMLMapping) { YAMLKeyValue yamlKeyValueController = YamlHelper.getYamlKeyValue(container, "_controller", true); if(yamlKeyValueController != null) { String valueText = yamlKeyValueController.getValueText(); if(StringUtils.isNotBlank(valueText)) { return valueText; } } } } String controller = YamlHelper.getYamlKeyValueAsString(psiElement, "controller"); if(controller != null && StringUtils.isNotBlank(controller)) { return controller; } return null; } @Nullable public static PsiElement getXmlRouteNameTarget(@NotNull XmlFile psiFile,@NotNull String routeName) { XmlDocumentImpl document = PsiTreeUtil.getChildOfType(psiFile, XmlDocumentImpl.class); if(document == null) { return null; } for(XmlTag xmlTag: PsiTreeUtil.getChildrenOfTypeAsList(psiFile.getFirstChild(), XmlTag.class)) { if(xmlTag.getName().equals("routes")) { for(XmlTag routeTag: xmlTag.getSubTags()) { if(routeTag.getName().equals("route")) { XmlAttribute xmlAttribute = routeTag.getAttribute("id"); if(xmlAttribute != null) { String attrValue = xmlAttribute.getValue(); if(routeName.equals(attrValue)) { return xmlAttribute; } } } } } } return null; } public static boolean isServiceController(@NotNull String shortcutName) { return !shortcutName.contains("::") && shortcutName.contains(":") && shortcutName.split(":").length == 2; } @NotNull public static List<Route> getRoutesOnControllerAction(@NotNull Method method) { Set<String> routeNames = new HashSet<>(); ContainerUtil.addIfNotNull(routeNames, RouteHelper.convertMethodToRouteControllerName(method)); ContainerUtil.addIfNotNull(routeNames, RouteHelper.convertMethodToRouteShortcutControllerName(method)); Map<String, Route> allRoutes = getAllRoutes(method.getProject()); List<Route> routes = new ArrayList<>(); // resolve indexed routes if(routeNames.size() > 0) { routes.addAll(allRoutes.values().stream() .filter(route -> route.getController() != null && routeNames.contains(route.getController())) .collect(Collectors.toList()) ); } // search for services routes.addAll( ServiceRouteContainer.build(allRoutes).getMethodMatches(method) ); return routes; } /** * Find every possible route name declaration inside yaml, xml or @Route annotation */ @Nullable public static PsiElement getRouteNameTarget(@NotNull Project project, @NotNull String routeName) { for(VirtualFile virtualFile: RouteHelper.getRouteDefinitionInsideFile(project, routeName)) { PsiFile psiFile = PsiManager.getInstance(project).findFile(virtualFile); if(psiFile instanceof YAMLFile) { return YAMLUtil.getQualifiedKeyInFile((YAMLFile) psiFile, routeName); } else if(psiFile instanceof XmlFile) { PsiElement target = RouteHelper.getXmlRouteNameTarget((XmlFile) psiFile, routeName); if(target != null) { return target; } } else if(psiFile instanceof PhpFile) { // find on @Route annotation for (PhpClass phpClass : PhpPsiUtil.findAllClasses((PhpFile) psiFile)) { // get prefix by PhpClass String prefix = getRouteNamePrefix(phpClass); for (Method method : phpClass.getOwnMethods()) { PhpDocComment docComment = method.getDocComment(); if(docComment == null) { continue; } PhpDocCommentAnnotation container = AnnotationUtil.getPhpDocCommentAnnotationContainer(docComment); if(container == null) { continue; } // multiple @Route annotation in bundles are allowed for (String routeClass : ROUTE_CLASSES) { PhpDocTagAnnotation phpDocTagAnnotation = container.getPhpDocBlock(routeClass); if(phpDocTagAnnotation != null) { String annotationRouteName = phpDocTagAnnotation.getPropertyValue("name"); if(annotationRouteName != null) { // name provided @Route(name="foobar") if(routeName.equals(prefix + annotationRouteName)) { return phpDocTagAnnotation.getPropertyValuePsi("name"); } } else { // just @Route() without name provided String routeByMethod = AnnotationBackportUtil.getRouteByMethod(phpDocTagAnnotation.getPhpDocTag()); if(routeName.equals(prefix + routeByMethod)) { return phpDocTagAnnotation.getPhpDocTag(); } } } } } } } } return null; } /** * Extract route name of @Route(name="foobar_") * Must return empty string for easier accessibility */ @NotNull private static String getRouteNamePrefix(@NotNull PhpClass phpClass) { PhpDocCommentAnnotation phpClassContainer = AnnotationUtil.getPhpDocCommentAnnotationContainer(phpClass.getDocComment()); if(phpClassContainer != null) { PhpDocTagAnnotation firstPhpDocBlock = phpClassContainer.getFirstPhpDocBlock(ROUTE_CLASSES.toArray(new String[ROUTE_CLASSES.size()])); if(firstPhpDocBlock != null) { String name = firstPhpDocBlock.getPropertyValue("name"); if(name != null && StringUtils.isNotBlank(name)) { return name; } } } return ""; } @Nullable public static String getRouteUrl(Route route) { if(route.getPath() != null) { return route.getPath(); } String url = ""; // copy list; List<Collection<String>> tokens = new ArrayList<>(route.getTokens()); Collections.reverse(tokens); for(Collection<String> token: tokens) { // copy, we are not allowed to mod list List<String> list = new ArrayList<>(token); if(list.size() >= 2 && list.get(1).equals("text")) { url = url.concat(list.get(0)); } if(list.size() >= 4 && list.get(3).equals("variable")) { url = url.concat(list.get(2) + "{" + list.get(0) + "}"); } } return url.length() == 0 ? null : url; } public static List<LookupElement> getRoutesLookupElements(final @NotNull Project project) { Map<String, Route> routes = RouteHelper.getCompiledRoutes(project); final List<LookupElement> lookupElements = new ArrayList<>(); final Set<String> uniqueSet = new HashSet<>(); for (Route route : routes.values()) { lookupElements.add(new RouteLookupElement(route)); uniqueSet.add(route.getName()); } for(String routeName: SymfonyProcessors.createResult(project, RoutesStubIndex.KEY, uniqueSet)) { if(uniqueSet.contains(routeName)) { continue; } for(StubIndexedRoute route: FileBasedIndex.getInstance().getValues(RoutesStubIndex.KEY, routeName, GlobalSearchScope.allScope(project))) { lookupElements.add(new RouteLookupElement(new Route(route), true)); uniqueSet.add(routeName); } } return lookupElements; } @NotNull public static List<PsiElement> getRouteDefinitionTargets(Project project, String routeName) { List<PsiElement> targets = new ArrayList<>(); Collections.addAll(targets, RouteHelper.getMethods(project, routeName)); PsiElement yamlKey = RouteHelper.getRouteNameTarget(project, routeName); if(yamlKey != null) { targets.add(yamlKey); } return targets; } @NotNull synchronized public static Map<String, Route> getAllRoutes(final @NotNull Project project) { return CachedValuesManager.getManager(project).getCachedValue( project, ROUTE_CACHE, () -> CachedValueProvider.Result.create(getAllRoutesProxy(project), PsiModificationTracker.MODIFICATION_COUNT), false ); } @NotNull private static Map<String, Route> getAllRoutesProxy(@NotNull Project project) { Map<String, Route> routes = new HashMap<>(RouteHelper.getCompiledRoutes(project)); Set<String> uniqueKeySet = new HashSet<>(routes.keySet()); for(String routeName: SymfonyProcessors.createResult(project, RoutesStubIndex.KEY, uniqueKeySet)) { if(uniqueKeySet.contains(routeName)) { continue; } for(StubIndexedRoute route: FileBasedIndex.getInstance().getValues(RoutesStubIndex.KEY, routeName, GlobalSearchScope.allScope(project))) { uniqueKeySet.add(routeName); routes.put(routeName, new Route(route)); } } return routes; } /** * Foobar/Bar => Foobar\Bar * \\Foobar\Foobar => Foobar\Bar */ @NotNull private static String normalizeRouteController(@NotNull String string) { string = string.replace("/", "\\"); string = StringUtils.stripStart(string,"\\"); return string; } /** * Support "use Symfony\Component\Routing\Annotation\Route as BaseRoute;" */ public static boolean isRouteClassAnnotation(@NotNull String clazz) { String myClazz = StringUtils.stripStart(clazz, "\\"); return ROUTE_CLASSES.stream().anyMatch(s -> s.equalsIgnoreCase(myClazz)); } }