package de.espend.idea.php.generics.utils; import com.intellij.openapi.project.Project; import com.intellij.psi.PsiElement; import com.intellij.psi.PsiReference; import com.jetbrains.php.PhpIndex; import com.jetbrains.php.codeInsight.PhpCodeInsightUtil; 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.*; import com.jetbrains.php.lang.psi.resolve.types.PhpType; import de.espend.idea.php.generics.dict.ParameterArrayType; import org.apache.commons.lang.StringUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; public class GenericsUtil { public static boolean isGenericsClass(@NotNull PhpClass phpClass) { PhpDocComment phpDocComment = phpClass.getDocComment(); if(phpDocComment != null) { // "@template T" // "@psalm-template Foo" for (PhpDocTag phpDocTag : getTagElementsByNameForAllFrameworks(phpDocComment, "template")) { String tagValue = phpDocTag.getTagValue(); if (StringUtils.isNotBlank(tagValue) && tagValue.matches("\\w+")) { return true; } } } return false; } @Nullable public static String getExpectedParameterInstanceOf(@NotNull PsiElement psiElement) { PsiElement parameterList = psiElement.getParent(); if (!(parameterList instanceof ParameterList)) { return null; } PsiElement functionReference = parameterList.getParent(); if (!(functionReference instanceof FunctionReference)) { return null; } Integer currentParameterIndex = getCurrentParameterIndex(psiElement); if (currentParameterIndex == null) { return null; } PsiElement resolve = ((FunctionReference) functionReference).resolve(); if (!(resolve instanceof Function)) { return null; } Parameter[] parameters = ((Function) resolve).getParameters(); if (parameters.length <= currentParameterIndex) { return null; } PhpDocComment docComment = ((Function) resolve).getDocComment(); if (docComment == null) { return null; } Map<String, String> asInstances = new HashMap<>(); // workarounds for inconsistently psi structure // https://youtrack.jetbrains.com/issue/WI-47644 for (PhpDocTag template : getTagElementsByNameForAllFrameworks(docComment, "template")) { Matcher matcher = Pattern.compile("([\\w_-]+)\\s+as\\s+([\\w_\\\\-]+)", Pattern.MULTILINE).matcher(template.getText()); if (!matcher.find()) { continue; } asInstances.put(matcher.group(1), matcher.group(2)); } String instance = null; for (PhpDocTag phpDocParamTag : getTagElementsByNameForAllFrameworks(docComment, "param")) { String tagText = phpDocParamTag.getText(); if (!tagText.contains("$" + parameters[currentParameterIndex].getName())) { continue; } Matcher matcher = Pattern.compile("\\s*([\\w_-]+)::class\\s*", Pattern.MULTILINE).matcher(tagText); if (!matcher.find()) { continue; } String group = matcher.group(1); if (!asInstances.containsKey(group)) { continue; } instance = asInstances.get(group); break; } if (instance == null) { return null; } Map<String, String> useImportMap = getUseImportMap(docComment); if (useImportMap.containsKey(instance)) { return StringUtils.stripStart(useImportMap.get(instance), "\\"); } return instance; } /** * - "@return array{optional?: string, bar: int}" * - "@return array{foo: string, bar: int}" * - "@psalm-param array{foo: string, bar: int}" */ @NotNull public static Collection<ParameterArrayType> getReturnArrayTypes(@NotNull PhpNamedElement phpNamedElement) { PhpDocComment docComment = phpNamedElement.getDocComment(); if (docComment == null) { return Collections.emptyList(); } Collection<ParameterArrayType> types = new ArrayList<>(); // workaround for invalid tags lexer on PhpStorm side for (PhpDocTag phpDocTag : getTagElementsByNameForAllFrameworks(docComment, "return")) { String text = phpDocTag.getText(); Matcher arrayElementsMatcher = Pattern.compile("array\\s*\\{(.*)}\\s*", Pattern.MULTILINE).matcher(text); if (arrayElementsMatcher.find()) { String group = arrayElementsMatcher.group(1); types.addAll(GenericsUtil.getParameterArrayTypes(group, phpDocTag)); } } return types; } /** * - "@return array{optional?: string, bar: int}" * - "@return array{foo: string, bar: int}" * - "@return array{foo: string, bar: int}" * - "@psalm-param array{foo: Foo, ?bar: int}" * - "@param array{foo: Foo, ?bar: int} $options" */ @NotNull public static Collection<ParameterArrayType> getParameterArrayTypes(@NotNull String content, @NotNull String parameter, @NotNull PsiElement context) { Matcher parameterNameMatcher = Pattern.compile(".*\\$([\\w_-]+)\\s*$", Pattern.MULTILINE).matcher(content); if (!parameterNameMatcher.find()) { return Collections.emptyList(); } String group = parameterNameMatcher.group(1); if (!parameter.equalsIgnoreCase(group)) { return Collections.emptyList(); } // array{foo: string, bar: int} Matcher arrayElementsMatcher = Pattern.compile("array\\s*\\{(.*)}\\s*", Pattern.MULTILINE).matcher(content); if (!arrayElementsMatcher.find()) { return Collections.emptyList(); } return getParameterArrayTypes(arrayElementsMatcher.group(1), context); } @NotNull private static Collection<ParameterArrayType> getParameterArrayTypes(@NotNull PhpDocComment phpDocComment, @NotNull String parameterName) { Collection<ParameterArrayType> vars = new ArrayList<>(); for (PhpDocTag phpDocTag : getTagElementsByNameForAllFrameworks(phpDocComment, "param")) { String tagValue = phpDocTag.getTagValue(); vars.addAll(GenericsUtil.getParameterArrayTypes(tagValue, parameterName, phpDocTag)); } // we need a workaround for "@param" as the lexer strips it all of after "array{" for (PhpDocTag phpDocTag : phpDocComment.getTagElementsByName("@param")) { // {foobar2: string} $foobar String tagValue = phpDocTag.getTagValue(); // extract the parameter name $foobar Matcher parameterNameMatcher = Pattern.compile(".*\\$([\\w_-]+)\\s*$", Pattern.MULTILINE).matcher(tagValue); if (!parameterNameMatcher.find()) { continue; } // @param array{foobar2: string} String text = phpDocTag.getText(); // try to build a valid string; make in as error prone safe as possible; we need provide as on "@psalm-param": // array{foobar2: string} $foobar String content = text.replaceAll("\\s*@param\\s*", "") + " $" + parameterNameMatcher.group(1); vars.addAll(GenericsUtil.getParameterArrayTypes(content, parameterName, phpDocTag)); } return vars; } /** * - "@return array{optional?: string, bar: int}" * - "@return array{foo: string, bar: int}" * - "@return array{foo: string, bar: int}" * - "@psalm-param array{foo: Foo, ?bar: int}" * - "@param array{foo: Foo, ?bar: int} $options" */ @NotNull private static Collection<ParameterArrayType> getParameterArrayTypes(@NotNull String array, @NotNull PsiElement context) { Collection<ParameterArrayType> parameters = new ArrayList<>(); for (String s : array.split(",")) { String trim = StringUtils.trim(s); String[] split = trim.split(":"); if(split.length != 2) { continue; } // @TODO: class resolve Set<String> types = Arrays.stream(split[1].split("\\|")) .map(StringUtils::trim) .collect(Collectors.toSet()); boolean isOptional = split[0].startsWith("?") || split[0].endsWith("?"); parameters.add(new ParameterArrayType( isOptional ? StringUtils.strip(split[0], "?") : split[0], types, isOptional, context )); } return parameters; } /** * Resolve the given parameter to find possible psalm docs recursively * * $foo->foo([]) * * TODO: method search in recursion */ @NotNull public static Collection<ParameterArrayType> getTypesForParameter(@NotNull PsiElement psiElement) { PsiElement parent = psiElement.getParent(); if (parent instanceof ParameterList) { PsiElement functionReference = parent.getParent(); if (functionReference instanceof FunctionReference) { PsiElement resolve = ((FunctionReference) functionReference).resolve(); if (resolve instanceof Function) { Parameter[] functionParameters = ((Function) resolve).getParameters(); int currentParameterIndex = PhpElementsUtil.getCurrentParameterIndex((ParameterList) parent, psiElement); if (currentParameterIndex >= 0 && functionParameters.length - 1 >= currentParameterIndex) { String name = functionParameters[currentParameterIndex].getName(); PhpDocComment docComment = ((Function) resolve).getDocComment(); if (docComment != null) { return GenericsUtil.getParameterArrayTypes(docComment, name); } } } } } return Collections.emptyList(); } @Nullable private static Integer getCurrentParameterIndex(PsiElement parameter) { PsiElement parameterList = parameter.getContext(); if(!(parameterList instanceof ParameterList)) { return null; } PsiElement[] parameters = ((ParameterList) parameterList).getParameters(); int i; for(i = 0; i < parameters.length; i = i + 1) { if(parameters[i].equals(parameter)) { return i; } } return null; } /* * Collect file use imports and resolve alias with their class name * * @param PhpDocComment current doc scope * @return map with class names as key and fqn on value */ @NotNull private static Map<String, String> getUseImportMap(@Nullable PhpDocComment phpDocComment) { if(phpDocComment == null) { return Collections.emptyMap(); } PhpPsiElement scope = PhpCodeInsightUtil.findScopeForUseOperator(phpDocComment); if(scope == null) { return Collections.emptyMap(); } Map<String, String> useImports = new HashMap<>(); for (PhpUseList phpUseList : PhpCodeInsightUtil.collectImports(scope)) { for(PhpUse phpUse : phpUseList.getDeclarations()) { String alias = phpUse.getAliasName(); if (alias != null) { useImports.put(alias, phpUse.getFQN()); } else { useImports.put(phpUse.getName(), phpUse.getFQN()); } } } return useImports; } /** * Resolve string definition in a recursive way * * $foo = Foo::class * $this->foo = Foo::class * $this->foo1 = $this->foo */ @Nullable public static String getStringValue(@Nullable PsiElement psiElement) { return getStringValue(psiElement, 0); } @Nullable private static String getStringValue(@Nullable PsiElement psiElement, int depth) { if(psiElement == null || ++depth > 5) { return null; } if(psiElement instanceof StringLiteralExpression) { String resolvedString = ((StringLiteralExpression) psiElement).getContents(); if(StringUtils.isEmpty(resolvedString)) { return null; } return resolvedString; } else if(psiElement instanceof Field) { return getStringValue(((Field) psiElement).getDefaultValue(), depth); } else if(psiElement instanceof ClassConstantReference && "class".equals(((ClassConstantReference) psiElement).getName())) { // Foobar::class return getClassConstantPhpFqn((ClassConstantReference) psiElement); } else if(psiElement instanceof PhpReference) { PsiReference psiReference = psiElement.getReference(); if(psiReference == null) { return null; } PsiElement ref = psiReference.resolve(); if(ref instanceof PhpReference) { return getStringValue(psiElement, depth); } if(ref instanceof Field) { return getStringValue(((Field) ref).getDefaultValue()); } } return null; } /** * Foo::class to its class fqn include namespace */ public static String getClassConstantPhpFqn(@NotNull ClassConstantReference classConstant) { PhpExpression classReference = classConstant.getClassReference(); if(!(classReference instanceof PhpReference)) { return null; } String typeName = ((PhpReference) classReference).getFQN(); return StringUtils.isNotBlank(typeName) ? StringUtils.stripStart(typeName, "\\") : null; } /** * @param subjectClass eg DateTime * @param expectedClass eg DateTimeInterface */ public static boolean isInstanceOf(@NotNull PhpClass subjectClass, @NotNull PhpClass expectedClass) { return new PhpType().add(expectedClass).isConvertibleFrom(new PhpType().add(subjectClass), PhpIndex.getInstance(subjectClass.getProject())); } /** * @param subjectClass eg DateTime * @param expectedClass eg DateTimeInterface */ public static boolean isInstanceOf(@NotNull PhpClass subjectClass, @NotNull String expectedClass) { return new PhpType().add(expectedClass).isConvertibleFrom(new PhpType().add(subjectClass), PhpIndex.getInstance(subjectClass.getProject())); } /** * @param subjectClass eg DateTime * @param expectedClass eg DateTimeInterface */ public static boolean isInstanceOf(@NotNull Project project, @NotNull String subjectClass, @NotNull String expectedClass) { return new PhpType().add(expectedClass).isConvertibleFrom(new PhpType().add(subjectClass), PhpIndex.getInstance(project)); } @Nullable static public PhpClass findClass(Project project, @NotNull String className) { Collection<PhpClass> phpClasses = PhpIndex.getInstance(project).getAnyByFQN(className); return phpClasses.size() == 0 ? null : phpClasses.iterator().next(); } @NotNull public static PhpDocTag[] getTagElementsByNameForAllFrameworks(@NotNull PhpDocComment phpDocComment, @NotNull String parameterName) { return Stream.of( phpDocComment.getTagElementsByName("@psalm-" + parameterName), phpDocComment.getTagElementsByName("@" + parameterName), phpDocComment.getTagElementsByName("@phpstan-" + parameterName) ).flatMap(Stream::of).toArray(PhpDocTag[]::new); } public static Collection<String> getReturnTypeTagValues(@NotNull PhpDocComment phpDocComment) { String[] strings = { "@psalm-", "@", "@phpstan-" }; Collection<String> returns = new HashSet<>(); for (String prefix : strings) { for (PhpDocTag phpDocTag : phpDocComment.getTagElementsByName(prefix + "return")) { String tagValue = StringUtils.trim(phpDocTag.getTagValue()); if (StringUtils.isNotBlank(tagValue)) { returns.add(tagValue); } } } // workaround for "@return T" is currently not WOrking for (PhpDocTag phpDocTag : phpDocComment.getTagElementsByName("@return")) { String text = StringUtils.trim(phpDocTag.getText().replaceAll("^\\s*@return\\s+", "")); if (StringUtils.isNotBlank(text)) { returns.add(text); } } return returns; } /** * Generate a full FQN class name out of a given short class name with respecting current namespace and use scope * * - "Foobar" needs to have its use statement attached * - No use statement match its on the same namespace as the class * * TODO: find a core function for this * * @param classNameScope \Foobar\Classes * @param shortClassName Foobar */ public static String getFqnClassNameFromScope(@NotNull String classNameScope, @NotNull String shortClassName, @NotNull Map<String, String> useImportMap) { // its already on the global namespace: "\Exception" if (shortClassName.startsWith("\\")) { return shortClassName; } // not use statement so stop here if (useImportMap.size() == 0) { return shortClassName; } // "Foo\Bar" split it on "subnamespace"; if no "subnamespace" only care about the first array item as out use match String[] split = shortClassName.split("\\\\"); if (useImportMap.containsKey(split[0])) { String shortClassImport = useImportMap.get(split[0]); // on "Foo\Bar" we must extend also "Bar" for the import // "Foo\Bar" => "\Car\Foo\Bar" if (split.length > 1) { String[] yourArray = Arrays.copyOfRange(split, 1, split.length); shortClassImport += "\\" + StringUtils.join(yourArray, "\\"); } return shortClassImport; } // strip the last namespace part and replace it with ours: "Foobar\Bar" => "Foobar\OurShortClass" return StringUtils.substringBeforeLast(classNameScope, "\\") + "\\" + shortClassName; } }