User: joe Date: 24 Jun 23 04:11 Revision: 0da34782e5c29f3e4f13ec0cefdc9bca4e1e8baa Summary: Add inspection for names of members that are added to classes via a mixin, so that they don't clash with other mods TeamCity URL: https://ci.mcdev.io/viewModification.html?tab=vcsModificationFiles&modId=8549&personal=false Index: src/main/kotlin/platform/mixin/inspection/addedMembers/AbstractAddedMembersInspection.kt =================================================================== --- src/main/kotlin/platform/mixin/inspection/addedMembers/AbstractAddedMembersInspection.kt (revision 0da34782e5c29f3e4f13ec0cefdc9bca4e1e8baa) +++ src/main/kotlin/platform/mixin/inspection/addedMembers/AbstractAddedMembersInspection.kt (revision 0da34782e5c29f3e4f13ec0cefdc9bca4e1e8baa) @@ -0,0 +1,88 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2023 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.mixin.inspection.addedMembers + +import com.demonwav.mcdev.platform.mixin.handlers.MixinAnnotationHandler +import com.demonwav.mcdev.platform.mixin.inspection.MixinInspection +import com.demonwav.mcdev.platform.mixin.util.MixinConstants +import com.demonwav.mcdev.platform.mixin.util.isMixin +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.psi.JavaElementVisitor +import com.intellij.psi.PsiElementVisitor +import com.intellij.psi.PsiField +import com.intellij.psi.PsiMethod +import com.intellij.psi.search.searches.SuperMethodsSearch + +abstract class AbstractAddedMembersInspection : MixinInspection() { + abstract fun visitAddedField(holder: ProblemsHolder, field: PsiField) + abstract fun visitAddedMethod(holder: ProblemsHolder, method: PsiMethod, isInherited: Boolean) + + final override fun buildVisitor(holder: ProblemsHolder): PsiElementVisitor = Visitor(holder) + + private inner class Visitor(private val holder: ProblemsHolder) : JavaElementVisitor() { + override fun visitField(field: PsiField) { + if (field.containingClass?.isMixin != true) { + return + } + + if (field.hasAnnotation(MixinConstants.Annotations.SHADOW)) { + return + } + + visitAddedField(holder, field) + } + + override fun visitMethod(method: PsiMethod) { + if (method.containingClass?.isMixin != true) { + return + } + + if (method.isConstructor) { + return + } + + val hasMixinAnnotation = method.annotations.any { + val fqn = it.qualifiedName ?: return@any false + fqn in ignoredMethodAnnotations || MixinAnnotationHandler.forMixinAnnotation( + fqn, + holder.project + ) != null + } + if (hasMixinAnnotation) { + return + } + + val superMethod = SuperMethodsSearch.search(method, null, true, false).findFirst() + visitAddedMethod(holder, method, superMethod != null) + } + } + + companion object { + private val ignoredMethodAnnotations = setOf( + MixinConstants.Annotations.SHADOW, + MixinConstants.Annotations.ACCESSOR, + MixinConstants.Annotations.INVOKER, + MixinConstants.Annotations.OVERWRITE, + MixinConstants.Annotations.INTRINSIC, + MixinConstants.Annotations.SOFT_OVERRIDE, + ) + } +} Index: src/main/kotlin/platform/mixin/inspection/addedMembers/AddedMembersNameFormatInspection.kt =================================================================== --- src/main/kotlin/platform/mixin/inspection/addedMembers/AddedMembersNameFormatInspection.kt (revision 0da34782e5c29f3e4f13ec0cefdc9bca4e1e8baa) +++ src/main/kotlin/platform/mixin/inspection/addedMembers/AddedMembersNameFormatInspection.kt (revision 0da34782e5c29f3e4f13ec0cefdc9bca4e1e8baa) @@ -0,0 +1,295 @@ +/* + * Minecraft Development for IntelliJ + * + * https://mcdev.io/ + * + * Copyright (C) 2023 minecraft-dev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published + * by the Free Software Foundation, version 3.0 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package com.demonwav.mcdev.platform.mixin.inspection.addedMembers + +import com.demonwav.mcdev.util.decapitalize +import com.demonwav.mcdev.util.findContainingClass +import com.demonwav.mcdev.util.onShown +import com.demonwav.mcdev.util.toJavaIdentifier +import com.intellij.codeInsight.CodeInsightBundle +import com.intellij.codeInsight.FileModificationService +import com.intellij.codeInspection.LocalQuickFixAndIntentionActionOnPsiElement +import com.intellij.codeInspection.ProblemsHolder +import com.intellij.ide.util.SuperMethodWarningUtil +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComponentValidator +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.util.text.StringUtil +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiField +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiNameIdentifierOwner +import com.intellij.psi.PsiNamedElement +import com.intellij.refactoring.rename.RenameProcessor +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextField +import com.intellij.ui.dsl.builder.COLUMNS_SHORT +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.columns +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.layout.ValidationInfoBuilder +import com.intellij.util.xmlb.Converter +import com.intellij.util.xmlb.annotations.Attribute +import java.util.function.Supplier +import java.util.regex.Pattern +import java.util.regex.PatternSyntaxException +import javax.swing.JComponent +import javax.swing.event.DocumentEvent +import kotlin.reflect.KMutableProperty0 +import org.intellij.lang.annotations.Language + +class AddedMembersNameFormatInspection : AbstractAddedMembersInspection() { + @Attribute(converter = RegexConverter::class) + @JvmField + var validNameFormat = ".+[_$].+".toRegex() + + @Attribute(converter = RegexConverter::class) + @JvmField + var validNameFixSearch = "^.+$".toRegex() + + @JvmField + var validNameFixReplace = "MOD_ID\\\$\$0" + + @JvmField + var ignoreFields = false + @JvmField + var ignoreMethods = false + @JvmField + var ignoreInheritedInterfaceMethods = false + + override fun getStaticDescription() = "Reports added members not matching the correct name format" + + override fun visitAddedField(holder: ProblemsHolder, field: PsiField) { + if (!ignoreFields) { + visitAdded(holder, field) + } + } + + override fun visitAddedMethod(holder: ProblemsHolder, method: PsiMethod, isInherited: Boolean) { + if (!shouldIgnoreMethod(method, isInherited)) { + visitAdded(holder, method) + } + } + + private fun shouldIgnoreMethod(method: PsiMethod, isInherited: Boolean): Boolean { + if (ignoreMethods) { + return true + } + + if (isInherited) { + if (ignoreInheritedInterfaceMethods) { + return true + } + + val superMethods = method.findDeepestSuperMethods() + val isInterfaceMethod = superMethods.any { + val clazz = it.findContainingClass() ?: return@any false + clazz.isInterface && clazz.containingFile?.isWritable == true + } + return !isInterfaceMethod + } + + return false + } + + private fun visitAdded(holder: ProblemsHolder, added: PsiNameIdentifierOwner) { + val name = added.name ?: return + if (validNameFormat.matches(name)) { + return + } + + // try to get a quick fix + val fixed = try { + validNameFixSearch.replace(name, validNameFixReplace) + .replace("MOD_ID", getAppropriatePrefix(holder.project)) + } catch (e: RuntimeException) { + null + } + + if (fixed != null && StringUtil.isJavaIdentifier(fixed) && validNameFormat.matches(fixed)) { + holder.registerProblem( + added.nameIdentifier ?: return, + "Name does not match the pattern for added mixin members: \"${validNameFormat.pattern}\"", + RenameWithInheritanceFix(added, fixed) + ) + } else { + holder.registerProblem( + added.nameIdentifier ?: return, + "Name does not match the pattern for added mixin members: \"${validNameFormat.pattern}\"", + ) + } + } + + private fun getAppropriatePrefix(project: Project): String { + return StringUtil.capitalizeWords(project.name, true) + .decapitalize() + .replace(" ", "") + .toJavaIdentifier(allowDollars = false) + } + + override fun createOptionsPanel(): JComponent { + return panel { + row("Valid name format:") { + textField() + .doBindText({ validNameFormat.pattern }, { validNameFormat = it.toRegexOrDefault(".+[_$].+") }) + .columns(COLUMNS_SHORT) + .regexValidator() + } + row("Valid name fix search:") { + textField() + .doBindText({ validNameFixSearch.pattern }, { validNameFixSearch = it.toRegexOrDefault(".+") }) + .columns(COLUMNS_SHORT) + .regexValidator() + } + row("Valid name fix replace:") { + textField().doBindText(::validNameFixReplace).columns(COLUMNS_SHORT) + } + + separator() + + var ignoreFields: Cell? = null + row { + ignoreFields = checkBox("Ignore fields").doBindSelected(::ignoreFields) + } + var ignoreMethods: Cell? = null + row { + ignoreMethods = checkBox("Ignore methods").doBindSelected(::ignoreMethods) + } + // make sure ignore fields and ignore methods can't be selected at the same time + ignoreFields!!.component.addActionListener { + if (ignoreFields!!.component.isSelected) { + ignoreMethods!!.component.isSelected = false + } + } + ignoreMethods!!.component.addActionListener { + if (ignoreMethods!!.component.isSelected) { + ignoreFields!!.component.isSelected = false + } + } + + row { + checkBox("Ignore inherited interface methods").doBindSelected(::ignoreInheritedInterfaceMethods) + } + } + } +} + +private fun String.toRegexOrDefault(@Language("RegExp") default: String): Regex { + return try { + this.toRegex() + } catch (e: PatternSyntaxException) { + default.toRegex() + } +} + +private fun Cell.doBindText(property: KMutableProperty0): Cell { + return doBindText(property.getter, property.setter) +} + +private fun Cell.doBindText(getter: () -> String, setter: (String) -> Unit): Cell { + component.text = getter() + component.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + setter(component.text) + } + }) + return this +} + +private fun Cell.doBindSelected(property: KMutableProperty0): Cell { + component.isSelected = property.get() + component.addActionListener { + property.set(component.isSelected) + } + return this +} + +private fun Cell.regexValidator(): Cell { + var hasRegisteredValidator = false + component.onShown { + if (!hasRegisteredValidator) { + hasRegisteredValidator = true + val disposable = DialogWrapper.findInstance(component)?.disposable ?: return@onShown + ComponentValidator(disposable).withValidator( + Supplier { + try { + Pattern.compile(component.text) + null + } catch (e: PatternSyntaxException) { + ValidationInfoBuilder(component).error("Invalid regex") + } + } + ).andRegisterOnDocumentListener(component).installOn(component) + } + } + return this +} + +private class RegexConverter : Converter() { + override fun toString(value: Regex) = value.pattern + + override fun fromString(value: String) = runCatching { value.toRegex() }.getOrNull() +} + +private class RenameWithInheritanceFix( + element: PsiNamedElement, + private val newName: String +) : LocalQuickFixAndIntentionActionOnPsiElement(element) { + private val isMethod = element is PsiMethod + private val text = CodeInsightBundle.message("rename.named.element.text", element.name, newName) + + override fun getFamilyName() = CodeInsightBundle.message("rename.element.family") + + override fun getText() = text + + override fun invoke( + project: Project, + file: PsiFile, + editor: Editor?, + startElement: PsiElement, + endElement: PsiElement + ) { + if (isMethod) { + val method = startElement as? PsiMethod ?: return + if (editor != null) { + SuperMethodWarningUtil.checkSuperMethod(method, { md -> + RenameProcessor(project, md, newName, false, false).run() + true + }, editor) + } else { + val superMethod = method.findDeepestSuperMethods().firstOrNull() + for (md in listOfNotNull(superMethod, method)) { + RenameProcessor(project, md, newName, false, false).run() + } + } + } else { + if (!FileModificationService.getInstance().prepareFileForWrite(file)) { + return + } + RenameProcessor(project, startElement, newName, false, false).run() + } + } + + override fun startInWriteAction() = isMethod +} Index: src/main/kotlin/platform/mixin/inspection/addedMembers/MissingUniqueAnnotationInspection.kt =================================================================== --- src/main/kotlin/platform/mixin/inspection/addedMembers/MissingUniqueAnnotationInspection.kt (revision 3aa2181c17f9286e605a65fc6589a5eb6124acc8) +++ src/main/kotlin/platform/mixin/inspection/addedMembers/MissingUniqueAnnotationInspection.kt (revision 0da34782e5c29f3e4f13ec0cefdc9bca4e1e8baa) @@ -20,67 +20,27 @@ package com.demonwav.mcdev.platform.mixin.inspection.addedMembers -import com.demonwav.mcdev.platform.mixin.handlers.MixinAnnotationHandler -import com.demonwav.mcdev.platform.mixin.inspection.MixinInspection import com.demonwav.mcdev.platform.mixin.util.MixinConstants -import com.demonwav.mcdev.platform.mixin.util.isMixin import com.intellij.codeInsight.intention.AddAnnotationFix import com.intellij.codeInspection.ProblemsHolder -import com.intellij.psi.JavaElementVisitor -import com.intellij.psi.PsiElementVisitor import com.intellij.psi.PsiField import com.intellij.psi.PsiMethod -import com.intellij.psi.search.searches.SuperMethodsSearch -class MissingUniqueAnnotationInspection : MixinInspection() { +class MissingUniqueAnnotationInspection : AbstractAddedMembersInspection() { override fun getStaticDescription() = "Reports missing @Unique annotations" - override fun buildVisitor(holder: ProblemsHolder): PsiElementVisitor = Visitor(holder) - - private class Visitor(private val holder: ProblemsHolder) : JavaElementVisitor() { - override fun visitField(field: PsiField) { - if (field.containingClass?.isMixin != true) { - return - } - - if (field.hasAnnotation(MixinConstants.Annotations.SHADOW)) { - return - } - + override fun visitAddedField(holder: ProblemsHolder, field: PsiField) { - if (!field.hasAnnotation(MixinConstants.Annotations.UNIQUE)) { - holder.registerProblem( - field.nameIdentifier, - "Missing @Unique annotation", - AddAnnotationFix(MixinConstants.Annotations.UNIQUE, field) - ) - } - } + if (!field.hasAnnotation(MixinConstants.Annotations.UNIQUE)) { + holder.registerProblem( + field.nameIdentifier, + "Missing @Unique annotation", + AddAnnotationFix(MixinConstants.Annotations.UNIQUE, field) + ) + } + } - override fun visitMethod(method: PsiMethod) { - if (method.containingClass?.isMixin != true) { - return - } - - if (method.isConstructor) { - return - } - - val superMethod = SuperMethodsSearch.search(method, null, true, false).findFirst() - if (superMethod != null) { - return - } - - val hasMixinAnnotation = method.annotations.any { - val fqn = it.qualifiedName ?: return@any false - fqn in ignoredMethodAnnotations || MixinAnnotationHandler.forMixinAnnotation( - fqn, - holder.project - ) != null - } - if (hasMixinAnnotation) { - return - } - + override fun visitAddedMethod(holder: ProblemsHolder, method: PsiMethod, isInherited: Boolean) { + if (!isInherited && !method.hasAnnotation(MixinConstants.Annotations.UNIQUE)) { holder.registerProblem( method.nameIdentifier ?: return, "Missing @Unique annotation", @@ -88,16 +48,4 @@ ) } } - - companion object { - private val ignoredMethodAnnotations = setOf( - MixinConstants.Annotations.UNIQUE, - MixinConstants.Annotations.SHADOW, - MixinConstants.Annotations.ACCESSOR, - MixinConstants.Annotations.INVOKER, - MixinConstants.Annotations.OVERWRITE, - MixinConstants.Annotations.INTRINSIC, - MixinConstants.Annotations.SOFT_OVERRIDE, - ) - } +} -} Index: src/main/resources/META-INF/plugin.xml =================================================================== --- src/main/resources/META-INF/plugin.xml (revision 3aa2181c17f9286e605a65fc6589a5eb6124acc8) +++ src/main/resources/META-INF/plugin.xml (revision 0da34782e5c29f3e4f13ec0cefdc9bca4e1e8baa) @@ -760,6 +760,14 @@ level="WARNING" hasStaticDescription="true" implementationClass="com.demonwav.mcdev.platform.mixin.inspection.addedMembers.MissingUniqueAnnotationInspection"/> +