package fr.adrienbrault.idea.symfony2plugin.config.yaml; import com.intellij.codeInsight.hint.HintManager; import com.intellij.codeInsight.intention.PsiElementBaseIntentionAction; import com.intellij.lang.annotation.AnnotationHolder; import com.intellij.lang.annotation.Annotator; import com.intellij.openapi.editor.Editor; import com.intellij.openapi.project.Project; import com.intellij.patterns.PlatformPatterns; import com.intellij.psi.PsiElement; import com.intellij.util.IncorrectOperationException; import com.intellij.util.containers.ContainerUtil; import com.jetbrains.php.lang.psi.elements.Method; import com.jetbrains.php.lang.psi.elements.Parameter; import com.jetbrains.php.lang.psi.elements.PhpClass; import fr.adrienbrault.idea.symfony2plugin.Settings; import fr.adrienbrault.idea.symfony2plugin.Symfony2ProjectComponent; import fr.adrienbrault.idea.symfony2plugin.dic.ContainerService; import fr.adrienbrault.idea.symfony2plugin.dic.container.dict.ServiceTypeHint; import fr.adrienbrault.idea.symfony2plugin.dic.container.util.ServiceContainerUtil; import fr.adrienbrault.idea.symfony2plugin.intentions.ui.ServiceSuggestDialog; import fr.adrienbrault.idea.symfony2plugin.stubs.ContainerCollectionResolver; import fr.adrienbrault.idea.symfony2plugin.util.PhpElementsUtil; import fr.adrienbrault.idea.symfony2plugin.util.PsiElementUtils; import fr.adrienbrault.idea.symfony2plugin.util.dict.ServiceUtil; import fr.adrienbrault.idea.symfony2plugin.util.yaml.YamlHelper; import org.apache.commons.lang.StringUtils; import org.jetbrains.annotations.Nls; import org.jetbrains.annotations.NotNull; import org.jetbrains.yaml.YAMLTokenTypes; import org.jetbrains.yaml.psi.YAMLScalar; import org.jetbrains.yaml.psi.YAMLSequenceItem; import java.util.Collection; import java.util.List; /** * @author Daniel Espendiller <daniel@espendiller.net> */ public class YamlAnnotator implements Annotator { private ContainerCollectionResolver.LazyServiceCollector lazyServiceCollector; @Override public void annotate(@NotNull final PsiElement psiElement, @NotNull AnnotationHolder holder) { if(!Symfony2ProjectComponent.isEnabled(psiElement.getProject()) || !Settings.getInstance(psiElement.getProject()).yamlAnnotateServiceConfig) { return; } this.annotateParameter(psiElement, holder); this.annotateClass(psiElement, holder); this.annotateService(psiElement, holder); // only match inside service definitions if(!YamlElementPatternHelper.getInsideKeyValue("services").accepts(psiElement)) { return; } this.annotateConstructorArguments(psiElement, holder); this.annotateCallsArguments(psiElement, holder); this.lazyServiceCollector = null; } private void annotateParameter(@NotNull final PsiElement psiElement, @NotNull AnnotationHolder holder) { if(!YamlElementPatternHelper.getServiceParameterDefinition().accepts(psiElement) || !YamlElementPatternHelper.getInsideServiceKeyPattern().accepts(psiElement)) { return; } // at least %a% // and not this one: %kernel.root_dir%/../web/ // %kernel.root_dir%/../web/%webpath_modelmasks% String parameterName = PsiElementUtils.getText(psiElement); if(!YamlHelper.isValidParameterName(parameterName)) { return; } // strip "%" parameterName = parameterName.substring(1, parameterName.length() - 1); // parameter a always lowercase see #179 parameterName = parameterName.toLowerCase(); if (!ContainerCollectionResolver.getParameterNames(psiElement.getProject()).contains(parameterName)) { holder.createWarningAnnotation(psiElement, "Missing Parameter"); } } private void annotateService(@NotNull final PsiElement psiElement, @NotNull AnnotationHolder holder) { if(!YamlElementPatternHelper.getServiceDefinition().accepts(psiElement) || !YamlElementPatternHelper.getInsideServiceKeyPattern().accepts(psiElement)) { return; } String serviceName = getServiceName(psiElement); // dont mark "@", "@?", "@@" escaping and expressions if(serviceName.length() < 2 || serviceName.startsWith("=") || serviceName.startsWith("@")) { return; } if(ContainerCollectionResolver.hasServiceNames(psiElement.getProject(), serviceName)) { return; } holder.createWarningAnnotation(psiElement, "Missing Service"); } private void annotateClass(@NotNull final PsiElement element, @NotNull AnnotationHolder holder) { if(!((YamlElementPatternHelper.getSingleLineScalarKey("class", "factory_class").accepts(element) || YamlElementPatternHelper.getParameterClassPattern().accepts(element)) && YamlElementPatternHelper.getInsideServiceKeyPattern().accepts(element))) { return; } String className = PsiElementUtils.getText(element); if(YamlHelper.isValidParameterName(className)) { String resolvedParameter = ContainerCollectionResolver.resolveParameter(element.getProject(), className); if(resolvedParameter != null && PhpElementsUtil.getClassInterfacePsiElements(element.getProject(), resolvedParameter) != null) { return ; } } if(PhpElementsUtil.getClassInterface(element.getProject(), className) == null) { holder.createWarningAnnotation(element, "Missing Class"); } } /** * foo: * class: Foo * arguments: [@<caret>] * arguments: * - @<caret> */ private void annotateConstructorArguments(@NotNull final PsiElement psiElement, @NotNull AnnotationHolder holder) { ServiceTypeHint methodTypeHint = ServiceContainerUtil.getYamlConstructorTypeHint(psiElement, getLazyServiceCollector(psiElement.getProject())); if(methodTypeHint == null) { return; } attachInstanceAnnotation(psiElement, holder, methodTypeHint.getIndex(), methodTypeHint.getMethod()); } public static boolean isStringValue(@NotNull PsiElement psiElement) { // @TODO use new YAMLScalar element return PlatformPatterns.psiElement(YAMLTokenTypes.TEXT).accepts(psiElement) || PlatformPatterns.psiElement(YAMLTokenTypes.SCALAR_DSTRING).accepts(psiElement) || PlatformPatterns.psiElement(YAMLTokenTypes.SCALAR_STRING).accepts(psiElement) ; } /** * class: FooClass * tags: * - [ setFoo, [@args_bar] ] */ private void annotateCallsArguments(@NotNull final PsiElement psiElement, @NotNull AnnotationHolder holder) { if(!isStringValue(psiElement)){ return; } PsiElement yamlScalar = psiElement.getContext(); if(!(yamlScalar instanceof YAMLScalar)) { return; } YamlHelper.visitServiceCallArgument((YAMLScalar) yamlScalar, visitor -> { PhpClass serviceClass = ServiceUtil.getResolvedClassDefinition(psiElement.getProject(), visitor.getClassName(), getLazyServiceCollector(psiElement.getProject())); if(serviceClass == null) { return; } Method method = serviceClass.findMethodByName(visitor.getMethod()); if (method == null) { return; } attachInstanceAnnotation(psiElement, holder, visitor.getParameterIndex(), method); }); } private void attachInstanceAnnotation(PsiElement psiElement, AnnotationHolder holder, int parameterIndex, Method constructor) { String serviceName = getServiceName(psiElement); if(StringUtils.isBlank(serviceName)) { return; } PhpClass serviceParameterClass = ServiceUtil.getResolvedClassDefinition(psiElement.getProject(), getServiceName(psiElement), this.getLazyServiceCollector(psiElement.getProject())); if(serviceParameterClass == null) { return; } Parameter[] constructorParameter = constructor.getParameters(); if(parameterIndex >= constructorParameter.length) { return; } PhpClass expectedClass = PhpElementsUtil.getClassInterface(psiElement.getProject(), constructorParameter[parameterIndex].getDeclaredType().toString()); if(expectedClass == null) { return; } if(!PhpElementsUtil.isInstanceOf(serviceParameterClass, expectedClass)) { holder.createWeakWarningAnnotation(psiElement, "Expect instance of: " + expectedClass.getPresentableFQN()) .registerFix(new MySuggestIntentionAction(expectedClass, psiElement)); } } private String getServiceName(PsiElement psiElement) { return YamlHelper.trimSpecialSyntaxServiceName(PsiElementUtils.getText(psiElement)); } private ContainerCollectionResolver.LazyServiceCollector getLazyServiceCollector(Project project) { return this.lazyServiceCollector == null ? this.lazyServiceCollector = new ContainerCollectionResolver.LazyServiceCollector(project) : this.lazyServiceCollector; } private static class MySuggestIntentionAction extends PsiElementBaseIntentionAction { @NotNull private final PhpClass expectedClass; @NotNull private final PsiElement myPsiElement; public MySuggestIntentionAction(@NotNull PhpClass expectedClass, @NotNull PsiElement psiElement) { this.expectedClass = expectedClass; this.myPsiElement = psiElement; } @Override public void invoke(@NotNull final Project project, final Editor editor, @NotNull PsiElement psiElement) throws IncorrectOperationException { Collection<ContainerService> suggestions = ServiceUtil.getServiceSuggestionForPhpClass(expectedClass, ContainerCollectionResolver.getServices(project)); if(suggestions.size() == 0) { HintManager.getInstance().showErrorHint(editor, "No suggestion found"); return; } ServiceSuggestDialog.create( editor, ContainerUtil.map(suggestions, ContainerService::getName), new MyInsertCallback(editor, myPsiElement) ); } @Override public boolean isAvailable(@NotNull Project project, Editor editor, @NotNull PsiElement psiElement) { return true; } @Nls @NotNull @Override public String getFamilyName() { return "Symfony"; } @NotNull @Override public String getText() { return "Symfony: Suggest Service"; } } /** * This class replace a service name by plain text modification. * This resolve every crazy yaml use case and lexer styles like: * * - @, @? * - "@foo", '@foo', @foo */ private static class MyInsertCallback implements ServiceSuggestDialog.Callback { @NotNull private final Editor editor; @NotNull private final PsiElement psiElement; public MyInsertCallback(@NotNull Editor editor, @NotNull PsiElement psiElement) { this.editor = editor; this.psiElement = psiElement; } @Override public void insert(@NotNull String selected) { String text = this.psiElement.getText(); int i = getServiceChar(text); if(i < 0) { HintManager.getInstance().showErrorHint(editor, "No valid char in text range"); return; } String afterAtText = text.substring(i); // strip ending quotes int length = StringUtils.stripEnd(afterAtText, "'\"").length(); int startOffset = this.psiElement.getTextRange().getStartOffset(); int afterAt = startOffset + i + 1; editor.getDocument().deleteString(afterAt, afterAt + length - 1); editor.getDocument().insertString(afterAt, selected); } private int getServiceChar(@NotNull String text) { int i = text.lastIndexOf("@?"); if(i >= 0) { return i + 1; } return text.lastIndexOf("@"); } } }