diff --git a/src/main/kotlin/com/vk/kphpstorm/inspections/helpers/PhpDocPsiBuilder.kt b/src/main/kotlin/com/vk/kphpstorm/inspections/helpers/PhpDocPsiBuilder.kt index 8d77eece..efc99c6b 100644 --- a/src/main/kotlin/com/vk/kphpstorm/inspections/helpers/PhpDocPsiBuilder.kt +++ b/src/main/kotlin/com/vk/kphpstorm/inspections/helpers/PhpDocPsiBuilder.kt @@ -13,8 +13,10 @@ import com.jetbrains.php.lang.psi.PhpPsiElementFactory import com.jetbrains.php.lang.psi.elements.Field import com.jetbrains.php.lang.psi.elements.Function import com.jetbrains.php.lang.psi.elements.Parameter +import com.jetbrains.php.lang.psi.elements.PhpClass import com.jetbrains.php.lang.psi.elements.PhpNamedElement import com.vk.kphpstorm.helpers.parentDocComment +import com.vk.kphpstorm.kphptags.KphpDocTag import com.vk.kphpstorm.kphptags.KphpSerializedFieldDocTag /** @@ -68,6 +70,13 @@ object PhpDocPsiBuilder { return field.docComment!!.addTag(project, "@var", "type") } + fun addTagToClass(klass: PhpClass, annotation: KphpDocTag): PhpDocTag { + val project = klass.project + val docComment = klass.docComment ?: createDocComment(project, klass) + + return docComment.transformToMultiline(project).addTag(project, annotation.nameWithAt) + } + /** * Create empty phpdoc and insert it before function/class/field diff --git a/src/main/kotlin/com/vk/kphpstorm/intentions/AddImmutableClassAnnotationIntention.kt b/src/main/kotlin/com/vk/kphpstorm/intentions/AddImmutableClassAnnotationIntention.kt new file mode 100644 index 00000000..472c3771 --- /dev/null +++ b/src/main/kotlin/com/vk/kphpstorm/intentions/AddImmutableClassAnnotationIntention.kt @@ -0,0 +1,80 @@ +package com.vk.kphpstorm.intentions + +import com.intellij.codeInsight.intention.PsiElementBaseIntentionAction +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiElement +import com.intellij.psi.search.LocalSearchScope +import com.intellij.psi.search.searches.ReferencesSearch +import com.intellij.psi.util.findParentInFile +import com.jetbrains.php.lang.psi.elements.AssignmentExpression +import com.jetbrains.php.lang.psi.elements.PhpClass +import com.vk.kphpstorm.inspections.helpers.PhpDocPsiBuilder +import com.vk.kphpstorm.kphptags.KphpImmutableClassDocTag + +class AddImmutableClassAnnotationIntention : PsiElementBaseIntentionAction() { + override fun getText(): String = "Add @kphp-immutable-class" + override fun getFamilyName(): String = getText() + + override fun isAvailable(project: Project, editor: Editor?, element: PsiElement): Boolean { + if (!element.isClassNameNode()) { + return false + } + + val klass = element.parent as PhpClass + if (klass.isAbstract || klass.isInterface || klass.isTrait || klass.isAnonymous) { + return false + } + + val klassDocNode = klass.docComment + + // do not suggest if already present + if (klassDocNode != null && KphpImmutableClassDocTag.existsInDocComment(klassDocNode)) { + return false + } + + return !isClassLocallyImmutable(klass) + } + + /** + * Simple local class mutability check. If there is any field mutation in class, + * the class is mutable. The only exception is the class constructor + */ + private fun isClassLocallyImmutable(klass: PhpClass): Boolean { + val searchScope = LocalSearchScope(klass) + for (field in klass.fields) { + val hasAnyMutation = ReferencesSearch.search(field, searchScope).any { ref -> + val element = ref.element + + isMutatingOp(element) && !isInClassConstructor(klass, element) + } + + if (hasAnyMutation) { + return true + } + } + + return false + } + + private fun isMutatingOp(psiElement: PsiElement): Boolean { + val parent = psiElement.parent + + return parent is AssignmentExpression && parent.variable == psiElement + } + + private fun isInClassConstructor(klass: PhpClass, psiElement: PsiElement): Boolean { + val classConstructor = klass.constructor + return classConstructor != null && psiElement.findParentInFile { e -> e == classConstructor } != null + } + + override fun invoke(project: Project, editor: Editor?, element: PsiElement) { + val klass = element.parent as PhpClass + PhpDocPsiBuilder.addTagToClass(klass, KphpImmutableClassDocTag) + } + + private fun PsiElement.isClassNameNode(): Boolean { + val klass = this.parent as? PhpClass ?: return false + return klass.nameIdentifier == this + } +} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f455ec5a..a8184dca 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -77,7 +77,12 @@ PHP com.vk.kphpstorm.intentions.PrettifyPhpdocBlockIntention PHP - PrettifyPhpdocBlockIntention + + + + PHP + com.vk.kphpstorm.intentions.AddImmutableClassAnnotationIntention + PHP diff --git a/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/after.php.template b/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/after.php.template new file mode 100644 index 00000000..732a8060 --- /dev/null +++ b/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/after.php.template @@ -0,0 +1,7 @@ +/** + * My favorite class + * @kphp-immutable-class + */ +class MyClass { + // ... +} diff --git a/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/before.php.template b/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/before.php.template new file mode 100644 index 00000000..b73f8840 --- /dev/null +++ b/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/before.php.template @@ -0,0 +1,6 @@ +/** + * My favorite class + */ +class MyClass { + // ... +} diff --git a/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/description.html b/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/description.html new file mode 100644 index 00000000..3efe6e20 --- /dev/null +++ b/src/main/resources/intentionDescriptions/AddImmutableClassAnnotationIntention/description.html @@ -0,0 +1 @@ +Add @kphp-immutable-class annotation to the class diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-1.fixture.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-1.fixture.php new file mode 100644 index 00000000..85a48864 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-1.fixture.php @@ -0,0 +1,5 @@ +C1 { + // ... +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-1.qf.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-1.qf.php new file mode 100644 index 00000000..8d2e25bf --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-1.qf.php @@ -0,0 +1,8 @@ +C1 { } diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-10.qf.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-10.qf.php new file mode 100644 index 00000000..7b1edbb7 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-10.qf.php @@ -0,0 +1,8 @@ +C1 { } diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-11.nointent.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-11.nointent.php new file mode 100644 index 00000000..de0d4de0 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-11.nointent.php @@ -0,0 +1,3 @@ +C1 { } diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-12.nointent.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-12.nointent.php new file mode 100644 index 00000000..2d4abd6b --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-12.nointent.php @@ -0,0 +1,3 @@ +C1 { } diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-13.nointent.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-13.nointent.php new file mode 100644 index 00000000..725e5487 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-13.nointent.php @@ -0,0 +1,3 @@ +C1 { } diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-2.nointention.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-2.nointention.php new file mode 100644 index 00000000..5db4aa7a --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-2.nointention.php @@ -0,0 +1,8 @@ +C1 { + // ... +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-3.nointention.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-3.nointention.php new file mode 100644 index 00000000..fc153494 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-3.nointention.php @@ -0,0 +1,7 @@ +C2 { + // ... +} + +class C2 {} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-4.nointention.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-4.nointention.php new file mode 100644 index 00000000..29fc15c4 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-4.nointention.php @@ -0,0 +1,6 @@ + + // ... +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-5.nointention.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-5.nointention.php new file mode 100644 index 00000000..541c5712 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-5.nointention.php @@ -0,0 +1,9 @@ +C1 { + public int $field; + + public function foo() { + $this->field = 1; // mutate the field + } +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-6.fixture.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-6.fixture.php new file mode 100644 index 00000000..db93a28d --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-6.fixture.php @@ -0,0 +1,10 @@ +C1 { + public int $field; + + public function __construct(int $arg) + { + $this->field = $arg; // field mutation in constructor, that's ok + } +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-6.qf.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-6.qf.php new file mode 100644 index 00000000..7a3a88d3 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-6.qf.php @@ -0,0 +1,13 @@ +C1 { + public int $field; + + public function __construct(int $arg) + { + $this->field = $arg; // field mutation in constructor, that's ok + } +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-7.fixture.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-7.fixture.php new file mode 100644 index 00000000..0b3dee26 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-7.fixture.php @@ -0,0 +1,11 @@ +C1 { + public int $field; +} + +function foo() +{ + $v = new C1(); + $v->field = 1; // mutation outside of a class, that's ok +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-7.qf.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-7.qf.php new file mode 100644 index 00000000..70116c8e --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-7.qf.php @@ -0,0 +1,14 @@ +C1 { + public int $field; +} + +function foo() +{ + $v = new C1(); + $v->field = 1; // mutation outside of a class, that's ok +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-8.fixture.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-8.fixture.php new file mode 100644 index 00000000..57d70c26 --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-8.fixture.php @@ -0,0 +1,9 @@ +C1 { + public int $field = 1; + + public function foo() { + $tmp = $this->field; // no field mutation + } +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-8.qf.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-8.qf.php new file mode 100644 index 00000000..1d725fdc --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-8.qf.php @@ -0,0 +1,12 @@ +C1 { + public int $field = 1; + + public function foo() { + $tmp = $this->field; // no field mutation + } +} diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-9.fixture.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-9.fixture.php new file mode 100644 index 00000000..09bc0a4d --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-9.fixture.php @@ -0,0 +1,4 @@ +C1 { } diff --git a/src/test/fixtures/kphp_intentions/immutable_class_intention-9.qf.php b/src/test/fixtures/kphp_intentions/immutable_class_intention-9.qf.php new file mode 100644 index 00000000..2209f3df --- /dev/null +++ b/src/test/fixtures/kphp_intentions/immutable_class_intention-9.qf.php @@ -0,0 +1,7 @@ +C1 { } diff --git a/src/test/kotlin/com/vk/kphpstorm/testing/infrastructure/IntentionTestBase.kt b/src/test/kotlin/com/vk/kphpstorm/testing/infrastructure/IntentionTestBase.kt index 0a5ecc88..cc39a302 100644 --- a/src/test/kotlin/com/vk/kphpstorm/testing/infrastructure/IntentionTestBase.kt +++ b/src/test/kotlin/com/vk/kphpstorm/testing/infrastructure/IntentionTestBase.kt @@ -34,4 +34,19 @@ abstract class IntentionTestBase( val qfFile = fixtureFile.replace(".fixture.php", ".qf.php") myFixture.checkResultByFile(qfFile) } + + /** + * Assert there are no intention [intentionToExecute] in file [fixtureFile] + */ + protected fun assertNoIntention(fixtureFile: String) { + setupLanguageLevel() + + KphpStormConfiguration.saveThatSetupForProjectDone(project) + myFixture.configureByFile(fixtureFile) + val availableIntentions = myFixture + .availableIntentions + .filter { intentionAction -> intentionAction.familyName == intentionToExecute.familyName } + + assertEmpty(availableIntentions) + } } diff --git a/src/test/kotlin/com/vk/kphpstorm/testing/tests/AddImmutableAnnoIntentionTest.kt b/src/test/kotlin/com/vk/kphpstorm/testing/tests/AddImmutableAnnoIntentionTest.kt new file mode 100644 index 00000000..c25888a5 --- /dev/null +++ b/src/test/kotlin/com/vk/kphpstorm/testing/tests/AddImmutableAnnoIntentionTest.kt @@ -0,0 +1,58 @@ +package com.vk.kphpstorm.testing.tests + +import com.vk.kphpstorm.intentions.AddImmutableClassAnnotationIntention +import com.vk.kphpstorm.testing.infrastructure.IntentionTestBase + +class AddImmutableAnnoIntentionTest : IntentionTestBase(AddImmutableClassAnnotationIntention()) { + fun testAddImmutableAnno1() { + runIntention("kphp_intentions/immutable_class_intention-1.fixture.php") + } + + fun testAddImmutableAnno2() { + assertNoIntention("kphp_intentions/immutable_class_intention-2.nointention.php") + } + + fun testAddImmutableAnno3() { + assertNoIntention("kphp_intentions/immutable_class_intention-3.nointention.php") + } + + fun testAddImmutableAnno4() { + assertNoIntention("kphp_intentions/immutable_class_intention-4.nointention.php") + } + + fun testAddImmutableAnno5() { + assertNoIntention("kphp_intentions/immutable_class_intention-5.nointention.php") + } + + fun testAddImmutableAnno6() { + runIntention("kphp_intentions/immutable_class_intention-6.fixture.php") + } + + fun testAddImmutableAnno7() { + runIntention("kphp_intentions/immutable_class_intention-7.fixture.php") + } + + fun testAddImmutableAnno8() { + runIntention("kphp_intentions/immutable_class_intention-8.fixture.php") + } + + fun testAddImmutableAnno9() { + runIntention("kphp_intentions/immutable_class_intention-9.fixture.php") + } + + fun testAddImmutableAnno10() { + runIntention("kphp_intentions/immutable_class_intention-10.fixture.php") + } + + fun testAddImmutableAnno11() { + assertNoIntention("kphp_intentions/immutable_class_intention-11.nointent.php") + } + + fun testAddImmutableAnno12() { + assertNoIntention("kphp_intentions/immutable_class_intention-12.nointent.php") + } + + fun testAddImmutableAnno13() { + assertNoIntention("kphp_intentions/immutable_class_intention-13.nointent.php") + } +}