package fr.adrienbrault.idea.symfony2plugin.templating.util;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.Condition;
import com.intellij.openapi.util.Key;
import com.intellij.patterns.PlatformPatterns;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiRecursiveElementWalkingVisitor;
import com.intellij.psi.util.*;
import com.jetbrains.php.PhpIcons;
import com.jetbrains.php.PhpIndex;
import com.jetbrains.php.lang.parser.PhpElementTypes;
import com.jetbrains.php.lang.psi.PhpPsiUtil;
import com.jetbrains.php.lang.psi.elements.*;
import com.jetbrains.php.phpunit.PhpUnitUtil;
import fr.adrienbrault.idea.symfony2plugin.templating.dict.TwigExtension;
import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil;
import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils;
import org.apache.commons.lang.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.util.*;
import java.util.stream.Collectors;
/**
* @author Daniel Espendiller <daniel@espendiller.net>
*/
public class TwigExtensionParser {
private static final Key<CachedValue<Map<String, TwigExtension>>> FUNCTION_CACHE = new Key<>("TWIG_EXTENSIONS_FUNCTION");
private static final Key<CachedValue<Map<String, TwigExtension>>> TEST_CACHE = new Key<>("TWIG_EXTENSIONS_TEST");
private static final Key<CachedValue<Map<String, TwigExtension>>> FILTERS_CACHE = new Key<>("TWIG_EXTENSIONS_FILTERS");
private static final Key<CachedValue<Map<String, TwigExtension>>> OPERATORS_CACHE = new Key<>("TWIG_EXTENSIONS_OPERATORS");
@NotNull
private Project project;
private Map<String, TwigExtension> functions;
private Map<String, TwigExtension> simpleTest;
private Map<String, TwigExtension> filters;
private Map<String, TwigExtension> operators;
public TwigExtensionParser(@NotNull Project project) {
this.project = project;
}
@NotNull
public Map<String, TwigExtension> getFunctions() {
if(functions == null) {
this.parseElementType(TwigElementType.METHOD);
}
return functions;
}
@NotNull
public Map<String, TwigExtension> getFilters() {
if(filters == null) {
this.parseElementType(TwigElementType.FILTER);
}
return filters;
}
@NotNull
public Map<String, TwigExtension> getSimpleTest() {
if(simpleTest == null) {
this.parseElementType(TwigElementType.SIMPLE_TEST);
}
return simpleTest;
}
@NotNull
public Map<String, TwigExtension> getOperators() {
if(operators == null) {
this.parseElementType(TwigElementType.OPERATOR);
}
return operators;
}
private enum TwigElementType {
FILTER, METHOD, SIMPLE_TEST, OPERATOR
}
public enum TwigExtensionType {
FUNCTION_METHOD, FUNCTION_NODE, SIMPLE_FUNCTION, FILTER, SIMPLE_TEST, OPERATOR
}
private void parseElementType(@NotNull TwigElementType type) {
if(type.equals(TwigElementType.FILTER)) {
CachedValue<Map<String, TwigExtension>> cache = project.getUserData(FILTERS_CACHE);
if(cache == null) {
cache = CachedValuesManager.getManager(project).createCachedValue(() ->
CachedValueProvider.Result.create(parseFilters(getTwigExtensionClasses()), PsiModificationTracker.MODIFICATION_COUNT),
false
);
project.putUserData(FILTERS_CACHE, cache);
}
this.filters = cache.getValue();
} else if(type.equals(TwigElementType.METHOD)) {
CachedValue<Map<String, TwigExtension>> cache = project.getUserData(FUNCTION_CACHE);
if(cache == null) {
cache = CachedValuesManager.getManager(project).createCachedValue(() ->
CachedValueProvider.Result.create(parseFunctions(getTwigExtensionClasses()), PsiModificationTracker.MODIFICATION_COUNT),
false
);
project.putUserData(FUNCTION_CACHE, cache);
}
this.functions = cache.getValue();
} else if(type.equals(TwigElementType.SIMPLE_TEST)) {
CachedValue<Map<String, TwigExtension>> cache = project.getUserData(TEST_CACHE);
if(cache == null) {
cache = CachedValuesManager.getManager(project).createCachedValue(() ->
CachedValueProvider.Result.create(parseTests(getTwigExtensionClasses()), PsiModificationTracker.MODIFICATION_COUNT),
false
);
project.putUserData(TEST_CACHE, cache);
}
this.simpleTest = cache.getValue();
} else if(type.equals(TwigElementType.OPERATOR)) {
CachedValue<Map<String, TwigExtension>> cache = project.getUserData(OPERATORS_CACHE);
if(cache == null) {
cache = CachedValuesManager.getManager(project).createCachedValue(() ->
CachedValueProvider.Result.create(parseOperators(getTwigExtensionClasses()), PsiModificationTracker.MODIFICATION_COUNT),
false
);
project.putUserData(OPERATORS_CACHE, cache);
}
this.operators = cache.getValue();
}
}
@NotNull
private Collection<PhpClass> getTwigExtensionClasses() {
Collection<PhpClass> phpClasses = new ArrayList<>();
// only the interface gave use all elements; service container dont hold all
// dont add unit tests classes
phpClasses.addAll(PhpIndex.getInstance(this.project).getAllSubclasses("\\Twig_ExtensionInterface").stream()
.filter(phpClass -> !PhpUnitUtil.isPhpUnitTestFile(phpClass.getContainingFile()))
.collect(Collectors.toList()));
return phpClasses;
}
@NotNull
private Map<String, TwigExtension> parseFilters(@NotNull Collection<PhpClass> phpClasses) {
Map<String, TwigExtension> extensions = new HashMap<>();
for(PhpClass phpClass : phpClasses) {
Method method = phpClass.findMethodByName("getFilters");
if(method != null) {
parseFilter(method, extensions);
}
}
return extensions;
}
@NotNull
private Map<String, TwigExtension> parseFunctions(@NotNull Collection<PhpClass> phpClasses) {
Map<String, TwigExtension> extensions = new HashMap<>();
for(PhpClass phpClass : phpClasses) {
Method method = phpClass.findMethodByName("getFunctions");
if(method != null) {
parseFunctions(method, extensions);
}
}
return extensions;
}
@NotNull
private Map<String, TwigExtension> parseTests(@NotNull Collection<PhpClass> phpClasses) {
Map<String, TwigExtension> extensions = new HashMap<>();
for(PhpClass phpClass : phpClasses) {
Method method = phpClass.findMethodByName("getTests");
if(method != null) {
parseSimpleTest(method, extensions);
}
}
return extensions;
}
@NotNull
private Map<String, TwigExtension> parseOperators(@NotNull Collection<PhpClass> phpClasses) {
Map<String, TwigExtension> extensions = new HashMap<>();
for(PhpClass phpClass : phpClasses) {
Method method = phpClass.findMethodByName("getOperators");
if(method != null) {
parseOperators(method, extensions);
}
}
return extensions;
}
private void parseFunctions(@NotNull Method method, @NotNull Map<String, TwigExtension> filters) {
final PhpClass containingClass = method.getContainingClass();
if(containingClass == null) {
return;
}
method.acceptChildren(new TwigFunctionVisitor(method, filters, containingClass));
}
/**
* Get signature for callable like array($this, 'getUrl'), or 'function'
*/
@Nullable
private static String getCallableSignature(PsiElement psiElement, Method method) {
// array($this, 'getUrl')
if(psiElement instanceof ArrayCreationExpression) {
List<PsiElement> arrayValues = (List<PsiElement>) PsiElementUtils.getChildrenOfTypeAsList(psiElement, PlatformPatterns.psiElement(PhpElementTypes.ARRAY_VALUE));
if(arrayValues.size() > 1) {
PsiElement firstChild = arrayValues.get(0).getFirstChild();
if(firstChild instanceof Variable && "this".equals(((Variable) firstChild).getName())) {
String methodName = PhpElementsUtil.getStringValue(arrayValues.get(1).getFirstChild());
if(StringUtils.isNotBlank(methodName)) {
PhpClass phpClass = method.getContainingClass();
if(phpClass != null) {
return String.format("#M#C\\%s.%s", phpClass.getPresentableFQN(), methodName);
}
}
}
}
} else {
String funcTargetName = PhpElementsUtil.getStringValue(psiElement);
if(funcTargetName != null) {
if(funcTargetName.contains("::")) {
// 'SqlFormatter::format'
String[] splits = funcTargetName.split("::");
if(splits.length >= 2) {
return String.format("#M#C\\%s.%s", splits[0], splits[1]);
}
} else {
return "#F" + funcTargetName;
}
}
}
return null;
}
private void parseFilter(@NotNull Method method, @NotNull Map<String, TwigExtension> filters) {
final PhpClass containingClass = method.getContainingClass();
if(containingClass == null) {
return;
}
method.acceptChildren(new TwigFilterVisitor(method, filters, containingClass));
}
private void parseOperators(@NotNull Method method, @NotNull Map<String, TwigExtension> filters) {
final PhpClass containingClass = method.getContainingClass();
if(containingClass == null) {
return;
}
/*
return array(
array(
'not' => array(),
),
array(
'or' => array(),
),
);
*/
// getOperator return values, should one one by default
for (PhpReturn phpReturn : PsiTreeUtil.findChildrenOfType(method, PhpReturn.class)) {
// return element needs to be an array
PhpPsiElement firstPsiChild = phpReturn.getFirstPsiChild();
if(firstPsiChild instanceof ArrayCreationExpression) {
// twig core returns nested array with 2 items array creation elements
List<PsiElement> arrayValues = PhpPsiUtil.getChildren(firstPsiChild, new PsiElementTypCondition());
if(arrayValues.size() > 0) {
for (PsiElement psiElement : arrayValues) {
// double check for non crazy syntax
if(!(psiElement instanceof PhpPsiElement)) {
continue;
}
// finally get all array keys with operator string
PhpPsiElement arrayValue = ((PhpPsiElement) psiElement).getFirstPsiChild();
if(arrayValue instanceof ArrayCreationExpression) {
for (ArrayHashElement arrayHashElement : PsiTreeUtil.findChildrenOfType(arrayValue, ArrayHashElement.class)) {
PhpPsiElement key = arrayHashElement.getKey();
String stringValue = PhpElementsUtil.getStringValue(key);
if(stringValue != null && StringUtils.isNotBlank(stringValue)) {
filters.put(stringValue, new TwigExtension(TwigExtensionType.OPERATOR));
}
}
}
}
}
}
}
}
private void parseSimpleTest(@NotNull Method method, @NotNull Map<String, TwigExtension> filters) {
final PhpClass containingClass = method.getContainingClass();
if(containingClass == null) {
return;
}
method.acceptChildren(new TwigSimpleTestVisitor(method, filters));
}
@NotNull
public static Icon getIcon(@NotNull TwigExtensionType twigExtensionType) {
if(twigExtensionType == TwigExtensionType.FUNCTION_NODE) {
return PhpIcons.CLASS_INITIALIZER;
}
if(twigExtensionType == TwigExtensionType.SIMPLE_FUNCTION) {
return PhpIcons.FUNCTION;
}
if(twigExtensionType == TwigExtensionType.FUNCTION_METHOD) {
return PhpIcons.METHOD_ICON;
}
if(twigExtensionType == TwigExtensionType.FILTER) {
return PhpIcons.STATIC_FIELD;
}
if(twigExtensionType == TwigExtensionType.SIMPLE_TEST) {
return PhpIcons.CONSTANT;
}
if(twigExtensionType == TwigExtensionType.OPERATOR) {
return PhpIcons.VARIABLE;
}
return PhpIcons.WEB_ICON;
}
@Nullable
public static PsiElement getExtensionTarget(@NotNull Project project, @NotNull TwigExtension twigExtension) {
String signature = twigExtension.getSignature();
if(signature == null) {
return null;
}
Collection<? extends PhpNamedElement> elements = PhpIndex.getInstance(project).getBySignature(signature);
if(elements.size() == 0) {
return null;
}
return elements.iterator().next();
}
private static class TwigFilterVisitor extends PsiRecursiveElementWalkingVisitor {
@NotNull
private final Method method;
@NotNull
private final Map<String, TwigExtension> filters;
@NotNull
private final PhpClass containingClass;
TwigFilterVisitor(@NotNull Method method, @NotNull Map<String, TwigExtension> filters, @NotNull PhpClass containingClass) {
this.method = method;
this.filters = filters;
this.containingClass = containingClass;
}
@Override
public void visitElement(@NotNull PsiElement element) {
if(element instanceof NewExpression) {
this.visitNewExpression((NewExpression) element);
}
super.visitElement(element);
}
private void visitNewExpression(@NotNull NewExpression element) {
// new \Twig_SimpleFunction('url', array($this, 'getUrl'), array('is_safe_callback' => array($this, 'isUrlGenerationSafe'))),
if(PhpElementsUtil.isNewExpressionPhpClassWithInstance(element, "Twig_SimpleFilter")) {
PsiElement[] psiElement = element.getParameters();
if(psiElement.length > 0) {
String funcName = PhpElementsUtil.getStringValue(psiElement[0]);
if(funcName != null && !funcName.contains("*")) {
String signature = null;
if(psiElement.length > 1) {
signature = getCallableSignature(psiElement[1], method);
}
TwigExtension twigExtension = new TwigExtension(TwigExtensionType.FILTER, signature);
if(psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression) {
decorateOptions((ArrayCreationExpression) psiElement[2], twigExtension);
}
filters.put(funcName, twigExtension);
}
}
return;
}
// array('shuffle' => new Twig_Filter_Function('twig_shuffle_filter'),)
if(PhpElementsUtil.isNewExpressionPhpClassWithInstance(element, "Twig_Filter_Function")) {
PsiElement arrayValue = element.getParent();
if(arrayValue != null && arrayValue.getNode().getElementType() == PhpElementTypes.ARRAY_VALUE) {
PsiElement arrayHash = arrayValue.getParent();
if(arrayHash instanceof ArrayHashElement) {
PsiElement arrayKey = ((ArrayHashElement) arrayHash).getKey();
String funcName = PhpElementsUtil.getStringValue(arrayKey);
if(funcName != null && !funcName.contains("*")) {
PsiElement[] parameters = element.getParameters();
String signature = null;
if(parameters.length > 0) {
signature = getCallableSignature(parameters[0], method);
}
filters.put(funcName, new TwigExtension(TwigExtensionType.FILTER, signature));
}
}
}
return;
}
// return array('serialize' => new \Twig_Filter_Method($this, 'serialize'), );
if(PhpElementsUtil.isNewExpressionPhpClassWithInstance(element, "Twig_Filter_Method")) {
PsiElement arrayValue = element.getParent();
if(arrayValue != null && arrayValue.getNode().getElementType() == PhpElementTypes.ARRAY_VALUE) {
PsiElement arrayHash = arrayValue.getParent();
if(arrayHash instanceof ArrayHashElement) {
PsiElement arrayKey = ((ArrayHashElement) arrayHash).getKey();
String funcName = PhpElementsUtil.getStringValue(arrayKey);
if(funcName != null && funcName.matches("\\w+")) {
PsiElement[] parameters = element.getParameters();
String signature = null;
if(parameters.length > 1) {
if(parameters[0] instanceof Variable && "this".equals(((Variable) parameters[0]).getName())) {
String methodName = PhpElementsUtil.getStringValue(parameters[1]);
if(methodName != null) {
signature = String.format("#M#C\\%s.%s", containingClass.getPresentableFQN(), methodName);
}
}
}
filters.put(funcName, new TwigExtension(TwigExtensionType.FILTER, signature));
}
}
}
}
}
}
/**
* Add needs_environment, needs_context values to twig extension object
*/
private static void decorateOptions(@NotNull ArrayCreationExpression arrayCreationExpression, @NotNull TwigExtension twigExtension) {
for(String optionTrue: new String[] {"needs_environment", "needs_context"}) {
PhpPsiElement phpPsiElement = PhpElementsUtil.getArrayValue(arrayCreationExpression, optionTrue);
if(phpPsiElement instanceof ConstantReference) {
String value = phpPsiElement.getName();
if(value != null && value.toLowerCase().equals("true")) {
twigExtension.putOption(optionTrue, "true");
}
}
}
}
private static class TwigFunctionVisitor extends PsiRecursiveElementWalkingVisitor {
@NotNull
private final Method method;
@NotNull
private final Map<String, TwigExtension> filters;
@NotNull
private final PhpClass containingClass;
TwigFunctionVisitor(@NotNull Method method, @NotNull Map<String, TwigExtension> filters, @NotNull PhpClass containingClass) {
this.method = method;
this.filters = filters;
this.containingClass = containingClass;
}
@Override
public void visitElement(PsiElement element) {
if(element instanceof NewExpression) {
this.visitNewExpression((NewExpression) element);
}
super.visitElement(element);
}
private void visitNewExpression(@NotNull NewExpression element) {
// new \Twig_SimpleFunction('url', array($this, 'getUrl'), array('is_safe_callback' => array($this, 'isUrlGenerationSafe'))),
if(PhpElementsUtil.isNewExpressionPhpClassWithInstance(element, "Twig_SimpleFunction")) {
PsiElement[] psiElement = element.getParameters();
if(psiElement.length > 0) {
String funcName = PhpElementsUtil.getStringValue(psiElement[0]);
if(funcName != null && !funcName.contains("*")) {
String signature = null;
if(psiElement.length > 1) {
signature = getCallableSignature(psiElement[1], method);
}
TwigExtension twigExtension = new TwigExtension(TwigExtensionType.SIMPLE_FUNCTION, signature);
if(psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression) {
decorateOptions((ArrayCreationExpression) psiElement[2], twigExtension);
}
filters.put(funcName, twigExtension);
}
}
return;
}
//array('form_javascript' => new \Twig_Function_Method($this, 'renderJavascript', array('is_safe' => array('html'))),);
if(PhpElementsUtil.isNewExpressionPhpClassWithInstance(element, "Twig_Function_Method")) {
PsiElement arrayValue = element.getParent();
if(arrayValue != null && arrayValue.getNode().getElementType() == PhpElementTypes.ARRAY_VALUE) {
PsiElement arrayHash = arrayValue.getParent();
if(arrayHash instanceof ArrayHashElement) {
PsiElement arrayKey = ((ArrayHashElement) arrayHash).getKey();
String funcName = PhpElementsUtil.getStringValue(arrayKey);
if(funcName != null && !funcName.contains("*")) {
PsiElement[] parameters = element.getParameters();
String signature = null;
if(parameters.length > 1) {
if(parameters[0] instanceof Variable && "this".equals(((Variable) parameters[0]).getName())) {
String methodName = PhpElementsUtil.getStringValue(parameters[1]);
if(methodName != null) {
signature = String.format("#M#C\\%s.%s", containingClass.getPresentableFQN(), methodName);
}
}
}
filters.put(funcName, new TwigExtension(TwigExtensionType.FUNCTION_METHOD, signature));
}
}
}
return;
}
// array('form_help' => new \Twig_Function_Node('Symfony\Bridge\Twig\Node\SearchAndRenderBlockNode', array('is_safe' => array('html'))),)
if(PhpElementsUtil.isNewExpressionPhpClassWithInstance(element, "Twig_Function_Node")) {
PsiElement arrayValue = element.getParent();
if(arrayValue != null && arrayValue.getNode().getElementType() == PhpElementTypes.ARRAY_VALUE) {
PsiElement arrayHash = arrayValue.getParent();
if(arrayHash instanceof ArrayHashElement) {
PsiElement arrayKey = ((ArrayHashElement) arrayHash).getKey();
String funcName = PhpElementsUtil.getStringValue(arrayKey);
if(funcName != null && !funcName.contains("*")) {
PsiElement[] parameters = element.getParameters();
String signature = null;
if(parameters.length > 0) {
String className = PhpElementsUtil.getStringValue(parameters[0]);
if(className != null) {
signature = String.format("#M#C\\%s.%s", StringUtils.stripStart(className, "\\"), "compile");
}
}
filters.put(funcName, new TwigExtension(TwigExtensionType.FUNCTION_NODE, signature));
}
}
}
}
}
}
private static class TwigSimpleTestVisitor extends PsiRecursiveElementWalkingVisitor {
@NotNull
private final Method method;
@NotNull
private final Map<String, TwigExtension> filters;
TwigSimpleTestVisitor(@NotNull Method method, @NotNull Map<String, TwigExtension> filters) {
this.method = method;
this.filters = filters;
}
@Override
public void visitElement(PsiElement element) {
if(element instanceof NewExpression) {
this.visitNewExpression((NewExpression) element);
}
super.visitElement(element);
}
private void visitNewExpression(@NotNull NewExpression element) {
// new Twig_SimpleTest('even', null, array('node_class' => 'Twig_Node_Expression_Test_Even')),
if(PhpElementsUtil.isNewExpressionPhpClassWithInstance(element, "Twig_SimpleTest")) {
PsiElement[] psiElement = element.getParameters();
if(psiElement.length > 0) {
String funcName = PhpElementsUtil.getStringValue(psiElement[0]);
if(funcName != null && !funcName.contains("*")) {
PhpClass phpClass = method.getContainingClass();
String signature = null;
// new \Twig_SimpleTest('my_test', null, array('node_class' => 'My_Node_Test'))
if(psiElement.length > 1 && psiElement[1] instanceof StringLiteralExpression) {
String contents = ((StringLiteralExpression) psiElement[1]).getContents();
if(StringUtils.isNotBlank(contents)) {
signature = "#F" + contents;
}
}
// new \Twig_SimpleTest('empty', 'foo_test')
if(signature == null && psiElement.length > 2 && psiElement[2] instanceof ArrayCreationExpression) {
String nodeClass = PhpElementsUtil.getArrayHashValue((ArrayCreationExpression) psiElement[2], "node_class");
if(StringUtils.isNotBlank(nodeClass)) {
signature = String.format("#M#C\\%s.%s", StringUtils.stripStart(nodeClass, "\\"), "compile");
}
}
filters.put(funcName, new TwigExtension(TwigExtensionType.SIMPLE_TEST, signature));
}
}
}
}
}
private static class PsiElementTypCondition implements Condition<PsiElement> {
@Override
public boolean value(PsiElement psiElement) {
return psiElement.getNode().getElementType() == PhpElementTypes.ARRAY_VALUE;
}
}
}