diff --git a/idea/src/META-INF/plugin.xml b/idea/src/META-INF/plugin.xml index 0c92e38ff6a..fd458449854 100644 --- a/idea/src/META-INF/plugin.xml +++ b/idea/src/META-INF/plugin.xml @@ -1433,6 +1433,12 @@ + + + + + + diff --git a/idea/src/org/jetbrains/kotlin/idea/injection/KotlinLanguageInjectionSupport.kt b/idea/src/org/jetbrains/kotlin/idea/injection/KotlinLanguageInjectionSupport.kt new file mode 100644 index 00000000000..b06e9418409 --- /dev/null +++ b/idea/src/org/jetbrains/kotlin/idea/injection/KotlinLanguageInjectionSupport.kt @@ -0,0 +1,243 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.kotlin.idea.injection + +import com.intellij.codeInsight.AnnotationUtil +import com.intellij.lang.Language +import com.intellij.openapi.module.ModuleUtilCore +import com.intellij.openapi.util.text.StringUtil +import com.intellij.psi.* +import com.intellij.psi.search.GlobalSearchScope +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.psi.util.PsiTreeUtil.getDeepestLast +import com.intellij.util.ArrayUtil +import com.intellij.util.Processor +import org.intellij.plugins.intelliLang.Configuration +import org.intellij.plugins.intelliLang.inject.AbstractLanguageInjectionSupport +import org.intellij.plugins.intelliLang.inject.InjectLanguageAction +import org.intellij.plugins.intelliLang.inject.InjectedLanguage +import org.intellij.plugins.intelliLang.inject.TemporaryPlacesRegistry +import org.jetbrains.annotations.NonNls +import org.jetbrains.kotlin.idea.util.addAnnotation +import org.jetbrains.kotlin.idea.util.application.executeWriteCommand +import org.jetbrains.kotlin.idea.util.findAnnotation +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.* +import org.jetbrains.kotlin.psi.psiUtil.endOffset +import org.jetbrains.kotlin.psi.psiUtil.siblings +import org.jetbrains.kotlin.psi.psiUtil.startOffset + +@NonNls val KOTLIN_SUPPORT_ID = "kotlin" +val LINE_LANGUAGE_REGEXP_COMMENT = Regex("//\\s*language=[\\w-]+") + +class KotlinLanguageInjectionSupport : AbstractLanguageInjectionSupport() { + override fun getId(): String = KOTLIN_SUPPORT_ID + + override fun getPatternClasses() = ArrayUtil.EMPTY_CLASS_ARRAY + + override fun isApplicableTo(host: PsiLanguageInjectionHost?) = host is KtElement + + override fun addInjectionInPlace(language: Language?, host: PsiLanguageInjectionHost?): Boolean { + if (language == null || host == null) return false + + val configuration = Configuration.getProjectInstance(host.project).advancedConfiguration + if (!configuration.isSourceModificationAllowed) { + // It's not allowed to modify code without explicit permission. Postpone adding @Inject or comment till it granted. + host.putUserData(InjectLanguageAction.FIX_KEY, Processor { host -> addInjectionInstructionInCode(language, host) }) + return false + } + + if (!addInjectionInstructionInCode(language, host)) { + return false + } + + TemporaryPlacesRegistry.getInstance(host.project).addHostWithUndo(host, InjectedLanguage.create(language.id)) + return true + } + + override fun removeInjectionInPlace(psiElement: PsiLanguageInjectionHost?): Boolean { + if (psiElement == null || psiElement !is KtElement) return false + + val project = psiElement.getProject() + + val injectionAnnotation = findAnnotationInjection(psiElement) + val injectionComment = findInjectionComment(psiElement) + + val injectInstructions = listOf(injectionAnnotation, injectionComment).filterNotNull() + + val configuration = Configuration.getProjectInstance(project) + + TemporaryPlacesRegistry.getInstance(project).removeHostWithUndo(project, psiElement) + configuration.replaceInjectionsWithUndo(project, listOf(), listOf(), injectInstructions) + + return true + } + + fun findInjectionComment(host: KtElement): PsiComment? { + if (findCommentInjection(host, null) == null) return null + + val commentsToCheck: Sequence = + findElementToInjectWithAnnotation(host)?.findChildrenComments() ?: + findElementToInjectWithComment(host)?.findCommentsBeforeElement() ?: + return null + + return commentsToCheck.firstOrNull { + it.node.elementType == KtTokens.EOL_COMMENT && it.text.matches(LINE_LANGUAGE_REGEXP_COMMENT) + } + } + + fun findAnnotationInjectionLanguageId(host: KtElement): String? = doFindAnnotationInjectionLanguageId(host) +} + +private fun doFindAnnotationInjectionLanguageId(host: KtElement): String? { + val annotationEntry = findAnnotationInjection(host) ?: return null + val firstArgument: ValueArgument = annotationEntry.valueArguments.firstOrNull() ?: return null + + val firstStringArgument = firstArgument.getArgumentExpression() as? KtStringTemplateExpression ?: return null + val firstStringEntry = firstStringArgument.entries.singleOrNull() ?: return null + + return firstStringEntry.text +} + +private fun findAnnotationInjection(host: KtElement): KtAnnotationEntry? { + val modifierListOwner = findElementToInjectWithAnnotation(host) ?: return null + + val modifierList = modifierListOwner.modifierList ?: return null + + // Host can't be before annotation + if (host.startOffset < modifierList.endOffset) return null + + return modifierListOwner.findAnnotation(FqName(AnnotationUtil.LANGUAGE)) +} + +private fun canInjectWithAnnotation(host: PsiElement): Boolean { + val module = ModuleUtilCore.findModuleForPsiElement(host) ?: return false + val javaPsiFacade = JavaPsiFacade.getInstance(module.project) + + return javaPsiFacade.findClass(AnnotationUtil.LANGUAGE, GlobalSearchScope.moduleWithDependenciesAndLibrariesScope(module)) != null +} + +private fun findElementToInjectWithAnnotation(host: KtElement): KtModifierListOwner? { + return PsiTreeUtil.getParentOfType( + host, + KtModifierListOwner::class.java, + false, /* strict */ + KtBlockExpression::class.java, KtParameterList::class.java, KtTypeParameterList::class.java /* Stop at */ + ) +} + +private fun findElementToInjectWithComment(host: KtElement): KtExpression? { + val parentBlockExpression = PsiTreeUtil.getParentOfType( + host, + KtBlockExpression::class.java, + true, /* strict */ + KtDeclaration::class.java /* Stop at */ + ) ?: return null + + return parentBlockExpression.statements.firstOrNull { statement -> + PsiTreeUtil.isAncestor(statement, host, false) && checkIsValidPlaceForInjectionWithLineComment(statement, host) + } +} + +private fun addInjectionInstructionInCode(language: Language, host: PsiLanguageInjectionHost): Boolean { + val ktHost = host as? KtElement ?: return false + val project = ktHost.project + + // Find the place where injection can be stated with annotation or comment + val modifierListOwner = findElementToInjectWithAnnotation(ktHost) + + if (modifierListOwner != null && canInjectWithAnnotation(ktHost)) { + project.executeWriteCommand("Add injection annotation") { + modifierListOwner.addAnnotation(FqName(AnnotationUtil.LANGUAGE), "\"${language.id}\"") + } + + return true + } + + // Find the place where injection can be done with one-line comment + val commentBeforeAnchor: PsiElement = + modifierListOwner?.firstNonCommentChild() ?: + findElementToInjectWithComment(ktHost) ?: + return false + + val psiFactory = KtPsiFactory(project) + val injectComment = psiFactory.createComment("//language=" + language.id) + + project.executeWriteCommand("Add injection comment") { + commentBeforeAnchor.parent.addBefore(injectComment, commentBeforeAnchor) + } + + return true +} + +// Inspired with InjectorUtils.findCommentInjection() +private fun checkIsValidPlaceForInjectionWithLineComment(statement: KtExpression, host: KtElement): Boolean { + // make sure comment is close enough and ... + val statementStartOffset = statement.startOffset + val hostStart = host.startOffset + if (hostStart < statementStartOffset || hostStart - statementStartOffset > 120) { + return false + } + + if (hostStart - statementStartOffset > 2) { + // ... there's no non-empty valid host in between comment and e2 + if (prevWalker(host, statement).asSequence().takeWhile { it != null }.any { + it is PsiLanguageInjectionHost && it.isValidHost && !StringUtil.isEmptyOrSpaces(it.text) + }) { + return false + } + } + + return true +} + +private fun PsiElement.findChildrenComments(): Sequence { + return firstChild.siblings().takeWhile { it is PsiComment || it is PsiWhiteSpace }.filterIsInstance() +} + +private fun PsiElement.findCommentsBeforeElement(): Sequence { + return siblings(forward = false, withItself = false).takeWhile { it is PsiComment || it is PsiWhiteSpace }.filterIsInstance() +} + +private fun PsiElement.firstNonCommentChild(): PsiElement? { + return firstChild.siblings().dropWhile { it is PsiComment || it is PsiWhiteSpace }.firstOrNull() +} + +// Based on InjectorUtils.prevWalker +private fun prevWalker(element: PsiElement, scope: PsiElement): Iterator { + return object : Iterator { + private var e: PsiElement? = element + + override fun hasNext(): Boolean = true + override fun next(): PsiElement? { + val current = e + + if (current == null || current === scope) return null + val prev = current.prevSibling + if (prev != null) { + e = getDeepestLast(prev) + return e + } + else { + val parent = current.parent + e = if (parent === scope || parent is PsiFile) null else parent + return e + } + } + } +} diff --git a/idea/src/org/jetbrains/kotlin/idea/injection/KotlinLanguageInjector.kt b/idea/src/org/jetbrains/kotlin/idea/injection/KotlinLanguageInjector.kt new file mode 100644 index 00000000000..22e4ac2e301 --- /dev/null +++ b/idea/src/org/jetbrains/kotlin/idea/injection/KotlinLanguageInjector.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.kotlin.idea.injection + +import com.intellij.openapi.util.TextRange +import com.intellij.psi.InjectedLanguagePlaces +import com.intellij.psi.LanguageInjector +import com.intellij.psi.PsiLanguageInjectionHost +import org.intellij.plugins.intelliLang.inject.InjectorUtils +import org.jetbrains.kotlin.psi.KtElement +import java.util.* + +class KotlinLanguageInjector : LanguageInjector { + val kotlinSupport: KotlinLanguageInjectionSupport? by lazy { + ArrayList(InjectorUtils.getActiveInjectionSupports()).filterIsInstance(KotlinLanguageInjectionSupport::class.java).firstOrNull() + } + + override fun getLanguagesToInject(host: PsiLanguageInjectionHost, injectionPlacesRegistrar: InjectedLanguagePlaces) { + if (host !is KtElement) return + if (!host.isValidHost) return + val support = kotlinSupport ?: return + + val languageId = support.findAnnotationInjectionLanguageId(host) ?: return + + val language = InjectorUtils.getLanguageByString(languageId) ?: return + + injectionPlacesRegistrar.addPlace(language, TextRange.from(0, host.getTextLength()), null, null) + } +} \ No newline at end of file diff --git a/idea/tests/org/jetbrains/kotlin/psi/KotlinInjectionTest.kt b/idea/tests/org/jetbrains/kotlin/psi/KotlinInjectionTest.kt new file mode 100644 index 00000000000..d0813397d77 --- /dev/null +++ b/idea/tests/org/jetbrains/kotlin/psi/KotlinInjectionTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright 2010-2016 JetBrains s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.kotlin.psi + +import com.intellij.openapi.module.Module +import com.intellij.openapi.roots.ContentEntry +import com.intellij.openapi.roots.ModifiableRootModel +import com.intellij.openapi.roots.OrderRootType +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.psi.impl.source.resolve.reference.impl.providers.FileReference +import com.intellij.psi.injection.Injectable +import com.intellij.testFramework.LightProjectDescriptor +import com.intellij.testFramework.fixtures.DefaultLightProjectDescriptor +import junit.framework.TestCase +import org.intellij.plugins.intelliLang.Configuration +import org.intellij.plugins.intelliLang.inject.InjectLanguageAction +import org.intellij.plugins.intelliLang.inject.UnInjectLanguageAction +import org.intellij.plugins.intelliLang.references.FileReferenceInjector +import org.jetbrains.kotlin.idea.test.KotlinLightCodeInsightFixtureTestCase +import java.io.File + +class KotlinInjectionTest : KotlinLightCodeInsightFixtureTestCase() { + fun testInjectUnInjectOnSimpleString() { + myFixture.configureByText("test.kt", + """val test = "simple" """) + TestCase.assertTrue(InjectLanguageAction().isAvailable(project, myFixture.editor, myFixture.file)) + TestCase.assertFalse(UnInjectLanguageAction().isAvailable(project, myFixture.editor, myFixture.file)) + + InjectLanguageAction.invokeImpl(project, myFixture.editor, myFixture.file, FileReferenceInjector()) + TestCase.assertTrue(myFixture.getReferenceAtCaretPosition() is FileReference) + + TestCase.assertFalse(InjectLanguageAction().isAvailable(project, myFixture.editor, myFixture.file)) + TestCase.assertTrue(UnInjectLanguageAction().isAvailable(project, myFixture.editor, myFixture.file)) + + UnInjectLanguageAction.invokeImpl(project, myFixture.editor, myFixture.file) + TestCase.assertNull(myFixture.getReferenceAtCaretPosition()) + } + + fun testInjectionWithCommentOnProperty() = doTestInjection( + """ + |//language=file-reference + |val test = "simple" + """) + + fun testInjectionWithMultipleCommentsOnFun() = doTestInjection( + """ + |// Some comment + |// Other comment + |//language=file-reference + |fun test() = "simple" + """) + + fun testInjectionWithAnnotationOnPropertyWithAnnotation() = doTestInjection( + """ + |@org.intellij.lang.annotations.Language("file-reference") + |val test = "simple" + """) + + fun testInjectWithCommentOnProperty() = doFileReferenceInjectTest( + """ + |val test = "simple" + """, + """ + |//language=file-reference + |val test = "simple" + """ + ) + + fun testInjectWithCommentOnCommentedProperty() = doFileReferenceInjectTest( + """ + |// Hello + |val test = "simple" + """, + """ + |// Hello + |//language=file-reference + |val test = "simple" + """ + ) + + fun testInjectWithCommentOnPropertyWithKDoc() = doFileReferenceInjectTest( + """ + |/** + | * Hi + | */ + |val test = "simple" + """, + """ + |/** + | * Hi + | */ + |//language=file-reference + |val test = "simple" + """ + ) + + fun testInjectWithCommentOnExpression() = doFileReferenceInjectTest( + """ + |fun test() { + | "" + |} + """, + """ + |fun test() { + | //language=file-reference + | "" + |} + """ + ) + + fun testInjectWithCommentOnDeepExpression() = doFileReferenceInjectTest( + """ + |fun test() { + | "" + "" + |} + """, + """ + |fun test() { + | "" + "" + |} + """ + ) + + fun testInjectOnPropertyWithAnnotation() = doFileReferenceInjectTest( + """ + |val test = "simple" + """, + """ + |import org.intellij.lang.annotations.Language + | + |@Language("file-reference") + |val test = "simple" + """ + ) + + fun testInjectWithOnExpressionWithAnnotation() = doFileReferenceInjectTest( + """ + |fun test() { + | "" + |} + """, + """ + |fun test() { + | //language=file-reference + | "" + |} + """ + ) + + // TODO: add test for non-default language annotation + + fun testRemoveInjectionWithAnnotation() = doRemoveInjectionTest( + """ + |import org.intellij.lang.annotations.Language + | + |@Language("file-reference") + |val test = "simple" + """, + """ + |import org.intellij.lang.annotations.Language + | + |val test = "simple" + """ + ) + + fun testRemoveInjectionWithComment() = doRemoveInjectionTest( + """ + |//language=file-reference + |val test = "simple" + """, + """ + |val test = "simple" + """ + ) + + fun testRemoveInjectionWithCommentNotFirst() = doRemoveInjectionTest( + """ + |// Some comment. To do a language injection, add a line comment language=some instruction. + |// language=file-reference + |val test = "simple" + """, + """ + |// Some comment. To do a language injection, add a line comment language=some instruction. + |val test = "simple" + """ + ) + + fun testRemoveInjectionWithCommentAfterKDoc() = doRemoveInjectionTest( + """ + |/**Property*/ + |// language=file-reference + |val test = "simple" + """, + """ + |/**Property*/ + |val test = "simple" + """ + ) + + fun testRemoveInjectionWithCommentInExpression() = doRemoveInjectionTest( + """ + |fun test() { + | // This is my favorite part + | // language=RegExp + | "something" + |} + """, + """ + |fun test() { + | // This is my favorite part + | "something" + |} + """ + ) + + override fun getProjectDescriptor(): LightProjectDescriptor { + if (getTestName(true).endsWith("WithAnnotation")) { + return PROJECT_DESCRIPTOR_WITH_LANGUAGE_ANNOTATION_LIB + } + + return JAVA_LATEST + } + + private fun doTestInjection(text: String) { + myFixture.configureByText("${getTestName(true)}.kt", text.trimMargin()) + + TestCase.assertFalse(InjectLanguageAction().isAvailable(project, myFixture.editor, myFixture.file)) + TestCase.assertTrue(UnInjectLanguageAction().isAvailable(project, myFixture.editor, myFixture.file)) + } + + private fun doRemoveInjectionTest(before: String, after: String) { + myFixture.setCaresAboutInjection(false) + + myFixture.configureByText("${getTestName(true)}.kt", before.trimMargin()) + + TestCase.assertTrue(UnInjectLanguageAction().isAvailable(project, myFixture.editor, myFixture.file)) + UnInjectLanguageAction.invokeImpl(project, myFixture.editor, myFixture.file) + + myFixture.checkResult(after.trimMargin()) + } + + private fun doFileReferenceInjectTest(before: String, after: String) { + doTest(FileReferenceInjector(), before, after) + } + + private fun doTest(injectable: Injectable, before: String, after: String) { + val configuration = Configuration.getProjectInstance(project).advancedConfiguration + val allowed = configuration.isSourceModificationAllowed + + configuration.isSourceModificationAllowed = true + try { + myFixture.configureByText("${getTestName(true)}.kt", before.trimMargin()) + InjectLanguageAction.invokeImpl(project, myFixture.editor, myFixture.file, injectable) + myFixture.checkResult(after.trimMargin()) + } + finally { + configuration.isSourceModificationAllowed = allowed + } + } +} + +private val PROJECT_DESCRIPTOR_WITH_LANGUAGE_ANNOTATION_LIB = object : DefaultLightProjectDescriptor() { + override fun configureModule(module: Module, model: ModifiableRootModel, contentEntry: ContentEntry) { + super.configureModule(module, model, contentEntry) + + val jarUrl = VfsUtil.getUrlForLibraryRoot(File("ideaSDK/lib/annotations.jar")) + val libraryModel = model.moduleLibraryTable.modifiableModel.createLibrary("annotations").modifiableModel + libraryModel.addRoot(jarUrl, OrderRootType.CLASSES) + + libraryModel.commit() + } +}