package fr.adrienbrault.idea.symfony2plugin.templating.util;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.patterns.PlatformPatterns;
import com.intellij.psi.PsiComment;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiWhiteSpace;
import com.intellij.psi.tree.IElementType;
import com.intellij.psi.util.PsiTreeUtil;
import com.jetbrains.php.PhpIndex;
import com.jetbrains.php.lang.psi.elements.Field;
import com.jetbrains.php.lang.psi.elements.Method;
import com.jetbrains.php.lang.psi.elements.PhpClass;
import com.jetbrains.php.lang.psi.elements.PhpNamedElement;
import com.jetbrains.php.lang.psi.resolve.types.PhpType;
import com.jetbrains.twig.TwigFile;
import com.jetbrains.twig.TwigTokenTypes;
import com.jetbrains.twig.elements.TwigCompositeElement;
import com.jetbrains.twig.elements.TwigElementTypes;
import fr.adrienbrault.idea.symfony2plugin.TwigHelper;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.TwigFileVariableCollector;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.TwigFileVariableCollectorParameter;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.TwigTypeContainer;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.collector.*;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.dict.PsiVariable;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.resolver.FormFieldResolver;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.resolver.FormVarsResolver;
import fr.adrienbrault.idea.symfony2plugin.templating.variable.resolver.TwigTypeResolver;
import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil;
import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils;
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 java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author Daniel Espendiller <daniel@espendiller.net>
*/
public class TwigTypeResolveUtil {
public static final String DEPRECATED_DOC_TYPE_PATTERN = "\\{#[\\s]+([\\w]+)[\\s]+([\\w\\\\\\[\\]]+)[\\s]+#}";
public static final String DOC_TYPE_PATTERN = "@var[\\s]+([\\w]+)[\\s]+([\\w\\\\\\[\\]]+)[\\s]*";
// for supporting completion and navigation of one line element
public static final String DOC_TYPE_PATTERN_SINGLE = "\\{#[\\s]+@var[\\s]+([\\w]+)[\\s]+([\\w\\\\\\[\\]]+)[\\s]+#}";
private static String[] propertyShortcuts = new String[] {"get", "is"};
private static TwigFileVariableCollector[] twigFileVariableCollectors = new TwigFileVariableCollector[] {
new StaticVariableCollector(),
new GlobalExtensionVariableCollector(),
new ControllerDocVariableCollector(),
new ServiceContainerVariableCollector(),
new FileDocVariableCollector(),
new ControllerVariableCollector(),
new IncludeVariableCollector()
};
private static TwigTypeResolver[] twigTypeResolvers = new TwigTypeResolver[] {
new FormVarsResolver(),
new FormFieldResolver(),
};
public static String[] formatPsiTypeName(PsiElement psiElement, boolean includeCurrent) {
ArrayList<String> strings = new ArrayList<>(Arrays.asList(formatPsiTypeName(psiElement)));
strings.add(psiElement.getText());
return strings.toArray(new String[strings.size()]);
}
/**
* Get items before foo.bar.car, foo.bar.car()
*
* ["foo", "bar"]
*/
@NotNull
public static String[] formatPsiTypeName(@NotNull PsiElement psiElement) {
String typeNames = PhpElementsUtil.getPrevSiblingAsTextUntil(psiElement, PlatformPatterns.or(
PlatformPatterns.psiElement(TwigTokenTypes.LBRACE),
PlatformPatterns.psiElement(PsiWhiteSpace.class
)));
if(typeNames.trim().length() == 0) {
return new String[]{};
}
if(typeNames.endsWith(".")) {
typeNames = typeNames.substring(0, typeNames.length() -1);
}
String[] possibleTypes;
if(typeNames.contains(".")) {
possibleTypes = typeNames.split("\\.");
} else {
possibleTypes = new String[]{typeNames};
}
return possibleTypes;
}
/**
* Collects all possible variables in given path for last given item of "typeName"
*
* @param typeName Variable path "foo.bar" => ["foo", "bar"]
* @return types for last item of typeName parameter
*/
@NotNull
public static Collection<TwigTypeContainer> resolveTwigMethodName(@NotNull PsiElement psiElement, @NotNull String[] typeName) {
if(typeName.length == 0) {
return Collections.emptyList();
}
List<PsiVariable> rootVariables = getRootVariableByName(psiElement, typeName[0]);
if(typeName.length == 1) {
Collection<TwigTypeContainer> twigTypeContainers = TwigTypeContainer.fromCollection(psiElement.getProject(), rootVariables);
for(TwigTypeResolver twigTypeResolver: twigTypeResolvers) {
twigTypeResolver.resolve(twigTypeContainers, twigTypeContainers, typeName[0], new ArrayList<>(), rootVariables);
}
return twigTypeContainers;
}
Collection<TwigTypeContainer> type = TwigTypeContainer.fromCollection(psiElement.getProject(), rootVariables);
Collection<List<TwigTypeContainer>> previousElements = new ArrayList<>();
previousElements.add(new ArrayList<>(type));
for (int i = 1; i <= typeName.length - 1; i++ ) {
type = resolveTwigMethodName(type, typeName[i], previousElements);
previousElements.add(new ArrayList<>(type));
// we can stop on empty list
if(type.size() == 0) {
return Collections.emptyList();
}
}
return type;
}
/**
* Find scope related inline @var docs
*
* "@var foo \Foo"
*/
private static Map<String, String> findInlineStatementVariableDocBlock(PsiElement psiInsideBlock, final IElementType parentStatement) {
PsiElement twigCompositeElement = PsiTreeUtil.findFirstParent(psiInsideBlock, psiElement -> {
if (psiElement instanceof TwigCompositeElement) {
if (PlatformPatterns.psiElement(parentStatement).accepts(psiElement)) {
return true;
}
}
return false;
});
Map<String, String> variables = new HashMap<>();
if(twigCompositeElement == null) {
return variables;
}
return getInlineCommentDocsVars(twigCompositeElement);
}
/**
* Find file related doc blocks:
*
* "@var foo \Foo"
*/
public static Map<String, String> findFileVariableDocBlock(@NotNull TwigFile twigFile) {
return getInlineCommentDocsVars(twigFile);
}
private static Map<String, String> getInlineCommentDocsVars(@NotNull PsiElement twigCompositeElement) {
Map<String, String> variables = new HashMap<>();
// wtf in completion { | } root we have no comments in child context !?
Pattern[] patterns = new Pattern[] {
Pattern.compile(DOC_TYPE_PATTERN, Pattern.MULTILINE),
Pattern.compile(DEPRECATED_DOC_TYPE_PATTERN),
};
for(PsiElement psiComment: YamlHelper.getChildrenFix(twigCompositeElement)) {
if(!(psiComment instanceof PsiComment)) {
continue;
}
String text = psiComment.getText();
if(StringUtils.isBlank(text)) {
continue;
}
for (Pattern pattern : patterns) {
Matcher matcher = pattern.matcher(text);
while (matcher.find()) {
variables.put(matcher.group(1), matcher.group(2));
}
}
}
return variables;
}
private static Map<String, Set<String>> convertHashMapToTypeSet(Map<String, String> hashMap) {
Map<String, Set<String>> globalVars = new HashMap<>();
for(final Map.Entry<String, String> entry: hashMap.entrySet()) {
globalVars.put(entry.getKey(), new HashSet<>(Collections.singletonList(entry.getValue())));
}
return globalVars;
}
@NotNull
public static Map<String, PsiVariable> collectScopeVariables(@NotNull PsiElement psiElement) {
return collectScopeVariables(psiElement, new HashSet<>());
}
@NotNull
public static Map<String, PsiVariable> collectScopeVariables(@NotNull PsiElement psiElement, @NotNull Set<VirtualFile> visitedFiles) {
Map<String, Set<String>> globalVars = new HashMap<>();
Map<String, PsiVariable> controllerVars = new HashMap<>();
VirtualFile virtualFile = psiElement.getContainingFile().getVirtualFile();
if(visitedFiles.contains(virtualFile)) {
return controllerVars;
}
visitedFiles.add(virtualFile);
TwigFileVariableCollectorParameter collectorParameter = new TwigFileVariableCollectorParameter(psiElement, visitedFiles);
for(TwigFileVariableCollector collector: twigFileVariableCollectors) {
collector.collect(collectorParameter, globalVars);
if(collector instanceof TwigFileVariableCollector.TwigFileVariableCollectorExt) {
((TwigFileVariableCollector.TwigFileVariableCollectorExt) collector).collectVars(collectorParameter, controllerVars);
}
}
// globals first
globalVars.putAll(convertHashMapToTypeSet(findInlineStatementVariableDocBlock(psiElement, TwigElementTypes.BLOCK_STATEMENT)));
globalVars.putAll(convertHashMapToTypeSet(findInlineStatementVariableDocBlock(psiElement, TwigElementTypes.MACRO_STATEMENT)));
globalVars.putAll(convertHashMapToTypeSet(findInlineStatementVariableDocBlock(psiElement, TwigElementTypes.FOR_STATEMENT)));
for(Map.Entry<String, Set<String>> entry: globalVars.entrySet()) {
controllerVars.put(entry.getKey(), new PsiVariable(entry.getValue(), null));
}
// check if we are in "for" scope and resolve types ending with []
collectForArrayScopeVariables(psiElement, controllerVars);
return controllerVars;
}
private static Collection<String> collectForArrayScopeVariablesFoo(Project project, String[] typeName, PsiVariable psiVariable) {
Collection<String> previousElements = psiVariable.getTypes();
for (int i = 1; i <= typeName.length - 1; i++ ) {
previousElements = resolveTwigMethodName(project, previousElements, typeName[i]);
// we can stop on empty list
if(previousElements.size() == 0) {
return Collections.emptyList();
}
}
return previousElements;
}
private static void collectForArrayScopeVariables(PsiElement psiElement, Map<String, PsiVariable> globalVars) {
PsiElement twigCompositeElement = PsiTreeUtil.findFirstParent(psiElement, psiElement1 -> {
if (psiElement1 instanceof TwigCompositeElement) {
if (PlatformPatterns.psiElement(TwigElementTypes.FOR_STATEMENT).accepts(psiElement1)) {
return true;
}
}
return false;
});
if(!(twigCompositeElement instanceof TwigCompositeElement)) {
return;
}
// {% for user in "users" %}
PsiElement forTag = twigCompositeElement.getFirstChild();
PsiElement inVariable = PsiElementUtils.getChildrenOfType(forTag, TwigHelper.getForTagInVariablePattern());
if(inVariable == null) {
return;
}
String variableName = inVariable.getText();
if(!globalVars.containsKey(variableName)) {
return;
}
// {% for "user" in users %}
PsiElement forScopeVariable = PsiElementUtils.getChildrenOfType(forTag, TwigHelper.getForTagVariablePattern());
if(forScopeVariable == null) {
return;
}
PhpType phpType = new PhpType();
String[] forTagInIdentifierString = getForTagIdentifierAsString(forTag);
// {% for coolBar in coolBars.foos %}
if (forTagInIdentifierString != null && forTagInIdentifierString.length > 1) {
// nested resolve
if(globalVars.containsKey(forTagInIdentifierString[0])) {
PsiVariable psiVariable = globalVars.get(forTagInIdentifierString[0]);
for (String arrayType : collectForArrayScopeVariablesFoo(psiElement.getProject(), forTagInIdentifierString, psiVariable)) {
phpType.add(arrayType);
}
}
} else {
// add single "for" var
for (String s : globalVars.get(variableName).getTypes()) {
phpType.add(s);
}
}
String scopeVariable = forScopeVariable.getText();
// find array types; since they are phptypes they ends with []
Set<String> types = new HashSet<>();
for(String arrayType: PhpIndex.getInstance(psiElement.getProject()).completeType(psiElement.getProject(), phpType, new HashSet<>()).getTypes()) {
if(arrayType.endsWith("[]")) {
types.add(arrayType.substring(0, arrayType.length() -2));
}
}
// we already have same variable in scope, so merge types
if(globalVars.containsKey(scopeVariable)) {
globalVars.get(scopeVariable).getTypes().addAll(types);
} else {
globalVars.put(scopeVariable, new PsiVariable(types));
}
}
private static List<PsiVariable> getRootVariableByName(PsiElement psiElement, String variableName) {
List<PsiVariable> phpNamedElements = new ArrayList<>();
for(Map.Entry<String, PsiVariable> variable : collectScopeVariables(psiElement).entrySet()) {
if(variable.getKey().equals(variableName)) {
phpNamedElements.add(variable.getValue());
//phpNamedElements.addAll(PhpElementsUtil.getClassFromPhpTypeSet(psiElement.getProject(), variable.getValue().getTypes()));
}
}
return phpNamedElements;
}
private static Collection<TwigTypeContainer> resolveTwigMethodName(Collection<TwigTypeContainer> previousElement, String typeName, Collection<List<TwigTypeContainer>> twigTypeContainer) {
ArrayList<TwigTypeContainer> phpNamedElements = new ArrayList<>();
for(TwigTypeContainer phpNamedElement: previousElement) {
if(phpNamedElement.getPhpNamedElement() != null) {
for(PhpNamedElement target : getTwigPhpNameTargets(phpNamedElement.getPhpNamedElement(), typeName)) {
PhpType phpType = target.getType();
for(String typeString: phpType.getTypes()) {
PhpNamedElement phpNamedElement1 = PhpElementsUtil.getClassInterface(phpNamedElement.getPhpNamedElement().getProject(), typeString);
if(phpNamedElement1 != null) {
phpNamedElements.add(new TwigTypeContainer(phpNamedElement1));
}
}
}
}
for(TwigTypeResolver twigTypeResolver: twigTypeResolvers) {
twigTypeResolver.resolve(phpNamedElements, previousElement, typeName, twigTypeContainer, null);
}
}
return phpNamedElements;
}
private static Set<String> resolveTwigMethodName(Project project, Collection<String> previousElement, String typeName) {
Set<String> types = new HashSet<>();
for(String prevClass: previousElement) {
for (PhpClass phpClass : PhpElementsUtil.getClassesInterface(project, prevClass)) {
for(PhpNamedElement target : getTwigPhpNameTargets(phpClass, typeName)) {
types.addAll(target.getType().getTypes());
}
}
}
return types;
}
/**
*
* "phpNamedElement.variableName", "phpNamedElement.getVariableName" will resolve php type eg method
*
* @param phpNamedElement php class method or field
* @param variableName variable name shortcut property possible
* @return matched php types
*/
public static Collection<? extends PhpNamedElement> getTwigPhpNameTargets(PhpNamedElement phpNamedElement, String variableName) {
Collection<PhpNamedElement> targets = new ArrayList<>();
if(phpNamedElement instanceof PhpClass) {
for(Method method: ((PhpClass) phpNamedElement).getMethods()) {
String methodName = method.getName();
if(method.getModifier().isPublic() && (methodName.equalsIgnoreCase(variableName) || isPropertyShortcutMethodEqual(methodName, variableName))) {
targets.add(method);
}
}
for(Field field: ((PhpClass) phpNamedElement).getFields()) {
String fieldName = field.getName();
if(field.getModifier().isPublic() && fieldName.equalsIgnoreCase(variableName)) {
targets.add(field);
}
}
}
return targets;
}
public static String getTypeDisplayName(Project project, Set<String> types) {
Collection<PhpClass> classFromPhpTypeSet = PhpElementsUtil.getClassFromPhpTypeSet(project, types);
if(classFromPhpTypeSet.size() > 0) {
return classFromPhpTypeSet.iterator().next().getPresentableFQN();
}
PhpType phpType = new PhpType();
for (String type : types) {
phpType.add(type);
}
PhpType phpTypeFormatted = PhpIndex.getInstance(project).completeType(project, phpType, new HashSet<>());
if(phpTypeFormatted.getTypes().size() > 0) {
return StringUtils.join(phpTypeFormatted.getTypes(), "|");
}
if(types.size() > 0) {
return types.iterator().next();
}
return "";
}
public static boolean isPropertyShortcutMethod(Method method) {
for(String shortcut: propertyShortcuts) {
if(method.getName().startsWith(shortcut) && method.getName().length() > shortcut.length()) {
return true;
}
}
return false;
}
public static boolean isPropertyShortcutMethodEqual(String methodName, String variableName) {
for(String shortcut: propertyShortcuts) {
if(methodName.equalsIgnoreCase(shortcut + variableName)) {
return true;
}
}
return false;
}
public static String getPropertyShortcutMethodName(Method method) {
String methodName = method.getName();
for(String shortcut: propertyShortcuts) {
// strip possible property shortcut and make it lcfirst
if(method.getName().startsWith(shortcut) && method.getName().length() > shortcut.length()) {
methodName = methodName.substring(shortcut.length());
return Character.toLowerCase(methodName.charAt(0)) + methodName.substring(1);
}
}
return methodName;
}
/**
* Get the "for IN" variable identifier as separated string
*
* {% for car in "cars" %}
* {% for car in "cars"|length %}
* {% for car in "cars.test" %}
*/
@Nullable
private static String[] getForTagIdentifierAsString(PsiElement forTag) {
if(forTag.getNode().getElementType() != TwigElementTypes.FOR_TAG) {
return null;
}
// getChildren hack
PsiElement firstChild = forTag.getFirstChild();
if(firstChild == null) {
return null;
}
// find IN token
PsiElement psiIn = PsiElementUtils.getNextSiblingOfType(firstChild, PlatformPatterns.psiElement(TwigTokenTypes.IN));
if(psiIn == null) {
return null;
}
// find next IDENTIFIER, eg skip whitespaces
PsiElement psiIdentifier = PsiElementUtils.getNextSiblingOfType(psiIn, PlatformPatterns.psiElement(TwigTokenTypes.IDENTIFIER));
if(psiIdentifier == null) {
return null;
}
// find non common token type. we only allow: "test.test"
PsiElement afterInVarPsiElement = PsiElementUtils.getNextSiblingOfType(psiIdentifier, PlatformPatterns.psiElement().andNot(PlatformPatterns.or(
PlatformPatterns.psiElement((TwigTokenTypes.IDENTIFIER)),
PlatformPatterns.psiElement((TwigTokenTypes.DOT))
)));
if(afterInVarPsiElement == null) {
return null;
}
return TwigTypeResolveUtil.formatPsiTypeName(afterInVarPsiElement);
}
}