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")
+ }
+}